diff --git a/web/oss/src/components/AgentChatSlice/AgentConversation.tsx b/web/oss/src/components/AgentChatSlice/AgentConversation.tsx
index f047c3c8c3..ba1117295b 100644
--- a/web/oss/src/components/AgentChatSlice/AgentConversation.tsx
+++ b/web/oss/src/components/AgentChatSlice/AgentConversation.tsx
@@ -17,6 +17,8 @@ import {
} from "@agenta/entities/session"
import {markTraceAsFresh} from "@agenta/entities/trace"
import {
+ contextWindowForModel,
+ harnessCapabilitiesAtomFamily,
invalidateAgentCommittedRevisionCache,
workflowBuildKitOverlayReadyAtomFamily,
workflowMolecule,
@@ -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
@@ -2255,7 +2265,7 @@ const AgentConversation = ({
{!onboardingActive ? (
) : null}
diff --git a/web/oss/src/components/AgentChatSlice/assets/contextBudget.ts b/web/oss/src/components/AgentChatSlice/assets/contextBudget.ts
index d2df764c13..efe11ff019 100644
--- a/web/oss/src/components/AgentChatSlice/assets/contextBudget.ts
+++ b/web/oss/src/components/AgentChatSlice/assets/contextBudget.ts
@@ -1,97 +1,31 @@
/**
- * 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 = {
- // 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.
*/
@@ -99,24 +33,18 @@ 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,
}
}
diff --git a/web/oss/src/components/AgentChatSlice/components/ContextBudgetIndicator.tsx b/web/oss/src/components/AgentChatSlice/components/ContextBudgetIndicator.tsx
index 2b2dcbb996..16b687d1e2 100644
--- a/web/oss/src/components/AgentChatSlice/components/ContextBudgetIndicator.tsx
+++ b/web/oss/src/components/AgentChatSlice/components/ContextBudgetIndicator.tsx
@@ -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 = {
+ normal: "bg-colorTextTertiary",
+ warn: "bg-colorWarning",
+ danger: "bg-colorError opacity-70",
+}
+
+const TEXT: Record = {
+ 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 (
- Ctx — tokens in the context window right now (latest turn). Predicts
- when the conversation compacts, and drops after it does.
-
-
- Σ — total tokens across the whole session (cumulative usage).
+ Context window:
+
+ As it fills up, earlier messages are compressed into summaries, which may
+ reduce recall of older details.
- {budget.maxTokens
- ? `Model window ≈ ${fmt(budget.maxTokens)} tokens`
- : "Model context window unknown"}
+ {max
+ ? `${fmt(occ)} / ${fmt(max)} tokens`
+ : `${fmt(occ)} tokens · model window unknown`}
}
placement="topRight"
mouseEnterDelay={0.4}
>
-
- {budget.occupancyTokens != null ? (
-
- Ctx {fmt(budget.occupancyTokens)}
- {maxSuffix}
- {pctLabel(budget.occupancyPct)}
-
- ) : null}
- {budget.runningSumTokens != null ? (
-
- · Σ {fmt(budget.runningSumTokens)}
- {pctLabel(budget.runningSumPct)}
+
+ {pctInt != null ? (
+
+
) : null}
-
+
+ {tier === "danger" ? (
+
+ ) : null}
+ {label}
+
+
)
}
diff --git a/web/oss/src/components/AgentChatSlice/hooks/useAgentModelKeyStatus.ts b/web/oss/src/components/AgentChatSlice/hooks/useAgentModelKeyStatus.ts
index d34d944318..e9fd8f2e87 100644
--- a/web/oss/src/components/AgentChatSlice/hooks/useAgentModelKeyStatus.ts
+++ b/web/oss/src/components/AgentChatSlice/hooks/useAgentModelKeyStatus.ts
@@ -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). */
@@ -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
@@ -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
@@ -90,6 +101,7 @@ export function useAgentModelKeyStatus(entityId: string): AgentModelKeyStatus {
return {
provider,
model,
+ harness,
hasKey: !!providerEntry?.key,
providerEntry,
loading,
diff --git a/web/packages/agenta-entities/src/workflow/index.ts b/web/packages/agenta-entities/src/workflow/index.ts
index 409b879943..663ea84fdc 100644
--- a/web/packages/agenta-entities/src/workflow/index.ts
+++ b/web/packages/agenta-entities/src/workflow/index.ts
@@ -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,
diff --git a/web/packages/agenta-entities/src/workflow/state/index.ts b/web/packages/agenta-entities/src/workflow/state/index.ts
index 9e80c04bc7..57e3959bd5 100644
--- a/web/packages/agenta-entities/src/workflow/state/index.ts
+++ b/web/packages/agenta-entities/src/workflow/state/index.ts
@@ -16,6 +16,7 @@ export {workflowMolecule, type WorkflowMolecule} from "./molecule"
export {
harnessCapabilitiesAtomFamily,
+ contextWindowForModel,
type HarnessCapabilities,
type HarnessCapabilitiesMap,
type ModelCatalogEntry,
diff --git a/web/packages/agenta-entities/src/workflow/state/inspectMeta.ts b/web/packages/agenta-entities/src/workflow/state/inspectMeta.ts
index 19e0a13a7c..81d25a8aef 100644
--- a/web/packages/agenta-entities/src/workflow/state/inspectMeta.ts
+++ b/web/packages/agenta-entities/src/workflow/state/inspectMeta.ts
@@ -126,3 +126,19 @@ export const harnessCapabilitiesAtomFamily = atomFamily((_harnessRef: string) =>
return query.data ?? null
}),
)
+
+/**
+ * The model's context window (max input tokens) from the harness catalog, matched by exact id — the
+ * same `id`-keyed join the model picker uses. `null` when the catalog, harness, or entry is absent,
+ * or the entry carries no `context_window`. Source of truth is the SDK model catalog
+ * (`model_catalog.py`), so no window is ever hardcoded on the frontend.
+ */
+export function contextWindowForModel(
+ capabilities: HarnessCapabilitiesMap | null | undefined,
+ harness: string | null | undefined,
+ modelId: string | null | undefined,
+): number | null {
+ if (!capabilities || !harness || !modelId) return null
+ const entry = capabilities[harness]?.model_catalog?.find((e) => e.id === modelId)
+ return entry?.context_window ?? null
+}