From 37461c4f98a7b27be5bcedf2418a1d02d3747586 Mon Sep 17 00:00:00 2001 From: YANG1024 Date: Sat, 5 Sep 2026 18:17:22 +0800 Subject: [PATCH] feat(runtime): add Session context-window caps --- README.md | 4 + README_ZH.md | 3 + app_server/dispatcher.py | 55 +++++++++++++- cli/execution_options.py | 31 ++++++++ cli/tui/app.py | 24 +++++- cli/tui/commands.py | 36 +++++++++ cli/tui/thread_client.py | 12 +++ core/application/thread_service.py | 45 ++++++++++- core/application/turn_service.py | 3 + core/application/views.py | 1 + core/application/workflow_service.py | 1 + core/domain/execution_profile.py | 24 +++++- core/domain/thread.py | 18 +++++ core/persistence/migrations.py | 19 +++++ core/persistence/thread_repository.py | 21 +++++- core/providers/profiles.py | 20 ++++- desktop/src/App.test.tsx | 10 +++ desktop/src/App.tsx | 8 +- desktop/src/app/useWorkspaceController.ts | 3 + desktop/src/app/workspaceState.test.ts | 1 + .../automations/AutomationsPage.test.tsx | 1 + desktop/src/features/execution/Composer.tsx | 1 + .../features/execution/ModelPicker.module.css | 29 +++++++ .../src/features/execution/ModelPicker.tsx | 75 ++++++++++++++++++- .../workbench/useCodeWorkbench.test.tsx | 1 + desktop/src/generated/app-server.ts | 9 +++ docs/guide/models.md | 17 +++++ protocol/app-server.schema.json | 24 ++++++ tests/app_server/test_server.py | 32 +++++++- .../test_llm_configuration_service.py | 25 +++++++ .../test_turn_execution_profiles.py | 8 ++ tests/application/test_workflow_service.py | 2 + tests/persistence/test_database.py | 1 + .../persistence/test_web_access_migration.py | 23 +++++- tests/test_tui.py | 37 +++++++++ 35 files changed, 606 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index ed644b09..3d1b11aa 100644 --- a/README.md +++ b/README.md @@ -896,6 +896,7 @@ and CLI without export or conversion. | Attach a file | Use the composer attachment | `@path/to/file` | | Change the next Turn's model | Composer model picker | `/model` | | Adjust Thinking effort | Composer effort picker | `/effort` | +| Cap the Session context window | Model picker context control | `/context` | | Choose tool access | Composer access picker | `/permissions` | | Load Skills for the next Turn | Composer Skills control | `/skill ` | | Create a reusable Skill | **Skills → Create Skill** | `$skill-creator` | @@ -918,6 +919,9 @@ permanent deletion removes the Session records but never repository files. Desktop provides connection setup and verification under **Settings → AI providers**. In the CLI, `/model` changes the connection and model for future Turns, while `/effort` selects a Thinking level supported by that model. +Use the model picker's context control, or `/context 64k` in the TUI, to make +future Turns compact history sooner; `/context auto` restores the model's +published window. Model changes never rewrite earlier history or alter an active Turn. Thinking effort controls the request sent to the provider; transcript detail controls diff --git a/README_ZH.md b/README_ZH.md index 6500ea7c..1ffa1026 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -735,6 +735,7 @@ Project,或者从项目目录启动 `deepcode`,然后创建新 Session 或 | 附加文件 | 使用输入框附件 | `@文件路径` | | 修改下一个 Turn 的模型 | 输入框模型选择器 | `/model` | | 调整 Thinking 档位 | 输入框 Thinking 选择器 | `/effort` | +| 限制 Session 上下文窗口 | 模型选择器上下文控件 | `/context` | | 选择工具权限 | 输入框权限选择器 | `/permissions` | | 为下一个 Turn 加载 Skills | 输入框 Skills 控件 | `/skill <名称>` | | 创建可复用 Skill | **Skills → Create Skill** | `$skill-creator` | @@ -749,6 +750,8 @@ Session,不删除历史;永久删除只移除 Session 记录,不会删除 Desktop 在 **Settings → AI providers** 中提供连接配置与验证。CLI 使用 `/model` 修改后续 Turn 的连接和模型,使用 `/effort` 选择该模型支持的 Thinking 档位。 +在模型选择器中设置上下文上限,或在 TUI 中输入 `/context 64k`,可以让 +后续 Turn 更早压缩历史;`/context auto` 会恢复模型公布的上下文窗口。 模型切换不会改写已有历史,也不会改变正在运行的 Turn。Thinking 档位决定 发送给 Provider 的请求;transcript 详细程度只影响界面展示。DeepCode 只在 diff --git a/app_server/dispatcher.py b/app_server/dispatcher.py index 07489852..cd183fd9 100644 --- a/app_server/dispatcher.py +++ b/app_server/dispatcher.py @@ -59,7 +59,11 @@ AutomationActivationStatus, AutomationScheduleKind, ) -from core.domain.execution_profile import ExecutionSelection +from core.domain.execution_profile import ( + MAX_CONTEXT_WINDOW_TOKENS, + MIN_CONTEXT_WINDOW_TOKENS, + ExecutionSelection, +) from core.domain.execution_security import ExecutionAccessPreset from core.domain.message_provenance import ClientSurface from core.domain.project import TrustState @@ -170,6 +174,28 @@ def nullable_string(self, name: str) -> str | None: raise InvalidParams(f"{name} must be a non-empty string or null") return value + def nullable_integer( + self, + name: str, + *, + minimum: int = 0, + maximum: int | None = None, + ) -> int | None: + if name not in self.values: + raise InvalidParams(f"{name} is required") + value = self.values[name] + if value is None: + return None + if isinstance(value, bool) or not isinstance(value, int): + raise InvalidParams(f"{name} must be an integer or null") + if value < minimum or (maximum is not None and value > maximum): + if maximum is None: + raise InvalidParams(f"{name} must be at least {minimum} or null") + raise InvalidParams( + f"{name} must be between {minimum} and {maximum}, or null" + ) + return value + Handler = Callable[[Params], Any] @@ -862,6 +888,7 @@ def _thread_start(self, params: Params) -> dict[str, Any]: "connectionId", "model", "reasoningEffort", + "contextWindow", "workspacePath", "parentThreadId", "agentPreset", @@ -878,6 +905,11 @@ def _thread_start(self, params: Params) -> dict[str, Any]: connection_id=params.string("connectionId", required=False), model=params.string("model", required=False), reasoning_effort=params.string("reasoningEffort", required=False), + context_window=params.optional_integer( + "contextWindow", + minimum=MIN_CONTEXT_WINDOW_TOKENS, + maximum=MAX_CONTEXT_WINDOW_TOKENS, + ), workspace_path=params.string("workspacePath", required=False), parent_thread_id=params.string("parentThreadId", required=False), agent_preset=params.string("agentPreset", required=False), @@ -928,6 +960,7 @@ def _thread_model(self, params: Params) -> dict[str, Any]: connection_id=connection_id, model_id=model, reasoning_effort=current.reasoning_effort, + context_window=current.context_window, ), ) thread = self.application.threads.set_execution_selection( @@ -944,6 +977,7 @@ def _thread_model(self, params: Params) -> dict[str, Any]: connection_id=current.connection_id, model_id=model, reasoning_effort=current.reasoning_effort, + context_window=current.context_window, ), ) thread = self.application.threads.set_model( @@ -955,18 +989,34 @@ def _thread_model(self, params: Params) -> dict[str, Any]: def _thread_execution_update(self, params: Params) -> dict[str, Any]: """Atomically validate and update the future-Turn execution choice.""" - params.only("threadId", "connectionId", "model", "reasoningEffort") + params.only( + "threadId", + "connectionId", + "model", + "reasoningEffort", + "contextWindow", + ) thread_id = str(params.string("threadId")) current = self.application.threads.read(thread_id) connection_id = params.nullable_string("connectionId") model = params.nullable_string("model") reasoning_effort = params.nullable_string("reasoningEffort") + context_window = ( + params.nullable_integer( + "contextWindow", + minimum=MIN_CONTEXT_WINDOW_TOKENS, + maximum=MAX_CONTEXT_WINDOW_TOKENS, + ) + if "contextWindow" in params.values + else current.context_window + ) self.application.llm.resolve( current.workspace_path, ExecutionSelection( connection_id=connection_id, model_id=model, reasoning_effort=reasoning_effort, + context_window=context_window, ), ) thread = self.application.threads.set_execution_selection( @@ -974,6 +1024,7 @@ def _thread_execution_update(self, params: Params) -> dict[str, Any]: connection_id=connection_id, model=model, reasoning_effort=reasoning_effort, + context_window=context_window, ) return {"thread": thread_view(thread)} diff --git a/cli/execution_options.py b/cli/execution_options.py index 625a7bf9..e1f6c2dd 100644 --- a/cli/execution_options.py +++ b/cli/execution_options.py @@ -3,9 +3,16 @@ from __future__ import annotations import argparse +import re +from core.domain.execution_profile import ( + MAX_CONTEXT_WINDOW_TOKENS, + MIN_CONTEXT_WINDOW_TOKENS, +) from core.domain.execution_security import ExecutionAccessPreset +_CONTEXT_WINDOW = re.compile(r"^(\d+(?:\.\d+)?)\s*([km]?)$", re.IGNORECASE) + def add_reasoning_effort_argument(parser: argparse.ArgumentParser) -> None: """Add the model-aware reasoning override used by every CLI surface. @@ -27,6 +34,29 @@ def add_reasoning_effort_argument(parser: argparse.ArgumentParser) -> None: ) +def parse_context_window(value: str) -> int | None: + """Parse a human context cap (``32k``, ``1m``), or ``auto`` to inherit.""" + + clean = value.strip().lower() + if clean in {"auto", "default", "inherit"}: + return None + match = _CONTEXT_WINDOW.fullmatch(clean) + if match is None: + raise ValueError("context window must be auto or a token count such as 32k") + amount = float(match.group(1)) + multiplier = {"": 1, "k": 1_000, "m": 1_000_000}[match.group(2).lower()] + tokens = amount * multiplier + if not tokens.is_integer(): + raise ValueError("context window must resolve to a whole token count") + parsed = int(tokens) + if not MIN_CONTEXT_WINDOW_TOKENS <= parsed <= MAX_CONTEXT_WINDOW_TOKENS: + raise ValueError( + "context window must be between " + f"{MIN_CONTEXT_WINDOW_TOKENS} and {MAX_CONTEXT_WINDOW_TOKENS} tokens" + ) + return parsed + + def add_access_preset_argument(parser: argparse.ArgumentParser) -> None: parser.add_argument( "--access", @@ -61,4 +91,5 @@ def parse_access_preset(value: str | None) -> ExecutionAccessPreset | None: "add_reasoning_effort_argument", "add_workspace_trust_argument", "parse_access_preset", + "parse_context_window", ] diff --git a/cli/tui/app.py b/cli/tui/app.py index 2dca0806..92d86571 100644 --- a/cli/tui/app.py +++ b/cli/tui/app.py @@ -102,6 +102,7 @@ def __init__( if reasoning_effort is not None else None ) + self._requested_context_window: int | None = None self.selected_skill_ids: list[str] = [] self._session_activity: FileLease | None = None self._leased_session_id: str | None = None @@ -132,6 +133,7 @@ def _sync_thread_state(self) -> None: self._requested_connection = profile.connection_id self._requested_model = profile.model_id self._requested_reasoning_effort = thread.reasoning_effort + self._requested_context_window = thread.context_window self.bridge = SessionBridge( store=self.thread_client.store, session_id=thread.id, @@ -236,6 +238,7 @@ async def switch_model( connection_id=connection_id, model=model, reasoning_effort=effort, + context_window=self._requested_context_window, ) self.model = profile.model_id self._requested_connection = profile.connection_id @@ -271,10 +274,23 @@ async def switch_reasoning_effort(self, effort: str) -> None: connection_id=self.thread_client.execution_profile.connection_id, model=self.thread_client.execution_profile.model_id, reasoning_effort=requested, + context_window=self._requested_context_window, ) self.model = profile.model_id self._requested_reasoning_effort = requested + async def switch_context_window(self, context_window: int | None) -> None: + """Change the context cap for future Turns in this Session.""" + + profile = self.thread_client.switch_execution( + connection_id=self.thread_client.execution_profile.connection_id, + model=self.thread_client.execution_profile.model_id, + reasoning_effort=self._requested_reasoning_effort, + context_window=context_window, + ) + self.model = profile.model_id + self._requested_context_window = context_window + def connection_views(self) -> list[dict]: data = self.thread_client.application.llm.list_connections( self.thread_client.project.id @@ -290,7 +306,8 @@ def model_overview(self) -> str: profile = self.thread_client.execution_profile current = ( f"connection: {profile.connection_id} · model: {self.model} · " - f"effort: {self.requested_reasoning_effort}" + f"effort: {self.requested_reasoning_effort} · " + f"context: {self.requested_context_window}" ) views = [ view @@ -415,6 +432,11 @@ def requested_reasoning_effort(self) -> str: return self._requested_reasoning_effort or "auto" + @property + def requested_context_window(self) -> str: + value = self._requested_context_window + return f"{value} tokens" if value is not None else "auto" + def clear_conversation(self) -> None: self.goal_controller.close() self.thread_client.clear_context() diff --git a/cli/tui/commands.py b/cli/tui/commands.py index c672b8bb..6a7d1bbd 100644 --- a/cli/tui/commands.py +++ b/cli/tui/commands.py @@ -17,6 +17,7 @@ from typing import Any from cli.transcript import TranscriptMode +from cli.execution_options import parse_context_window from cli.tui import theme from cli.tui.picker import Picker, PickerItem, PickerScope, PickerVariant from cli.tui.text import fit_head, short_path @@ -132,6 +133,14 @@ def _effort_levels(app, prefix: str) -> list[str]: return [level for level in levels if level.startswith(prefix)] +def _context_windows(app, prefix: str) -> list[str]: + return [ + value + for value in ("auto", "32k", "64k", "128k", "256k", "512k", "1m") + if value.startswith(prefix.lower()) + ] + + def _resolve_session_prefix(app, target: str) -> str | list[str]: """Resolve an id prefix to a full session id. @@ -444,6 +453,26 @@ async def _cmd_effort(app, args: str) -> str | None: ) +async def _cmd_context(app, args: str) -> str | None: + wanted = args.strip() + profile = app.thread_client.execution_profile + if not wanted: + return ( + f"context cap: {app.requested_context_window} · " + f"effective: {profile.context_window} tokens" + ) + try: + requested = parse_context_window(wanted) + await app.switch_context_window(requested) + except (OSError, RuntimeError, ValueError) as exc: + return f"context switch failed: {exc}" + profile = app.thread_client.execution_profile + return ( + f"context cap switched to {app.requested_context_window} " + f"(effective: {profile.context_window} tokens; history preserved)" + ) + + _PERMISSION_CHOICES: dict[str, str | None] = { "ask": "ask", "read-only": "read_only", @@ -765,6 +794,13 @@ async def _cmd_exit(app, args: str) -> str | None: _cmd_effort, arguments=_effort_levels, ), + Command( + "context", + "/context [auto|tokens]", + "show or set this Session's context-window cap", + _cmd_context, + arguments=_context_windows, + ), Command( "permissions", "/permissions [preset]", diff --git a/cli/tui/thread_client.py b/cli/tui/thread_client.py index 0e815f67..bc76c2c0 100644 --- a/cli/tui/thread_client.py +++ b/cli/tui/thread_client.py @@ -325,6 +325,7 @@ def new_thread(self, *, title: str = "") -> Thread: connection_id=self.execution_profile.connection_id, model=self.execution_profile.model_id, reasoning_effort=self._requested.reasoning_effort, + context_window=self._requested.context_window, workspace_path=self.workspace, ) self._replace_thread(thread) @@ -391,11 +392,13 @@ def switch_execution( connection_id: str | None, model: str | None, reasoning_effort: str | None, + context_window: int | None, ) -> ExecutionProfile: selection = ExecutionSelection( connection_id=connection_id, model_id=model, reasoning_effort=reasoning_effort, + context_window=context_window, ) profile = self.application.llm.resolve(self.workspace, selection) self.thread = self.application.threads.set_execution_selection( @@ -403,6 +406,7 @@ def switch_execution( connection_id=profile.connection_id, model=profile.model_id, reasoning_effort=reasoning_effort, + context_window=context_window, ) self._requested = selection self.execution_profile = profile @@ -462,6 +466,7 @@ def _open_thread(self, resume_id: str | None) -> Thread: connection_id=profile.connection_id, model=profile.model_id, reasoning_effort=self._requested.reasoning_effort, + context_window=self._requested.context_window, workspace_path=self.workspace, ) thread = self.application.threads.resume( @@ -476,6 +481,11 @@ def _open_thread(self, resume_id: str | None) -> Thread: if self._requested.reasoning_effort is not None else thread.reasoning_effort ), + context_window=( + self._requested.context_window + if self._requested.context_window is not None + else thread.context_window + ), ) self._requested = stored_selection profile = self.application.llm.resolve(self.workspace, stored_selection) @@ -484,6 +494,7 @@ def _open_thread(self, resume_id: str | None) -> Thread: connection_id=profile.connection_id, model=profile.model_id, reasoning_effort=stored_selection.reasoning_effort, + context_window=stored_selection.context_window, ) def _resolve_selection(self, thread: Thread) -> ExecutionProfile: @@ -495,6 +506,7 @@ def _resolve_selection(self, thread: Thread) -> ExecutionProfile: connection_id=thread.connection_id, model_id=thread.model, reasoning_effort=thread.reasoning_effort, + context_window=thread.context_window, ) self._requested = selection return self.application.llm.resolve(self.workspace, selection) diff --git a/core/application/thread_service.py b/core/application/thread_service.py index 456f96ac..8c7ead3d 100644 --- a/core/application/thread_service.py +++ b/core/application/thread_service.py @@ -22,7 +22,12 @@ from core.application.views import item_view, thread_view, turn_view, workflow_view from core.domain.common import new_id, utc_now from core.domain.event import DomainEvent -from core.domain.execution_profile import ExecutionProfile +from core.domain.execution_profile import ( + MAX_CONTEXT_WINDOW_TOKENS, + MIN_CONTEXT_WINDOW_TOKENS, + ExecutionProfile, + ExecutionSelection, +) from core.domain.execution_security import ( ExecutionAccessPreset, ExecutionSecurityProfile, @@ -140,6 +145,7 @@ def start( model: str | None = None, connection_id: str | None = None, reasoning_effort: str | None = None, + context_window: int | None = None, access_preset_override: ExecutionAccessPreset | None = None, workspace_path: str | None = None, parent_thread_id: str | None = None, @@ -178,6 +184,7 @@ def start( else None ) resolved_reasoning = normalize_reasoning_effort(reasoning_effort) + resolved_context_window = self._normalize_context_window(context_window) if access_preset_override is not None and not isinstance( access_preset_override, ExecutionAccessPreset, @@ -222,6 +229,7 @@ def start( "model": resolved_model, "connection_id": resolved_connection, "reasoning_effort": resolved_reasoning, + "context_window": resolved_context_window, "access_preset_override": ( access_preset_override.value if access_preset_override is not None @@ -468,6 +476,7 @@ def set_execution_selection( connection_id: str | None, model: str | None, reasoning_effort: str | None | object = _UNSET, + context_window: int | None | object = _UNSET, ) -> Thread: """Atomically change the selection used by future Turns.""" @@ -477,7 +486,7 @@ def set_execution_selection( else None ) resolved_model = model.strip() if model and model.strip() else None - metadata: dict[str, str | None] = { + metadata: dict[str, object] = { "connection_id": resolved_connection, "model": resolved_model, } @@ -485,6 +494,10 @@ def set_execution_selection( metadata["reasoning_effort"] = normalize_reasoning_effort( reasoning_effort if isinstance(reasoning_effort, str) else None ) + if context_window is not _UNSET: + metadata["context_window"] = self._normalize_context_window( + context_window if isinstance(context_window, int) else None + ) if not self.session_store.update_metadata(thread_id, metadata): raise ThreadNotFoundError(f"thread not found: {thread_id}") return self._project_updated_session(thread_id, "thread.model_changed") @@ -630,6 +643,7 @@ def _ensure_projection( model = self._model_for(metadata) connection_id = self._connection_for(metadata) reasoning_effort = self._reasoning_for(metadata) + context_window = self._context_window_for(metadata) access_preset_override = self._access_preset_for(metadata) archived = bool(metadata.get("archived")) archived_at = ( @@ -661,6 +675,7 @@ def _ensure_projection( model=model, connection_id=connection_id, reasoning_effort=reasoning_effort, + context_window=context_window, access_preset_override=access_preset_override, workspace_path=str(workspace), created_at=canonical_created, @@ -699,6 +714,7 @@ def _ensure_projection( model=model, connection_id=connection_id, reasoning_effort=reasoning_effort, + context_window=context_window, access_preset_override=access_preset_override, workspace_path=str(workspace), updated_at=updated_at, @@ -1237,6 +1253,7 @@ def _materialization_metadata( "model": thread.model, "connection_id": thread.connection_id, "reasoning_effort": thread.reasoning_effort, + "context_window": thread.context_window, "access_preset_override": ( thread.access_preset_override.value if thread.access_preset_override is not None @@ -1274,6 +1291,7 @@ def _materialization_signature( thread.model, thread.connection_id, thread.reasoning_effort, + thread.context_window, thread.access_preset_override, thread.status is ThreadStatus.ARCHIVED, thread.archived_at, @@ -1510,6 +1528,29 @@ def _reasoning_for(metadata: dict) -> str | None: raw = metadata.get("reasoning_effort") or metadata.get("reasoningEffort") return normalize_reasoning_effort(str(raw)) if raw is not None else None + @staticmethod + def _context_window_for(metadata: dict) -> int | None: + raw = metadata.get("context_window") or metadata.get("contextWindow") + if ( + isinstance(raw, bool) + or not isinstance(raw, int) + or raw < MIN_CONTEXT_WINDOW_TOKENS + or raw > MAX_CONTEXT_WINDOW_TOKENS + ): + return None + return raw + + @staticmethod + def _normalize_context_window(value: int | None) -> int | None: + if value is not None and ( + isinstance(value, bool) or not isinstance(value, int) + ): + raise InvalidArgumentError("context_window must be an integer or None") + try: + return ExecutionSelection(context_window=value).normalized().context_window + except ValueError as exc: + raise InvalidArgumentError(str(exc)) from exc + @staticmethod def _access_preset_for(metadata: dict) -> ExecutionAccessPreset | None: try: diff --git a/core/application/turn_service.py b/core/application/turn_service.py index fcec5492..14f23181 100644 --- a/core/application/turn_service.py +++ b/core/application/turn_service.py @@ -621,6 +621,7 @@ def _submit( if reasoning_effort is not None else thread.reasoning_effort ), + context_window=thread.context_window, ), ) goal_turns = ( @@ -1170,6 +1171,7 @@ async def compact_live_context(self, thread_id: str) -> dict[str, Any]: connection_id=thread.connection_id, model_id=thread.model, reasoning_effort=thread.reasoning_effort, + context_window=thread.context_window, ), ) return await self.session_runtimes.compact_live_history( @@ -1737,6 +1739,7 @@ def _mark_running( connection_id=thread.connection_id, model_id=thread.model, reasoning_effort=thread.reasoning_effort, + context_window=thread.context_window, ), ) running = replace(running, execution_profile=profile) diff --git a/core/application/views.py b/core/application/views.py index e1f61dd1..cde29d50 100644 --- a/core/application/views.py +++ b/core/application/views.py @@ -430,6 +430,7 @@ def thread_view(thread: Thread) -> dict[str, Any]: "model": thread.model, "connectionId": thread.connection_id, "reasoningEffort": thread.reasoning_effort, + "contextWindow": thread.context_window, "accessPresetOverride": ( thread.access_preset_override.value if thread.access_preset_override is not None diff --git a/core/application/workflow_service.py b/core/application/workflow_service.py index 6e511158..4445628a 100644 --- a/core/application/workflow_service.py +++ b/core/application/workflow_service.py @@ -695,6 +695,7 @@ def _start( connection_id=thread.connection_id, model_id=thread.model, reasoning_effort=thread.reasoning_effort, + context_window=thread.context_window, ) phase_profiles = self.llm_configuration.resolve_phases( context.root, diff --git a/core/domain/execution_profile.py b/core/domain/execution_profile.py index 8c27a237..01e3f4d2 100644 --- a/core/domain/execution_profile.py +++ b/core/domain/execution_profile.py @@ -6,6 +6,9 @@ from dataclasses import dataclass from typing import Any +MIN_CONTEXT_WINDOW_TOKENS = 4_096 +MAX_CONTEXT_WINDOW_TOKENS = 10_000_000 + @dataclass(frozen=True, slots=True) class ExecutionSelection: @@ -14,12 +17,26 @@ class ExecutionSelection: connection_id: str | None = None model_id: str | None = None reasoning_effort: str | None = None + context_window: int | None = None def normalized(self) -> "ExecutionSelection": + context_window = self.context_window + if context_window is not None: + if isinstance(context_window, bool) or not isinstance(context_window, int): + raise ValueError("context_window must be an integer or None") + if context_window < MIN_CONTEXT_WINDOW_TOKENS: + raise ValueError( + f"context_window must be at least {MIN_CONTEXT_WINDOW_TOKENS}" + ) + if context_window > MAX_CONTEXT_WINDOW_TOKENS: + raise ValueError( + f"context_window must be at most {MAX_CONTEXT_WINDOW_TOKENS}" + ) return ExecutionSelection( connection_id=_clean_optional(self.connection_id), model_id=_clean_optional(self.model_id), reasoning_effort=_clean_optional(self.reasoning_effort), + context_window=context_window, ) @@ -109,4 +126,9 @@ def _clean_optional(value: str | None) -> str | None: return clean or None -__all__ = ["ExecutionProfile", "ExecutionSelection"] +__all__ = [ + "MIN_CONTEXT_WINDOW_TOKENS", + "MAX_CONTEXT_WINDOW_TOKENS", + "ExecutionProfile", + "ExecutionSelection", +] diff --git a/core/domain/thread.py b/core/domain/thread.py index d269e367..28577aa7 100644 --- a/core/domain/thread.py +++ b/core/domain/thread.py @@ -13,6 +13,10 @@ require_prefixed_id, utc_now, ) +from core.domain.execution_profile import ( + MAX_CONTEXT_WINDOW_TOKENS, + MIN_CONTEXT_WINDOW_TOKENS, +) from core.domain.execution_security import ExecutionAccessPreset @@ -42,6 +46,7 @@ class Thread: model: str | None = None connection_id: str | None = None reasoning_effort: str | None = None + context_window: int | None = None access_preset_override: ExecutionAccessPreset | None = None parent_thread_id: str | None = None worktree_path: str | None = None @@ -63,6 +68,19 @@ def __post_init__(self) -> None: require_non_empty(self.connection_id, "connection_id") if self.reasoning_effort is not None: require_non_empty(self.reasoning_effort, "reasoning_effort") + if self.context_window is not None: + if isinstance(self.context_window, bool) or not isinstance( + self.context_window, int + ): + raise TypeError("context_window must be an integer or None") + if self.context_window < MIN_CONTEXT_WINDOW_TOKENS: + raise ValueError( + f"context_window must be at least {MIN_CONTEXT_WINDOW_TOKENS}" + ) + if self.context_window > MAX_CONTEXT_WINDOW_TOKENS: + raise ValueError( + f"context_window must be at most {MAX_CONTEXT_WINDOW_TOKENS}" + ) if self.access_preset_override is not None and not isinstance( self.access_preset_override, ExecutionAccessPreset, diff --git a/core/persistence/migrations.py b/core/persistence/migrations.py index 4e48d0fd..0682096b 100644 --- a/core/persistence/migrations.py +++ b/core/persistence/migrations.py @@ -1310,6 +1310,19 @@ class Migration: _REMOVE_WEB_ACCESS_POLICY_V16 = _DROP_WEB_ACCESS_POLICY_V15 _RESTORE_WEB_ACCESS_POLICY_V16 = _WEB_ACCESS_POLICY_V15 +_SESSION_CONTEXT_WINDOW_V17 = r""" +ALTER TABLE threads + ADD COLUMN context_window INTEGER + CHECK ( + context_window IS NULL OR + context_window BETWEEN 4096 AND 10000000 + ); +""" + +_DROP_SESSION_CONTEXT_WINDOW_V17 = r""" +ALTER TABLE threads DROP COLUMN context_window; +""" + MIGRATIONS = ( Migration(1, "initial_domain", _INITIAL_SCHEMA, _DROP_INITIAL_SCHEMA), Migration( @@ -1402,6 +1415,12 @@ class Migration: _REMOVE_WEB_ACCESS_POLICY_V16, _RESTORE_WEB_ACCESS_POLICY_V16, ), + Migration( + 17, + "session_context_window", + _SESSION_CONTEXT_WINDOW_V17, + _DROP_SESSION_CONTEXT_WINDOW_V17, + ), ) LATEST_SCHEMA_VERSION = MIGRATIONS[-1].version diff --git a/core/persistence/thread_repository.py b/core/persistence/thread_repository.py index 8297d558..e7c7c8fe 100644 --- a/core/persistence/thread_repository.py +++ b/core/persistence/thread_repository.py @@ -39,6 +39,9 @@ def add(self, thread: Thread) -> None: dump_datetime(thread.updated_at), dump_datetime(thread.archived_at), ) + if self._has_context_window_column(): + columns += ", context_window" + values += (thread.context_window,) if self._has_access_preset_column(): columns += ", access_preset_override" values += ( @@ -56,6 +59,9 @@ def update(self, thread: Thread) -> None: access_assignment = ( ", access_preset_override = ?" if self._has_access_preset_column() else "" ) + context_assignment = ( + ", context_window = ?" if self._has_context_window_column() else "" + ) values: tuple[object, ...] = ( thread.project_id, thread.parent_thread_id, @@ -70,6 +76,8 @@ def update(self, thread: Thread) -> None: dump_datetime(thread.updated_at), dump_datetime(thread.archived_at), ) + if context_assignment: + values += (thread.context_window,) if access_assignment: values += ( thread.access_preset_override.value @@ -81,7 +89,8 @@ def update(self, thread: Thread) -> None: "UPDATE threads SET project_id = ?, parent_thread_id = ?, title = ?, " "mode = ?, status = ?, model = ?, connection_id = ?, " "workspace_path = ?, worktree_path = ?, reasoning_effort = ?, " - f"updated_at = ?, archived_at = ?{access_assignment} WHERE id = ?", + f"updated_at = ?, archived_at = ?{context_assignment}" + f"{access_assignment} WHERE id = ?", values, ) if cursor.rowcount != 1: @@ -133,6 +142,7 @@ def remove(self, thread_id: str) -> bool: @staticmethod def _from_row(row: sqlite3.Row) -> Thread: access_preset_available = "access_preset_override" in row.keys() + context_window_available = "context_window" in row.keys() return Thread( id=row["id"], project_id=row["project_id"], @@ -143,6 +153,9 @@ def _from_row(row: sqlite3.Row) -> Thread: model=row["model"], connection_id=row["connection_id"], reasoning_effort=row["reasoning_effort"], + context_window=( + row["context_window"] if context_window_available else None + ), access_preset_override=( ExecutionAccessPreset(row["access_preset_override"]) if access_preset_available and row["access_preset_override"] is not None @@ -160,3 +173,9 @@ def _has_access_preset_column(self) -> bool: row["name"] == "access_preset_override" for row in self.connection.execute("PRAGMA table_info(threads)") ) + + def _has_context_window_column(self) -> bool: + return any( + row["name"] == "context_window" + for row in self.connection.execute("PRAGMA table_info(threads)") + ) diff --git a/core/providers/profiles.py b/core/providers/profiles.py index ae0daf4a..1b910b83 100644 --- a/core/providers/profiles.py +++ b/core/providers/profiles.py @@ -181,12 +181,26 @@ def execution_profile( connection, model = self.resolve_selection(normalized, phase=phase) settings = self.config.resolve_phase(phase) info = resolve_model_info(model) - context_window, max_output_tokens = model_limits or ( + published_context_window, max_output_tokens = model_limits or ( info.context_window, info.max_output_tokens, ) - if context_window < 1 or max_output_tokens < 1: + if published_context_window < 1 or max_output_tokens < 1: raise ConfigError(f"Invalid model limits for '{model}'") + max_tokens = min(settings.max_tokens, max_output_tokens) + context_window = published_context_window + if normalized.context_window is not None: + if normalized.context_window > published_context_window: + raise ConfigError( + f"Context window cap {normalized.context_window} exceeds " + f"the published {published_context_window} token window for '{model}'" + ) + if normalized.context_window <= max_tokens: + raise ConfigError( + f"Context window cap must exceed the {max_tokens} token " + "generation limit" + ) + context_window = normalized.context_window capabilities = reasoning_capabilities or infer_reasoning_capabilities( model, provider_name=connection.provider_name, @@ -206,7 +220,7 @@ def execution_profile( model_id=model, context_window=context_window, max_output_tokens=max_output_tokens, - max_tokens=min(settings.max_tokens, max_output_tokens), + max_tokens=max_tokens, temperature=settings.temperature, reasoning_effort=reasoning_effort, config_revision=self.connection_revision(connection), diff --git a/desktop/src/App.test.tsx b/desktop/src/App.test.tsx index c6a61c57..05270107 100644 --- a/desktop/src/App.test.tsx +++ b/desktop/src/App.test.tsx @@ -746,6 +746,10 @@ class TestRuntime implements DesktopRuntime { connectionId: request.connectionId, model: request.model, reasoningEffort: request.reasoningEffort, + contextWindow: + request.contextWindow === undefined + ? this.threadState[index].contextWindow + : request.contextWindow, }; return { thread: this.threadState[index] } as MethodResults[M]; } @@ -1128,6 +1132,7 @@ const thread: Thread = { model: null, connectionId: null, reasoningEffort: null, + contextWindow: null, accessPresetOverride: null, workspacePath: project.canonicalPath, worktreePath: null, @@ -2933,6 +2938,10 @@ describe("desktop command center", () => { const highEffort = screen.getByRole("radio", { name: "High" }); fireEvent.click(highEffort); expect(highEffort.getAttribute("aria-checked")).toBe("true"); + fireEvent.change( + screen.getByRole("combobox", { name: "Context window cap" }), + { target: { value: "64000" } }, + ); fireEvent.click(screen.getByRole("button", { name: "Apply" })); await waitFor(() => { expect(runtime.calls).toContain("thread/execution/update"); @@ -2947,6 +2956,7 @@ describe("desktop command center", () => { connectionId: "openai", model: "gpt-5-mini", reasoningEffort: "high", + contextWindow: 64_000, }); fireEvent.change(permissions, { target: { value: "read_only" } }); diff --git a/desktop/src/App.tsx b/desktop/src/App.tsx index 7f29374b..700f4d25 100644 --- a/desktop/src/App.tsx +++ b/desktop/src/App.tsx @@ -350,11 +350,17 @@ export function App({ runtime = tauriRuntime }: { runtime?: DesktopRuntime }) { disabledReason={disabledReason} transcriptMode={transcript.mode} onTranscriptModeChange={transcript.selectMode} - onModelChange={(connectionId, model, reasoningEffort) => + onModelChange={( + connectionId, + model, + reasoningEffort, + contextWindow, + ) => void controller.setThreadExecution( connectionId, model, reasoningEffort, + contextWindow, ) } onAccessPresetChange={controller.setAccessPreset} diff --git a/desktop/src/app/useWorkspaceController.ts b/desktop/src/app/useWorkspaceController.ts index c710ef1d..203eb2b9 100644 --- a/desktop/src/app/useWorkspaceController.ts +++ b/desktop/src/app/useWorkspaceController.ts @@ -91,6 +91,7 @@ export interface WorkspaceController { connectionId: string | null, model: string | null, reasoningEffort: string | null, + contextWindow: number | null, ): Promise; setAccessPreset(preset: ExecutionAccessPreset | null): Promise; refreshSettings(): Promise; @@ -506,6 +507,7 @@ export function useWorkspaceController(runtime: DesktopRuntime): WorkspaceContro connectionId: string | null, model: string | null, reasoningEffort: string | null, + contextWindow: number | null, ) => withBusy(async () => { if (!selectedThread) return; @@ -514,6 +516,7 @@ export function useWorkspaceController(runtime: DesktopRuntime): WorkspaceContro connectionId, model, reasoningEffort, + contextWindow, }); dispatch({ type: "thread-upsert", thread: result.thread }); }), diff --git a/desktop/src/app/workspaceState.test.ts b/desktop/src/app/workspaceState.test.ts index ff08dd1b..14e1b0e8 100644 --- a/desktop/src/app/workspaceState.test.ts +++ b/desktop/src/app/workspaceState.test.ts @@ -67,6 +67,7 @@ describe("workspace event projection", () => { model: null, connectionId: null, reasoningEffort: null, + contextWindow: null, accessPresetOverride: null, workspacePath: "/workspace/project-1", worktreePath: null, diff --git a/desktop/src/features/automations/AutomationsPage.test.tsx b/desktop/src/features/automations/AutomationsPage.test.tsx index 9943c756..94a033f4 100644 --- a/desktop/src/features/automations/AutomationsPage.test.tsx +++ b/desktop/src/features/automations/AutomationsPage.test.tsx @@ -50,6 +50,7 @@ function goalThread(id = "thread-automation"): Thread { model: null, connectionId: null, reasoningEffort: null, + contextWindow: null, accessPresetOverride: null, workspacePath: project.canonicalPath, worktreePath: null, diff --git a/desktop/src/features/execution/Composer.tsx b/desktop/src/features/execution/Composer.tsx index d55d60e8..2ce5c795 100644 --- a/desktop/src/features/execution/Composer.tsx +++ b/desktop/src/features/execution/Composer.tsx @@ -67,6 +67,7 @@ interface ComposerProps { connectionId: string | null, model: string | null, reasoningEffort: string | null, + contextWindow: number | null, ): void; onAccessPresetChange(preset: ExecutionAccessPreset | null): Promise; onSetGoal(input: GoalDefinitionInput): Promise; diff --git a/desktop/src/features/execution/ModelPicker.module.css b/desktop/src/features/execution/ModelPicker.module.css index b43c8cd4..24bfdaf7 100644 --- a/desktop/src/features/execution/ModelPicker.module.css +++ b/desktop/src/features/execution/ModelPicker.module.css @@ -285,6 +285,35 @@ color: var(--text-primary); } +.context { + display: grid; + grid-template-columns: minmax(0, 1fr) 150px; + align-items: center; + gap: 12px; + padding: 11px 14px; + border-top: 1px solid var(--border-subtle); +} + +.context > div { + display: grid; + gap: 2px; +} + +.context strong { + color: var(--text-primary); + font-size: var(--text-2xs); +} + +.context span { + color: var(--text-tertiary); + font-size: var(--text-micro); +} + +.context select { + min-height: 34px; + font: var(--text-2xs) var(--font-mono); +} + .menu > footer { display: grid; grid-template-columns: minmax(0, 1fr) auto auto auto; diff --git a/desktop/src/features/execution/ModelPicker.tsx b/desktop/src/features/execution/ModelPicker.tsx index 3f4b9b1f..bd7fbc0d 100644 --- a/desktop/src/features/execution/ModelPicker.tsx +++ b/desktop/src/features/execution/ModelPicker.tsx @@ -29,6 +29,7 @@ interface ModelPickerProps { connectionId: string | null, model: string | null, reasoningEffort: string | null, + contextWindow: number | null, ): void; } @@ -49,6 +50,7 @@ export function ModelPicker({ const [connectionId, setConnectionId] = useState(""); const [modelId, setModelId] = useState(""); const [reasoningEffort, setReasoningEffort] = useState("auto"); + const [contextWindow, setContextWindow] = useState(null); const [catalog, setCatalog] = useState(null); const [refreshingModels, setRefreshingModels] = useState(false); const [modelFailure, setModelFailure] = useState<{ @@ -83,6 +85,15 @@ export function ModelPicker({ : "auto"; const effectiveEffort = thread?.reasoningEffort ?? defaults.reasoningEffort ?? "auto"; + const effectiveContextWindow = thread?.contextWindow ?? null; + const contextOptions = useMemo( + () => + contextWindowOptions( + selectedModel?.contextWindow ?? null, + contextWindow, + ), + [contextWindow, selectedModel?.contextWindow], + ); useEffect(() => { if (!open || !selectedConnectionId) return; @@ -151,7 +162,12 @@ export function ModelPicker({ const apply = () => { if (!selectedConnectionId || !modelId) return; - onChange(selectedConnectionId, modelId, validReasoningEffort); + onChange( + selectedConnectionId, + modelId, + validReasoningEffort, + contextWindow, + ); setOpen(false); setQuery(""); setManualModel(""); @@ -185,6 +201,7 @@ export function ModelPicker({ if (!open) { setModelId(effectiveModel ?? ""); setReasoningEffort(effectiveEffort); + setContextWindow(effectiveContextWindow); } setOpen((current) => !current); }} @@ -198,7 +215,12 @@ export function ModelPicker({ {effectiveConnection?.label ?? "Automatic"} {effectiveModel || "Configured model"} - {effortLabel(effectiveEffort)} + + {effortLabel(effectiveEffort)} ·{" "} + {effectiveContextWindow + ? `${formatTokens(effectiveContextWindow)} context cap` + : "Auto context"} + @@ -232,6 +254,7 @@ export function ModelPicker({ setConnectionId(event.target.value); setModelId(""); setReasoningEffort("auto"); + setContextWindow(null); setModelFailure(null); setQuery(""); }} @@ -273,6 +296,7 @@ export function ModelPicker({ onClick={() => { setModelId(model.id); setReasoningEffort("auto"); + setContextWindow(null); }} > @@ -321,6 +345,38 @@ export function ModelPicker({ +
+
+ Context + + Cap future Turns below the model's published window. Lower + caps compact history sooner. + +
+ +
+