From c4e48b5c71b9e23749f3dcd9aa623adb4872ffcc Mon Sep 17 00:00:00 2001 From: majdyz Date: Mon, 13 Apr 2026 00:39:30 +0000 Subject: [PATCH 01/15] perf(backend): enable cross-user prompt caching via SystemPromptPreset Use SystemPromptPreset with exclude_dynamic_sections=True in the SDK path so the Claude Code default prompt serves as a cacheable prefix shared across all users. Our custom prompt is appended after it, and dynamic sections (working dir, git status, auto-memory) are excluded from the prefix -- giving cross-user cache hits that reduce input token cost by ~90%. Add claude_agent_exclude_dynamic_sections config field (default True) to make this configurable, with fallback to raw string when disabled. --- .../backend/backend/copilot/config.py | 8 ++ .../backend/backend/copilot/sdk/service.py | 18 ++++- .../backend/copilot/sdk/service_test.py | 79 ++++++++++++++++++- 3 files changed, 101 insertions(+), 4 deletions(-) diff --git a/autogpt_platform/backend/backend/copilot/config.py b/autogpt_platform/backend/backend/copilot/config.py index 6da1cae52bb9..d62162c71b2a 100644 --- a/autogpt_platform/backend/backend/copilot/config.py +++ b/autogpt_platform/backend/backend/copilot/config.py @@ -172,6 +172,14 @@ class ChatConfig(BaseSettings): description="Maximum number of retries for transient API errors " "(429, 5xx, ECONNRESET) before surfacing the error to the user.", ) + claude_agent_exclude_dynamic_sections: bool = Field( + default=True, + description="Use SystemPromptPreset with exclude_dynamic_sections=True to " + "enable cross-user prompt caching. The Claude Code default prompt " + "becomes a cacheable prefix shared across all users, and our custom " + "prompt is appended after it. Set to False to fall back to passing " + "the system prompt as a raw string.", + ) use_openrouter: bool = Field( default=True, description="Enable routing API calls through the OpenRouter proxy. " diff --git a/autogpt_platform/backend/backend/copilot/sdk/service.py b/autogpt_platform/backend/backend/copilot/sdk/service.py index 23f8041d5367..dbe8988b4159 100644 --- a/autogpt_platform/backend/backend/copilot/sdk/service.py +++ b/autogpt_platform/backend/backend/copilot/sdk/service.py @@ -2220,8 +2220,24 @@ def _on_stderr(line: str) -> None: sid, ) + # When exclude_dynamic_sections is enabled, use SystemPromptPreset + # so the Claude Code default prompt is a cacheable prefix shared + # across all users. Our custom prompt is appended after it and + # dynamic sections (working dir, git status, auto-memory) are + # excluded from the prefix — giving us cross-user cache hits that + # reduce input token cost by ~90%. + if config.claude_agent_exclude_dynamic_sections: + system_prompt_value: str | dict[str, Any] = { + "type": "preset", + "preset": "claude_code", + "append": system_prompt, + "exclude_dynamic_sections": True, + } + else: + system_prompt_value = system_prompt + sdk_options_kwargs: dict[str, Any] = { - "system_prompt": system_prompt, + "system_prompt": system_prompt_value, "mcp_servers": {"copilot": mcp_server}, "allowed_tools": allowed, "disallowed_tools": disallowed, diff --git a/autogpt_platform/backend/backend/copilot/sdk/service_test.py b/autogpt_platform/backend/backend/copilot/sdk/service_test.py index 5eb9981c5b61..74b0769bebe6 100644 --- a/autogpt_platform/backend/backend/copilot/sdk/service_test.py +++ b/autogpt_platform/backend/backend/copilot/sdk/service_test.py @@ -234,9 +234,9 @@ def test_baseline_supplement_completeness(self): for tool_name, tool in TOOL_REGISTRY.items(): if not tool.is_available: continue - assert ( - f"`{tool_name}`" in docs - ), f"Tool '{tool_name}' missing from baseline supplement" + assert f"`{tool_name}`" in docs, ( + f"Tool '{tool_name}' missing from baseline supplement" + ) def test_pause_task_scheduled_before_transcript_upload(self): """Pause is scheduled as a background task before transcript upload begins. @@ -656,3 +656,76 @@ async def test_unrelated_value_error_propagates(self): client.__aexit__ = AsyncMock(side_effect=ValueError("invalid argument")) with pytest.raises(ValueError, match="invalid argument"): await _safe_close_sdk_client(client, "[test]") + + +# --------------------------------------------------------------------------- +# SystemPromptPreset — cross-user prompt caching +# --------------------------------------------------------------------------- + + +class TestSystemPromptPreset: + """Tests for SystemPromptPreset construction with exclude_dynamic_sections.""" + + def _make_config(self, exclude: bool, monkeypatch, _clean_config_env): + from backend.copilot import config as cfg_mod + + return cfg_mod.ChatConfig( + use_openrouter=False, + api_key=None, + base_url=None, + use_claude_code_subscription=False, + claude_agent_exclude_dynamic_sections=exclude, + ) + + def test_preset_dict_structure_when_enabled(self, monkeypatch, _clean_config_env): + """When exclude_dynamic_sections is True, system_prompt should be a + SystemPromptPreset dict with the correct keys.""" + cfg = self._make_config(True, monkeypatch, _clean_config_env) + + custom_prompt = "You are a helpful assistant." + if cfg.claude_agent_exclude_dynamic_sections: + result = { + "type": "preset", + "preset": "claude_code", + "append": custom_prompt, + "exclude_dynamic_sections": True, + } + else: + result = custom_prompt + + assert isinstance(result, dict) + assert result["type"] == "preset" + assert result["preset"] == "claude_code" + assert result["append"] == custom_prompt + assert result["exclude_dynamic_sections"] is True + + def test_raw_string_when_disabled(self, monkeypatch, _clean_config_env): + """When exclude_dynamic_sections is False, system_prompt should be a + raw string.""" + cfg = self._make_config(False, monkeypatch, _clean_config_env) + + custom_prompt = "You are a helpful assistant." + if cfg.claude_agent_exclude_dynamic_sections: + result = { + "type": "preset", + "preset": "claude_code", + "append": custom_prompt, + "exclude_dynamic_sections": True, + } + else: + result = custom_prompt + + assert isinstance(result, str) + assert result == custom_prompt + + def test_default_is_enabled(self, monkeypatch, _clean_config_env): + """The default value for claude_agent_exclude_dynamic_sections is True.""" + from backend.copilot import config as cfg_mod + + cfg = cfg_mod.ChatConfig( + use_openrouter=False, + api_key=None, + base_url=None, + use_claude_code_subscription=False, + ) + assert cfg.claude_agent_exclude_dynamic_sections is True From 54f507b54b0dc5b38206870c8415b7ebda57f7cd Mon Sep 17 00:00:00 2001 From: majdyz Date: Mon, 13 Apr 2026 00:48:37 +0000 Subject: [PATCH 02/15] =?UTF-8?q?fix(backend):=20address=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20extract=20testable=20helper,=20add=20TypedDict,=20r?= =?UTF-8?q?ename=20config=20field?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract _build_system_prompt_value() helper so tests exercise production code instead of reconstructing the dict locally. - Add _SystemPromptPreset TypedDict for proper type annotation (replaces str | dict[str, Any]). - Rename claude_agent_exclude_dynamic_sections → claude_agent_cross_user_prompt_cache for clarity. --- .../backend/backend/copilot/config.py | 13 ++--- .../backend/backend/copilot/sdk/service.py | 54 +++++++++++++++---- .../backend/copilot/sdk/service_test.py | 54 ++++--------------- 3 files changed, 61 insertions(+), 60 deletions(-) diff --git a/autogpt_platform/backend/backend/copilot/config.py b/autogpt_platform/backend/backend/copilot/config.py index d62162c71b2a..7bf2decc8fbd 100644 --- a/autogpt_platform/backend/backend/copilot/config.py +++ b/autogpt_platform/backend/backend/copilot/config.py @@ -172,13 +172,14 @@ class ChatConfig(BaseSettings): description="Maximum number of retries for transient API errors " "(429, 5xx, ECONNRESET) before surfacing the error to the user.", ) - claude_agent_exclude_dynamic_sections: bool = Field( + claude_agent_cross_user_prompt_cache: bool = Field( default=True, - description="Use SystemPromptPreset with exclude_dynamic_sections=True to " - "enable cross-user prompt caching. The Claude Code default prompt " - "becomes a cacheable prefix shared across all users, and our custom " - "prompt is appended after it. Set to False to fall back to passing " - "the system prompt as a raw string.", + description="Enable cross-user prompt caching via SystemPromptPreset. " + "The Claude Code default prompt becomes a cacheable prefix shared " + "across all users, and our custom prompt is appended after it. " + "Dynamic sections (working dir, git status, auto-memory) are excluded " + "from the prefix. Set to False to fall back to passing the system " + "prompt as a raw string.", ) use_openrouter: bool = Field( default=True, diff --git a/autogpt_platform/backend/backend/copilot/sdk/service.py b/autogpt_platform/backend/backend/copilot/sdk/service.py index dbe8988b4159..0af308be93a8 100644 --- a/autogpt_platform/backend/backend/copilot/sdk/service.py +++ b/autogpt_platform/backend/backend/copilot/sdk/service.py @@ -13,7 +13,22 @@ import uuid from collections.abc import AsyncGenerator, AsyncIterator from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, NamedTuple, cast +from typing import TYPE_CHECKING, Any, Literal, NamedTuple, TypedDict, cast + + +class _SystemPromptPreset(TypedDict): + """Local stand-in for the SDK's SystemPromptPreset. + + The production SDK (>=0.1.58) defines this type natively. We keep a + local copy so that: + 1. The code type-checks on older SDK pins (e.g. 0.1.45 on dev). + 2. Tests can import and verify the shape without an SDK upgrade. + """ + + type: Literal["preset"] + preset: Literal["claude_code"] + append: str + exclude_dynamic_sections: bool if TYPE_CHECKING: from backend.copilot.permissions import CopilotPermissions @@ -699,6 +714,28 @@ def _is_fallback_stderr(line: str) -> bool: return "fallback model" in line.lower() +def _build_system_prompt_value( + system_prompt: str, + cross_user_cache: bool, +) -> str | _SystemPromptPreset: + """Build the ``system_prompt`` argument for :class:`ClaudeAgentOptions`. + + When *cross_user_cache* is enabled, returns a :class:`_SystemPromptPreset` + dict so the Claude Code default prompt becomes a cacheable prefix shared + across all users; our custom *system_prompt* is appended after it. + + When disabled, the raw *system_prompt* string is returned unchanged. + """ + if cross_user_cache: + return _SystemPromptPreset( + type="preset", + preset="claude_code", + append=system_prompt, + exclude_dynamic_sections=True, + ) + return system_prompt + + def _make_sdk_cwd(session_id: str) -> str: """Create a safe, session-specific working directory path. @@ -2220,21 +2257,16 @@ def _on_stderr(line: str) -> None: sid, ) - # When exclude_dynamic_sections is enabled, use SystemPromptPreset + # When cross-user prompt caching is enabled, use SystemPromptPreset # so the Claude Code default prompt is a cacheable prefix shared # across all users. Our custom prompt is appended after it and # dynamic sections (working dir, git status, auto-memory) are # excluded from the prefix — giving us cross-user cache hits that # reduce input token cost by ~90%. - if config.claude_agent_exclude_dynamic_sections: - system_prompt_value: str | dict[str, Any] = { - "type": "preset", - "preset": "claude_code", - "append": system_prompt, - "exclude_dynamic_sections": True, - } - else: - system_prompt_value = system_prompt + system_prompt_value = _build_system_prompt_value( + system_prompt, + cross_user_cache=config.claude_agent_cross_user_prompt_cache, + ) sdk_options_kwargs: dict[str, Any] = { "system_prompt": system_prompt_value, diff --git a/autogpt_platform/backend/backend/copilot/sdk/service_test.py b/autogpt_platform/backend/backend/copilot/sdk/service_test.py index 74b0769bebe6..b196b132f19c 100644 --- a/autogpt_platform/backend/backend/copilot/sdk/service_test.py +++ b/autogpt_platform/backend/backend/copilot/sdk/service_test.py @@ -9,6 +9,7 @@ import pytest from .service import ( + _build_system_prompt_value, _is_sdk_disconnect_error, _normalize_model_name, _prepare_file_attachments, @@ -664,34 +665,12 @@ async def test_unrelated_value_error_propagates(self): class TestSystemPromptPreset: - """Tests for SystemPromptPreset construction with exclude_dynamic_sections.""" - - def _make_config(self, exclude: bool, monkeypatch, _clean_config_env): - from backend.copilot import config as cfg_mod - - return cfg_mod.ChatConfig( - use_openrouter=False, - api_key=None, - base_url=None, - use_claude_code_subscription=False, - claude_agent_exclude_dynamic_sections=exclude, - ) - - def test_preset_dict_structure_when_enabled(self, monkeypatch, _clean_config_env): - """When exclude_dynamic_sections is True, system_prompt should be a - SystemPromptPreset dict with the correct keys.""" - cfg = self._make_config(True, monkeypatch, _clean_config_env) + """Tests for _build_system_prompt_value — cross-user prompt caching.""" + def test_preset_dict_structure_when_enabled(self): + """When cross_user_cache is True, returns a _SystemPromptPreset dict.""" custom_prompt = "You are a helpful assistant." - if cfg.claude_agent_exclude_dynamic_sections: - result = { - "type": "preset", - "preset": "claude_code", - "append": custom_prompt, - "exclude_dynamic_sections": True, - } - else: - result = custom_prompt + result = _build_system_prompt_value(custom_prompt, cross_user_cache=True) assert isinstance(result, dict) assert result["type"] == "preset" @@ -699,27 +678,16 @@ def test_preset_dict_structure_when_enabled(self, monkeypatch, _clean_config_env assert result["append"] == custom_prompt assert result["exclude_dynamic_sections"] is True - def test_raw_string_when_disabled(self, monkeypatch, _clean_config_env): - """When exclude_dynamic_sections is False, system_prompt should be a - raw string.""" - cfg = self._make_config(False, monkeypatch, _clean_config_env) - + def test_raw_string_when_disabled(self): + """When cross_user_cache is False, returns the raw string.""" custom_prompt = "You are a helpful assistant." - if cfg.claude_agent_exclude_dynamic_sections: - result = { - "type": "preset", - "preset": "claude_code", - "append": custom_prompt, - "exclude_dynamic_sections": True, - } - else: - result = custom_prompt + result = _build_system_prompt_value(custom_prompt, cross_user_cache=False) assert isinstance(result, str) assert result == custom_prompt - def test_default_is_enabled(self, monkeypatch, _clean_config_env): - """The default value for claude_agent_exclude_dynamic_sections is True.""" + def test_default_config_is_enabled(self, monkeypatch, _clean_config_env): + """The default value for claude_agent_cross_user_prompt_cache is True.""" from backend.copilot import config as cfg_mod cfg = cfg_mod.ChatConfig( @@ -728,4 +696,4 @@ def test_default_is_enabled(self, monkeypatch, _clean_config_env): base_url=None, use_claude_code_subscription=False, ) - assert cfg.claude_agent_exclude_dynamic_sections is True + assert cfg.claude_agent_cross_user_prompt_cache is True From fa6cc99a8a9d42b6af2b425f22530ad7574133fc Mon Sep 17 00:00:00 2001 From: majdyz Date: Mon, 13 Apr 2026 00:54:01 +0000 Subject: [PATCH 03/15] fix(backend): format service.py and test files --- .claude/worktrees/pr-12725-fixes | 1 + autogpt_platform/backend/backend/copilot/sdk/service.py | 1 + .../backend/backend/copilot/sdk/service_test.py | 6 +++--- autogpt_platform/backend/backend/data/platform_cost_test.py | 1 - 4 files changed, 5 insertions(+), 4 deletions(-) create mode 160000 .claude/worktrees/pr-12725-fixes diff --git a/.claude/worktrees/pr-12725-fixes b/.claude/worktrees/pr-12725-fixes new file mode 160000 index 000000000000..5f92082f9cbe --- /dev/null +++ b/.claude/worktrees/pr-12725-fixes @@ -0,0 +1 @@ +Subproject commit 5f92082f9cbe68ec82b9d87a9df0177d1a088141 diff --git a/autogpt_platform/backend/backend/copilot/sdk/service.py b/autogpt_platform/backend/backend/copilot/sdk/service.py index 0af308be93a8..e2e2bd0a339e 100644 --- a/autogpt_platform/backend/backend/copilot/sdk/service.py +++ b/autogpt_platform/backend/backend/copilot/sdk/service.py @@ -30,6 +30,7 @@ class _SystemPromptPreset(TypedDict): append: str exclude_dynamic_sections: bool + if TYPE_CHECKING: from backend.copilot.permissions import CopilotPermissions diff --git a/autogpt_platform/backend/backend/copilot/sdk/service_test.py b/autogpt_platform/backend/backend/copilot/sdk/service_test.py index b196b132f19c..8c5be805ecf1 100644 --- a/autogpt_platform/backend/backend/copilot/sdk/service_test.py +++ b/autogpt_platform/backend/backend/copilot/sdk/service_test.py @@ -235,9 +235,9 @@ def test_baseline_supplement_completeness(self): for tool_name, tool in TOOL_REGISTRY.items(): if not tool.is_available: continue - assert f"`{tool_name}`" in docs, ( - f"Tool '{tool_name}' missing from baseline supplement" - ) + assert ( + f"`{tool_name}`" in docs + ), f"Tool '{tool_name}' missing from baseline supplement" def test_pause_task_scheduled_before_transcript_upload(self): """Pause is scheduled as a background task before transcript upload begins. diff --git a/autogpt_platform/backend/backend/data/platform_cost_test.py b/autogpt_platform/backend/backend/data/platform_cost_test.py index dacd2c42ea98..4a2372628b64 100644 --- a/autogpt_platform/backend/backend/data/platform_cost_test.py +++ b/autogpt_platform/backend/backend/data/platform_cost_test.py @@ -35,7 +35,6 @@ def test_large_value(self): assert usd_to_microdollars(1.0) == 1_000_000 - class TestMaskEmail: def test_typical_email(self): assert _mask_email("user@example.com") == "us***@example.com" From 0ab7c9852cc902c7da84e532c8689852968d4651 Mon Sep 17 00:00:00 2001 From: majdyz Date: Mon, 13 Apr 2026 00:54:11 +0000 Subject: [PATCH 04/15] fix: remove accidentally committed worktree, add to gitignore --- .claude/worktrees/pr-12725-fixes | 1 - .gitignore | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) delete mode 160000 .claude/worktrees/pr-12725-fixes diff --git a/.claude/worktrees/pr-12725-fixes b/.claude/worktrees/pr-12725-fixes deleted file mode 160000 index 5f92082f9cbe..000000000000 --- a/.claude/worktrees/pr-12725-fixes +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 5f92082f9cbe68ec82b9d87a9df0177d1a088141 diff --git a/.gitignore b/.gitignore index 9a9db80e40e4..d4f00d031502 100644 --- a/.gitignore +++ b/.gitignore @@ -193,3 +193,4 @@ test.db .next # Implementation plans (generated by AI agents) plans/ +.claude/worktrees/ From cd8079dba202e9cdd9cd4d409e19034d9e307d67 Mon Sep 17 00:00:00 2001 From: majdyz Date: Mon, 13 Apr 2026 01:23:08 +0000 Subject: [PATCH 05/15] test: add CHAT_CLAUDE_AGENT_CROSS_USER_PROMPT_CACHE to _CONFIG_ENV_VARS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test_default_config_is_enabled uses _clean_config_env to ensure env vars don't pollute the ChatConfig constructor test. The new claude_agent_cross_user_prompt_cache field reads from CHAT_CLAUDE_AGENT_CROSS_USER_PROMPT_CACHE, but that var was missing from the list — leaving the test non-deterministic if that env var is set in CI. --- autogpt_platform/backend/backend/copilot/sdk/service_test.py | 1 + 1 file changed, 1 insertion(+) diff --git a/autogpt_platform/backend/backend/copilot/sdk/service_test.py b/autogpt_platform/backend/backend/copilot/sdk/service_test.py index 8c5be805ecf1..a45210eeeed1 100644 --- a/autogpt_platform/backend/backend/copilot/sdk/service_test.py +++ b/autogpt_platform/backend/backend/copilot/sdk/service_test.py @@ -398,6 +398,7 @@ async def test_rejects_path_outside_prefix(self, tmp_path): "OPENAI_BASE_URL", "CHAT_USE_CLAUDE_CODE_SUBSCRIPTION", "CHAT_USE_CLAUDE_AGENT_SDK", + "CHAT_CLAUDE_AGENT_CROSS_USER_PROMPT_CACHE", ) From b7bec5d352cc1983e585d9188a6331059a7333c2 Mon Sep 17 00:00:00 2001 From: majdyz Date: Mon, 13 Apr 2026 01:39:42 +0000 Subject: [PATCH 06/15] fix(backend): align _SystemPromptPreset with SDK shape, drop unused monkeypatch - Make append and exclude_dynamic_sections NotRequired to match the SDK's SystemPromptPreset (append is NotRequired[str] in SDK; exclude_dynamic_sections is absent in 0.1.45 and will be optional once #12747 bumps to >=0.1.58) - Remove redundant monkeypatch fixture from test_default_config_is_enabled (_clean_config_env already owns the monkeypatch instance) --- autogpt_platform/backend/backend/copilot/sdk/service.py | 6 ++++-- .../backend/backend/copilot/sdk/service_test.py | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/autogpt_platform/backend/backend/copilot/sdk/service.py b/autogpt_platform/backend/backend/copilot/sdk/service.py index e2e2bd0a339e..3db3bd410402 100644 --- a/autogpt_platform/backend/backend/copilot/sdk/service.py +++ b/autogpt_platform/backend/backend/copilot/sdk/service.py @@ -15,6 +15,8 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Literal, NamedTuple, TypedDict, cast +from typing_extensions import NotRequired + class _SystemPromptPreset(TypedDict): """Local stand-in for the SDK's SystemPromptPreset. @@ -27,8 +29,8 @@ class _SystemPromptPreset(TypedDict): type: Literal["preset"] preset: Literal["claude_code"] - append: str - exclude_dynamic_sections: bool + append: NotRequired[str] + exclude_dynamic_sections: NotRequired[bool] # SDK >=0.1.58 if TYPE_CHECKING: diff --git a/autogpt_platform/backend/backend/copilot/sdk/service_test.py b/autogpt_platform/backend/backend/copilot/sdk/service_test.py index a45210eeeed1..af1bce4dd903 100644 --- a/autogpt_platform/backend/backend/copilot/sdk/service_test.py +++ b/autogpt_platform/backend/backend/copilot/sdk/service_test.py @@ -687,7 +687,7 @@ def test_raw_string_when_disabled(self): assert isinstance(result, str) assert result == custom_prompt - def test_default_config_is_enabled(self, monkeypatch, _clean_config_env): + def test_default_config_is_enabled(self, _clean_config_env): """The default value for claude_agent_cross_user_prompt_cache is True.""" from backend.copilot import config as cfg_mod From f6f70e1c15615b0fcab920849a42a97b58a3f093 Mon Sep 17 00:00:00 2001 From: majdyz Date: Mon, 13 Apr 2026 02:07:46 +0000 Subject: [PATCH 07/15] fix(backend): add SystemPromptPreset compat test, move inline import to top level - sdk_compat_test.py: add test_agent_options_accepts_system_prompt_preset_dict to guard against SDK upgrades breaking the dict-variant system_prompt path introduced by cross-user prompt caching - service_test.py: move `from backend.copilot import config as cfg_mod` to top-level imports (AGENTS.md: no local/inner imports) --- .../backend/copilot/sdk/sdk_compat_test.py | 19 +++++++++++++++++++ .../backend/copilot/sdk/service_test.py | 4 ++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/autogpt_platform/backend/backend/copilot/sdk/sdk_compat_test.py b/autogpt_platform/backend/backend/copilot/sdk/sdk_compat_test.py index 45a7cf443454..77c1662b0440 100644 --- a/autogpt_platform/backend/backend/copilot/sdk/sdk_compat_test.py +++ b/autogpt_platform/backend/backend/copilot/sdk/sdk_compat_test.py @@ -90,6 +90,25 @@ def test_agent_options_accepts_required_fields(): assert opts.cwd == "/tmp" +def test_agent_options_accepts_system_prompt_preset_dict(): + """Verify ClaudeAgentOptions accepts a SystemPromptPreset dict for system_prompt. + + The cross-user prompt caching path passes a SystemPromptPreset dict instead + of a plain string. This test guards against SDK upgrades that change + SystemPromptPreset handling (e.g. renaming fields, rejecting unknown keys). + """ + from claude_agent_sdk import ClaudeAgentOptions + from claude_agent_sdk.types import SystemPromptPreset + + preset: SystemPromptPreset = { + "type": "preset", + "preset": "claude_code", + "append": "custom system prompt", + } + opts = ClaudeAgentOptions(system_prompt=preset) + assert opts.system_prompt == preset + + def test_agent_options_accepts_all_our_fields(): """Comprehensive check of every field we use in service.py.""" from claude_agent_sdk import ClaudeAgentOptions diff --git a/autogpt_platform/backend/backend/copilot/sdk/service_test.py b/autogpt_platform/backend/backend/copilot/sdk/service_test.py index af1bce4dd903..b3e27ed4db7a 100644 --- a/autogpt_platform/backend/backend/copilot/sdk/service_test.py +++ b/autogpt_platform/backend/backend/copilot/sdk/service_test.py @@ -8,6 +8,8 @@ import pytest +from backend.copilot import config as cfg_mod + from .service import ( _build_system_prompt_value, _is_sdk_disconnect_error, @@ -689,8 +691,6 @@ def test_raw_string_when_disabled(self): def test_default_config_is_enabled(self, _clean_config_env): """The default value for claude_agent_cross_user_prompt_cache is True.""" - from backend.copilot import config as cfg_mod - cfg = cfg_mod.ChatConfig( use_openrouter=False, api_key=None, From 34832ca70c6d79f0e7a8c2788ef126c3d429f96d Mon Sep 17 00:00:00 2001 From: majdyz Date: Mon, 13 Apr 2026 03:34:19 +0000 Subject: [PATCH 08/15] test(backend): compat-test the exact preset dict sent to ClaudeAgentOptions The existing compat test for SystemPromptPreset omitted exclude_dynamic_sections, diverging from the actual dict _build_system_prompt_value produces. The new test calls the production helper directly and passes its output through ClaudeAgentOptions, so any SDK version that rejects the extra key is caught at test time. --- .../backend/copilot/sdk/sdk_compat_test.py | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/autogpt_platform/backend/backend/copilot/sdk/sdk_compat_test.py b/autogpt_platform/backend/backend/copilot/sdk/sdk_compat_test.py index 77c1662b0440..e846e2074129 100644 --- a/autogpt_platform/backend/backend/copilot/sdk/sdk_compat_test.py +++ b/autogpt_platform/backend/backend/copilot/sdk/sdk_compat_test.py @@ -7,6 +7,7 @@ """ import inspect +from typing import cast import pytest @@ -109,6 +110,32 @@ def test_agent_options_accepts_system_prompt_preset_dict(): assert opts.system_prompt == preset +def test_agent_options_accepts_system_prompt_preset_with_exclude_dynamic_sections(): + """Verify ClaudeAgentOptions accepts the exact preset dict _build_system_prompt_value produces. + + The production code always includes ``exclude_dynamic_sections=True`` in the preset + dict. This compat test mirrors that exact shape so any SDK version that starts + rejecting unknown keys will be caught here rather than at runtime. + """ + from claude_agent_sdk import ClaudeAgentOptions + from claude_agent_sdk.types import SystemPromptPreset + + from .service import _build_system_prompt_value + + # Call the production helper directly so this test is tied to the real + # dict shape rather than a hand-rolled copy. + preset = _build_system_prompt_value("custom system prompt", cross_user_cache=True) + assert isinstance( + preset, dict + ), "_build_system_prompt_value must return a dict when caching is on" + + # Cast to the SDK type: _SystemPromptPreset is structurally identical to + # SystemPromptPreset and both are plain dicts at runtime. + sdk_preset = cast(SystemPromptPreset, preset) + opts = ClaudeAgentOptions(system_prompt=sdk_preset) + assert opts.system_prompt == sdk_preset + + def test_agent_options_accepts_all_our_fields(): """Comprehensive check of every field we use in service.py.""" from claude_agent_sdk import ClaudeAgentOptions From 2cf737dc0508a7753d067ed8425cfc0ef657b29f Mon Sep 17 00:00:00 2001 From: majdyz Date: Mon, 13 Apr 2026 06:43:57 +0000 Subject: [PATCH 09/15] fix(backend): address review comments on cross-user prompt caching PR - Add TODO(#12747) to _SystemPromptPreset for cleanup tracking - Update docstring to note SDK version and migration path - Add debug logging in _build_system_prompt_value for observability - Document empty-string edge case in docstring - Trim redundant block comment at call site to single line - Add test for empty-string system_prompt with cache enabled - Add test for CHAT_CLAUDE_AGENT_CROSS_USER_PROMPT_CACHE=false env var --- .../backend/backend/copilot/sdk/service.py | 28 ++++++++++--------- .../backend/copilot/sdk/service_test.py | 21 ++++++++++++++ 2 files changed, 36 insertions(+), 13 deletions(-) diff --git a/autogpt_platform/backend/backend/copilot/sdk/service.py b/autogpt_platform/backend/backend/copilot/sdk/service.py index 3db3bd410402..c09b2d5230b6 100644 --- a/autogpt_platform/backend/backend/copilot/sdk/service.py +++ b/autogpt_platform/backend/backend/copilot/sdk/service.py @@ -18,19 +18,20 @@ from typing_extensions import NotRequired +# TODO(#12747): Remove this local TypedDict and import SystemPromptPreset +# from claude_agent_sdk.types once SDK >=0.1.58 is the minimum pin. class _SystemPromptPreset(TypedDict): - """Local stand-in for the SDK's SystemPromptPreset. + """Local stand-in for the SDK's ``SystemPromptPreset`` (mirrors SDK >=0.1.58). - The production SDK (>=0.1.58) defines this type natively. We keep a - local copy so that: - 1. The code type-checks on older SDK pins (e.g. 0.1.45 on dev). - 2. Tests can import and verify the shape without an SDK upgrade. + Kept only for backwards compat with older SDK pins (e.g. 0.1.45 on dev). + Once the minimum SDK version is pinned to >=0.1.58, replace this with a + direct import from ``claude_agent_sdk.types``. """ type: Literal["preset"] preset: Literal["claude_code"] append: NotRequired[str] - exclude_dynamic_sections: NotRequired[bool] # SDK >=0.1.58 + exclude_dynamic_sections: NotRequired[bool] if TYPE_CHECKING: @@ -727,15 +728,21 @@ def _build_system_prompt_value( dict so the Claude Code default prompt becomes a cacheable prefix shared across all users; our custom *system_prompt* is appended after it. - When disabled, the raw *system_prompt* string is returned unchanged. + When disabled (or if the SDK is too old to support ``SystemPromptPreset``), + the raw *system_prompt* string is returned unchanged. + + An empty *system_prompt* is accepted: the preset dict will have + ``append: ""`` which the SDK treats as no custom suffix. """ if cross_user_cache: + logger.debug("Using SystemPromptPreset for cross-user prompt cache") return _SystemPromptPreset( type="preset", preset="claude_code", append=system_prompt, exclude_dynamic_sections=True, ) + logger.debug("Cross-user prompt cache disabled, using raw string") return system_prompt @@ -2260,12 +2267,7 @@ def _on_stderr(line: str) -> None: sid, ) - # When cross-user prompt caching is enabled, use SystemPromptPreset - # so the Claude Code default prompt is a cacheable prefix shared - # across all users. Our custom prompt is appended after it and - # dynamic sections (working dir, git status, auto-memory) are - # excluded from the prefix — giving us cross-user cache hits that - # reduce input token cost by ~90%. + # Use SystemPromptPreset for cross-user prompt caching (see _build_system_prompt_value). system_prompt_value = _build_system_prompt_value( system_prompt, cross_user_cache=config.claude_agent_cross_user_prompt_cache, diff --git a/autogpt_platform/backend/backend/copilot/sdk/service_test.py b/autogpt_platform/backend/backend/copilot/sdk/service_test.py index b3e27ed4db7a..caa3d1b597cc 100644 --- a/autogpt_platform/backend/backend/copilot/sdk/service_test.py +++ b/autogpt_platform/backend/backend/copilot/sdk/service_test.py @@ -689,6 +689,16 @@ def test_raw_string_when_disabled(self): assert isinstance(result, str) assert result == custom_prompt + def test_empty_string_with_cache_enabled(self): + """Empty system_prompt with cross_user_cache=True produces append=''.""" + result = _build_system_prompt_value("", cross_user_cache=True) + + assert isinstance(result, dict) + assert result["type"] == "preset" + assert result["preset"] == "claude_code" + assert result["append"] == "" + assert result["exclude_dynamic_sections"] is True + def test_default_config_is_enabled(self, _clean_config_env): """The default value for claude_agent_cross_user_prompt_cache is True.""" cfg = cfg_mod.ChatConfig( @@ -698,3 +708,14 @@ def test_default_config_is_enabled(self, _clean_config_env): use_claude_code_subscription=False, ) assert cfg.claude_agent_cross_user_prompt_cache is True + + def test_env_var_disables_cache(self, _clean_config_env, monkeypatch): + """CHAT_CLAUDE_AGENT_CROSS_USER_PROMPT_CACHE=false disables caching.""" + monkeypatch.setenv("CHAT_CLAUDE_AGENT_CROSS_USER_PROMPT_CACHE", "false") + cfg = cfg_mod.ChatConfig( + use_openrouter=False, + api_key=None, + base_url=None, + use_claude_code_subscription=False, + ) + assert cfg.claude_agent_cross_user_prompt_cache is False From ae8608ab103efe9508ab4dce2097d50b7bfe7e6a Mon Sep 17 00:00:00 2001 From: majdyz Date: Mon, 13 Apr 2026 23:38:43 +0700 Subject: [PATCH 10/15] fix(backend): make _SystemPromptPreset.append required, remove hand-rolled compat test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Change append field from NotRequired[str] to str with clarifying comment since _build_system_prompt_value always provides it — Required matches usage contract - Add NOTE comment about exclude_dynamic_sections requiring SDK >=0.1.58 (PR #12747) - Remove test_agent_options_accepts_system_prompt_preset_dict hand-rolled test that constructed a preset dict without exclude_dynamic_sections (a shape the production code never emits); the stronger test calling _build_system_prompt_value directly is the canonical guard --- .../backend/copilot/sdk/sdk_compat_test.py | 25 +++---------------- .../backend/backend/copilot/sdk/service.py | 6 +++-- 2 files changed, 7 insertions(+), 24 deletions(-) diff --git a/autogpt_platform/backend/backend/copilot/sdk/sdk_compat_test.py b/autogpt_platform/backend/backend/copilot/sdk/sdk_compat_test.py index e846e2074129..aa7743f67a3c 100644 --- a/autogpt_platform/backend/backend/copilot/sdk/sdk_compat_test.py +++ b/autogpt_platform/backend/backend/copilot/sdk/sdk_compat_test.py @@ -91,25 +91,6 @@ def test_agent_options_accepts_required_fields(): assert opts.cwd == "/tmp" -def test_agent_options_accepts_system_prompt_preset_dict(): - """Verify ClaudeAgentOptions accepts a SystemPromptPreset dict for system_prompt. - - The cross-user prompt caching path passes a SystemPromptPreset dict instead - of a plain string. This test guards against SDK upgrades that change - SystemPromptPreset handling (e.g. renaming fields, rejecting unknown keys). - """ - from claude_agent_sdk import ClaudeAgentOptions - from claude_agent_sdk.types import SystemPromptPreset - - preset: SystemPromptPreset = { - "type": "preset", - "preset": "claude_code", - "append": "custom system prompt", - } - opts = ClaudeAgentOptions(system_prompt=preset) - assert opts.system_prompt == preset - - def test_agent_options_accepts_system_prompt_preset_with_exclude_dynamic_sections(): """Verify ClaudeAgentOptions accepts the exact preset dict _build_system_prompt_value produces. @@ -125,9 +106,9 @@ def test_agent_options_accepts_system_prompt_preset_with_exclude_dynamic_section # Call the production helper directly so this test is tied to the real # dict shape rather than a hand-rolled copy. preset = _build_system_prompt_value("custom system prompt", cross_user_cache=True) - assert isinstance( - preset, dict - ), "_build_system_prompt_value must return a dict when caching is on" + assert isinstance(preset, dict), ( + "_build_system_prompt_value must return a dict when caching is on" + ) # Cast to the SDK type: _SystemPromptPreset is structurally identical to # SystemPromptPreset and both are plain dicts at runtime. diff --git a/autogpt_platform/backend/backend/copilot/sdk/service.py b/autogpt_platform/backend/backend/copilot/sdk/service.py index c09b2d5230b6..996ba7e6ac07 100644 --- a/autogpt_platform/backend/backend/copilot/sdk/service.py +++ b/autogpt_platform/backend/backend/copilot/sdk/service.py @@ -30,8 +30,10 @@ class _SystemPromptPreset(TypedDict): type: Literal["preset"] preset: Literal["claude_code"] - append: NotRequired[str] - exclude_dynamic_sections: NotRequired[bool] + append: str # always provided by _build_system_prompt_value + # NOTE: exclude_dynamic_sections requires claude-agent-sdk >= 0.1.58. + # This PR cannot be merged before PR #12747 (SDK upgrade) lands. + exclude_dynamic_sections: NotRequired[bool] # SDK >= 0.1.58 (PR #12747) if TYPE_CHECKING: From 5f366f34b7bdd469a7bf515f067bafb54fe8ba0e Mon Sep 17 00:00:00 2001 From: majdyz Date: Tue, 14 Apr 2026 11:19:49 +0700 Subject: [PATCH 11/15] dx(backend): fix black formatting in sdk_compat_test.py --- .../backend/backend/copilot/sdk/sdk_compat_test.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/autogpt_platform/backend/backend/copilot/sdk/sdk_compat_test.py b/autogpt_platform/backend/backend/copilot/sdk/sdk_compat_test.py index aa7743f67a3c..f10175686d81 100644 --- a/autogpt_platform/backend/backend/copilot/sdk/sdk_compat_test.py +++ b/autogpt_platform/backend/backend/copilot/sdk/sdk_compat_test.py @@ -106,9 +106,9 @@ def test_agent_options_accepts_system_prompt_preset_with_exclude_dynamic_section # Call the production helper directly so this test is tied to the real # dict shape rather than a hand-rolled copy. preset = _build_system_prompt_value("custom system prompt", cross_user_cache=True) - assert isinstance(preset, dict), ( - "_build_system_prompt_value must return a dict when caching is on" - ) + assert isinstance( + preset, dict + ), "_build_system_prompt_value must return a dict when caching is on" # Cast to the SDK type: _SystemPromptPreset is structurally identical to # SystemPromptPreset and both are plain dicts at runtime. From 2ba2a2b94b69578095eaeb3ec3051bad46915889 Mon Sep 17 00:00:00 2001 From: majdyz Date: Tue, 14 Apr 2026 12:18:56 +0700 Subject: [PATCH 12/15] fix(backend): move _SystemPromptPreset TypedDict below all imports Resolves E402 import ordering issue flagged by coderabbitai: the TypedDict was defined before module-level third-party imports. --- .../backend/backend/copilot/sdk/service.py | 37 +++++++++---------- 1 file changed, 18 insertions(+), 19 deletions(-) diff --git a/autogpt_platform/backend/backend/copilot/sdk/service.py b/autogpt_platform/backend/backend/copilot/sdk/service.py index 996ba7e6ac07..d909256d6748 100644 --- a/autogpt_platform/backend/backend/copilot/sdk/service.py +++ b/autogpt_platform/backend/backend/copilot/sdk/service.py @@ -17,25 +17,6 @@ from typing_extensions import NotRequired - -# TODO(#12747): Remove this local TypedDict and import SystemPromptPreset -# from claude_agent_sdk.types once SDK >=0.1.58 is the minimum pin. -class _SystemPromptPreset(TypedDict): - """Local stand-in for the SDK's ``SystemPromptPreset`` (mirrors SDK >=0.1.58). - - Kept only for backwards compat with older SDK pins (e.g. 0.1.45 on dev). - Once the minimum SDK version is pinned to >=0.1.58, replace this with a - direct import from ``claude_agent_sdk.types``. - """ - - type: Literal["preset"] - preset: Literal["claude_code"] - append: str # always provided by _build_system_prompt_value - # NOTE: exclude_dynamic_sections requires claude-agent-sdk >= 0.1.58. - # This PR cannot be merged before PR #12747 (SDK upgrade) lands. - exclude_dynamic_sections: NotRequired[bool] # SDK >= 0.1.58 (PR #12747) - - if TYPE_CHECKING: from backend.copilot.permissions import CopilotPermissions @@ -135,6 +116,24 @@ class _SystemPromptPreset(TypedDict): config = ChatConfig() +# TODO(#12747): Remove this local TypedDict and import SystemPromptPreset +# from claude_agent_sdk.types once SDK >=0.1.58 is the minimum pin. +class _SystemPromptPreset(TypedDict): + """Local stand-in for the SDK's ``SystemPromptPreset`` (mirrors SDK >=0.1.58). + + Kept only for backwards compat with older SDK pins (e.g. 0.1.45 on dev). + Once the minimum SDK version is pinned to >=0.1.58, replace this with a + direct import from ``claude_agent_sdk.types``. + """ + + type: Literal["preset"] + preset: Literal["claude_code"] + append: str # always provided by _build_system_prompt_value + # NOTE: exclude_dynamic_sections requires claude-agent-sdk >= 0.1.58. + # This PR cannot be merged before PR #12747 (SDK upgrade) lands. + exclude_dynamic_sections: NotRequired[bool] # SDK >= 0.1.58 (PR #12747) + + # On context-size errors the SDK query is retried with progressively # less context: (1) original transcript → (2) compacted transcript → # (3) no transcript (DB messages only). From 43b33779bd43c8014be1894c0c7e33a35e8fe88c Mon Sep 17 00:00:00 2001 From: majdyz Date: Tue, 14 Apr 2026 17:29:42 +0700 Subject: [PATCH 13/15] fix(backend/copilot): disable excludeDynamicSections preset on --resume turns CLI 2.1.97 (claude-agent-sdk 0.1.58) exits with code 1 when excludeDynamicSections=True is sent in the SDK initialize request AND --resume is active. This caused every second message in a copilot session to fail immediately. Workaround: disable the SystemPromptPreset (fall back to a plain system prompt string) on resumed turns so excludeDynamicSections is never sent when --resume is active. Turn 1 still gets the preset for cross-user prompt caching; turns 2+ use a plain string. --- autogpt_platform/backend/backend/copilot/sdk/service.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/autogpt_platform/backend/backend/copilot/sdk/service.py b/autogpt_platform/backend/backend/copilot/sdk/service.py index 7330e7248f96..376b84f7bac1 100644 --- a/autogpt_platform/backend/backend/copilot/sdk/service.py +++ b/autogpt_platform/backend/backend/copilot/sdk/service.py @@ -2332,10 +2332,15 @@ def _on_stderr(line: str) -> None: sid, ) - # Use SystemPromptPreset for cross-user prompt caching (see _build_system_prompt_value). + # Use SystemPromptPreset for cross-user prompt caching. + # WORKAROUND: CLI 2.1.97 (sdk 0.1.58) exits code 1 when + # excludeDynamicSections=True is in the initialize request AND + # --resume is active. Disable the preset on resumed turns. + # Turn 1 still gets the preset (no --resume). + _cross_user = config.claude_agent_cross_user_prompt_cache and not use_resume system_prompt_value = _build_system_prompt_value( system_prompt, - cross_user_cache=config.claude_agent_cross_user_prompt_cache, + cross_user_cache=_cross_user, ) sdk_options_kwargs: dict[str, Any] = { From 896ebeaff8cb4f22dde1dea690bffab092c8ec45 Mon Sep 17 00:00:00 2001 From: majdyz Date: Tue, 14 Apr 2026 20:46:38 +0700 Subject: [PATCH 14/15] refactor(backend/copilot): replace local _SystemPromptPreset with SDK import SDK is now pinned to >=0.1.58 which exports SystemPromptPreset from claude_agent_sdk.types. Remove the local TypedDict workaround (TODO #12747) and import the type directly from the SDK. --- .../backend/copilot/sdk/sdk_compat_test.py | 2 -- .../backend/backend/copilot/sdk/service.py | 29 ++++--------------- 2 files changed, 5 insertions(+), 26 deletions(-) diff --git a/autogpt_platform/backend/backend/copilot/sdk/sdk_compat_test.py b/autogpt_platform/backend/backend/copilot/sdk/sdk_compat_test.py index 58970d59b6c7..c61a8078a96c 100644 --- a/autogpt_platform/backend/backend/copilot/sdk/sdk_compat_test.py +++ b/autogpt_platform/backend/backend/copilot/sdk/sdk_compat_test.py @@ -110,8 +110,6 @@ def test_agent_options_accepts_system_prompt_preset_with_exclude_dynamic_section preset, dict ), "_build_system_prompt_value must return a dict when caching is on" - # Cast to the SDK type: _SystemPromptPreset is structurally identical to - # SystemPromptPreset and both are plain dicts at runtime. sdk_preset = cast(SystemPromptPreset, preset) opts = ClaudeAgentOptions(system_prompt=sdk_preset) assert opts.system_prompt == sdk_preset diff --git a/autogpt_platform/backend/backend/copilot/sdk/service.py b/autogpt_platform/backend/backend/copilot/sdk/service.py index 376b84f7bac1..13a9c0528694 100644 --- a/autogpt_platform/backend/backend/copilot/sdk/service.py +++ b/autogpt_platform/backend/backend/copilot/sdk/service.py @@ -14,9 +14,7 @@ from collections.abc import AsyncGenerator, AsyncIterator from dataclasses import dataclass from dataclasses import field as dataclass_field -from typing import TYPE_CHECKING, Any, Literal, NamedTuple, TypedDict, cast - -from typing_extensions import NotRequired +from typing import TYPE_CHECKING, Any, NamedTuple, cast if TYPE_CHECKING: from backend.copilot.permissions import CopilotPermissions @@ -31,6 +29,7 @@ ToolResultBlock, ToolUseBlock, ) +from claude_agent_sdk.types import SystemPromptPreset from langfuse import propagate_attributes from langsmith.integrations.claude_agent_sdk import configure_claude_agent_sdk from opentelemetry import trace as otel_trace @@ -119,24 +118,6 @@ config = ChatConfig() -# TODO(#12747): Remove this local TypedDict and import SystemPromptPreset -# from claude_agent_sdk.types once SDK >=0.1.58 is the minimum pin. -class _SystemPromptPreset(TypedDict): - """Local stand-in for the SDK's ``SystemPromptPreset`` (mirrors SDK >=0.1.58). - - Kept only for backwards compat with older SDK pins (e.g. 0.1.45 on dev). - Once the minimum SDK version is pinned to >=0.1.58, replace this with a - direct import from ``claude_agent_sdk.types``. - """ - - type: Literal["preset"] - preset: Literal["claude_code"] - append: str # always provided by _build_system_prompt_value - # NOTE: exclude_dynamic_sections requires claude-agent-sdk >= 0.1.58. - # This PR cannot be merged before PR #12747 (SDK upgrade) lands. - exclude_dynamic_sections: NotRequired[bool] # SDK >= 0.1.58 (PR #12747) - - # On context-size errors the SDK query is retried with progressively # less context: (1) original transcript → (2) compacted transcript → # (3) no transcript (DB messages only). @@ -725,10 +706,10 @@ def _is_fallback_stderr(line: str) -> bool: def _build_system_prompt_value( system_prompt: str, cross_user_cache: bool, -) -> str | _SystemPromptPreset: +) -> str | SystemPromptPreset: """Build the ``system_prompt`` argument for :class:`ClaudeAgentOptions`. - When *cross_user_cache* is enabled, returns a :class:`_SystemPromptPreset` + When *cross_user_cache* is enabled, returns a :class:`SystemPromptPreset` dict so the Claude Code default prompt becomes a cacheable prefix shared across all users; our custom *system_prompt* is appended after it. @@ -740,7 +721,7 @@ def _build_system_prompt_value( """ if cross_user_cache: logger.debug("Using SystemPromptPreset for cross-user prompt cache") - return _SystemPromptPreset( + return SystemPromptPreset( type="preset", preset="claude_code", append=system_prompt, From f80d8108f41f6246bdc04ff17d018e5442144334 Mon Sep 17 00:00:00 2001 From: majdyz Date: Tue, 14 Apr 2026 21:08:01 +0700 Subject: [PATCH 15/15] fix(backend/copilot): recompute system_prompt on retry when use_resume changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On context-reduction retry, ctx.use_resume can flip from False to True (compaction creates a CLI session file). Previously the retry block copied system_prompt_value from the initial options unchanged, so a SystemPromptPreset (with exclude_dynamic_sections=True) could be combined with --resume — which crashes CLI 2.1.97 (as documented in the T1 workaround comment). Recompute system_prompt_value for the retry using the updated ctx.use_resume so the preset is always disabled when --resume is active. Fixes Sentry prediction: r3079924820 --- .../backend/backend/copilot/sdk/sdk_compat_test.py | 9 +++++++++ .../backend/backend/copilot/sdk/service.py | 10 ++++++++++ 2 files changed, 19 insertions(+) diff --git a/autogpt_platform/backend/backend/copilot/sdk/sdk_compat_test.py b/autogpt_platform/backend/backend/copilot/sdk/sdk_compat_test.py index c61a8078a96c..5d132aa94dd2 100644 --- a/autogpt_platform/backend/backend/copilot/sdk/sdk_compat_test.py +++ b/autogpt_platform/backend/backend/copilot/sdk/sdk_compat_test.py @@ -115,6 +115,15 @@ def test_agent_options_accepts_system_prompt_preset_with_exclude_dynamic_section assert opts.system_prompt == sdk_preset +def test_build_system_prompt_value_returns_plain_string_when_cross_user_cache_off(): + """When cross_user_cache=False (e.g. on --resume turns), the helper must return + a plain string so the preset+resume crash is avoided.""" + from .service import _build_system_prompt_value + + result = _build_system_prompt_value("my prompt", cross_user_cache=False) + assert result == "my prompt", "Must return the raw string, not a preset dict" + + def test_agent_options_accepts_all_our_fields(): """Comprehensive check of every field we use in service.py.""" from claude_agent_sdk import ClaudeAgentOptions diff --git a/autogpt_platform/backend/backend/copilot/sdk/service.py b/autogpt_platform/backend/backend/copilot/sdk/service.py index 13a9c0528694..636d94f03bfc 100644 --- a/autogpt_platform/backend/backend/copilot/sdk/service.py +++ b/autogpt_platform/backend/backend/copilot/sdk/service.py @@ -2540,6 +2540,16 @@ def _on_stderr(line: str) -> None: sdk_options_kwargs_retry["resume"] = ctx.resume_file elif "resume" in sdk_options_kwargs_retry: del sdk_options_kwargs_retry["resume"] + # Recompute system_prompt for retry — ctx.use_resume may have + # changed (context reduction enabled --resume). CLI 2.1.97 + # crashes when excludeDynamicSections=True is combined with + # --resume, so disable the cross-user preset on resumed turns. + _cross_user_retry = ( + config.claude_agent_cross_user_prompt_cache and not ctx.use_resume + ) + sdk_options_kwargs_retry["system_prompt"] = _build_system_prompt_value( + system_prompt, cross_user_cache=_cross_user_retry + ) state.options = ClaudeAgentOptions(**sdk_options_kwargs_retry) # type: ignore[arg-type] # dynamic kwargs state.query_message, state.was_compacted = await _build_query_message( current_message,