From c2ebe58989fe3ee4aff3b8f095ab170408bc7948 Mon Sep 17 00:00:00 2001 From: Raing5Days Date: Fri, 23 Oct 2026 01:03:47 +0800 Subject: [PATCH 1/6] feat(settings): add context compression config surface and settings page Add the configuration contract for the context compression roadmap (option-first; every default preserves current behavior): - ai.context_compression_method (summary | native_first, reserved) - ai.context_compression_summary_mode (rewrite | iterative, reserved) - ai.context_compression_summary_max_tokens (reserved) - ai.context_compression_request_elision (default on) - ai.context_compression_prune_on_compact (default off, reserved) - ai.context_compression_elide_min_tokens (default 400) The engine does not read the reserved keys yet; they land with the following compression PRs. Typed serde tests cover legacy payloads that predate the keys, defaulted-key omission, and explicit-value round trips, and the config export/import round-trip fixture covers the new paths. Surfaces the existing ai.enable_context_compression_prefetch flag on a new ai.context settings page (en-US, zh-CN, zh-TW) so prefetch stops being a config-file-only option. No new Tauri commands; the page reads and writes through the existing config API. Verification: - cargo test -p openbitfun-config-contracts --lib (112 passed) - cargo test --locked -p openbitfun-core --no-default-features --features agent-runtime,git,ssh-remote --lib -- config_export_round_trip_covers_persisted_preference_groups every_catalog_config_option (4 passed) - pnpm --dir src/web-ui run test:run ContextSettingsPage (3 passed) - pnpm run i18n:audit; pnpm run theme:color-audit - pnpm --dir src/web-ui run type-check --- .../core/src/service/config/service.rs | 6 + .../contracts/config-contracts/src/types.rs | 115 +++++++++++++++++ .../pages/ai/ContextSettingsPage.test.tsx | 121 ++++++++++++++++++ .../settings/pages/ai/ContextSettingsPage.tsx | 114 +++++++++++++++++ .../scenes/settings/settingsDataPreload.ts | 1 + .../app/scenes/settings/settingsRegistry.ts | 18 +++ .../src/app/scenes/settings/settingsTypes.ts | 1 + .../i18n/presets/namespaceRegistry.ts | 1 + src/web-ui/src/locales/en-US/settings.json | 4 + .../src/locales/en-US/settings/context.json | 21 +++ src/web-ui/src/locales/zh-CN/settings.json | 4 + .../src/locales/zh-CN/settings/context.json | 21 +++ src/web-ui/src/locales/zh-TW/settings.json | 4 + .../src/locales/zh-TW/settings/context.json | 21 +++ 14 files changed, 452 insertions(+) create mode 100644 src/web-ui/src/app/scenes/settings/pages/ai/ContextSettingsPage.test.tsx create mode 100644 src/web-ui/src/app/scenes/settings/pages/ai/ContextSettingsPage.tsx create mode 100644 src/web-ui/src/locales/en-US/settings/context.json create mode 100644 src/web-ui/src/locales/zh-CN/settings/context.json create mode 100644 src/web-ui/src/locales/zh-TW/settings/context.json diff --git a/src/crates/assembly/core/src/service/config/service.rs b/src/crates/assembly/core/src/service/config/service.rs index 012aa6b6f9..3269e9d44c 100644 --- a/src/crates/assembly/core/src/service/config/service.rs +++ b/src/crates/assembly/core/src/service/config/service.rs @@ -1457,6 +1457,12 @@ mod tests { ("ai.web_search.provider", json!("tavily")), ("ai.allow_tool_json_repair", json!(false)), ("ai.enable_context_compression_prefetch", json!(false)), + ("ai.context_compression_method", json!("native_first")), + ("ai.context_compression_summary_mode", json!("iterative")), + ("ai.context_compression_summary_max_tokens", json!(8192)), + ("ai.context_compression_request_elision", json!(false)), + ("ai.context_compression_prune_on_compact", json!(true)), + ("ai.context_compression_elide_min_tokens", json!(256)), ("ai.enable_edit_constraint_guard", json!(true)), ("tool_permissions.interaction.auto_approve_ask", json!(true)), ("memories.use_memories", json!(true)), diff --git a/src/crates/contracts/config-contracts/src/types.rs b/src/crates/contracts/config-contracts/src/types.rs index 7b5c7c4989..d11d5c2198 100644 --- a/src/crates/contracts/config-contracts/src/types.rs +++ b/src/crates/contracts/config-contracts/src/types.rs @@ -1181,6 +1181,44 @@ pub struct AIConfig { #[serde(default = "default_true", skip_serializing_if = "is_true")] pub enable_context_compression_prefetch: bool, + /// How automatic compression produces its summary. `summary` keeps the + /// local model summarization path; `native_first` prefers a + /// provider-native compaction route when the active model supports one. + /// Reserved for the provider-native compression rollout; the engine + /// currently ignores the value and always summarizes locally. + #[serde(default = "default_context_compression_method")] + pub context_compression_method: String, + + /// `rewrite` re-summarizes the summarized region from scratch on every + /// compression; `iterative` feeds the previous summary to an update + /// prompt. Reserved for the iterative summary rollout; the engine + /// currently always rewrites. + #[serde(default = "default_context_compression_summary_mode")] + pub context_compression_summary_mode: String, + + /// Hard cap for tokens generated by one compression summary. `None` lets + /// the engine derive the cap from the model's output reserve. Reserved + /// for the summary budget rollout; the engine currently applies no cap. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub context_compression_summary_max_tokens: Option, + + /// Fold oversized fenced/XML blocks in the oldest summarized tool results + /// when the summary request cannot otherwise fit its budget. Stored + /// history is never rewritten by this path. + #[serde(default = "default_true", skip_serializing_if = "is_true")] + pub context_compression_request_elision: bool, + + /// Replace tool results superseded by a newer successful read of the same + /// target while a compression commit rebuilds the context. Reserved for + /// the pruning rollout; the engine currently never prunes. + #[serde(default, skip_serializing_if = "is_false")] + pub context_compression_prune_on_compact: bool, + + /// Minimum token size for a fenced/XML block to be eligible for + /// request-time elision or pruning. + #[serde(default = "default_context_compression_elide_min_tokens")] + pub context_compression_elide_min_tokens: u32, + /// Enable the evaluation-oriented edit constraint guard. #[serde(default, skip_serializing_if = "is_false")] pub enable_edit_constraint_guard: bool, @@ -1560,6 +1598,18 @@ fn is_true(value: &bool) -> bool { *value } +fn default_context_compression_method() -> String { + "summary".to_string() +} + +fn default_context_compression_summary_mode() -> String { + "rewrite".to_string() +} + +fn default_context_compression_elide_min_tokens() -> u32 { + 400 +} + /// Default streaming idle timeout between chunks. fn default_stream_idle_timeout() -> Option { Some(600) @@ -2142,6 +2192,12 @@ impl Default for AIConfig { user_question_timeout_secs: default_user_question_timeout(), enable_deferred_tool_loading: default_enable_deferred_tool_loading(), enable_context_compression_prefetch: true, + context_compression_method: default_context_compression_method(), + context_compression_summary_mode: default_context_compression_summary_mode(), + context_compression_summary_max_tokens: None, + context_compression_request_elision: true, + context_compression_prune_on_compact: false, + context_compression_elide_min_tokens: default_context_compression_elide_min_tokens(), enable_edit_constraint_guard: false, allow_tool_json_repair: true, computer_use_enabled: false, @@ -3475,6 +3531,65 @@ mod tests { assert!(!reloaded.enable_context_compression_prefetch); } + #[test] + fn context_compression_options_default_to_current_behavior_and_round_trip() { + // Configs written before the keys existed must deserialize to the + // compression behavior they were written under. + let legacy: AIConfig = + serde_json::from_value(serde_json::json!({ "max_rounds": 42 })).unwrap(); + assert_eq!(legacy.context_compression_method, "summary"); + assert_eq!(legacy.context_compression_summary_mode, "rewrite"); + assert_eq!(legacy.context_compression_summary_max_tokens, None); + assert!(legacy.context_compression_request_elision); + assert!(!legacy.context_compression_prune_on_compact); + assert_eq!(legacy.context_compression_elide_min_tokens, 400); + + let payload = serde_json::to_value(&legacy).unwrap(); + assert!(payload.get("context_compression_request_elision").is_none()); + assert!(payload + .get("context_compression_prune_on_compact") + .is_none()); + assert!(payload + .get("context_compression_summary_max_tokens") + .is_none()); + + let customized: AIConfig = serde_json::from_value(serde_json::json!({ + "context_compression_method": "native_first", + "context_compression_summary_mode": "iterative", + "context_compression_summary_max_tokens": 8192, + "context_compression_request_elision": false, + "context_compression_prune_on_compact": true, + "context_compression_elide_min_tokens": 256, + })) + .unwrap(); + assert_eq!(customized.context_compression_method, "native_first"); + assert_eq!(customized.context_compression_summary_mode, "iterative"); + assert_eq!( + customized.context_compression_summary_max_tokens, + Some(8192) + ); + assert!(!customized.context_compression_request_elision); + assert!(customized.context_compression_prune_on_compact); + assert_eq!(customized.context_compression_elide_min_tokens, 256); + + let payload = serde_json::to_value(&customized).unwrap(); + assert_eq!(payload["context_compression_method"], "native_first"); + assert_eq!(payload["context_compression_summary_mode"], "iterative"); + assert_eq!(payload["context_compression_summary_max_tokens"], 8192); + assert_eq!(payload["context_compression_request_elision"], false); + assert_eq!(payload["context_compression_prune_on_compact"], true); + assert_eq!(payload["context_compression_elide_min_tokens"], 256); + + let reloaded: AIConfig = serde_json::from_value(payload).unwrap(); + assert_eq!(reloaded.context_compression_method, "native_first"); + assert_eq!(reloaded.context_compression_summary_mode, "iterative"); + assert_eq!(reloaded.context_compression_summary_max_tokens, Some(8192)); + assert!(!reloaded.context_compression_request_elision); + assert!(reloaded.context_compression_prune_on_compact); + assert_eq!(reloaded.context_compression_elide_min_tokens, 256); + assert_eq!(reloaded.max_rounds, AIConfig::default().max_rounds); + } + #[test] fn subagent_model_selection_uses_a_tagged_persistent_shape() { let selection = SubagentModelSelection::fixed("fast"); diff --git a/src/web-ui/src/app/scenes/settings/pages/ai/ContextSettingsPage.test.tsx b/src/web-ui/src/app/scenes/settings/pages/ai/ContextSettingsPage.test.tsx new file mode 100644 index 0000000000..8d01370e76 --- /dev/null +++ b/src/web-ui/src/app/scenes/settings/pages/ai/ContextSettingsPage.test.tsx @@ -0,0 +1,121 @@ +// @vitest-environment jsdom +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import ContextSettingsPage from '@/app/scenes/settings/pages/ai/ContextSettingsPage'; + +const mocks = vi.hoisted(() => ({ + getConfig: vi.fn(), + setConfig: vi.fn(), + success: vi.fn(), + error: vi.fn(), +})); + +vi.mock('@/infrastructure/config/services/ConfigManager', () => ({ + configManager: { + getConfig: mocks.getConfig, + setConfig: mocks.setConfig, + }, +})); + +vi.mock('@/shared/notification-system', () => ({ + notificationService: { + success: mocks.success, + error: mocks.error, + }, +})); + +vi.mock('react-i18next', async original => ({ + ...await original(), + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +vi.mock('@openbitfun/ui', async original => ({ + ...await original(), + Switch: ({ + checked, + disabled, + onChange, + }: { + checked: boolean; + disabled?: boolean; + onChange: (event: { target: { checked: boolean } }) => void; + }) => ( + onChange({ target: { checked: event.target.checked } })} + /> + ), +})); + +function findSwitch(container: HTMLElement): HTMLInputElement { + const input = container.querySelector('input[aria-label="prefetch-switch"]'); + expect(input).not.toBeNull(); + return input as HTMLInputElement; +} + +describe('ContextSettingsPage', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + mocks.getConfig.mockReset().mockImplementation(async () => true); + mocks.setConfig.mockReset().mockImplementation(async () => undefined); + mocks.success.mockReset(); + mocks.error.mockReset(); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it('renders the prefetch switch from the stored config value', async () => { + mocks.getConfig.mockImplementation(async () => false); + await act(async () => { + root.render(); + }); + expect(findSwitch(container).checked).toBe(false); + expect(mocks.getConfig).toHaveBeenCalledWith('ai.enable_context_compression_prefetch'); + }); + + it('saves the toggled prefetch value and reports success', async () => { + await act(async () => { + root.render(); + }); + const switchInput = findSwitch(container); + expect(switchInput.checked).toBe(true); + await act(async () => { + switchInput.click(); + }); + expect(mocks.setConfig).toHaveBeenCalledWith('ai.enable_context_compression_prefetch', false); + expect(mocks.success).toHaveBeenCalled(); + expect(findSwitch(container).checked).toBe(false); + }); + + it('rolls the switch back and reports an error when saving fails', async () => { + mocks.setConfig.mockImplementation(async () => { + throw new Error('persist failed'); + }); + await act(async () => { + root.render(); + }); + const switchInput = findSwitch(container); + await act(async () => { + switchInput.click(); + }); + expect(mocks.setConfig).toHaveBeenCalled(); + expect(mocks.error).toHaveBeenCalled(); + expect(findSwitch(container).checked).toBe(true); + }); +}); diff --git a/src/web-ui/src/app/scenes/settings/pages/ai/ContextSettingsPage.tsx b/src/web-ui/src/app/scenes/settings/pages/ai/ContextSettingsPage.tsx new file mode 100644 index 0000000000..00de655e49 --- /dev/null +++ b/src/web-ui/src/app/scenes/settings/pages/ai/ContextSettingsPage.tsx @@ -0,0 +1,114 @@ +import '@/app/scenes/settings/pages/shared/RuntimeSettings.scss'; +import { + ConfigLoadingState, + ConfigPageRow, + ConfigPageSection, + ConfigRetryState, +} from '@/infrastructure/config/components/common'; +import { configManager } from '@/infrastructure/config/services/ConfigManager'; +import { notificationService } from '@/shared/notification-system'; +import { createLogger } from '@/shared/utils/logger'; +import { Switch } from '@openbitfun/ui'; +import React, { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { SettingsPage } from '../shared/SettingsPage'; + +const log = createLogger('ContextSettingsPage'); + +const PREFETCH_CONFIG_PATH = 'ai.enable_context_compression_prefetch'; + +/** + * Context settings. Long conversations are compressed automatically so the + * working history stays inside the model window; this page exposes the + * compression options that apply to every session on this host. + */ +const ContextSettingsPage: React.FC = () => { + const { t } = useTranslation('settings/context'); + + const [isLoading, setIsLoading] = useState(true); + const [loadError, setLoadError] = useState(false); + const [prefetchEnabled, setPrefetchEnabled] = useState(true); + const [prefetchSaving, setPrefetchSaving] = useState(false); + + const loadPageData = useCallback(async () => { + setIsLoading(true); + setLoadError(false); + try { + const prefetch = await configManager.getConfig(PREFETCH_CONFIG_PATH); + setPrefetchEnabled(prefetch ?? true); + } catch (error) { + log.error('Failed to load context settings', error); + setLoadError(true); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + void loadPageData(); + }, [loadPageData]); + + const handlePrefetchChange = async (checked: boolean) => { + if (prefetchSaving) return; + const previous = prefetchEnabled; + setPrefetchEnabled(checked); + setPrefetchSaving(true); + try { + await configManager.setConfig(PREFETCH_CONFIG_PATH, checked); + notificationService.success(t('messages.saveSuccess'), { duration: 2000 }); + } catch (error) { + log.error('Failed to save enable_context_compression_prefetch', error); + notificationService.error( + `${t('messages.saveFailed')}: ` + (error instanceof Error ? error.message : String(error)) + ); + setPrefetchEnabled(previous); + } finally { + setPrefetchSaving(false); + } + }; + + return ( + + {loadError ? ( + void loadPageData()} + /> + ) : isLoading ? ( + + ) : ( + + +
+ void handlePrefetchChange(event.target.checked)} + disabled={prefetchSaving} + /> +
+
+
+ )} +
+ ); +}; + +export default ContextSettingsPage; diff --git a/src/web-ui/src/app/scenes/settings/settingsDataPreload.ts b/src/web-ui/src/app/scenes/settings/settingsDataPreload.ts index 824200b53c..7978b09e6d 100644 --- a/src/web-ui/src/app/scenes/settings/settingsDataPreload.ts +++ b/src/web-ui/src/app/scenes/settings/settingsDataPreload.ts @@ -9,6 +9,7 @@ const CONFIG_PATHS: Partial> = { 'application.pet': ['app.ai_experience'], 'application.input': ['app.ai_experience', 'app.flow_chat.auto_show_selection_toolbar'], 'ai.session-memory': ['app.flow_chat', 'app.ai_experience', 'ai.models', 'ai.task_models', 'memories'], + 'ai.context': ['ai.enable_context_compression_prefetch'], 'ai.execution': ['ai.models', 'ai.enable_deferred_tool_loading', 'ai.subagent_max_concurrency', 'ai.swarm_max_concurrency', 'ai.tool_execution_timeout_secs', 'ai.subagent_batch_execution_policy', 'ai.user_question_timeout_secs'], 'ai.permissions': ['tool_permissions', 'app.flow_chat.show_permission_mode_control'], 'development.editor': ['editor'], diff --git a/src/web-ui/src/app/scenes/settings/settingsRegistry.ts b/src/web-ui/src/app/scenes/settings/settingsRegistry.ts index 87ed6a6162..e675902ba7 100644 --- a/src/web-ui/src/app/scenes/settings/settingsRegistry.ts +++ b/src/web-ui/src/app/scenes/settings/settingsRegistry.ts @@ -182,6 +182,24 @@ export const SETTINGS_PAGE_MANIFESTS: readonly SettingsPageManifest[] = [ ], load: () => import('./pages/ai/ModelSettingsPage'), }), + definePage({ + id: 'ai.context', + categoryId: 'ai', + labelKey: 'navigation.pages.context.label', + descriptionKey: 'navigation.pages.context.description', + keywords: [ + 'context', 'compression', 'compact', 'summarization', 'summary', + 'memory', 'prune', 'token window', 'context window', + ], + namespaces: ['settings', 'settings/context'], + searchPhrases: [ + phrase('settings/context', 'title'), + phrase('settings/context', 'subtitle'), + phrase('settings/context', 'compression.sectionTitle'), + phrase('settings/context', 'compression.prefetch.label'), + ], + load: () => import('./pages/ai/ContextSettingsPage'), + }), definePage({ id: 'ai.session-memory', categoryId: 'ai', diff --git a/src/web-ui/src/app/scenes/settings/settingsTypes.ts b/src/web-ui/src/app/scenes/settings/settingsTypes.ts index bfa3e8f525..2669861a9d 100644 --- a/src/web-ui/src/app/scenes/settings/settingsTypes.ts +++ b/src/web-ui/src/app/scenes/settings/settingsTypes.ts @@ -11,6 +11,7 @@ export type SettingsPageId = | 'application.pet' | 'application.input' | 'ai.models' + | 'ai.context' | 'ai.session-memory' | 'ai.execution' | 'ai.permissions' diff --git a/src/web-ui/src/infrastructure/i18n/presets/namespaceRegistry.ts b/src/web-ui/src/infrastructure/i18n/presets/namespaceRegistry.ts index 2f1fa74a13..2268181ef3 100644 --- a/src/web-ui/src/infrastructure/i18n/presets/namespaceRegistry.ts +++ b/src/web-ui/src/infrastructure/i18n/presets/namespaceRegistry.ts @@ -29,6 +29,7 @@ export const ALL_NAMESPACES = [ 'settings/ai-features', 'settings/appearance', 'settings/application', + 'settings/context', 'settings/default-model', 'settings/editor', 'settings/external-apps', diff --git a/src/web-ui/src/locales/en-US/settings.json b/src/web-ui/src/locales/en-US/settings.json index 25b9049676..32dd5f1bcd 100644 --- a/src/web-ui/src/locales/en-US/settings.json +++ b/src/web-ui/src/locales/en-US/settings.json @@ -36,6 +36,10 @@ "label": "Models", "description": "Connect model providers and choose models for each type of task" }, + "context": { + "label": "Context", + "description": "Configure how long conversations are compressed to stay within the model window" + }, "execution": { "label": "Execution & Collaboration", "description": "Configure tool timeouts, agent concurrency, and review capacity" diff --git a/src/web-ui/src/locales/en-US/settings/context.json b/src/web-ui/src/locales/en-US/settings/context.json new file mode 100644 index 0000000000..e4cdaf2464 --- /dev/null +++ b/src/web-ui/src/locales/en-US/settings/context.json @@ -0,0 +1,21 @@ +{ + "title": "Context", + "subtitle": "Long conversations are compressed automatically so the working history stays inside the model window", + "compression": { + "sectionTitle": "Automatic compression", + "sectionDescription": "When the conversation approaches the context limit, older history is summarized and the most recent exchanges are kept verbatim", + "prefetch": { + "label": "Prepare compression early", + "description": "Starts the summary in the background just before the limit is reached, so compression finishes faster when it is needed" + } + }, + "messages": { + "saveSuccess": "Settings saved", + "saveFailed": "Failed to save settings" + }, + "loading": { + "text": "Loading context settings…", + "failed": "Failed to load context settings", + "retry": "Retry" + } +} diff --git a/src/web-ui/src/locales/zh-CN/settings.json b/src/web-ui/src/locales/zh-CN/settings.json index 9b78253a13..b7cb7e5251 100644 --- a/src/web-ui/src/locales/zh-CN/settings.json +++ b/src/web-ui/src/locales/zh-CN/settings.json @@ -36,6 +36,10 @@ "label": "模型", "description": "连接模型服务,并选择各类任务使用的模型" }, + "context": { + "label": "上下文", + "description": "设置长对话如何自动压缩,以保持在模型窗口范围内" + }, "execution": { "label": "执行与协作", "description": "设置工具运行时限、智能体并行方式与审核容量" diff --git a/src/web-ui/src/locales/zh-CN/settings/context.json b/src/web-ui/src/locales/zh-CN/settings/context.json new file mode 100644 index 0000000000..27f98c2432 --- /dev/null +++ b/src/web-ui/src/locales/zh-CN/settings/context.json @@ -0,0 +1,21 @@ +{ + "title": "上下文", + "subtitle": "长对话会自动压缩,让工作历史始终保持在模型窗口范围内", + "compression": { + "sectionTitle": "自动压缩", + "sectionDescription": "当对话接近上下文上限时,较早的历史会被摘要,最近的交流保持原样保留", + "prefetch": { + "label": "提前准备压缩", + "description": "在接近上限前于后台预先开始摘要,使真正需要压缩时完成得更快" + } + }, + "messages": { + "saveSuccess": "设置已保存", + "saveFailed": "保存设置失败" + }, + "loading": { + "text": "正在加载上下文设置…", + "failed": "加载上下文设置失败", + "retry": "重试" + } +} diff --git a/src/web-ui/src/locales/zh-TW/settings.json b/src/web-ui/src/locales/zh-TW/settings.json index f968420ac0..f172f395f5 100644 --- a/src/web-ui/src/locales/zh-TW/settings.json +++ b/src/web-ui/src/locales/zh-TW/settings.json @@ -36,6 +36,10 @@ "label": "模型", "description": "連線模型服務,並選擇各類任務使用的模型" }, + "context": { + "label": "上下文", + "description": "設定長對話如何自動壓縮,以保持在模型視窗範圍內" + }, "execution": { "label": "執行與協作", "description": "設定工具執行時限、智慧體並行方式與審核容量" diff --git a/src/web-ui/src/locales/zh-TW/settings/context.json b/src/web-ui/src/locales/zh-TW/settings/context.json new file mode 100644 index 0000000000..5c546357d8 --- /dev/null +++ b/src/web-ui/src/locales/zh-TW/settings/context.json @@ -0,0 +1,21 @@ +{ + "title": "上下文", + "subtitle": "長對話會自動壓縮,讓工作歷史始終保持在模型視窗範圍內", + "compression": { + "sectionTitle": "自動壓縮", + "sectionDescription": "當對話接近上下文上限時,較早的歷史會被摘要,最近的交流保持原樣保留", + "prefetch": { + "label": "提前準備壓縮", + "description": "在接近上限前於背景預先開始摘要,使真正需要壓縮時完成得更快" + } + }, + "messages": { + "saveSuccess": "設定已儲存", + "saveFailed": "儲存設定失敗" + }, + "loading": { + "text": "正在載入上下文設定…", + "failed": "載入上下文設定失敗", + "retry": "重試" + } +} From 91667d73c06a6c9d4e865cca56c7fb4acbbbfc33 Mon Sep 17 00:00:00 2001 From: Raing5Days Date: Fri, 23 Oct 2026 01:13:26 +0800 Subject: [PATCH 2/6] feat(compression): structure the compact prompt as a handoff template Rewrite build_compact_prompt from a free-form bullet list into a fixed sectioned handoff template, keeping the summary-only turn contract and the anti-bulk-copy / reloadable-guidance rules from the previous text: - Fixed sections in order: Goal, Constraints & Preferences, Progress (Done / In Progress / Blocked), Key Decisions, Next Steps, Critical Context; sections with no content may be omitted. - Retention requirements: exact file paths, symbol names, commands, and error messages; user questions still awaiting an answer preserved verbatim; repository state (branch, uncommitted changes) when relevant. - Next Steps are imperative actions addressed to the successor, and the handoff itself must never appear as progress or a next step. Default behavior change is prompt text only: the request shape, retry ladder, todo checkpoint, transcript reference, and summary message assembly are untouched. Verification: - cargo test --locked -p openbitfun-core --no-default-features --features agent-runtime,git,ssh-remote --lib compression (60 passed, incl. new compact_prompt_is_structured_ascii_and_constrained) --- .../agentic/session/compression/compressor.rs | 79 ++++++++++++++++--- 1 file changed, 69 insertions(+), 10 deletions(-) diff --git a/src/crates/assembly/core/src/agentic/session/compression/compressor.rs b/src/crates/assembly/core/src/agentic/session/compression/compressor.rs index 78ae458353..e17ebe154f 100644 --- a/src/crates/assembly/core/src/agentic/session/compression/compressor.rs +++ b/src/crates/assembly/core/src/agentic/session/compression/compressor.rs @@ -584,19 +584,39 @@ impl ContextCompressor { pub(crate) fn build_compact_prompt(&self) -> String { String::from( - r#"You are performing a CONTEXT CHECKPOINT COMPACTION. Create a handoff summary for another LLM that will resume the task. + r#"You are performing a CONTEXT CHECKPOINT COMPACTION. Write a structured handoff summary of the conversation above for another LLM that will resume this task without access to it. -Include: -- Current progress and key decisions made -- Important context, constraints, or user preferences -- What remains to be done (clear next steps) -- Any critical data, examples, or references needed to continue +Output exactly the sections below, in this order. Omit a section only when it has no content. -Be concise, structured, and focused on helping the next LLM seamlessly continue the work. +## Goal +[What the user is trying to accomplish. List multiple goals if the session covers different tasks.] -Note: Preserve durable, task-specific state, but do not reproduce information that can be obtained again from its source: -- Do not paste large file contents, long code blocks, command output, logs, tool results, or other bulky source material. Record the file path or source reference, plus a one-sentence description of its purpose or relevant contents. Include only a small exact snippet when it is essential and cannot be reliably reconstructed. -- Do not copy Skill instructions or other reloadable guidance. Record the Skill name, why it is relevant, and that the next LLM should reload it when needed. +## Constraints & Preferences +- [Requirements, preferences, or restrictions the user stated or that the work discovered.] + +## Progress +### Done +- [x] [Completed work with specifics.] +### In Progress +- [ ] [Current work and its exact state.] +### Blocked +- [ ] [What is blocking progress and why.] + +## Key Decisions +- **[Decision]**: [Brief rationale.] + +## Next Steps +1. [Ordered actions for the successor, written as imperatives, e.g. "Run X", "Fix Y".] + +## Critical Context +- [Exact data, examples, or references needed to continue. Preserve any user question that is still awaiting an answer verbatim. Include repository state (branch, uncommitted changes) when relevant.] + +Rules: +- Capture exact technical state, not abstractions. Preserve exact file paths, function or symbol names, commands, error messages, and relevant tool results or command output values. +- Preserve durable, task-specific state, but do not reproduce information that can be obtained again from its source: + - Do not paste large file contents, long code blocks, command output, logs, tool results, or other bulky source material. Record the file path or source reference, plus a one-sentence description of its purpose or relevant contents. Include only a small exact snippet when it is essential and cannot be reliably reconstructed. + - Do not copy Skill instructions or other reloadable guidance. Record the Skill name, why it is relevant, and that the next LLM should reload it when needed. +- Keep every section concise. The handoff itself is invisible to the document: never list writing, generating, or delivering a summary as progress or a next step. IMPORTANT: This is a summary-only turn. Do not call tools or perform additional work. Respond with the handoff summary as plain text. Any tool call will be rejected and you will fail the task. "#, @@ -675,6 +695,45 @@ mod tests { }) } + #[test] + fn compact_prompt_is_structured_ascii_and_constrained() { + let prompt = ContextCompressor::new().build_compact_prompt(); + + for section in [ + "## Goal", + "## Constraints & Preferences", + "## Progress", + "### Done", + "### In Progress", + "### Blocked", + "## Key Decisions", + "## Next Steps", + "## Critical Context", + ] { + assert!(prompt.contains(section), "missing section: {section}"); + } + + // Retention requirements: exact technical state, unanswered user + // questions, and repository state survive the summary. + assert!(prompt.contains("Preserve exact file paths")); + assert!(prompt.contains("error messages")); + assert!(prompt.contains("still awaiting an answer verbatim")); + assert!(prompt.contains("repository state (branch, uncommitted changes)")); + + // The anti-bulk-copy and reloadable-guidance constraints carry over + // from the previous template. + assert!(prompt.contains("Do not paste large file contents")); + assert!(prompt.contains("Do not copy Skill instructions")); + + // The summary-only turn contract must stay: tool calls are rejected + // downstream and retried as model errors. + assert!(prompt.contains("summary-only turn")); + assert!(prompt.contains("Do not call tools")); + + // Model-facing copy stays ASCII-only English. + assert!(prompt.chars().all(|character| character.is_ascii())); + } + #[test] fn recent_context_keeps_the_exact_suffix_that_fits_the_budget() { let compressor = ContextCompressor::new(); From 3c73c5b3a0b39c64ad5e095da67b94700e941f0a Mon Sep 17 00:00:00 2001 From: Raing5Days Date: Fri, 23 Oct 2026 01:41:59 +0800 Subject: [PATCH 3/6] feat(compression): elide the summary request when planning dead-ends The planner floors the cutoff above the first atomic unit, so a gigantic first message pins the summary input: the recent-tail ladder can never shrink it, every plan overflows, and the turn terminates with no summary before L2 emergency truncation drops rounds silently. Add a request-side elision ladder that runs only when planning dead-ends (cutoff stops moving, or the tail ladder has no room left): - New session::compression::elision module: a pure scanner folds complete fenced code blocks and top-level lowercase XML blocks (>= a threshold, oldest first, per-pass budget capped) across message text and result_for_assistant copies. Stored history, message identities, and the compression plan are never rewritten. - CompressionJob::run applies up to three progressively deeper passes (threshold, /2, /4) to the summary request copy after the unelided attempt; the recent-tail ladder keeps priority while it can move the cutoff. Exhausted elision keeps the existing terminal semantics (cannot-reduce-further and plan-N overflow errors) with no local summary fallback. - Honors ai.context_compression_request_elision and ai.context_compression_elide_min_tokens from the PR-1 config surface, defaulting to enabled/400 when the config service is unavailable. Verification: - cargo test --locked -p openbitfun-core --no-default-features --features agent-runtime,git,ssh-remote --lib compression (70 passed: 8 new elision unit tests, dead-end recovery + exhaustion integration tests; existing ladder and prefetch tests unchanged) --- .../src/agentic/execution/compression_job.rs | 79 +++- .../agentic/execution/compression_tests.rs | 86 ++++ .../agentic/session/compression/elision.rs | 426 ++++++++++++++++++ .../src/agentic/session/compression/mod.rs | 2 + 4 files changed, 584 insertions(+), 9 deletions(-) create mode 100644 src/crates/assembly/core/src/agentic/session/compression/elision.rs diff --git a/src/crates/assembly/core/src/agentic/execution/compression_job.rs b/src/crates/assembly/core/src/agentic/execution/compression_job.rs index 695976f9aa..a214946e08 100644 --- a/src/crates/assembly/core/src/agentic/execution/compression_job.rs +++ b/src/crates/assembly/core/src/agentic/execution/compression_job.rs @@ -242,6 +242,7 @@ impl CompressionJob { .initial_recent .min(context_window.saturating_div(2).max(1)); let mut previous_cutoff = None; + let mut elision = Self::request_elision_ladder().await; for attempt in 0..ExecutionEngine::MAX_COMPRESSION_OVERFLOW_ATTEMPTS { let Some(plan) = self.compressor.plan_compression_for_turn( @@ -256,12 +257,22 @@ impl CompressionJob { "Context compression has no eligible plan".to_string(), )); }; - if previous_cutoff.is_some_and(|cutoff| plan.cutoff_message_index >= cutoff) { + // The planner floors the cutoff above the first atomic unit, so a + // cutoff that stops moving means the summary input cannot shrink + // by planning alone. Fold oversized blocks in the request copy + // instead, deepest pass last; stored history is never rewritten. + let cutoff_dead_end = + previous_cutoff.is_some_and(|cutoff| plan.cutoff_message_index >= cutoff); + if cutoff_dead_end && !elision.can_deepen() { return Err(OpenBitFunError::AIClient( "Context compression cannot reduce the summary input further".to_string(), )); } - previous_cutoff = Some(plan.cutoff_message_index); + if cutoff_dead_end { + elision.deepen(); + } else { + previous_cutoff = Some(plan.cutoff_message_index); + } info!( "Compression context plan: session_id={}, turn_id={}, attempt={}/{}, retained_user_token_budget={}, retained_user_tokens={}, retained_user_messages={}, recent_target_tokens={}, recent_tail_tokens={}, cutoff_message_index={}, summary_messages={}, recent_tail_messages={}", session_id, @@ -278,7 +289,23 @@ impl CompressionJob { plan.recent_tail_messages.len() ); - let summary_result = self.request_summary(&plan.summary_request_messages).await; + let mut request_messages = plan.summary_request_messages.clone(); + if elision.active() { + let report = elision.apply(&mut request_messages); + info!( + "Compression summary request elision: session_id={}, turn_id={}, attempt={}/{}, depth={}/{}, elided_blocks={}, elided_tokens={}", + session_id, + dialog_turn_id, + attempt + 1, + ExecutionEngine::MAX_COMPRESSION_OVERFLOW_ATTEMPTS, + elision.depth(), + crate::agentic::session::compression::elision::MAX_ELISION_PASSES, + report.elided_blocks, + report.elided_tokens + ); + } + + let summary_result = self.request_summary(&request_messages).await; match summary_result { Ok(summary) => { @@ -290,7 +317,7 @@ impl CompressionJob { } Err(err) if err.is_recoverable_context_overflow() => { warn!( - "Compression request exceeded provider context: session_id={}, turn_id={}, attempt={}/{}, recent_target_tokens={}, cutoff_message_index={}, next_recent_target_tokens={:?}, error={}", + "Compression request exceeded provider context: session_id={}, turn_id={}, attempt={}/{}, recent_target_tokens={}, cutoff_message_index={}, next_recent_target_tokens={:?}, cutoff_dead_end={}, elision_depth={}/{}, error={}", session_id, dialog_turn_id, attempt + 1, @@ -298,16 +325,26 @@ impl CompressionJob { plan.recent_target_tokens, plan.cutoff_message_index, plan.next_recent_target_tokens, + cutoff_dead_end, + elision.depth(), + crate::agentic::session::compression::elision::MAX_ELISION_PASSES, err ); - let can_retry = attempt + 1 - < ExecutionEngine::MAX_COMPRESSION_OVERFLOW_ATTEMPTS + let ladder_can_move = !cutoff_dead_end + && attempt + 1 < ExecutionEngine::MAX_COMPRESSION_OVERFLOW_ATTEMPTS && plan.next_recent_target_tokens.is_some(); - let next_recent_target = plan.next_recent_target_tokens; - if can_retry { + if ladder_can_move { recent_target = recent_target .saturating_add(ContextCompressor::RECENT_CONTEXT_RETRY_STEP_TOKENS) - .max(next_recent_target.expect("retry target checked above")); + .max( + plan.next_recent_target_tokens + .expect("retry target checked above"), + ); + continue; + } + if elision.can_deepen() + && attempt + 1 < ExecutionEngine::MAX_COMPRESSION_OVERFLOW_ATTEMPTS + { continue; } return Err(compression_plan_error(err, attempt + 1)); @@ -318,4 +355,28 @@ impl CompressionJob { unreachable!("compression planning returns a result or terminal error") } + + /// Ladder state for request-side elision. Disabled or misconfigured + /// settings fall back to the documented defaults so the ladder stays + /// available as the overflow dead-end rescue. + async fn request_elision_ladder() -> crate::agentic::session::RequestElisionLadder { + use crate::agentic::session::compression::elision::DEFAULT_ELIDE_MIN_TOKENS; + match crate::service::config::get_global_config_service().await { + Ok(service) => { + let enabled = service + .get_config::(Some("ai.context_compression_request_elision")) + .await + .unwrap_or(true); + let min_tokens = service + .get_config::(Some("ai.context_compression_elide_min_tokens")) + .await + .unwrap_or(DEFAULT_ELIDE_MIN_TOKENS as u32) + .max(1) as usize; + crate::agentic::session::RequestElisionLadder::new(enabled, min_tokens) + } + Err(_) => { + crate::agentic::session::RequestElisionLadder::new(true, DEFAULT_ELIDE_MIN_TOKENS) + } + } + } } diff --git a/src/crates/assembly/core/src/agentic/execution/compression_tests.rs b/src/crates/assembly/core/src/agentic/execution/compression_tests.rs index e31cc53d0f..ab3999c029 100644 --- a/src/crates/assembly/core/src/agentic/execution/compression_tests.rs +++ b/src/crates/assembly/core/src/agentic/execution/compression_tests.rs @@ -532,6 +532,92 @@ async fn compression_overflow_exhausts_four_plans_without_local_summary() { assert_eq!(server.requests.lock().unwrap().len(), 4); } +/// The planner floors the cutoff above the first atomic unit, so a gigantic +/// first message pins the summary input: the recent-tail ladder can never +/// shrink it. Request-side elision folds the oversized fenced block in the +/// request copy instead of failing the turn. +fn dead_end_messages() -> Vec { + vec![ + Message::system("system".into()), + Message::user(format!( + "Please work with this snapshot:\n```text\nMEGA_BLOCK_MARKER\n{}\n```\n", + "x".repeat(400_000) + )), + Message::assistant("Understood.".into()), + Message::user("Continue with the next step.".into()), + Message::assistant("Ready.".into()), + ] +} + +#[tokio::test] +async fn compression_dead_end_elides_the_summary_request_and_recovers() { + let (_temp, engine) = engine(); + let server = RetryTestServer::new(vec![overflow(), retry_test_success()]); + let result = engine + .build_planned_compression_result( + "session", + "turn", + &dead_end_messages(), + 128_000, + None, + server.client(), + &ModelRequestContext::default(), + &None, + &PrependedPromptReminders::default(), + false, + None, + None, + None, + ) + .await + .unwrap() + .unwrap(); + assert!(result + .messages + .iter() + .any(|message| message.content.to_string().contains("Recovered"))); + let requests = server.requests.lock().unwrap(); + assert_eq!(requests.len(), 2); + let unelided = requests[0].to_string(); + let elided = requests[1].to_string(); + assert!(unelided.contains("MEGA_BLOCK_MARKER")); + assert!(!elided.contains("MEGA_BLOCK_MARKER")); + assert!(elided.contains("[Elided ")); + assert!(elided.contains("code block from message]")); +} + +#[tokio::test] +async fn compression_dead_end_elision_exhaustion_stays_terminal() { + let (_temp, engine) = engine(); + let server = RetryTestServer::new(vec![overflow()]); + let error = engine + .build_planned_compression_result( + "session", + "turn", + &dead_end_messages(), + 128_000, + None, + server.client(), + &ModelRequestContext::default(), + &None, + &PrependedPromptReminders::default(), + false, + None, + None, + None, + ) + .await + .unwrap_err(); + assert!(error.to_string().contains("plan 4"), "{error}"); + let requests = server.requests.lock().unwrap(); + assert_eq!(requests.len(), 4); + assert!(requests[0].to_string().contains("MEGA_BLOCK_MARKER")); + assert!(!requests[0].to_string().contains("[Elided")); + for request in &requests[1..] { + assert!(request.to_string().contains("[Elided")); + } +} + #[tokio::test] async fn compression_failure_preserves_context_and_success_count() { let (_temp, engine) = engine(); diff --git a/src/crates/assembly/core/src/agentic/session/compression/elision.rs b/src/crates/assembly/core/src/agentic/session/compression/elision.rs new file mode 100644 index 0000000000..2018fdb6e9 --- /dev/null +++ b/src/crates/assembly/core/src/agentic/session/compression/elision.rs @@ -0,0 +1,426 @@ +//! Request-side elision for the compression summary input. +//! +//! When the summary request cannot shrink any further by planning alone +//! (the cutoff is already at its floor), this ladder folds oversized fenced +//! and top-level XML blocks in the OLDEST message text first, deepest pass +//! last. The folds apply to a request-scoped copy only: stored history, +//! message identities, and the compression plan are never rewritten here. + +use crate::agentic::core::{Message, MessageContent}; +use crate::util::TokenCounter; + +/// Default minimum block size eligible for elision, mirroring the +/// `ai.context_compression_elide_min_tokens` config default. +pub(crate) const DEFAULT_ELIDE_MIN_TOKENS: usize = 400; + +/// Elision passes available after the unelided attempt. Pass depth halves +/// the configured threshold: configured, /2, /4. +pub(crate) const MAX_ELISION_PASSES: usize = 3; + +/// Upper bound on gross tokens folded per pass, so a pass can never elide +/// the whole request. A block that starts within budget is folded whole. +const ELISION_BUDGET_TOKENS_PER_PASS: usize = 40_000; + +#[derive(Debug, Default, Clone, Copy)] +pub struct ElisionReport { + pub elided_blocks: usize, + pub elided_tokens: usize, +} + +/// Progressive elision state for one summary request ladder. +#[derive(Debug)] +pub struct RequestElisionLadder { + enabled: bool, + min_tokens: usize, + depth: usize, +} + +impl RequestElisionLadder { + pub fn new(enabled: bool, min_tokens: usize) -> Self { + Self { + enabled, + min_tokens: min_tokens.max(1), + depth: 0, + } + } + + /// True once at least one pass has been applied to request copies. + pub fn active(&self) -> bool { + self.enabled && self.depth > 0 + } + + pub fn can_deepen(&self) -> bool { + self.enabled && self.depth < MAX_ELISION_PASSES + } + + pub fn deepen(&mut self) { + if self.can_deepen() { + self.depth += 1; + } + } + + pub fn depth(&self) -> usize { + self.depth + } + + fn effective_min_tokens(&self) -> usize { + (self.min_tokens >> self.depth.saturating_sub(1)).max(1) + } + + /// Fold eligible blocks across all text-bearing messages, oldest first. + /// Blocks are folded whole; the budget only stops NEW folds. + pub fn apply(&self, messages: &mut [Message]) -> ElisionReport { + let mut report = ElisionReport::default(); + if !self.active() { + return report; + } + let min_tokens = self.effective_min_tokens(); + let mut budget = ELISION_BUDGET_TOKENS_PER_PASS; + for message in messages.iter_mut() { + fold_message_text(message, min_tokens, &mut budget, &mut report); + if budget == 0 { + break; + } + } + report + } +} + +/// Fold one message's text in place. +fn fold_message_text( + message: &mut Message, + min_tokens: usize, + budget: &mut usize, + report: &mut ElisionReport, +) { + match &mut message.content { + MessageContent::Text(text) => { + fold_text_with_label(text, "message", min_tokens, budget, report); + } + MessageContent::Multimodal { text, .. } => { + fold_text_with_label(text, "message", min_tokens, budget, report); + } + MessageContent::Mixed { text, .. } => { + fold_text_with_label(text, "message", min_tokens, budget, report); + } + MessageContent::ToolResult { + tool_name, + result_for_assistant, + .. + } => { + let Some(text) = result_for_assistant + .as_mut() + .filter(|text| !text.is_empty()) + else { + return; + }; + fold_text_with_label(text, tool_name, min_tokens, budget, report); + } + } +} + +fn fold_text_with_label( + text: &mut String, + label: &str, + min_tokens: usize, + budget: &mut usize, + report: &mut ElisionReport, +) -> bool { + let ranges = scan_elidable_block_ranges(text); + if ranges.is_empty() { + return false; + } + let mut replacement = String::with_capacity(text.len()); + let mut last_end = 0usize; + let mut changed = false; + for range in ranges { + let block = &text[range.start..range.end]; + let block_tokens = TokenCounter::estimate_tokens(block); + if block_tokens < min_tokens || *budget == 0 { + continue; + } + *budget = budget.saturating_sub(block_tokens); + replacement.push_str(&text[last_end..range.start]); + replacement.push_str(&format!( + "[Elided {block_tokens}-token {} block from {label}]", + range.kind + )); + last_end = range.end; + report.elided_blocks += 1; + report.elided_tokens += block_tokens; + changed = true; + } + if changed { + replacement.push_str(&text[last_end..]); + *text = replacement; + } + changed +} + +struct ElidableBlockRange { + start: usize, + end: usize, + kind: &'static str, +} + +/// Locate complete fenced code blocks and top-level lowercase XML blocks. +/// Conservative by design: unterminated fences or tags yield no range, XML +/// detection is suppressed inside fences, and only exact-line lowercase tags +/// are considered. +fn scan_elidable_block_ranges(text: &str) -> Vec { + let mut ranges = Vec::new(); + let mut in_fence = false; + let mut fence_start = 0usize; + let mut fence_marker: Option = None; + let mut tag_stack: Vec = Vec::new(); + let mut xml_start = 0usize; + let bytes = text.as_bytes(); + let mut line_start = 0usize; + while line_start < text.len() { + let line_end = bytes[line_start..] + .iter() + .position(|byte| *byte == b'\n') + .map_or(text.len(), |position| line_start + position); + let line = &text[line_start..line_end]; + let trimmed = line.trim_start(); + let fence = trimmed.starts_with("```") || trimmed.starts_with("~~~"); + if fence { + let marker = trimmed.chars().next(); + if !in_fence { + in_fence = true; + fence_start = line_start; + fence_marker = marker; + } else if marker == fence_marker { + in_fence = false; + fence_marker = None; + ranges.push(ElidableBlockRange { + start: fence_start, + end: line_end, + kind: "code", + }); + } + } else if !in_fence { + if let Some(name) = opening_xml_tag(line) { + if tag_stack.is_empty() { + xml_start = line_start; + } + tag_stack.push(name.to_string()); + } else if let Some(name) = closing_xml_tag(line) { + if tag_stack.last().is_some_and(|open| open == name) { + tag_stack.pop(); + if tag_stack.is_empty() { + ranges.push(ElidableBlockRange { + start: xml_start, + end: line_end, + kind: "xml", + }); + } + } + } + } + if line_end >= text.len() { + break; + } + line_start = line_end + 1; + } + ranges +} + +/// `` or `` on an exact, otherwise-empty line. +fn opening_xml_tag(line: &str) -> Option<&str> { + let rest = line.strip_prefix('<')?; + let name_end = rest.find(['>', ' ', '\t'])?; + let name = &rest[..name_end]; + if !is_xml_name(name) { + return None; + } + let after = &rest[name_end..]; + if after == ">" { + return Some(name); + } + let attributes = after.strip_suffix('>')?; + if attributes.is_empty() + || attributes.contains('<') + || !attributes.bytes().all(|byte| { + byte.is_ascii_alphanumeric() + || matches!( + byte, + b' ' | b'\t' | b'=' | b'"' | b'\'' | b'-' | b'_' | b'.' | b':' | b'/' + ) + }) + { + return None; + } + Some(name) +} + +/// `` on an exact line. +fn closing_xml_tag(line: &str) -> Option<&str> { + let name = line.strip_prefix("')?; + if !is_xml_name(name) { + return None; + } + Some(name) +} + +fn is_xml_name(name: &str) -> bool { + !name.is_empty() + && name + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte == b'_' || byte == b'-') +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agentic::core::Message; + + fn fenced_block(lines: usize) -> String { + let mut block = String::from("```rust\n"); + for index in 0..lines { + block.push_str(&format!( + "let value_{index} = {index}; value_{index} + 42;\n" + )); + } + block.push_str("```\n"); + block + } + + fn tool_result_with_text(text: &str) -> Message { + let mut message = Message::assistant("working".to_string()); + message.content = MessageContent::ToolResult { + tool_id: "tool-1".to_string(), + tool_name: "Read".to_string(), + effective_tool_name: None, + result: serde_json::json!({}), + result_for_assistant: Some(text.to_string()), + is_error: false, + image_attachments: None, + }; + message + } + + fn ladder_at_depth(min_tokens: usize, depth: usize) -> RequestElisionLadder { + let mut ladder = RequestElisionLadder::new(true, min_tokens); + for _ in 0..depth { + ladder.deepen(); + } + ladder + } + + #[test] + fn disabled_ladder_is_inert() { + let ladder = RequestElisionLadder::new(false, 400); + assert!(!ladder.active()); + assert!(!ladder.can_deepen()); + let mut messages = vec![tool_result_with_text(&fenced_block(200))]; + let report = ladder.apply(&mut messages); + assert_eq!(report.elided_blocks, 0); + assert!(messages[0].content.to_string().contains("let value_0")); + } + + #[test] + fn fences_above_threshold_are_elided_oldest_first_within_budget() { + let ladder = ladder_at_depth(400, 1); + let mut messages = vec![ + tool_result_with_text(&fenced_block(300)), + tool_result_with_text(&fenced_block(300)), + ]; + let report = ladder.apply(&mut messages); + assert_eq!(report.elided_blocks, 2); + let first = messages[0].content.to_string(); + let second = messages[1].content.to_string(); + assert!(first.contains("[Elided ") && first.contains("code block from Read]")); + assert!(!first.contains("let value_0")); + assert!(second.contains("[Elided ")); + } + + #[test] + fn elision_stops_when_the_pass_budget_is_spent() { + // One fold of this size exhausts the per-pass budget, so the second + // message keeps its block. + let ladder = ladder_at_depth(400, 1); + let huge = fenced_block(20_000); + let mut messages = vec![ + tool_result_with_text(&huge), + tool_result_with_text(&fenced_block(300)), + ]; + let report = ladder.apply(&mut messages); + assert_eq!(report.elided_blocks, 1); + assert!(messages[0].content.to_string().contains("[Elided ")); + assert!(messages[1].content.to_string().contains("let value_0")); + } + + #[test] + fn deepening_lowers_the_threshold() { + let mut ladder = RequestElisionLadder::new(true, 400); + // ~30 short lines stay under the 400-token depth-1 threshold but + // clear the 200-token depth-2 threshold. + let mut messages = vec![tool_result_with_text(&fenced_block(30))]; + ladder.deepen(); + assert!(ladder.active()); + let report = ladder.apply(&mut messages); + assert_eq!(report.elided_blocks, 0, "depth 1 keeps the 400 threshold"); + ladder.deepen(); + let report = ladder.apply(&mut messages); + assert_eq!( + report.elided_blocks, 1, + "depth 2 lowers the threshold to 200" + ); + } + + #[test] + fn unterminated_blocks_and_mixed_case_xml_are_preserved() { + let text = "start\n```rust\nnever closed\nmore code\nand stays\n"; + let ladder = ladder_at_depth(1, 1); + let mut messages = vec![tool_result_with_text(text)]; + let report = ladder.apply(&mut messages); + assert_eq!(report.elided_blocks, 0); + assert!(messages[0].content.to_string().contains("never closed")); + } + + #[test] + fn complete_top_level_xml_blocks_are_elided() { + let text = "\nline one\nline two\n\nafter\n"; + let ladder = ladder_at_depth(1, 1); + let mut messages = vec![tool_result_with_text(text)]; + let report = ladder.apply(&mut messages); + assert_eq!(report.elided_blocks, 1); + let folded = messages[0].content.to_string(); + assert!(folded.contains("xml block from Read]")); + assert!(!folded.contains("line one")); + assert!(folded.contains("after")); + } + + #[test] + fn user_and_plain_text_messages_fold_their_fences() { + let ladder = ladder_at_depth(400, 1); + let mut messages = vec![ + Message::user(format!("Please use this:\n{}", fenced_block(300))), + Message::assistant("plain answer without blocks".to_string()), + ]; + let report = ladder.apply(&mut messages); + assert_eq!(report.elided_blocks, 1); + assert!(messages[0].content.to_string().contains("[Elided ")); + assert!(messages[1].content.to_string().contains("plain answer")); + } + + #[test] + fn tool_results_without_assistant_text_are_untouched() { + let mut message = Message::assistant("working".to_string()); + message.content = MessageContent::ToolResult { + tool_id: "tool-1".to_string(), + tool_name: "Read".to_string(), + effective_tool_name: None, + result: serde_json::json!({ "content": fenced_block(300) }), + result_for_assistant: None, + is_error: false, + image_attachments: None, + }; + let ladder = ladder_at_depth(1, 1); + let mut messages = vec![message]; + let report = ladder.apply(&mut messages); + assert_eq!(report.elided_blocks, 0); + assert!(messages[0].content.to_string().contains("content")); + } +} diff --git a/src/crates/assembly/core/src/agentic/session/compression/mod.rs b/src/crates/assembly/core/src/agentic/session/compression/mod.rs index 4f70429f6b..4eedde6d8e 100644 --- a/src/crates/assembly/core/src/agentic/session/compression/mod.rs +++ b/src/crates/assembly/core/src/agentic/session/compression/mod.rs @@ -11,5 +11,7 @@ //! `execution_engine.rs`. pub mod compressor; +pub mod elision; pub use compressor::*; +pub use elision::{ElisionReport, RequestElisionLadder}; From e7bf6273e4664aa0e39211ad02144cd81048b461 Mon Sep 17 00:00:00 2001 From: Raing5Days Date: Fri, 23 Oct 2026 15:11:29 +0800 Subject: [PATCH 4/6] fix(capabilities): register the ai.context settings page in the semantic manual CI Frontend Build failed because capabilities:check requires every settings page registered in settingsRegistry.ts to have a semantic capability entry. Add setting.ai.context to the interactive-capabilities manual (bilingual titles/summary/keywords/highlights/steps, two documented items for compression prefetch and summary-request elision, destination ai.context) and regenerate the derived artifacts: docs/interactive-capabilities, the global-search runtime catalog, the generated product-control catalog, and the productControl TS bindings. Both items are classified as open controls (unstructuredInteraction): their config paths stay out of the agent-controllable option registry for now because ai.enable_context_compression_prefetch is deliberately omitted from persisted config when true, so the config-path option readback contract does not apply to it yet. Verification: - node scripts/generate-interactive-capabilities.mjs --check (verified 23 features + 22 settings) - node --test scripts/interactive-capabilities.test.mjs (16 passed) - cargo test --locked -p openbitfun-core --no-default-features --features agent-runtime,git --lib -- every_catalog_config_option config_export_round_trip_covers_persisted_preference_groups (4 passed) --- docs/interactive-capabilities/README.md | 12 +- .../capabilities.json | 264 +++++++++++++++++- .../capabilities/setting.ai.context.md | 57 ++++ .../technical/product-control-open-audit.json | 40 ++- .../technical/tauri-command-map.json | 2 +- .../generated/product-control-catalog.json | 264 +++++++++++++++++- .../interactive-capabilities/catalog.json | 91 ++++++ .../generated/interactive-capabilities.json | 264 +++++++++++++++++- .../api/generated/productControl.ts | 6 +- 9 files changed, 973 insertions(+), 27 deletions(-) create mode 100644 docs/interactive-capabilities/capabilities/setting.ai.context.md diff --git a/docs/interactive-capabilities/README.md b/docs/interactive-capabilities/README.md index d56e47e7f5..59a743e67b 100644 --- a/docs/interactive-capabilities/README.md +++ b/docs/interactive-capabilities/README.md @@ -1,9 +1,9 @@ # OpenBitFun 功能与设置目录 / OpenBitFun Features & Settings -OpenBitFun Playbook 当前包含 **23 个功能**和 **21 个设置页**,共 **44 个**用户可理解的条目、**332 项**有源码证据的子能力。每个条目有独立 Markdown,并直接服务于说明书网站、OpenBitFun 全局搜索和 `OpenBitFunControl` 智能体工具。 +OpenBitFun Playbook 当前包含 **23 个功能**和 **22 个设置页**,共 **45 个**用户可理解的条目、**334 项**有源码证据的子能力。每个条目有独立 Markdown,并直接服务于说明书网站、OpenBitFun 全局搜索和 `OpenBitFunControl` 智能体工具。 -OpenBitFun Playbook currently contains **23 features**, **21 settings pages**, and **332** source-backed sub-capabilities across **44** user-facing entries. Every entry has its own Markdown page and directly powers the website, in-app global search, and the `OpenBitFunControl` agent tool. +OpenBitFun Playbook currently contains **23 features**, **22 settings pages**, and **334** source-backed sub-capabilities across **45** user-facing entries. Every entry has its own Markdown page and directly powers the website, in-app global search, and the `OpenBitFunControl` agent tool. ## 唯一事实源 / Single source of truth @@ -33,14 +33,14 @@ Docs, website, search, and agents see only features, settings, and documented su ## 控制边界 / Control boundary -- 每个子能力都明确标记为直接控制、委托给专用智能体工具、需交互打开或不支持;“打开页面”不会再被统计成“智能体已控制”。当前覆盖:直接 **51**、委托 **65**、需交互 **216**、不支持 **0**。 +- 每个子能力都明确标记为直接控制、委托给专用智能体工具、需交互打开或不支持;“打开页面”不会再被统计成“智能体已控制”。当前覆盖:直接 **51**、委托 **65**、需交互 **218**、不支持 **0**。 - 稳定行为声明为带 JSON 输入契约的 `operations` 或 `options`,并绑定原生产品控制 Provider;智能体不接触原始 Tauri Command。 -- `OpenBitFunControl list` 和 `search` 都返回带 `nextCursor` 的精简分页结果;目录可持续增长,不靠固定总量上限。完整目录和 332 项子能力都不会写入 system prompt。 +- `OpenBitFunControl list` 和 `search` 都返回带 `nextCursor` 的精简分页结果;目录可持续增长,不靠固定总量上限。完整目录和 334 项子能力都不会写入 system prompt。 - 目录发现与契约读取不依赖 React 或可见窗口。普通配置型 option 统一由 Product Assembly 的共享 ConfigService 执行器读、写并回读,因此 Desktop、CLI 与 Headless 表面走同一份实现;只有宿主原生 operation/provider option 和界面导航按表面注册适配器,缺失时必须明确返回不可用,禁止静默回退本机。只读智能体只能发现和读取目录。 -- Every documented item is classified as direct control, delegated Agent control, interactive opening, or unsupported; opening a page is never counted as direct control. Current coverage is **51 direct**, **65 delegated**, **216 interactive**, and **0 unsupported**. +- Every documented item is classified as direct control, delegated Agent control, interactive opening, or unsupported; opening a page is never counted as direct control. Current coverage is **51 direct**, **65 delegated**, **218 interactive**, and **0 unsupported**. - Stable behavior becomes a typed `operation` or `option` with a JSON input contract and a native product-control provider. Agents never receive raw Tauri commands. -- `OpenBitFunControl list` and `search` return compact pages with a `nextCursor`; the catalog can grow without a fixed total-size ceiling. Neither the full catalog nor its 332 documented items enters the system prompt. +- `OpenBitFunControl list` and `search` return compact pages with a `nextCursor`; the catalog can grow without a fixed total-size ceiling. Neither the full catalog nor its 334 documented items enters the system prompt. - Discovery and contract lookup do not depend on React or a visible window. Ordinary config-backed options are read, written, and read back by one Product Assembly ConfigService executor shared by Desktop, CLI, and headless surfaces. Only host-native operations/provider options and presentation routes install surface adapters; missing adapters return explicit unavailability without local fallback. Read-only agents may only discover and inspect entries. ## 防腐化门禁 / Anti-drift gates diff --git a/docs/interactive-capabilities/capabilities.json b/docs/interactive-capabilities/capabilities.json index 66aeb64143..da72e721e5 100644 --- a/docs/interactive-capabilities/capabilities.json +++ b/docs/interactive-capabilities/capabilities.json @@ -4,7 +4,7 @@ "title": "OpenBitFun Playbook", "origin": "https://playbook.openbitfun.com", "source": "src/shared/interactive-capabilities/catalog.json", - "digest": "c7c38fecfad3e83e4a9f6f451a37edbf7d42ef399409238253f9a0fd124f879b", + "digest": "f73b2d5ce1cc4824923459e1f2b24c58bba3d3ca930416b059136449aa522d0f", "ownerDigest": "103fc8646df7b35e9fac1c52149a35b86e9c6115b462b0aa36240a4600903ce4", "searchAcceptance": [ { @@ -145,13 +145,13 @@ ], "counts": { "features": 23, - "settings": 21, - "userFacing": 44, - "documentedItems": 332, + "settings": 22, + "userFacing": 45, + "documentedItems": 334, "controlCoverage": { "direct": 51, "delegated": 65, - "interactive": 216, + "interactive": 218, "unsupported": 0 } }, @@ -14599,6 +14599,144 @@ "pageId": "ai.models" } }, + { + "id": "setting.ai.context:query", + "capabilityId": "setting.ai.context", + "itemIds": [ + "compression-prefetch", + "request-elision" + ], + "kind": "query", + "risk": "read", + "executionHost": "productHost", + "availability": { + "desktop": { + "available": true + }, + "cli": { + "available": true + }, + "peer": { + "available": true, + "requiredCapabilities": [ + "product_control_v1" + ] + }, + "remoteControl": { + "available": true + }, + "detachedDispatch": { + "available": true + } + }, + "inputSchema": { + "type": "object", + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "additionalProperties": true + }, + "valueSource": { + "kind": "static" + }, + "presentationTarget": { + "kind": "settings", + "pageId": "ai.context" + } + }, + { + "id": "setting.ai.context:open:compression-prefetch", + "capabilityId": "setting.ai.context", + "itemIds": [ + "compression-prefetch" + ], + "kind": "open", + "risk": "ui", + "executionHost": "presentationSurface", + "availability": { + "desktop": { + "available": true + }, + "cli": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + }, + "peer": { + "available": true, + "requiredCapabilities": [ + "product_control_v1", + "product_control_presentation_v1" + ] + }, + "remoteControl": { + "available": true + }, + "detachedDispatch": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + } + }, + "inputSchema": { + "type": "object", + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "additionalProperties": true + }, + "openReason": "unstructuredInteraction", + "presentationTarget": { + "kind": "settings", + "pageId": "ai.context" + } + }, + { + "id": "setting.ai.context:open:request-elision", + "capabilityId": "setting.ai.context", + "itemIds": [ + "request-elision" + ], + "kind": "open", + "risk": "ui", + "executionHost": "presentationSurface", + "availability": { + "desktop": { + "available": true + }, + "cli": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + }, + "peer": { + "available": true, + "requiredCapabilities": [ + "product_control_v1", + "product_control_presentation_v1" + ] + }, + "remoteControl": { + "available": true + }, + "detachedDispatch": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + } + }, + "inputSchema": { + "type": "object", + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "additionalProperties": true + }, + "openReason": "unstructuredInteraction", + "presentationTarget": { + "kind": "settings", + "pageId": "ai.context" + } + }, { "id": "setting.ai.memory:query", "capabilityId": "setting.ai.memory", @@ -27648,6 +27786,122 @@ ], "docsUrl": "https://playbook.openbitfun.com/capabilities/setting.ai.models/" }, + { + "id": "setting.ai.context", + "kind": "setting", + "categoryId": "assistant", + "titleZh": "上下文设置", + "titleEn": "Context settings", + "summaryZh": "管理长对话的自动压缩行为,包括后台预取压缩与摘要请求的溢出保护。", + "summaryEn": "Manage automatic compression for long conversations, including background compression prefetch and summary-request overflow protection.", + "keywordsZh": [ + "上下文设置", + "自动压缩", + "上下文压缩", + "预取", + "摘要", + "上下文窗口" + ], + "keywordsEn": [ + "context settings", + "automatic compression", + "context compression", + "prefetch", + "summary", + "context window" + ], + "highlightsZh": [ + "接近上限前于后台提前准备压缩", + "摘要请求无法缩小时自动折叠超大块", + "在设置页直接开关压缩预取" + ], + "highlightsEn": [ + "Prepare compression early in the background", + "Fold oversized blocks when the summary request cannot shrink further", + "Toggle compression prefetch directly from the settings page" + ], + "items": [ + { + "id": "compression-prefetch", + "titleZh": "控制是否在接近上下文上限前于后台提前准备压缩", + "titleEn": "Choose whether compression is prepared in the background before the context limit is reached", + "control": { + "kind": "open", + "reasonCode": "unstructuredInteraction", + "reasonZh": "“控制是否在接近上下文上限前于后台提前准备压缩”是设置页中的即时开关;智能体会打开精确入口,并把交互保留在用户可见界面。", + "reasonEn": "“Choose whether compression is prepared in the background before the context limit is reached” is an immediate toggle on the settings page; the Agent opens the exact entry and keeps the interaction visible to the user." + } + }, + { + "id": "request-elision", + "titleZh": "控制摘要请求无法再缩小时是否折叠超大围栏或 XML 块", + "titleEn": "Choose whether oversized fenced or XML blocks fold when the summary request cannot shrink further", + "control": { + "kind": "open", + "reasonCode": "unstructuredInteraction", + "reasonZh": "“控制摘要请求无法再缩小时是否折叠超大围栏或 XML 块”是设置页中的即时开关;智能体会打开精确入口,并把交互保留在用户可见界面。", + "reasonEn": "“Choose whether oversized fenced or XML blocks fold when the summary request cannot shrink further” is an immediate toggle on the settings page; the Agent opens the exact entry and keeps the interaction visible to the user." + } + } + ], + "stepsZh": [ + "打开设置", + "进入“AI > 上下文”", + "调整自动压缩选项,更改立即保存" + ], + "stepsEn": [ + "Open Settings", + "Go to AI > Context", + "Adjust the automatic compression options; changes save immediately" + ], + "agentExamplesZh": [ + "打开上下文设置", + "带我去上下文压缩设置" + ], + "agentExamplesEn": [ + "Open context settings", + "Take me to the context compression settings" + ], + "destination": { + "kind": "settings", + "pageId": "ai.context" + }, + "operations": [], + "options": [], + "searchTerms": [ + "setting.ai.context", + "上下文设置", + "Context settings", + "AI 与协作", + "AI & collaboration", + "自动压缩", + "上下文压缩", + "预取", + "摘要", + "上下文窗口", + "context settings", + "automatic compression", + "context compression", + "prefetch", + "summary", + "context window", + "接近上限前于后台提前准备压缩", + "摘要请求无法缩小时自动折叠超大块", + "在设置页直接开关压缩预取", + "Prepare compression early in the background", + "Fold oversized blocks when the summary request cannot shrink further", + "Toggle compression prefetch directly from the settings page", + "控制是否在接近上下文上限前于后台提前准备压缩", + "Choose whether compression is prepared in the background before the context limit is reached", + "控制摘要请求无法再缩小时是否折叠超大围栏或 XML 块", + "Choose whether oversized fenced or XML blocks fold when the summary request cannot shrink further", + "打开上下文设置", + "带我去上下文压缩设置", + "Open context settings", + "Take me to the context compression settings" + ], + "docsUrl": "https://playbook.openbitfun.com/capabilities/setting.ai.context/" + }, { "id": "setting.ai.memory", "kind": "setting", diff --git a/docs/interactive-capabilities/capabilities/setting.ai.context.md b/docs/interactive-capabilities/capabilities/setting.ai.context.md new file mode 100644 index 0000000000..9740482aca --- /dev/null +++ b/docs/interactive-capabilities/capabilities/setting.ai.context.md @@ -0,0 +1,57 @@ + +--- +id: setting.ai.context +kind: setting +category: assistant +title_zh: "上下文设置" +title_en: "Context settings" +--- + +# 上下文设置 / Context settings + +> 设置 / Setting + +管理长对话的自动压缩行为,包括后台预取压缩与摘要请求的溢出保护。 + +Manage automatic compression for long conversations, including background compression prefetch and summary-request overflow protection. + +## 完整功能清单 / Everything included + +- **智能体可定位入口,需交互完成 / Agent opens; interaction required** · 控制是否在接近上下文上限前于后台提前准备压缩 + - Choose whether compression is prepared in the background before the context limit is reached +- **智能体可定位入口,需交互完成 / Agent opens; interaction required** · 控制摘要请求无法再缩小时是否折叠超大围栏或 XML 块 + - Choose whether oversized fenced or XML blocks fold when the summary request cannot shrink further + +## 怎么用 / How to use it + +1. 打开设置 + Open Settings +2. 进入“AI > 上下文” + Go to AI > Context +3. 调整自动压缩选项,更改立即保存 + Adjust the automatic compression options; changes save immediately + +入口 / Entry: OpenBitFun 设置 + +## 智能体可替你做什么 / What an agent can do for you + +| 操作 / Action | 中文说明 | English description | +| --- | --- | --- | +| 打开对应界面 / Open the UI | 进入 OpenBitFun 中对应的功能界面。 | Open the matching feature in OpenBitFun. | + +## 可配置选项 / Configurable options + +| 选项 / Option | 可用值 / Values | 中文说明 | English description | +| --- | --- | --- | --- | +| 在界面中配置 / Configure in the UI | — | 此页面的设置在对应界面中完成。 | Configure this page in its matching UI. | + +## 可以直接对智能体说 / Try saying + +- “打开上下文设置” + - “Open context settings” +- “带我去上下文压缩设置” + - “Take me to the context compression settings” + +智能体会先查找相关功能或设置,确认目标后再替你打开、执行或修改。完整能力目录不会预先塞进对话上下文。 + +The agent first finds the relevant feature or setting, confirms the target, and then opens, runs, or changes it for you. The full catalog is never embedded in the conversation context. diff --git a/docs/interactive-capabilities/technical/product-control-open-audit.json b/docs/interactive-capabilities/technical/product-control-open-audit.json index a0fb2dcb4a..6549978a44 100644 --- a/docs/interactive-capabilities/technical/product-control-open-audit.json +++ b/docs/interactive-capabilities/technical/product-control-open-audit.json @@ -1,12 +1,12 @@ { "schemaVersion": 1, "generatedFrom": "src/shared/interactive-capabilities/catalog.json", - "catalogDigest": "c7c38fecfad3e83e4a9f6f451a37edbf7d42ef399409238253f9a0fd124f879b", - "count": 216, + "catalogDigest": "f73b2d5ce1cc4824923459e1f2b24c58bba3d3ca930416b059136449aa522d0f", + "count": 218, "reasonCounts": { "externalAuth": 4, "secretEntry": 5, - "unstructuredInteraction": 182, + "unstructuredInteraction": 184, "visualSelection": 25 }, "entries": [ @@ -2900,6 +2900,40 @@ "command:retry_model_pool_member" ] }, + { + "capabilityId": "setting.ai.context", + "itemId": "compression-prefetch", + "titleZh": "控制是否在接近上下文上限前于后台提前准备压缩", + "titleEn": "Choose whether compression is prepared in the background before the context limit is reached", + "reasonCode": "unstructuredInteraction", + "reasonZh": "“控制是否在接近上下文上限前于后台提前准备压缩”是设置页中的即时开关;智能体会打开精确入口,并把交互保留在用户可见界面。", + "reasonEn": "“Choose whether compression is prepared in the background before the context limit is reached” is an immediate toggle on the settings page; the Agent opens the exact entry and keeps the interaction visible to the user.", + "presentationTarget": { + "kind": "settings", + "pageId": "ai.context" + }, + "evidence": [ + "source:src/web-ui/src/locales/zh-CN/settings/context.json#compression.prefetch.label", + "source:src/crates/assembly/core/src/agentic/execution/AGENTS.md#ai.enable_context_compression_prefetch" + ] + }, + { + "capabilityId": "setting.ai.context", + "itemId": "request-elision", + "titleZh": "控制摘要请求无法再缩小时是否折叠超大围栏或 XML 块", + "titleEn": "Choose whether oversized fenced or XML blocks fold when the summary request cannot shrink further", + "reasonCode": "unstructuredInteraction", + "reasonZh": "“控制摘要请求无法再缩小时是否折叠超大围栏或 XML 块”是设置页中的即时开关;智能体会打开精确入口,并把交互保留在用户可见界面。", + "reasonEn": "“Choose whether oversized fenced or XML blocks fold when the summary request cannot shrink further” is an immediate toggle on the settings page; the Agent opens the exact entry and keeps the interaction visible to the user.", + "presentationTarget": { + "kind": "settings", + "pageId": "ai.context" + }, + "evidence": [ + "source:src/web-ui/src/locales/zh-CN/settings/context.json#compression.sectionTitle", + "source:src/crates/assembly/core/src/agentic/session/compression/elision.rs#RequestElisionLadder" + ] + }, { "capabilityId": "setting.ai.memory", "itemId": "extraction-model", diff --git a/docs/interactive-capabilities/technical/tauri-command-map.json b/docs/interactive-capabilities/technical/tauri-command-map.json index 20eda4f945..e705154091 100644 --- a/docs/interactive-capabilities/technical/tauri-command-map.json +++ b/docs/interactive-capabilities/technical/tauri-command-map.json @@ -1,7 +1,7 @@ { "schemaVersion": 2, "generatedFrom": "src/shared/interactive-capabilities/catalog.json", - "catalogDigest": "c7c38fecfad3e83e4a9f6f451a37edbf7d42ef399409238253f9a0fd124f879b", + "catalogDigest": "f73b2d5ce1cc4824923459e1f2b24c58bba3d3ca930416b059136449aa522d0f", "commandCount": 701, "coverage": { "commandCount": 701, diff --git a/src/crates/contracts/product-domains/src/generated/product-control-catalog.json b/src/crates/contracts/product-domains/src/generated/product-control-catalog.json index 22c42212f6..e2f5449b22 100644 --- a/src/crates/contracts/product-domains/src/generated/product-control-catalog.json +++ b/src/crates/contracts/product-domains/src/generated/product-control-catalog.json @@ -4,7 +4,7 @@ "title": "OpenBitFun Playbook", "origin": "https://playbook.openbitfun.com", "source": "src/shared/interactive-capabilities/catalog.json", - "digest": "c7c38fecfad3e83e4a9f6f451a37edbf7d42ef399409238253f9a0fd124f879b", + "digest": "f73b2d5ce1cc4824923459e1f2b24c58bba3d3ca930416b059136449aa522d0f", "ownerDigest": "103fc8646df7b35e9fac1c52149a35b86e9c6115b462b0aa36240a4600903ce4", "searchAcceptance": [ { @@ -145,13 +145,13 @@ ], "counts": { "features": 23, - "settings": 21, - "userFacing": 44, - "documentedItems": 332, + "settings": 22, + "userFacing": 45, + "documentedItems": 334, "controlCoverage": { "direct": 51, "delegated": 65, - "interactive": 216, + "interactive": 218, "unsupported": 0 } }, @@ -14599,6 +14599,144 @@ "pageId": "ai.models" } }, + { + "id": "setting.ai.context:query", + "capabilityId": "setting.ai.context", + "itemIds": [ + "compression-prefetch", + "request-elision" + ], + "kind": "query", + "risk": "read", + "executionHost": "productHost", + "availability": { + "desktop": { + "available": true + }, + "cli": { + "available": true + }, + "peer": { + "available": true, + "requiredCapabilities": [ + "product_control_v1" + ] + }, + "remoteControl": { + "available": true + }, + "detachedDispatch": { + "available": true + } + }, + "inputSchema": { + "type": "object", + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "additionalProperties": true + }, + "valueSource": { + "kind": "static" + }, + "presentationTarget": { + "kind": "settings", + "pageId": "ai.context" + } + }, + { + "id": "setting.ai.context:open:compression-prefetch", + "capabilityId": "setting.ai.context", + "itemIds": [ + "compression-prefetch" + ], + "kind": "open", + "risk": "ui", + "executionHost": "presentationSurface", + "availability": { + "desktop": { + "available": true + }, + "cli": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + }, + "peer": { + "available": true, + "requiredCapabilities": [ + "product_control_v1", + "product_control_presentation_v1" + ] + }, + "remoteControl": { + "available": true + }, + "detachedDispatch": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + } + }, + "inputSchema": { + "type": "object", + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "additionalProperties": true + }, + "openReason": "unstructuredInteraction", + "presentationTarget": { + "kind": "settings", + "pageId": "ai.context" + } + }, + { + "id": "setting.ai.context:open:request-elision", + "capabilityId": "setting.ai.context", + "itemIds": [ + "request-elision" + ], + "kind": "open", + "risk": "ui", + "executionHost": "presentationSurface", + "availability": { + "desktop": { + "available": true + }, + "cli": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + }, + "peer": { + "available": true, + "requiredCapabilities": [ + "product_control_v1", + "product_control_presentation_v1" + ] + }, + "remoteControl": { + "available": true + }, + "detachedDispatch": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + } + }, + "inputSchema": { + "type": "object", + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "additionalProperties": true + }, + "openReason": "unstructuredInteraction", + "presentationTarget": { + "kind": "settings", + "pageId": "ai.context" + } + }, { "id": "setting.ai.memory:query", "capabilityId": "setting.ai.memory", @@ -27883,6 +28021,122 @@ ], "docsUrl": "https://playbook.openbitfun.com/capabilities/setting.ai.models/" }, + { + "id": "setting.ai.context", + "kind": "setting", + "categoryId": "assistant", + "titleZh": "上下文设置", + "titleEn": "Context settings", + "summaryZh": "管理长对话的自动压缩行为,包括后台预取压缩与摘要请求的溢出保护。", + "summaryEn": "Manage automatic compression for long conversations, including background compression prefetch and summary-request overflow protection.", + "keywordsZh": [ + "上下文设置", + "自动压缩", + "上下文压缩", + "预取", + "摘要", + "上下文窗口" + ], + "keywordsEn": [ + "context settings", + "automatic compression", + "context compression", + "prefetch", + "summary", + "context window" + ], + "highlightsZh": [ + "接近上限前于后台提前准备压缩", + "摘要请求无法缩小时自动折叠超大块", + "在设置页直接开关压缩预取" + ], + "highlightsEn": [ + "Prepare compression early in the background", + "Fold oversized blocks when the summary request cannot shrink further", + "Toggle compression prefetch directly from the settings page" + ], + "items": [ + { + "id": "compression-prefetch", + "titleZh": "控制是否在接近上下文上限前于后台提前准备压缩", + "titleEn": "Choose whether compression is prepared in the background before the context limit is reached", + "control": { + "kind": "open", + "reasonCode": "unstructuredInteraction", + "reasonZh": "“控制是否在接近上下文上限前于后台提前准备压缩”是设置页中的即时开关;智能体会打开精确入口,并把交互保留在用户可见界面。", + "reasonEn": "“Choose whether compression is prepared in the background before the context limit is reached” is an immediate toggle on the settings page; the Agent opens the exact entry and keeps the interaction visible to the user." + } + }, + { + "id": "request-elision", + "titleZh": "控制摘要请求无法再缩小时是否折叠超大围栏或 XML 块", + "titleEn": "Choose whether oversized fenced or XML blocks fold when the summary request cannot shrink further", + "control": { + "kind": "open", + "reasonCode": "unstructuredInteraction", + "reasonZh": "“控制摘要请求无法再缩小时是否折叠超大围栏或 XML 块”是设置页中的即时开关;智能体会打开精确入口,并把交互保留在用户可见界面。", + "reasonEn": "“Choose whether oversized fenced or XML blocks fold when the summary request cannot shrink further” is an immediate toggle on the settings page; the Agent opens the exact entry and keeps the interaction visible to the user." + } + } + ], + "stepsZh": [ + "打开设置", + "进入“AI > 上下文”", + "调整自动压缩选项,更改立即保存" + ], + "stepsEn": [ + "Open Settings", + "Go to AI > Context", + "Adjust the automatic compression options; changes save immediately" + ], + "agentExamplesZh": [ + "打开上下文设置", + "带我去上下文压缩设置" + ], + "agentExamplesEn": [ + "Open context settings", + "Take me to the context compression settings" + ], + "destination": { + "kind": "settings", + "pageId": "ai.context" + }, + "operations": [], + "options": [], + "searchTerms": [ + "setting.ai.context", + "上下文设置", + "Context settings", + "AI 与协作", + "AI & collaboration", + "自动压缩", + "上下文压缩", + "预取", + "摘要", + "上下文窗口", + "context settings", + "automatic compression", + "context compression", + "prefetch", + "summary", + "context window", + "接近上限前于后台提前准备压缩", + "摘要请求无法缩小时自动折叠超大块", + "在设置页直接开关压缩预取", + "Prepare compression early in the background", + "Fold oversized blocks when the summary request cannot shrink further", + "Toggle compression prefetch directly from the settings page", + "控制是否在接近上下文上限前于后台提前准备压缩", + "Choose whether compression is prepared in the background before the context limit is reached", + "控制摘要请求无法再缩小时是否折叠超大围栏或 XML 块", + "Choose whether oversized fenced or XML blocks fold when the summary request cannot shrink further", + "打开上下文设置", + "带我去上下文压缩设置", + "Open context settings", + "Take me to the context compression settings" + ], + "docsUrl": "https://playbook.openbitfun.com/capabilities/setting.ai.context/" + }, { "id": "setting.ai.memory", "kind": "setting", diff --git a/src/shared/interactive-capabilities/catalog.json b/src/shared/interactive-capabilities/catalog.json index 91ae873e7e..d0562c5077 100644 --- a/src/shared/interactive-capabilities/catalog.json +++ b/src/shared/interactive-capabilities/catalog.json @@ -7011,6 +7011,97 @@ } ] }, + { + "id": "setting.ai.context", + "kind": "setting", + "categoryId": "assistant", + "titleZh": "上下文设置", + "titleEn": "Context settings", + "summaryZh": "管理长对话的自动压缩行为,包括后台预取压缩与摘要请求的溢出保护。", + "summaryEn": "Manage automatic compression for long conversations, including background compression prefetch and summary-request overflow protection.", + "keywordsZh": [ + "上下文设置", + "自动压缩", + "上下文压缩", + "预取", + "摘要", + "上下文窗口" + ], + "keywordsEn": [ + "context settings", + "automatic compression", + "context compression", + "prefetch", + "summary", + "context window" + ], + "highlightsZh": [ + "接近上限前于后台提前准备压缩", + "摘要请求无法缩小时自动折叠超大块", + "在设置页直接开关压缩预取" + ], + "highlightsEn": [ + "Prepare compression early in the background", + "Fold oversized blocks when the summary request cannot shrink further", + "Toggle compression prefetch directly from the settings page" + ], + "items": [ + { + "id": "compression-prefetch", + "titleZh": "控制是否在接近上下文上限前于后台提前准备压缩", + "titleEn": "Choose whether compression is prepared in the background before the context limit is reached", + "control": { + "kind": "open", + "reasonCode": "unstructuredInteraction", + "reasonZh": "“控制是否在接近上下文上限前于后台提前准备压缩”是设置页中的即时开关;智能体会打开精确入口,并把交互保留在用户可见界面。", + "reasonEn": "“Choose whether compression is prepared in the background before the context limit is reached” is an immediate toggle on the settings page; the Agent opens the exact entry and keeps the interaction visible to the user." + }, + "evidence": [ + "source:src/web-ui/src/locales/zh-CN/settings/context.json#compression.prefetch.label", + "source:src/crates/assembly/core/src/agentic/execution/AGENTS.md#ai.enable_context_compression_prefetch" + ] + }, + { + "id": "request-elision", + "titleZh": "控制摘要请求无法再缩小时是否折叠超大围栏或 XML 块", + "titleEn": "Choose whether oversized fenced or XML blocks fold when the summary request cannot shrink further", + "control": { + "kind": "open", + "reasonCode": "unstructuredInteraction", + "reasonZh": "“控制摘要请求无法再缩小时是否折叠超大围栏或 XML 块”是设置页中的即时开关;智能体会打开精确入口,并把交互保留在用户可见界面。", + "reasonEn": "“Choose whether oversized fenced or XML blocks fold when the summary request cannot shrink further” is an immediate toggle on the settings page; the Agent opens the exact entry and keeps the interaction visible to the user." + }, + "evidence": [ + "source:src/web-ui/src/locales/zh-CN/settings/context.json#compression.sectionTitle", + "source:src/crates/assembly/core/src/agentic/session/compression/elision.rs#RequestElisionLadder" + ] + } + ], + "stepsZh": [ + "打开设置", + "进入“AI > 上下文”", + "调整自动压缩选项,更改立即保存" + ], + "stepsEn": [ + "Open Settings", + "Go to AI > Context", + "Adjust the automatic compression options; changes save immediately" + ], + "agentExamplesZh": [ + "打开上下文设置", + "带我去上下文压缩设置" + ], + "agentExamplesEn": [ + "Open context settings", + "Take me to the context compression settings" + ], + "destination": { + "kind": "settings", + "pageId": "ai.context" + }, + "operations": [], + "options": [] + }, { "id": "setting.ai.memory", "kind": "setting", diff --git a/src/web-ui/src/app/global-search/generated/interactive-capabilities.json b/src/web-ui/src/app/global-search/generated/interactive-capabilities.json index 66aeb64143..da72e721e5 100644 --- a/src/web-ui/src/app/global-search/generated/interactive-capabilities.json +++ b/src/web-ui/src/app/global-search/generated/interactive-capabilities.json @@ -4,7 +4,7 @@ "title": "OpenBitFun Playbook", "origin": "https://playbook.openbitfun.com", "source": "src/shared/interactive-capabilities/catalog.json", - "digest": "c7c38fecfad3e83e4a9f6f451a37edbf7d42ef399409238253f9a0fd124f879b", + "digest": "f73b2d5ce1cc4824923459e1f2b24c58bba3d3ca930416b059136449aa522d0f", "ownerDigest": "103fc8646df7b35e9fac1c52149a35b86e9c6115b462b0aa36240a4600903ce4", "searchAcceptance": [ { @@ -145,13 +145,13 @@ ], "counts": { "features": 23, - "settings": 21, - "userFacing": 44, - "documentedItems": 332, + "settings": 22, + "userFacing": 45, + "documentedItems": 334, "controlCoverage": { "direct": 51, "delegated": 65, - "interactive": 216, + "interactive": 218, "unsupported": 0 } }, @@ -14599,6 +14599,144 @@ "pageId": "ai.models" } }, + { + "id": "setting.ai.context:query", + "capabilityId": "setting.ai.context", + "itemIds": [ + "compression-prefetch", + "request-elision" + ], + "kind": "query", + "risk": "read", + "executionHost": "productHost", + "availability": { + "desktop": { + "available": true + }, + "cli": { + "available": true + }, + "peer": { + "available": true, + "requiredCapabilities": [ + "product_control_v1" + ] + }, + "remoteControl": { + "available": true + }, + "detachedDispatch": { + "available": true + } + }, + "inputSchema": { + "type": "object", + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "additionalProperties": true + }, + "valueSource": { + "kind": "static" + }, + "presentationTarget": { + "kind": "settings", + "pageId": "ai.context" + } + }, + { + "id": "setting.ai.context:open:compression-prefetch", + "capabilityId": "setting.ai.context", + "itemIds": [ + "compression-prefetch" + ], + "kind": "open", + "risk": "ui", + "executionHost": "presentationSurface", + "availability": { + "desktop": { + "available": true + }, + "cli": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + }, + "peer": { + "available": true, + "requiredCapabilities": [ + "product_control_v1", + "product_control_presentation_v1" + ] + }, + "remoteControl": { + "available": true + }, + "detachedDispatch": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + } + }, + "inputSchema": { + "type": "object", + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "additionalProperties": true + }, + "openReason": "unstructuredInteraction", + "presentationTarget": { + "kind": "settings", + "pageId": "ai.context" + } + }, + { + "id": "setting.ai.context:open:request-elision", + "capabilityId": "setting.ai.context", + "itemIds": [ + "request-elision" + ], + "kind": "open", + "risk": "ui", + "executionHost": "presentationSurface", + "availability": { + "desktop": { + "available": true + }, + "cli": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + }, + "peer": { + "available": true, + "requiredCapabilities": [ + "product_control_v1", + "product_control_presentation_v1" + ] + }, + "remoteControl": { + "available": true + }, + "detachedDispatch": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + } + }, + "inputSchema": { + "type": "object", + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "additionalProperties": true + }, + "openReason": "unstructuredInteraction", + "presentationTarget": { + "kind": "settings", + "pageId": "ai.context" + } + }, { "id": "setting.ai.memory:query", "capabilityId": "setting.ai.memory", @@ -27648,6 +27786,122 @@ ], "docsUrl": "https://playbook.openbitfun.com/capabilities/setting.ai.models/" }, + { + "id": "setting.ai.context", + "kind": "setting", + "categoryId": "assistant", + "titleZh": "上下文设置", + "titleEn": "Context settings", + "summaryZh": "管理长对话的自动压缩行为,包括后台预取压缩与摘要请求的溢出保护。", + "summaryEn": "Manage automatic compression for long conversations, including background compression prefetch and summary-request overflow protection.", + "keywordsZh": [ + "上下文设置", + "自动压缩", + "上下文压缩", + "预取", + "摘要", + "上下文窗口" + ], + "keywordsEn": [ + "context settings", + "automatic compression", + "context compression", + "prefetch", + "summary", + "context window" + ], + "highlightsZh": [ + "接近上限前于后台提前准备压缩", + "摘要请求无法缩小时自动折叠超大块", + "在设置页直接开关压缩预取" + ], + "highlightsEn": [ + "Prepare compression early in the background", + "Fold oversized blocks when the summary request cannot shrink further", + "Toggle compression prefetch directly from the settings page" + ], + "items": [ + { + "id": "compression-prefetch", + "titleZh": "控制是否在接近上下文上限前于后台提前准备压缩", + "titleEn": "Choose whether compression is prepared in the background before the context limit is reached", + "control": { + "kind": "open", + "reasonCode": "unstructuredInteraction", + "reasonZh": "“控制是否在接近上下文上限前于后台提前准备压缩”是设置页中的即时开关;智能体会打开精确入口,并把交互保留在用户可见界面。", + "reasonEn": "“Choose whether compression is prepared in the background before the context limit is reached” is an immediate toggle on the settings page; the Agent opens the exact entry and keeps the interaction visible to the user." + } + }, + { + "id": "request-elision", + "titleZh": "控制摘要请求无法再缩小时是否折叠超大围栏或 XML 块", + "titleEn": "Choose whether oversized fenced or XML blocks fold when the summary request cannot shrink further", + "control": { + "kind": "open", + "reasonCode": "unstructuredInteraction", + "reasonZh": "“控制摘要请求无法再缩小时是否折叠超大围栏或 XML 块”是设置页中的即时开关;智能体会打开精确入口,并把交互保留在用户可见界面。", + "reasonEn": "“Choose whether oversized fenced or XML blocks fold when the summary request cannot shrink further” is an immediate toggle on the settings page; the Agent opens the exact entry and keeps the interaction visible to the user." + } + } + ], + "stepsZh": [ + "打开设置", + "进入“AI > 上下文”", + "调整自动压缩选项,更改立即保存" + ], + "stepsEn": [ + "Open Settings", + "Go to AI > Context", + "Adjust the automatic compression options; changes save immediately" + ], + "agentExamplesZh": [ + "打开上下文设置", + "带我去上下文压缩设置" + ], + "agentExamplesEn": [ + "Open context settings", + "Take me to the context compression settings" + ], + "destination": { + "kind": "settings", + "pageId": "ai.context" + }, + "operations": [], + "options": [], + "searchTerms": [ + "setting.ai.context", + "上下文设置", + "Context settings", + "AI 与协作", + "AI & collaboration", + "自动压缩", + "上下文压缩", + "预取", + "摘要", + "上下文窗口", + "context settings", + "automatic compression", + "context compression", + "prefetch", + "summary", + "context window", + "接近上限前于后台提前准备压缩", + "摘要请求无法缩小时自动折叠超大块", + "在设置页直接开关压缩预取", + "Prepare compression early in the background", + "Fold oversized blocks when the summary request cannot shrink further", + "Toggle compression prefetch directly from the settings page", + "控制是否在接近上下文上限前于后台提前准备压缩", + "Choose whether compression is prepared in the background before the context limit is reached", + "控制摘要请求无法再缩小时是否折叠超大围栏或 XML 块", + "Choose whether oversized fenced or XML blocks fold when the summary request cannot shrink further", + "打开上下文设置", + "带我去上下文压缩设置", + "Open context settings", + "Take me to the context compression settings" + ], + "docsUrl": "https://playbook.openbitfun.com/capabilities/setting.ai.context/" + }, { "id": "setting.ai.memory", "kind": "setting", diff --git a/src/web-ui/src/infrastructure/api/generated/productControl.ts b/src/web-ui/src/infrastructure/api/generated/productControl.ts index b9ed924d2c..5c2d3227f4 100644 --- a/src/web-ui/src/infrastructure/api/generated/productControl.ts +++ b/src/web-ui/src/infrastructure/api/generated/productControl.ts @@ -1,7 +1,7 @@ // Generated by scripts/generate-interactive-capabilities.mjs; do not edit. -export const PRODUCT_CONTROL_GRAPH_DIGEST = "c7c38fecfad3e83e4a9f6f451a37edbf7d42ef399409238253f9a0fd124f879b" as const; +export const PRODUCT_CONTROL_GRAPH_DIGEST = "f73b2d5ce1cc4824923459e1f2b24c58bba3d3ca930416b059136449aa522d0f" as const; -export type ProductControlCapabilityId = "feature.ai-assistant" | "feature.agents" | "feature.personal-assistants" | "feature.projects" | "feature.files-editor" | "feature.terminal" | "feature.git" | "feature.code-review" | "feature.browser" | "feature.computer-use" | "feature.skills" | "feature.miniapps" | "feature.canvas" | "feature.tasks-automation" | "feature.insights" | "feature.ecosystem-compatibility" | "feature.remote-workspaces" | "feature.remote-connect" | "feature.detached-dispatch" | "feature.pages" | "feature.voice-input" | "feature.desktop-pet" | "feature.desktop-cards" | "setting.application.general" | "setting.application.appearance" | "setting.application.pet" | "setting.application.input" | "setting.application.shortcuts" | "setting.application.development" | "setting.ai.models" | "setting.ai.memory" | "setting.workspace.session" | "setting.workspace.worktrees" | "setting.tools.execution" | "setting.application.terminal" | "setting.tools.desktop-control" | "setting.tools.browser-control" | "setting.tools.automation" | "setting.tools.web-search" | "setting.tools.mcp" | "setting.tools.acp" | "setting.data.usage" | "setting.data.archived" | "setting.data.diagnostics"; +export type ProductControlCapabilityId = "feature.ai-assistant" | "feature.agents" | "feature.personal-assistants" | "feature.projects" | "feature.files-editor" | "feature.terminal" | "feature.git" | "feature.code-review" | "feature.browser" | "feature.computer-use" | "feature.skills" | "feature.miniapps" | "feature.canvas" | "feature.tasks-automation" | "feature.insights" | "feature.ecosystem-compatibility" | "feature.remote-workspaces" | "feature.remote-connect" | "feature.detached-dispatch" | "feature.pages" | "feature.voice-input" | "feature.desktop-pet" | "feature.desktop-cards" | "setting.application.general" | "setting.application.appearance" | "setting.application.pet" | "setting.application.input" | "setting.application.shortcuts" | "setting.application.development" | "setting.ai.models" | "setting.ai.context" | "setting.ai.memory" | "setting.workspace.session" | "setting.workspace.worktrees" | "setting.tools.execution" | "setting.application.terminal" | "setting.tools.desktop-control" | "setting.tools.browser-control" | "setting.tools.automation" | "setting.tools.web-search" | "setting.tools.mcp" | "setting.tools.acp" | "setting.data.usage" | "setting.data.archived" | "setting.data.diagnostics"; export interface ProductControlOptionIdsByCapability { "feature.ai-assistant": never; @@ -34,6 +34,7 @@ export interface ProductControlOptionIdsByCapability { "setting.application.shortcuts": never; "setting.application.development": "editor-font-family" | "editor-font-weight" | "editor-font-size" | "editor-line-height" | "editor-cursor-style" | "editor-cursor-blinking" | "editor-tab-size" | "editor-insert-spaces" | "editor-detect-indentation" | "editor-word-wrap" | "editor-line-numbers" | "editor-smooth-scrolling" | "editor-minimap-enabled" | "editor-minimap-side" | "editor-minimap-size" | "editor-render-whitespace" | "editor-render-line-highlight" | "editor-scroll-beyond-last-line" | "editor-auto-save" | "editor-auto-save-delay" | "editor-semantic-highlighting" | "editor-bracket-pair-colorization" | "editor-format-on-save" | "editor-format-on-paste" | "editor-trim-auto-whitespace"; "setting.ai.models": "stream-ttft-timeout-seconds" | "stream-idle-timeout-seconds"; + "setting.ai.context": never; "setting.ai.memory": "enabled" | "btw-sessions" | "external-context-policy" | "minimum-rollout-idle-hours" | "maximum-rollout-age-days" | "startup-rollout-limit" | "startup-rollout-scan-limit" | "extraction-concurrency" | "raw-memory-limit" | "memory-retention-days"; "setting.workspace.session": "session-title-generation" | "prompt-suggestion" | "workspace-search"; "setting.workspace.worktrees": "worktree-root-path" | "worktree-branch-prefix" | "worktree-copy-local-changes" | "worktree-auto-delete-enabled" | "worktree-auto-delete-limit"; @@ -81,6 +82,7 @@ export interface ProductControlOperationIdsByCapability { "setting.application.shortcuts": never; "setting.application.development": never; "setting.ai.models": never; + "setting.ai.context": never; "setting.ai.memory": never; "setting.workspace.session": never; "setting.workspace.worktrees": never; From 8edd6426021ce190dfe7a0aef414820e9322dd1c Mon Sep 17 00:00:00 2001 From: Raing5Days Date: Fri, 23 Oct 2026 16:02:12 +0800 Subject: [PATCH 5/6] test(settings): expect the ai.context page in the information architecture The registry IA test asserts the exact per-category page list and total page count, so registering ai.context must update both. Verified with the full web-ui suite (settingsRegistry 30 passed) plus type-check after gen:types. --- src/web-ui/src/app/scenes/settings/settingsRegistry.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/web-ui/src/app/scenes/settings/settingsRegistry.test.ts b/src/web-ui/src/app/scenes/settings/settingsRegistry.test.ts index 0fc86b8cea..368fa09bc2 100644 --- a/src/web-ui/src/app/scenes/settings/settingsRegistry.test.ts +++ b/src/web-ui/src/app/scenes/settings/settingsRegistry.test.ts @@ -23,12 +23,12 @@ describe('settings information architecture', () => { it('groups flat pages by ownership and keeps the pet independent', () => { expect(Object.fromEntries(SETTINGS_CATEGORIES.map(category => [category.id, category.pages.map(page => page.id)]))).toEqual({ application: ['application.general', 'application.appearance', 'application.pet', 'application.input'], - ai: ['ai.models', 'ai.session-memory', 'ai.execution', 'ai.permissions'], + ai: ['ai.models', 'ai.context', 'ai.session-memory', 'ai.execution', 'ai.permissions'], development: ['development.editor', 'development.terminal', 'development.workspace'], tools: ['tools.web-search', 'tools.desktop-control', 'tools.mcp', 'tools.external-agents', 'tools.automation'], data: ['data.usage', 'data.archived', 'data.diagnostics'], }); - expect(new Set(SETTINGS_PAGE_MANIFESTS.map(page => page.id)).size).toBe(19); + expect(new Set(SETTINGS_PAGE_MANIFESTS.map(page => page.id)).size).toBe(20); expect(SETTINGS_PAGE_MANIFESTS.filter(page => page.views?.length).map(page => page.id)).toEqual(['tools.external-agents']); expect(getSettingsPageManifest('tools.external-agents').views?.map(view => view.id)).toEqual(['local', 'ssh', 'json']); expect(getSettingsPageManifest('application.input').sections?.map(section => section.id)).toEqual(['voice', 'message-sending', 'text-selection', 'shortcuts']); From 5df0a5f35c6c7d5584057d6f39bf459019ef5ea4 Mon Sep 17 00:00:00 2001 From: Raing5Days Date: Fri, 23 Oct 2026 16:30:01 +0800 Subject: [PATCH 6/6] fix(appearance): register the context view facet for runtime settings The appearance contract audit rejects data-openbitfun-view values missing from the surface descriptor, so the new ai.context settings page needs its own facet value. Verified with node scripts/audit-appearance-contracts.mjs and a full pnpm run build:web. --- .../scenes/settings/pages/shared/RuntimeSettings.appearance.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/web-ui/src/app/scenes/settings/pages/shared/RuntimeSettings.appearance.ts b/src/web-ui/src/app/scenes/settings/pages/shared/RuntimeSettings.appearance.ts index a53fe2bd2f..5c7a6dee72 100644 --- a/src/web-ui/src/app/scenes/settings/pages/shared/RuntimeSettings.appearance.ts +++ b/src/web-ui/src/app/scenes/settings/pages/shared/RuntimeSettings.appearance.ts @@ -28,6 +28,7 @@ export const runtimeSettingsAppearanceDescriptor: AppearanceSurfaceDescriptor = 'pet', 'permissions', 'execution', + 'context', 'browser-desktop-control', ], },