mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-30 11:52:04 +00:00
Merge pull request #54907 from NousResearch/austin/feat/context-usage-popover
feat(desktop): add context usage breakdown popover
This commit is contained in:
commit
3bbeb9e008
12 changed files with 524 additions and 4 deletions
156
agent/context_breakdown.py
Normal file
156
agent/context_breakdown.py
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
"""Live session context-window breakdown for UI surfaces.
|
||||
|
||||
Estimates how the next provider request is composed: system prompt tiers,
|
||||
tool schemas, and conversation history. Uses the same rough char/4 heuristic
|
||||
as ``agent.model_metadata.estimate_request_tokens_rough`` so numbers align
|
||||
with compression thresholds — not exact tokenizer counts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
||||
|
||||
_SKILLS_BLOCK_RE = re.compile(r"<available_skills>.*?</available_skills>", re.DOTALL)
|
||||
|
||||
_SUBAGENT_TOOL_NAMES = frozenset({"delegate_task"})
|
||||
|
||||
_CATEGORY_COLORS = {
|
||||
"system_prompt": "var(--context-usage-system)",
|
||||
"tool_definitions": "var(--context-usage-tools)",
|
||||
"rules": "var(--context-usage-rules)",
|
||||
"skills": "var(--context-usage-skills)",
|
||||
"mcp": "var(--context-usage-mcp)",
|
||||
"subagent_definitions": "var(--context-usage-subagents)",
|
||||
"memory": "var(--context-usage-memory)",
|
||||
"conversation": "var(--context-usage-conversation)",
|
||||
}
|
||||
|
||||
|
||||
def _chars_to_tokens(text: str) -> int:
|
||||
if not text:
|
||||
return 0
|
||||
return (len(text) + 3) // 4
|
||||
|
||||
|
||||
def _json_tokens(value: Any) -> int:
|
||||
if not value:
|
||||
return 0
|
||||
return _chars_to_tokens(json.dumps(value, ensure_ascii=False))
|
||||
|
||||
|
||||
def _tool_name(tool: dict) -> str:
|
||||
fn = tool.get("function") if isinstance(tool, dict) else None
|
||||
if isinstance(fn, dict):
|
||||
return str(fn.get("name") or "")
|
||||
return str(tool.get("name") or "")
|
||||
|
||||
|
||||
def _split_tools(tools: Sequence[dict]) -> Tuple[List[dict], List[dict], List[dict]]:
|
||||
builtin: List[dict] = []
|
||||
mcp: List[dict] = []
|
||||
subagent: List[dict] = []
|
||||
for tool in tools:
|
||||
name = _tool_name(tool)
|
||||
if name.startswith("mcp_"):
|
||||
mcp.append(tool)
|
||||
elif name in _SUBAGENT_TOOL_NAMES:
|
||||
subagent.append(tool)
|
||||
else:
|
||||
builtin.append(tool)
|
||||
return builtin, mcp, subagent
|
||||
|
||||
|
||||
def _memory_blocks(agent: Any) -> Tuple[str, str]:
|
||||
memory_block = ""
|
||||
user_block = ""
|
||||
store = getattr(agent, "_memory_store", None)
|
||||
if store is None:
|
||||
return memory_block, user_block
|
||||
try:
|
||||
if getattr(agent, "_memory_enabled", True):
|
||||
memory_block = store.format_for_system_prompt("memory") or ""
|
||||
if getattr(agent, "_user_profile_enabled", True):
|
||||
user_block = store.format_for_system_prompt("user") or ""
|
||||
except Exception:
|
||||
pass
|
||||
return memory_block, user_block
|
||||
|
||||
|
||||
def _strip_blocks(text: str, *blocks: str) -> str:
|
||||
out = text
|
||||
for block in blocks:
|
||||
if block:
|
||||
out = out.replace(block, "")
|
||||
return out.strip()
|
||||
|
||||
|
||||
def compute_session_context_breakdown(
|
||||
agent: Any,
|
||||
messages: Optional[List[dict]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Return a Cursor-style context usage breakdown for one live agent."""
|
||||
from agent.model_metadata import estimate_messages_tokens_rough
|
||||
from agent.system_prompt import build_system_prompt_parts
|
||||
|
||||
parts = build_system_prompt_parts(agent)
|
||||
stable = parts.get("stable", "") or ""
|
||||
context = parts.get("context", "") or ""
|
||||
volatile = parts.get("volatile", "") or ""
|
||||
|
||||
skills_match = _SKILLS_BLOCK_RE.search(stable)
|
||||
skills_index = skills_match.group(0) if skills_match else ""
|
||||
|
||||
memory_block, user_block = _memory_blocks(agent)
|
||||
memory_text = "\n\n".join(part for part in (memory_block, user_block) if part).strip()
|
||||
|
||||
system_core = _strip_blocks(stable, skills_index)
|
||||
system_tail = _strip_blocks(volatile, memory_block, user_block)
|
||||
system_prompt_text = "\n\n".join(part for part in (system_core, system_tail) if part).strip()
|
||||
|
||||
tools = list(getattr(agent, "tools", None) or [])
|
||||
builtin_tools, mcp_tools, subagent_tools = _split_tools(tools)
|
||||
|
||||
conversation_tokens = estimate_messages_tokens_rough(messages or [])
|
||||
|
||||
categories = [
|
||||
("system_prompt", "System prompt", _chars_to_tokens(system_prompt_text)),
|
||||
("tool_definitions", "Tool definitions", _json_tokens(builtin_tools)),
|
||||
("rules", "Rules", _chars_to_tokens(context)),
|
||||
("skills", "Skills", _chars_to_tokens(skills_index)),
|
||||
("mcp", "MCP", _json_tokens(mcp_tools)),
|
||||
("subagent_definitions", "Subagent definitions", _json_tokens(subagent_tools)),
|
||||
("memory", "Memory", _chars_to_tokens(memory_text)),
|
||||
("conversation", "Conversation", conversation_tokens),
|
||||
]
|
||||
|
||||
estimated_total = sum(tokens for _, _, tokens in categories)
|
||||
|
||||
comp = getattr(agent, "context_compressor", None)
|
||||
context_max = int(getattr(comp, "context_length", 0) or 0) if comp else 0
|
||||
measured_used = int(getattr(comp, "last_prompt_tokens", 0) or 0) if comp else 0
|
||||
context_used = measured_used if measured_used > 0 else estimated_total
|
||||
context_percent = (
|
||||
max(0, min(100, round(context_used / context_max * 100)))
|
||||
if context_max
|
||||
else 0
|
||||
)
|
||||
|
||||
return {
|
||||
"categories": [
|
||||
{
|
||||
"color": _CATEGORY_COLORS.get(category_id, "var(--ui-text-tertiary)"),
|
||||
"id": category_id,
|
||||
"label": label,
|
||||
"tokens": tokens,
|
||||
}
|
||||
for category_id, label, tokens in categories
|
||||
if tokens > 0
|
||||
],
|
||||
"context_max": context_max,
|
||||
"context_percent": context_percent,
|
||||
"context_used": context_used,
|
||||
"estimated_total": estimated_total,
|
||||
"model": getattr(agent, "model", "") or "",
|
||||
}
|
||||
147
apps/desktop/src/app/shell/context-usage-panel.tsx
Normal file
147
apps/desktop/src/app/shell/context-usage-panel.tsx
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { useI18n } from '@/i18n'
|
||||
import { formatK } from '@/lib/statusbar'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { ContextBreakdown, ContextUsageCategory, UsageStats } from '@/types/hermes'
|
||||
|
||||
interface ContextUsagePanelProps {
|
||||
currentUsage: UsageStats
|
||||
requestGateway: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
sessionId: string | null
|
||||
}
|
||||
|
||||
export function ContextUsagePanel({ currentUsage, requestGateway, sessionId }: ContextUsagePanelProps) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.shell.statusbar.contextUsagePanel
|
||||
const [breakdown, setBreakdown] = useState<ContextBreakdown | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId) {
|
||||
setBreakdown(null)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
|
||||
void requestGateway<ContextBreakdown>('session.context_breakdown', { session_id: sessionId })
|
||||
.then(data => {
|
||||
if (!cancelled) {
|
||||
setBreakdown(data)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setBreakdown(null)
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setLoading(false)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [requestGateway, sessionId])
|
||||
|
||||
const contextMax = breakdown?.context_max ?? currentUsage.context_max ?? 0
|
||||
const contextUsed = breakdown?.context_used ?? currentUsage.context_used ?? 0
|
||||
const contextPercent = Math.max(
|
||||
0,
|
||||
Math.min(100, Math.round(breakdown?.context_percent ?? currentUsage.context_percent ?? 0))
|
||||
)
|
||||
|
||||
const categories = useMemo(
|
||||
() =>
|
||||
(breakdown?.categories ?? []).map(category => ({
|
||||
...category,
|
||||
label: copy.categories[category.id as keyof typeof copy.categories] ?? category.label
|
||||
})),
|
||||
[breakdown?.categories, copy.categories]
|
||||
)
|
||||
|
||||
const segmentTotal = categories.reduce((sum, category) => sum + category.tokens, 0) || contextUsed || 1
|
||||
|
||||
return (
|
||||
<div className="flex w-72 flex-col gap-3 p-3 text-[0.75rem]" data-slot="context-usage-panel">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<p className="font-medium text-foreground">{copy.title}</p>
|
||||
|
||||
<span className="text-[0.6875rem] text-muted-foreground">
|
||||
{copy.tokenSummary(`~${formatK(contextUsed)}`, formatK(contextMax))}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-[0.6875rem] text-foreground">{copy.percentFull(contextPercent)}</p>
|
||||
|
||||
<ContextUsageBar categories={categories} segmentTotal={segmentTotal} />
|
||||
|
||||
<ul className="flex flex-col gap-1.5">
|
||||
{categories.map(category => (
|
||||
<li className="flex items-center justify-between gap-2" key={category.id}>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
className="size-2 shrink-0 rounded-[2px]"
|
||||
style={{ background: category.color }}
|
||||
/>
|
||||
|
||||
<span className="truncate text-muted-foreground">{category.label}</span>
|
||||
</span>
|
||||
|
||||
<span className="shrink-0 tabular-nums text-foreground">{formatCategoryTokens(category.tokens)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{loading && <p className="text-[0.6875rem] text-muted-foreground">{copy.loading}</p>}
|
||||
|
||||
{!loading && !categories.length && <p className="text-[0.6875rem] text-muted-foreground">{copy.empty}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextUsageBar({
|
||||
categories,
|
||||
segmentTotal
|
||||
}: {
|
||||
categories: readonly ContextUsageCategory[]
|
||||
segmentTotal: number
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-1.5 overflow-hidden rounded-full',
|
||||
categories.length ? 'bg-(--ui-stroke-tertiary)' : 'dither bg-(--ui-bg-elevated)'
|
||||
)}
|
||||
data-slot="context-usage-bar"
|
||||
>
|
||||
{categories.map(category => (
|
||||
<span
|
||||
className="h-full min-w-px"
|
||||
key={category.id}
|
||||
style={{
|
||||
background: category.color,
|
||||
width: `${(category.tokens / segmentTotal) * 100}%`
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatCategoryTokens(value: number): string {
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
return '0'
|
||||
}
|
||||
|
||||
if (value >= 1_000) {
|
||||
return `${formatK(value)}`
|
||||
}
|
||||
|
||||
return value.toLocaleString()
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import { useCallback, useMemo } from 'react'
|
|||
import type { CommandCenterSection } from '@/app/command-center'
|
||||
import { $terminalTakeover, setTerminalTakeover } from '@/app/right-sidebar/store'
|
||||
import { GatewayMenuPanel } from '@/app/shell/gateway-menu-panel'
|
||||
import { ContextUsagePanel } from '@/app/shell/context-usage-panel'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
|
||||
import { useI18n } from '@/i18n'
|
||||
|
|
@ -365,8 +366,17 @@ export function useStatusbarItems({
|
|||
hidden: !contextUsage,
|
||||
id: 'context-usage',
|
||||
label: contextUsage,
|
||||
title: copy.contextUsage,
|
||||
variant: 'text'
|
||||
menuAlign: 'end',
|
||||
menuClassName: 'w-auto border-(--ui-stroke-secondary) p-0',
|
||||
menuContent: (
|
||||
<ContextUsagePanel
|
||||
currentUsage={currentUsage}
|
||||
requestGateway={requestGateway}
|
||||
sessionId={activeSessionId}
|
||||
/>
|
||||
),
|
||||
title: copy.openContextUsage,
|
||||
variant: 'menu'
|
||||
},
|
||||
{
|
||||
detail: <LiveDuration since={sessionStartedAt} />,
|
||||
|
|
@ -402,18 +412,21 @@ export function useStatusbarItems({
|
|||
...(backendVersionItem ? [backendVersionItem] : [])
|
||||
],
|
||||
[
|
||||
activeSessionId,
|
||||
backendVersionItem,
|
||||
busy,
|
||||
chatOpen,
|
||||
clientVersionItem,
|
||||
contextBar,
|
||||
contextUsage,
|
||||
copy,
|
||||
currentUsage,
|
||||
requestGateway,
|
||||
sessionStartedAt,
|
||||
showYoloToggle,
|
||||
terminalTakeover,
|
||||
toggleYolo,
|
||||
turnStartedAt,
|
||||
clientVersionItem,
|
||||
backendVersionItem,
|
||||
yoloActive
|
||||
]
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1844,6 +1844,24 @@ export const en: Translations = {
|
|||
turnRunning: 'Running',
|
||||
currentTurnElapsed: 'Current turn elapsed',
|
||||
contextUsage: 'Context usage',
|
||||
contextUsagePanel: {
|
||||
categories: {
|
||||
conversation: 'Conversation',
|
||||
mcp: 'MCP',
|
||||
memory: 'Memory',
|
||||
rules: 'Rules',
|
||||
skills: 'Skills',
|
||||
subagent_definitions: 'Subagent definitions',
|
||||
system_prompt: 'System prompt',
|
||||
tool_definitions: 'Tool definitions'
|
||||
},
|
||||
empty: 'No context data yet',
|
||||
loading: 'Loading breakdown…',
|
||||
percentFull: percent => `${percent}% Full`,
|
||||
title: 'Context Usage',
|
||||
tokenSummary: (used, max) => `${used} / ${max} Tokens`
|
||||
},
|
||||
openContextUsage: 'Open context usage breakdown',
|
||||
session: 'Session',
|
||||
runtimeSessionElapsed: 'Runtime session elapsed',
|
||||
yoloOn: 'YOLO on — auto-approving dangerous commands. Click to turn off. Shift+click toggles it globally.',
|
||||
|
|
|
|||
|
|
@ -1964,6 +1964,24 @@ export const ja = defineLocale({
|
|||
turnRunning: '実行中',
|
||||
currentTurnElapsed: '現在のターン経過時間',
|
||||
contextUsage: 'コンテキスト使用状況',
|
||||
contextUsagePanel: {
|
||||
categories: {
|
||||
conversation: '会話',
|
||||
mcp: 'MCP',
|
||||
memory: 'メモリ',
|
||||
rules: 'ルール',
|
||||
skills: 'スキル',
|
||||
subagent_definitions: 'サブエージェント定義',
|
||||
system_prompt: 'システムプロンプト',
|
||||
tool_definitions: 'ツール定義'
|
||||
},
|
||||
empty: 'コンテキストデータはまだありません',
|
||||
loading: '内訳を読み込み中…',
|
||||
percentFull: percent => `${percent}% 使用中`,
|
||||
title: 'コンテキスト使用状況',
|
||||
tokenSummary: (used, max) => `${used} / ${max} Tokens`
|
||||
},
|
||||
openContextUsage: 'コンテキスト使用状況の内訳を開く',
|
||||
session: 'セッション',
|
||||
runtimeSessionElapsed: 'ランタイムセッション経過時間',
|
||||
yoloOn: 'YOLO オン — 危険なコマンドを自動承認中。クリックでオフに。Shift+クリックで全体に切り替え。',
|
||||
|
|
|
|||
|
|
@ -1497,6 +1497,24 @@ export interface Translations {
|
|||
turnRunning: string
|
||||
currentTurnElapsed: string
|
||||
contextUsage: string
|
||||
contextUsagePanel: {
|
||||
categories: {
|
||||
conversation: string
|
||||
mcp: string
|
||||
memory: string
|
||||
rules: string
|
||||
skills: string
|
||||
subagent_definitions: string
|
||||
system_prompt: string
|
||||
tool_definitions: string
|
||||
}
|
||||
empty: string
|
||||
loading: string
|
||||
percentFull: (percent: number) => string
|
||||
title: string
|
||||
tokenSummary: (used: string, max: string) => string
|
||||
}
|
||||
openContextUsage: string
|
||||
session: string
|
||||
runtimeSessionElapsed: string
|
||||
yoloOn: string
|
||||
|
|
|
|||
|
|
@ -1903,6 +1903,24 @@ export const zhHant = defineLocale({
|
|||
turnRunning: '執行中',
|
||||
currentTurnElapsed: '目前回合已用時間',
|
||||
contextUsage: '上下文使用量',
|
||||
contextUsagePanel: {
|
||||
categories: {
|
||||
conversation: '對話',
|
||||
mcp: 'MCP',
|
||||
memory: '記憶',
|
||||
rules: '規則',
|
||||
skills: '技能',
|
||||
subagent_definitions: '子代理定義',
|
||||
system_prompt: '系統提示詞',
|
||||
tool_definitions: '工具定義'
|
||||
},
|
||||
empty: '尚無上下文資料',
|
||||
loading: '正在載入明細…',
|
||||
percentFull: percent => `已用 ${percent}%`,
|
||||
title: '上下文使用量',
|
||||
tokenSummary: (used, max) => `${used} / ${max} Tokens`
|
||||
},
|
||||
openContextUsage: '開啟上下文使用量明細',
|
||||
session: '工作階段',
|
||||
runtimeSessionElapsed: '執行時工作階段已用時間',
|
||||
yoloOn: 'YOLO 已開啟 — 自動核准危險指令。點擊關閉。Shift+點擊可全域切換。',
|
||||
|
|
|
|||
|
|
@ -2015,6 +2015,24 @@ export const zh: Translations = {
|
|||
turnRunning: '运行中',
|
||||
currentTurnElapsed: '当前回合已用时间',
|
||||
contextUsage: '上下文用量',
|
||||
contextUsagePanel: {
|
||||
categories: {
|
||||
conversation: '对话',
|
||||
mcp: 'MCP',
|
||||
memory: '记忆',
|
||||
rules: '规则',
|
||||
skills: '技能',
|
||||
subagent_definitions: '子代理定义',
|
||||
system_prompt: '系统提示词',
|
||||
tool_definitions: '工具定义'
|
||||
},
|
||||
empty: '暂无上下文数据',
|
||||
loading: '正在加载明细…',
|
||||
percentFull: percent => `已用 ${percent}%`,
|
||||
title: '上下文用量',
|
||||
tokenSummary: (used, max) => `${used} / ${max} Tokens`
|
||||
},
|
||||
openContextUsage: '打开上下文用量明细',
|
||||
session: '会话',
|
||||
runtimeSessionElapsed: '运行时会话已用时间',
|
||||
yoloOn: 'YOLO 已开启 - 自动批准危险命令。点击关闭。Shift+点击可全局切换。',
|
||||
|
|
|
|||
|
|
@ -164,6 +164,14 @@
|
|||
--ui-cyan: #4c7f8c;
|
||||
--ui-blue: #0053fd;
|
||||
--ui-purple: #9e94d5;
|
||||
--context-usage-system: color-mix(in srgb, var(--ui-base) 55%, transparent);
|
||||
--context-usage-tools: var(--ui-purple);
|
||||
--context-usage-rules: var(--ui-green);
|
||||
--context-usage-skills: var(--ui-yellow);
|
||||
--context-usage-mcp: color-mix(in srgb, var(--ui-red) 72%, var(--ui-purple));
|
||||
--context-usage-subagents: color-mix(in srgb, var(--ui-blue) 70%, var(--ui-cyan));
|
||||
--context-usage-memory: color-mix(in srgb, var(--ui-orange) 80%, var(--ui-yellow));
|
||||
--context-usage-conversation: var(--ui-cyan);
|
||||
--ui-bg-chrome: color-mix(
|
||||
in srgb,
|
||||
var(--theme-background-seed) var(--theme-mix-chrome),
|
||||
|
|
|
|||
|
|
@ -428,6 +428,22 @@ export interface UsageStats {
|
|||
total: number
|
||||
}
|
||||
|
||||
export interface ContextUsageCategory {
|
||||
color: string
|
||||
id: string
|
||||
label: string
|
||||
tokens: number
|
||||
}
|
||||
|
||||
export interface ContextBreakdown {
|
||||
categories: ContextUsageCategory[]
|
||||
context_max: number
|
||||
context_percent: number
|
||||
context_used: number
|
||||
estimated_total: number
|
||||
model?: string
|
||||
}
|
||||
|
||||
export interface AnalyticsDailyEntry {
|
||||
actual_cost: number
|
||||
api_calls: number
|
||||
|
|
|
|||
60
tests/agent/test_context_breakdown.py
Normal file
60
tests/agent/test_context_breakdown.py
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
"""Tests for live session context breakdown."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from agent.context_breakdown import compute_session_context_breakdown
|
||||
|
||||
|
||||
def _make_agent(
|
||||
*,
|
||||
stable: str = "identity and guidance",
|
||||
context: str = "",
|
||||
volatile: str = "timestamp line",
|
||||
tools: list | None = None,
|
||||
context_length: int = 200_000,
|
||||
last_prompt_tokens: int = 0,
|
||||
):
|
||||
agent = MagicMock()
|
||||
agent.model = "openai/gpt-5.4"
|
||||
agent.tools = tools or [
|
||||
{"type": "function", "function": {"name": "terminal", "description": "run"}},
|
||||
{"type": "function", "function": {"name": "mcp_demo_tool", "description": "mcp"}},
|
||||
{"type": "function", "function": {"name": "delegate_task", "description": "spawn"}},
|
||||
]
|
||||
agent._memory_store = None
|
||||
agent._memory_enabled = True
|
||||
agent._user_profile_enabled = True
|
||||
agent.context_compressor = MagicMock(
|
||||
context_length=context_length,
|
||||
last_prompt_tokens=last_prompt_tokens,
|
||||
)
|
||||
return agent, {"stable": stable, "context": context, "volatile": volatile}
|
||||
|
||||
|
||||
def test_breakdown_includes_major_categories():
|
||||
stable = (
|
||||
"base guidance\n"
|
||||
"<available_skills>\n demo:\n - hello: hi\n</available_skills>"
|
||||
)
|
||||
context = "# Project Context\nFollow AGENTS.md"
|
||||
volatile = "Current time: now"
|
||||
history = [{"role": "user", "content": "hello there"}]
|
||||
agent, parts = _make_agent(stable=stable, context=context, volatile=volatile)
|
||||
|
||||
with patch("agent.system_prompt.build_system_prompt_parts", return_value=parts):
|
||||
data = compute_session_context_breakdown(agent, history)
|
||||
|
||||
ids = {item["id"] for item in data["categories"]}
|
||||
assert {"system_prompt", "tool_definitions", "rules", "skills", "mcp", "subagent_definitions", "conversation"} <= ids
|
||||
assert data["context_max"] == 200_000
|
||||
assert data["estimated_total"] > 0
|
||||
|
||||
|
||||
def test_breakdown_uses_measured_context_when_available():
|
||||
agent, parts = _make_agent(last_prompt_tokens=42_000)
|
||||
|
||||
with patch("agent.system_prompt.build_system_prompt_parts", return_value=parts):
|
||||
data = compute_session_context_breakdown(agent, [])
|
||||
|
||||
assert data["context_used"] == 42_000
|
||||
assert data["context_percent"] == 21
|
||||
|
|
@ -6165,6 +6165,36 @@ def _(rid, params: dict) -> dict:
|
|||
return _ok(rid, usage)
|
||||
|
||||
|
||||
@method("session.context_breakdown")
|
||||
def _(rid, params: dict) -> dict:
|
||||
session, err = _sess_nowait(params, rid)
|
||||
if err:
|
||||
return err
|
||||
agent = session.get("agent")
|
||||
if agent is None:
|
||||
usage = _get_usage(None)
|
||||
return _ok(
|
||||
rid,
|
||||
{
|
||||
"categories": [],
|
||||
"context_max": usage.get("context_max", 0) or 0,
|
||||
"context_percent": usage.get("context_percent", 0) or 0,
|
||||
"context_used": usage.get("context_used", 0) or 0,
|
||||
"estimated_total": 0,
|
||||
"model": "",
|
||||
},
|
||||
)
|
||||
with session["history_lock"]:
|
||||
history = list(session.get("history", []))
|
||||
try:
|
||||
from agent.context_breakdown import compute_session_context_breakdown
|
||||
|
||||
payload = compute_session_context_breakdown(agent, history)
|
||||
except Exception as exc:
|
||||
return _err(rid, 5000, f"Could not compute context breakdown: {exc}")
|
||||
return _ok(rid, payload)
|
||||
|
||||
|
||||
def _pet_frame_counts(spritesheet) -> dict:
|
||||
"""Real (padding-trimmed) frame count per state, for the desktop canvas.
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue