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
12 changes: 11 additions & 1 deletion web/oss/src/components/AgentChatSlice/AgentConversation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import {
} from "@agenta/entities/session"
import {markTraceAsFresh} from "@agenta/entities/trace"
import {
contextWindowForModel,
harnessCapabilitiesAtomFamily,
invalidateAgentCommittedRevisionCache,
workflowBuildKitOverlayReadyAtomFamily,
workflowMolecule,
Expand Down Expand Up @@ -724,6 +726,14 @@ const AgentConversation = ({
const modelKey = useAgentModelKeyStatus(entityId)
const modelBlocked = modelKey.gateActive

// Context-window denominator for the token-budget indicator: the SDK model catalog's own
// `context_window`, delivered on the (global) harness-capabilities document — never hardcoded.
const harnessCapabilities = useAtomValue(harnessCapabilitiesAtomFamily(""))
const contextMaxTokens = useMemo(
() => contextWindowForModel(harnessCapabilities, modelKey.harness, modelKey.model),
[harnessCapabilities, modelKey.harness, modelKey.model],
)

// ── Playground-native onboarding ──────────────────────────────────────────
// This chat panel IS the onboarding surface while the agent is ephemeral: the empty state shows the
// "what do you want to build?" hero and the composer renders Create-agent / Continue-in-IDE controls
Expand Down Expand Up @@ -2255,7 +2265,7 @@ const AgentConversation = ({
{!onboardingActive ? (
<ContextBudgetIndicator
messages={messages}
model={modelKey.model}
maxTokens={contextMaxTokens}
/>
) : null}
</div>
Expand Down
96 changes: 12 additions & 84 deletions web/oss/src/components/AgentChatSlice/assets/contextBudget.ts
Original file line number Diff line number Diff line change
@@ -1,122 +1,50 @@
/**
* Context token budget — the data behind the "Context 31.3k / 200k (16%)" indicator.
* Context token budget — the data behind the ambient "Context 39% used" meter above the composer.
*
* This slice runs each turn through `useChat`; the runner stamps real token usage onto every
* assistant message (`metadata.usage = {input, output, total, cost}` → read via `getMessageUsage`).
* From those per-turn totals we compute TWO candidate measures so the UI can show them side by
* side until we pick one:
* Occupancy = the LATEST assistant turn's total tokens: because the whole conversation is resent
* every turn, this equals how full the context window is right now, so it predicts compaction and
* correctly DROPS after a compaction/summarization.
*
* - **occupancy** = the LATEST assistant turn's total tokens. Because the whole conversation is
* resent every turn, this already grows with the chat AND equals how full the context window is
* right now — so it predicts compaction and correctly DROPS after a compaction/summarization.
* - **runningSum** = Σ of every assistant turn's total tokens. A cumulative usage meter; it
* double-counts the resent history (turn 2's prompt re-includes turn 1) so it climbs far faster
* than real occupancy and never drops. Included only for the comparison.
*
* The model's max context window is not exposed by the backend today (the registry stores only
* cost), so `MODEL_CONTEXT_WINDOWS` is the single hardcoded source for the "/ max (%)" denominator.
* The max context window (`maxTokens`) comes from the harness model catalog — the SDK's own
* `context_window` per model, delivered to the frontend on the harness-capabilities document and
* resolved via `contextWindowForModel` (@agenta/entities/workflow). Nothing is hardcoded here.
*/
import type {UIMessage} from "ai"

import {getMessageUsage} from "./trace"

/**
* Model id (substring) → total context window in tokens. Keys are matched case-insensitively,
* after stripping any `provider/` or `provider:` prefix, by exact then longest-substring match, so
* dated snapshots (`claude-opus-4-20250101`) resolve to their base entry. Keep values conservative
* and extend as models are added; a follow-up can source these from litellm `get_model_info()`.
*/
export const MODEL_CONTEXT_WINDOWS: Record<string, number> = {
// OpenAI
"gpt-4o-mini": 128_000,
"gpt-4o": 128_000,
"gpt-4.1-mini": 1_047_576,
"gpt-4.1-nano": 1_047_576,
"gpt-4.1": 1_047_576,
"gpt-4-turbo": 128_000,
"o1-mini": 128_000,
o1: 200_000,
"o3-mini": 200_000,
o3: 200_000,
"o4-mini": 200_000,
// Anthropic
"claude-3-5-sonnet": 200_000,
"claude-3-5-haiku": 200_000,
"claude-3-7-sonnet": 200_000,
"claude-opus-4": 200_000,
"claude-sonnet-4": 200_000,
"claude-haiku-4": 200_000,
// Google Gemini
"gemini-1.5-pro": 2_097_152,
"gemini-1.5-flash": 1_048_576,
"gemini-2.0-flash": 1_048_576,
"gemini-2.5-pro": 1_048_576,
"gemini-2.5-flash": 1_048_576,
}

/**
* Resolve the max context window for a model id. Returns `null` when the model is unknown, in which
* case the indicator shows the token count without a "/ max (%)" fraction.
*/
export function resolveModelContextWindow(model: string | null | undefined): number | null {
if (!model) return null
const id = model.toLowerCase()
const bare = id.replace(/^[a-z0-9._-]+[/:]/, "")
if (MODEL_CONTEXT_WINDOWS[bare]) return MODEL_CONTEXT_WINDOWS[bare]
let best: {key: string; win: number} | null = null
for (const [key, win] of Object.entries(MODEL_CONTEXT_WINDOWS)) {
if (bare.includes(key) && (!best || key.length > best.key.length)) {
best = {key, win}
}
}
return best?.win ?? null
}

export interface ContextBudget {
/** Latest assistant turn's total tokens — current window occupancy. `null` until a turn has usage. */
occupancyTokens: number | null
/** Σ of every assistant turn's total tokens this session. `null` until a turn has usage. */
runningSumTokens: number | null
/** Per-turn totals, oldest → newest (for the tooltip / debugging). */
perTurnTokens: number[]
/** Model context window, or `null` when unknown. */
maxTokens: number | null
/** occupancy / max, clamped 0..1; `null` when max unknown or no usage yet. */
occupancyPct: number | null
/** runningSum / max, clamped 0..1; `null` when max unknown or no usage yet. */
runningSumPct: number | null
}

const pctOf = (n: number | null, max: number | null): number | null =>
n != null && max ? Math.min(n / max, 1) : null

/**
* Compute both budget measures from a session's messages and its model's context window.
* Compute window occupancy from a session's messages and its model's context window.
* Reads real per-turn usage off assistant messages (`getMessageUsage`), preferring `totalTokens`
* and falling back to `promptTokens` when only the input side was stamped.
*/
export function computeContextBudget(
messages: readonly UIMessage[],
maxTokens: number | null,
): ContextBudget {
const perTurnTokens: number[] = []
let occupancyTokens: number | null = null
for (const message of messages) {
if (message.role !== "assistant") continue
const usage = getMessageUsage(message)
const total = usage?.totalTokens ?? usage?.promptTokens
if (typeof total === "number") perTurnTokens.push(total)
if (typeof total === "number") occupancyTokens = total
}

const hasUsage = perTurnTokens.length > 0
const occupancyTokens = hasUsage ? perTurnTokens[perTurnTokens.length - 1] : null
const runningSumTokens = hasUsage ? perTurnTokens.reduce((a, b) => a + b, 0) : null

return {
occupancyTokens,
runningSumTokens,
perTurnTokens,
maxTokens,
occupancyPct: pctOf(occupancyTokens, maxTokens),
runningSumPct: pctOf(runningSumTokens, maxTokens),
occupancyPct:
occupancyTokens != null && maxTokens ? Math.min(occupancyTokens / maxTokens, 1) : null,
}
}
Original file line number Diff line number Diff line change
@@ -1,84 +1,112 @@
/**
* ContextBudgetIndicator — the compact "Context 31.3k / 200k (16%)" strip above the composer.
* ContextBudgetIndicator — the ambient "Context 39% used" meter above the composer.
*
* v1 shows BOTH candidate measures side by side so we can compare them on a live agent and pick
* the right one (then drop the other + its label):
* - **Ctx** = occupancy (latest turn's tokens = how full the window is now; predicts compaction)
* - **Σ** = running sum (cumulative tokens across the session)
* A slim fill bar + one plain-language line. Quiet (neutral) while there's room, escalating to
* amber then red with alarm wording as the window fills, so non-technical users get a signal
* without reading token math. Exact token counts live in the tooltip for power users.
*
* Renders nothing until at least one turn has reported usage. When the model's context window is
* unknown it shows the token counts without the "/ max (%)" fraction.
* Renders nothing until a turn has reported usage. When the model's context window is unknown it
* falls back to a quiet raw token count with no bar or percent.
*/
import {useMemo} from "react"

import {Warning} from "@phosphor-icons/react"
import type {UIMessage} from "ai"
import {Tooltip, Typography} from "antd"
import {Tooltip} from "antd"

import {computeContextBudget, resolveModelContextWindow} from "../assets/contextBudget"

const {Text} = Typography
import {computeContextBudget} from "../assets/contextBudget"

export interface ContextBudgetIndicatorProps {
messages: readonly UIMessage[]
model: string | null
/** Max context window for the selected model, from the harness catalog; `null` when unknown. */
maxTokens: number | null
className?: string
}

/** Compact token formatting: 31_300 → "31.3k", 1_047_576 → "1.0M". */
/** Compact token formatting, no noisy trailing ".0": 34_200 → "34.2k", 407_027 → "407k", 1_048_576 → "1M". */
const compact = (n: number, div: number, suffix: string): string => {
const s = (n / div).toFixed(1)
return `${s.endsWith(".0") ? s.slice(0, -2) : s}${suffix}`
}
const fmt = (n: number): string => {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`
if (n >= 1_000_000) return compact(n, 1_000_000, "M")
if (n >= 1_000) return compact(n, 1_000, "k")
return `${n}`
}

const pctLabel = (pct: number | null): string => (pct == null ? "" : ` (${Math.round(pct * 100)}%)`)
type Tier = "normal" | "warn" | "danger"

const tierFor = (pct: number | null): Tier =>
pct == null ? "normal" : pct >= 0.9 ? "danger" : pct >= 0.75 ? "warn" : "normal"

const FILL: Record<Tier, string> = {
normal: "bg-colorTextTertiary",
warn: "bg-colorWarning",
danger: "bg-colorError opacity-70",
}

const TEXT: Record<Tier, string> = {
normal: "text-colorTextTertiary",
warn: "text-colorWarning",
danger: "text-colorError opacity-70",
}

const ContextBudgetIndicator = ({messages, model, className}: ContextBudgetIndicatorProps) => {
const budget = useMemo(() => {
const maxTokens = resolveModelContextWindow(model)
return computeContextBudget(messages, maxTokens)
}, [messages, model])
const ContextBudgetIndicator = ({messages, maxTokens, className}: ContextBudgetIndicatorProps) => {
const budget = useMemo(() => computeContextBudget(messages, maxTokens), [messages, maxTokens])

if (budget.occupancyTokens == null && budget.runningSumTokens == null) return null
const {occupancyTokens: occ, maxTokens: max, occupancyPct: pct} = budget
if (occ == null) return null

const maxSuffix = budget.maxTokens ? ` / ${fmt(budget.maxTokens)}` : ""
const tier = tierFor(pct)
const pctInt = pct != null ? Math.round(pct * 100) : null
const label =
pctInt != null
? tier === "danger"
? `Context almost full · ${pctInt}%`
: `Context ${pctInt}% used`
: `Context ${fmt(occ)} tokens`

return (
<Tooltip
title={
<div className="flex flex-col gap-1 text-xs">
<span>
<b>Ctx</b> — tokens in the context window right now (latest turn). Predicts
when the conversation compacts, and drops after it does.
</span>
<span>
<b>Σ</b> — total tokens across the whole session (cumulative usage).
Context window:
<br />
As it fills up, earlier messages are compressed into summaries, which may
reduce recall of older details.
</span>
<span className="opacity-70">
{budget.maxTokens
? `Model window ≈ ${fmt(budget.maxTokens)} tokens`
: "Model context window unknown"}
{max
? `${fmt(occ)} / ${fmt(max)} tokens`
: `${fmt(occ)} tokens · model window unknown`}
</span>
</div>
}
placement="topRight"
mouseEnterDelay={0.4}
>
<Text type="secondary" className={`text-xs whitespace-nowrap ${className ?? ""}`}>
{budget.occupancyTokens != null ? (
<span>
Ctx {fmt(budget.occupancyTokens)}
{maxSuffix}
{pctLabel(budget.occupancyPct)}
</span>
) : null}
{budget.runningSumTokens != null ? (
<span className="ml-2 opacity-80">
· Σ {fmt(budget.runningSumTokens)}
{pctLabel(budget.runningSumPct)}
<span
className={`inline-flex items-center gap-1.5 whitespace-nowrap ${className ?? ""}`}
>
{pctInt != null ? (
<span
className="h-1 w-10 shrink-0 overflow-hidden rounded-full bg-colorFillQuaternary"
aria-hidden
>
<span
className={`block h-full rounded-full ${FILL[tier]}`}
style={{width: `${pctInt}%`}}
/>
</span>
) : null}
</Text>
<span className={`inline-flex items-center gap-1 text-xs ${TEXT[tier]}`}>
{tier === "danger" ? (
<Warning size={12} weight="fill" className="shrink-0" />
) : null}
{label}
</span>
</span>
</Tooltip>
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export interface AgentModelKeyStatus {
provider: string | null
/** The selected model id (display). */
model: string | null
/** The selected harness type (e.g. "pi_core" / "claude"), from `agent.harness.kind`. */
harness: string | null
/** Whether the project's vault holds a key for that provider. */
hasKey: boolean
/** The canonical vault provider entry for the model's provider (to open the configure drawer). */
Expand All @@ -40,6 +42,10 @@ interface LlmRef {
connection?: {mode?: unknown} | null
}

interface HarnessRef {
kind?: unknown
}

/**
* Model → provider → vault-key detection for an agent. The `agent.llm` value is a structured ModelRef
* carrying its `provider`; we check the project's vault (`standardSecretsAtom`) for a key for that
Expand All @@ -64,8 +70,13 @@ export function useAgentModelKeyStatus(entityId: string): AgentModelKeyStatus {
const keySetupDone = useAtomValue(providerKeySetupDoneAtom)

return useMemo(() => {
const llm = (config as {agent?: {llm?: LlmRef}} | null)?.agent?.llm
const agent = (config as {agent?: {llm?: LlmRef; harness?: HarnessRef}} | null)?.agent
const llm = agent?.llm
const model = typeof llm?.model === "string" && llm.model ? llm.model : null
const harness =
typeof agent?.harness?.kind === "string" && agent.harness.kind
? agent.harness.kind
: null
// Provider is stored on the ModelRef; fall back to a `provider/id` model prefix (Pi naming).
const provider =
typeof llm?.provider === "string" && llm.provider
Expand All @@ -90,6 +101,7 @@ export function useAgentModelKeyStatus(entityId: string): AgentModelKeyStatus {
return {
provider,
model,
harness,
hasKey: !!providerEntry?.key,
providerEntry,
loading,
Expand Down
1 change: 1 addition & 0 deletions web/packages/agenta-entities/src/workflow/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export {
// Per-harness capability map from the `/inspect` response `meta` (agent playground picker).
export {
harnessCapabilitiesAtomFamily,
contextWindowForModel,
type HarnessCapabilities,
type HarnessCapabilitiesMap,
type ModelCatalogEntry,
Expand Down
1 change: 1 addition & 0 deletions web/packages/agenta-entities/src/workflow/state/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export {workflowMolecule, type WorkflowMolecule} from "./molecule"

export {
harnessCapabilitiesAtomFamily,
contextWindowForModel,
type HarnessCapabilities,
type HarnessCapabilitiesMap,
type ModelCatalogEntry,
Expand Down
Loading
Loading