zjcode 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. deepagents_code/__init__.py +42 -0
  2. deepagents_code/__main__.py +6 -0
  3. deepagents_code/_ask_user_types.py +90 -0
  4. deepagents_code/_cli_context.py +96 -0
  5. deepagents_code/_constants.py +41 -0
  6. deepagents_code/_debug.py +148 -0
  7. deepagents_code/_debug_buffer.py +204 -0
  8. deepagents_code/_env_vars.py +411 -0
  9. deepagents_code/_fake_models.py +66 -0
  10. deepagents_code/_git.py +521 -0
  11. deepagents_code/_paths.py +69 -0
  12. deepagents_code/_server_config.py +576 -0
  13. deepagents_code/_session_stats.py +235 -0
  14. deepagents_code/_startup_error.py +45 -0
  15. deepagents_code/_testing_models.py +305 -0
  16. deepagents_code/_textual_patches.py +420 -0
  17. deepagents_code/_tool_stream.py +694 -0
  18. deepagents_code/_version.py +46 -0
  19. deepagents_code/agent.py +1976 -0
  20. deepagents_code/app.py +19239 -0
  21. deepagents_code/app.tcss +438 -0
  22. deepagents_code/approval_mode.py +131 -0
  23. deepagents_code/ask_user.py +306 -0
  24. deepagents_code/auth_display.py +185 -0
  25. deepagents_code/auth_store.py +545 -0
  26. deepagents_code/built_in_skills/__init__.py +5 -0
  27. deepagents_code/built_in_skills/remember/SKILL.md +118 -0
  28. deepagents_code/built_in_skills/skill-creator/SKILL.md +383 -0
  29. deepagents_code/built_in_skills/skill-creator/scripts/init_skill.py +366 -0
  30. deepagents_code/built_in_skills/skill-creator/scripts/quick_validate.py +158 -0
  31. deepagents_code/client/__init__.py +1 -0
  32. deepagents_code/client/commands/__init__.py +1 -0
  33. deepagents_code/client/commands/auth.py +541 -0
  34. deepagents_code/client/commands/config.py +714 -0
  35. deepagents_code/client/commands/mcp.py +250 -0
  36. deepagents_code/client/commands/tools.py +416 -0
  37. deepagents_code/client/launch/__init__.py +1 -0
  38. deepagents_code/client/launch/server.py +978 -0
  39. deepagents_code/client/launch/server_manager.py +540 -0
  40. deepagents_code/client/non_interactive.py +1758 -0
  41. deepagents_code/client/remote_client.py +794 -0
  42. deepagents_code/clipboard.py +217 -0
  43. deepagents_code/command_registry.py +450 -0
  44. deepagents_code/config.py +4829 -0
  45. deepagents_code/config_manifest.py +1451 -0
  46. deepagents_code/configurable_model.py +577 -0
  47. deepagents_code/default_agent_prompt.md +12 -0
  48. deepagents_code/doctor.py +559 -0
  49. deepagents_code/editor.py +142 -0
  50. deepagents_code/event_bus.py +411 -0
  51. deepagents_code/extras_info.py +661 -0
  52. deepagents_code/file_ops.py +576 -0
  53. deepagents_code/formatting.py +135 -0
  54. deepagents_code/goal_rubric.py +497 -0
  55. deepagents_code/goal_tools.py +459 -0
  56. deepagents_code/hooks.py +348 -0
  57. deepagents_code/input.py +1041 -0
  58. deepagents_code/integrations/__init__.py +1 -0
  59. deepagents_code/integrations/openai_codex.py +551 -0
  60. deepagents_code/integrations/sandbox_config.py +198 -0
  61. deepagents_code/integrations/sandbox_factory.py +1124 -0
  62. deepagents_code/integrations/sandbox_provider.py +137 -0
  63. deepagents_code/integrations/sandbox_registry.py +350 -0
  64. deepagents_code/iterm_cursor_guide.py +176 -0
  65. deepagents_code/local_context.py +926 -0
  66. deepagents_code/main.py +3985 -0
  67. deepagents_code/managed_tools.py +642 -0
  68. deepagents_code/mcp_auth.py +1950 -0
  69. deepagents_code/mcp_config.py +176 -0
  70. deepagents_code/mcp_disabled.py +212 -0
  71. deepagents_code/mcp_login_service.py +281 -0
  72. deepagents_code/mcp_oauth_ui.py +199 -0
  73. deepagents_code/mcp_providers/__init__.py +23 -0
  74. deepagents_code/mcp_providers/_registry.py +39 -0
  75. deepagents_code/mcp_providers/base.py +133 -0
  76. deepagents_code/mcp_providers/github.py +102 -0
  77. deepagents_code/mcp_providers/slack.py +175 -0
  78. deepagents_code/mcp_tools.py +2427 -0
  79. deepagents_code/mcp_trust.py +207 -0
  80. deepagents_code/media_utils.py +826 -0
  81. deepagents_code/memory_guard.py +474 -0
  82. deepagents_code/model_config.py +4156 -0
  83. deepagents_code/notifications.py +247 -0
  84. deepagents_code/offload.py +402 -0
  85. deepagents_code/onboarding.py +223 -0
  86. deepagents_code/output.py +69 -0
  87. deepagents_code/paste_collapse.py +103 -0
  88. deepagents_code/project_utils.py +231 -0
  89. deepagents_code/py.typed +0 -0
  90. deepagents_code/reasoning_effort.py +641 -0
  91. deepagents_code/reliable_rubric.py +97 -0
  92. deepagents_code/resume_state.py +237 -0
  93. deepagents_code/server_graph.py +310 -0
  94. deepagents_code/session_end_summary.py +329 -0
  95. deepagents_code/sessions.py +1716 -0
  96. deepagents_code/skills/__init__.py +18 -0
  97. deepagents_code/skills/commands.py +1252 -0
  98. deepagents_code/skills/invocation.py +112 -0
  99. deepagents_code/skills/load.py +196 -0
  100. deepagents_code/skills/trust.py +547 -0
  101. deepagents_code/state_migration.py +136 -0
  102. deepagents_code/subagents.py +278 -0
  103. deepagents_code/system_prompt.md +204 -0
  104. deepagents_code/terminal_capabilities.py +115 -0
  105. deepagents_code/terminal_escape.py +287 -0
  106. deepagents_code/theme.py +891 -0
  107. deepagents_code/todo_list_prompt.md +12 -0
  108. deepagents_code/tool_catalog.py +509 -0
  109. deepagents_code/tool_display.py +367 -0
  110. deepagents_code/tools.py +516 -0
  111. deepagents_code/tui/__init__.py +1 -0
  112. deepagents_code/tui/textual_adapter.py +2553 -0
  113. deepagents_code/tui/widgets/__init__.py +9 -0
  114. deepagents_code/tui/widgets/_js_eval_display.py +139 -0
  115. deepagents_code/tui/widgets/_links.py +261 -0
  116. deepagents_code/tui/widgets/agent_selector.py +420 -0
  117. deepagents_code/tui/widgets/approval.py +602 -0
  118. deepagents_code/tui/widgets/ask_user.py +515 -0
  119. deepagents_code/tui/widgets/auth.py +1997 -0
  120. deepagents_code/tui/widgets/autocomplete.py +890 -0
  121. deepagents_code/tui/widgets/chat_input.py +3181 -0
  122. deepagents_code/tui/widgets/codex_auth.py +452 -0
  123. deepagents_code/tui/widgets/cwd_switch.py +242 -0
  124. deepagents_code/tui/widgets/debug_console.py +868 -0
  125. deepagents_code/tui/widgets/diff.py +252 -0
  126. deepagents_code/tui/widgets/effort_selector.py +189 -0
  127. deepagents_code/tui/widgets/goal_review.py +390 -0
  128. deepagents_code/tui/widgets/goal_status.py +50 -0
  129. deepagents_code/tui/widgets/history.py +194 -0
  130. deepagents_code/tui/widgets/install_confirm.py +248 -0
  131. deepagents_code/tui/widgets/launch_init.py +482 -0
  132. deepagents_code/tui/widgets/loading.py +227 -0
  133. deepagents_code/tui/widgets/mcp_login.py +539 -0
  134. deepagents_code/tui/widgets/mcp_reconnect.py +212 -0
  135. deepagents_code/tui/widgets/mcp_viewer.py +1634 -0
  136. deepagents_code/tui/widgets/message_store.py +999 -0
  137. deepagents_code/tui/widgets/messages.py +3846 -0
  138. deepagents_code/tui/widgets/model_selector.py +2343 -0
  139. deepagents_code/tui/widgets/notification_center.py +456 -0
  140. deepagents_code/tui/widgets/notification_detail.py +257 -0
  141. deepagents_code/tui/widgets/notification_settings.py +170 -0
  142. deepagents_code/tui/widgets/restart_prompt.py +158 -0
  143. deepagents_code/tui/widgets/skill_trust.py +131 -0
  144. deepagents_code/tui/widgets/startup_tip.py +91 -0
  145. deepagents_code/tui/widgets/status.py +781 -0
  146. deepagents_code/tui/widgets/subagent_panel.py +969 -0
  147. deepagents_code/tui/widgets/theme_selector.py +401 -0
  148. deepagents_code/tui/widgets/thread_selector.py +2564 -0
  149. deepagents_code/tui/widgets/tool_renderers.py +190 -0
  150. deepagents_code/tui/widgets/tool_widgets.py +304 -0
  151. deepagents_code/tui/widgets/update_available.py +311 -0
  152. deepagents_code/tui/widgets/update_confirm.py +184 -0
  153. deepagents_code/tui/widgets/update_progress.py +327 -0
  154. deepagents_code/tui/widgets/welcome.py +508 -0
  155. deepagents_code/turn_end_summary.py +522 -0
  156. deepagents_code/ui.py +858 -0
  157. deepagents_code/unicode_security.py +563 -0
  158. deepagents_code/update_check.py +3124 -0
  159. zjcode-0.0.1.data/data/deepagents_code/default_agent_prompt.md +12 -0
  160. zjcode-0.0.1.dist-info/METADATA +220 -0
  161. zjcode-0.0.1.dist-info/RECORD +163 -0
  162. zjcode-0.0.1.dist-info/WHEEL +4 -0
  163. zjcode-0.0.1.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,247 @@
1
+ """Registry of pending actionable notifications.
2
+
3
+ Stores plain data for notices the user can act on from a dedicated
4
+ modal screen. The registry is deliberately UI-agnostic: UI routing
5
+ (toast click, keybinds) lives in the app layer.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ from dataclasses import dataclass
12
+ from enum import StrEnum
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class ActionId(StrEnum):
18
+ """Stable identifiers for notification actions dispatched by the app."""
19
+
20
+ SUPPRESS = "suppress"
21
+ """Persist a suppression entry so the notice isn't re-raised."""
22
+
23
+ COPY_INSTALL = "copy_install"
24
+ """Copy the install command to the system clipboard."""
25
+
26
+ OPEN_WEBSITE = "open_website"
27
+ """Open the associated URL in the user's browser."""
28
+
29
+ ENTER_API_KEY = "enter_api_key"
30
+ """Open the `/auth` manager so the user can store an API key."""
31
+
32
+ INSTALL = "install"
33
+ """Run the upgrade command via `perform_upgrade`."""
34
+
35
+ SKIP_ONCE = "skip_once"
36
+ """Clear the notified marker so the update modal re-opens next launch."""
37
+
38
+ SKIP_VERSION = "skip_version"
39
+ """Mark this version as notified; silence until a newer version ships."""
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class NotificationAction:
44
+ """One button/action row in the notification modal."""
45
+
46
+ action_id: ActionId
47
+ label: str
48
+ primary: bool = False
49
+ """Whether to render this action in bold.
50
+
51
+ `UpdateAvailableScreen` also uses this flag to place the initial
52
+ cursor on the primary row; `NotificationCenterScreen` always starts
53
+ on the first row regardless.
54
+ """
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class MissingDepPayload:
59
+ """Typed payload for a missing-dependency notification."""
60
+
61
+ tool: str
62
+ """Name of the missing tool (e.g. `"ripgrep"`, `"tavily"`)."""
63
+
64
+ install_command: str | None = None
65
+ """Shell command that installs the tool, when one is known."""
66
+
67
+ url: str | None = None
68
+ """Install guide or sign-up URL, used when no direct command exists."""
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class UpdateAvailablePayload:
73
+ """Typed payload for an update-available notification."""
74
+
75
+ latest: str
76
+ """PyPI version string the user is being prompted to install."""
77
+
78
+ upgrade_cmd: str
79
+ """Shell command that upgrades to `latest`."""
80
+
81
+
82
+ Payload = MissingDepPayload | UpdateAvailablePayload
83
+
84
+
85
+ @dataclass(frozen=True)
86
+ class PendingNotification:
87
+ """A single notice waiting for user action.
88
+
89
+ Immutable value object: the registry owns the
90
+ key-to-toast-identity binding (see `NotificationRegistry`) so
91
+ external callers cannot corrupt click-routing indices by mutating
92
+ notifications after construction.
93
+ """
94
+
95
+ key: str
96
+ """Stable identifier used to dedupe and to remove the notice once handled."""
97
+
98
+ title: str
99
+ """One-line heading shown in the modal."""
100
+
101
+ body: str
102
+ """Longer description shown below the title.
103
+
104
+ May contain install instructions, links, or version info.
105
+ """
106
+
107
+ actions: tuple[NotificationAction, ...]
108
+ """Available actions, rendered as rows in the modal."""
109
+
110
+ payload: Payload
111
+ """Kind-specific typed data consumed by the action dispatcher."""
112
+
113
+ def __post_init__(self) -> None:
114
+ """Enforce basic invariants at construction time.
115
+
116
+ Raises:
117
+ ValueError: If `key` is empty, `actions` is empty, or more
118
+ than one action is marked `primary=True`.
119
+ """
120
+ if not self.key:
121
+ msg = "PendingNotification.key must be non-empty"
122
+ raise ValueError(msg)
123
+ if not self.actions:
124
+ msg = f"PendingNotification {self.key!r} must declare at least one action"
125
+ raise ValueError(msg)
126
+ primaries = sum(1 for a in self.actions if a.primary)
127
+ if primaries > 1:
128
+ msg = (
129
+ f"PendingNotification {self.key!r} has {primaries} primary actions; "
130
+ "at most one is allowed"
131
+ )
132
+ raise ValueError(msg)
133
+
134
+
135
+ class NotificationRegistry:
136
+ """In-memory store of pending notifications.
137
+
138
+ Instance-scoped (one per app) so test apps don't pollute each other.
139
+ Owns the bidirectional key-to-toast-identity binding so callers
140
+ cannot accidentally desynchronize the click-routing indices.
141
+ """
142
+
143
+ def __init__(self) -> None:
144
+ """Initialize an empty registry."""
145
+ self._entries: dict[str, PendingNotification] = {}
146
+ self._key_to_toast: dict[str, str] = {}
147
+ self._toast_to_key: dict[str, str] = {}
148
+
149
+ def add(self, notification: PendingNotification) -> None:
150
+ """Register a new notification or replace an existing one with the same key.
151
+
152
+ Replacing is intentional: re-registering with the same key
153
+ refreshes the entry rather than stacking duplicates. Any
154
+ previously bound toast identity is dropped, since the old toast
155
+ has been dismissed or replaced by the caller.
156
+
157
+ Args:
158
+ notification: Entry to add or replace.
159
+ """
160
+ old_identity = self._key_to_toast.pop(notification.key, None)
161
+ if old_identity is not None:
162
+ self._toast_to_key.pop(old_identity, None)
163
+ self._entries[notification.key] = notification
164
+
165
+ def remove(self, key: str) -> PendingNotification | None:
166
+ """Remove a notification by key.
167
+
168
+ Args:
169
+ key: Registry key of the entry to remove.
170
+
171
+ Returns:
172
+ The removed entry, or `None` when *key* was not registered.
173
+ """
174
+ entry = self._entries.pop(key, None)
175
+ identity = self._key_to_toast.pop(key, None)
176
+ if identity is not None:
177
+ self._toast_to_key.pop(identity, None)
178
+ return entry
179
+
180
+ def get(self, key: str) -> PendingNotification | None:
181
+ """Return the notification for *key*, or `None` when not registered."""
182
+ return self._entries.get(key)
183
+
184
+ def bind_toast(self, key: str, toast_identity: str) -> None:
185
+ """Attach a Textual toast identity to an existing notification.
186
+
187
+ Logs a warning when *key* is unknown — this only happens if a
188
+ caller binds a toast without first `add`-ing the entry, which is
189
+ a programming error.
190
+
191
+ Args:
192
+ key: Registry key of the entry.
193
+ toast_identity: `Notification.identity` of the originating toast.
194
+ """
195
+ if key not in self._entries:
196
+ logger.warning("bind_toast called for unknown key %r; ignoring", key)
197
+ return
198
+ prev = self._key_to_toast.get(key)
199
+ if prev is not None:
200
+ self._toast_to_key.pop(prev, None)
201
+ self._key_to_toast[key] = toast_identity
202
+ self._toast_to_key[toast_identity] = key
203
+
204
+ def toast_identity_for(self, key: str) -> str | None:
205
+ """Return the toast identity bound to *key*, or `None`."""
206
+ return self._key_to_toast.get(key)
207
+
208
+ def unbind_toast(self, toast_identity: str) -> None:
209
+ """Drop the binding for *toast_identity*, if any.
210
+
211
+ Does not remove the underlying notification entry; used when the
212
+ toast is being dismissed (e.g. the user opened the notification
213
+ center) but the entry should stay in the registry.
214
+
215
+ Args:
216
+ toast_identity: `Notification.identity` of the toast to unbind.
217
+ Unknown identities are a no-op.
218
+ """
219
+ key = self._toast_to_key.pop(toast_identity, None)
220
+ if key is not None:
221
+ self._key_to_toast.pop(key, None)
222
+
223
+ def key_for_toast(self, toast_identity: str) -> str | None:
224
+ """Return the registered key for *toast_identity*, or `None`."""
225
+ return self._toast_to_key.get(toast_identity)
226
+
227
+ def is_actionable_toast(self, toast_identity: str) -> bool:
228
+ """Return whether a click on *toast_identity* should open the modal."""
229
+ return toast_identity in self._toast_to_key
230
+
231
+ def list_all(self) -> list[PendingNotification]:
232
+ """Return all pending notifications in insertion order."""
233
+ return list(self._entries.values())
234
+
235
+ def __len__(self) -> int:
236
+ """Return the number of pending notifications."""
237
+ return len(self._entries)
238
+
239
+ def __bool__(self) -> bool:
240
+ """Return `True` when at least one notification is pending."""
241
+ return bool(self._entries)
242
+
243
+ def clear(self) -> None:
244
+ """Remove all entries. Primarily useful for tests."""
245
+ self._entries.clear()
246
+ self._key_to_toast.clear()
247
+ self._toast_to_key.clear()
@@ -0,0 +1,402 @@
1
+ """Business logic for the `/offload` command.
2
+
3
+ Extracts the core offload workflow from the UI layer so it can be
4
+ tested independently of the Textual app.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from dataclasses import dataclass
11
+ from datetime import UTC, datetime
12
+ from typing import TYPE_CHECKING, Any, cast
13
+
14
+ from langchain_core.messages import get_buffer_string
15
+ from langchain_core.messages.utils import count_tokens_approximately
16
+
17
+ from deepagents_code._session_stats import format_token_count
18
+ from deepagents_code.config import create_model
19
+
20
+ if TYPE_CHECKING:
21
+ from deepagents.backends.protocol import BackendProtocol
22
+ from deepagents.middleware.summarization import (
23
+ SummarizationEvent,
24
+ SummarizationMiddleware,
25
+ )
26
+ from langchain_core.messages import AnyMessage, HumanMessage
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ # ---------------------------------------------------------------------------
32
+ # Result types
33
+ # ---------------------------------------------------------------------------
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class OffloadResult:
38
+ """Successful offload result."""
39
+
40
+ new_event: SummarizationEvent
41
+ """The summarization event to write into agent state."""
42
+
43
+ messages_offloaded: int
44
+ """Number of older messages that were offloaded."""
45
+
46
+ messages_kept: int
47
+ """Number of recent messages retained in context."""
48
+
49
+ tokens_before: int
50
+ """Approximate token count of the conversation before offloading."""
51
+
52
+ tokens_after: int
53
+ """Approximate token count of the conversation after offloading."""
54
+
55
+ pct_decrease: int
56
+ """Percentage decrease in token usage."""
57
+
58
+ offload_warning: str | None
59
+ """Non-`None` when the backend write failed (non-fatal)."""
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class OffloadThresholdNotMet:
64
+ """Offload was a no-op — conversation is within the retention budget."""
65
+
66
+ conversation_tokens: int
67
+ """Approximate token count of the conversation messages alone."""
68
+
69
+ total_context_tokens: int
70
+ """Total context token count including system overhead, or `0` when no
71
+ token tracker is available."""
72
+
73
+ context_limit: int | None
74
+ """Model context window limit, if available."""
75
+
76
+ budget_str: str
77
+ """Human-readable retention budget (e.g. "20.0K tokens")."""
78
+
79
+
80
+ class OffloadModelError(Exception):
81
+ """Raised when the model cannot be created for offloading."""
82
+
83
+
84
+ # ---------------------------------------------------------------------------
85
+ # Helpers
86
+ # ---------------------------------------------------------------------------
87
+
88
+
89
+ def format_offload_limit(
90
+ keep: tuple[str, int | float], context_limit: int | None
91
+ ) -> str:
92
+ """Format offload retention settings into a human-readable limit string.
93
+
94
+ Args:
95
+ keep: Retention policy tuple `(type, value)` from summarization
96
+ defaults, where `type` is one of `"messages"`, `"tokens"`, or
97
+ `"fraction"`.
98
+ context_limit: Model context limit when available.
99
+
100
+ Returns:
101
+ A short display string describing the offload retention limit.
102
+ """
103
+ keep_type, keep_value = keep
104
+
105
+ if keep_type == "messages":
106
+ count = int(keep_value)
107
+ noun = "message" if count == 1 else "messages"
108
+ return f"last {count} {noun}"
109
+
110
+ if keep_type == "tokens":
111
+ return f"{format_token_count(int(keep_value))} tokens"
112
+
113
+ if keep_type == "fraction":
114
+ percent = float(keep_value) * 100
115
+ if context_limit is not None:
116
+ token_limit = max(1, int(context_limit * float(keep_value)))
117
+ return f"{format_token_count(token_limit)} tokens"
118
+ return f"{percent:.0f}% of context window"
119
+
120
+ return "current retention threshold"
121
+
122
+
123
+ async def offload_messages_to_backend(
124
+ messages: list[Any],
125
+ middleware: SummarizationMiddleware,
126
+ *,
127
+ thread_id: str,
128
+ backend: BackendProtocol,
129
+ ) -> str | None:
130
+ """Write messages to backend storage before offloading.
131
+
132
+ Appends messages as a timestamped markdown section to the conversation
133
+ history file, matching the `SummarizationMiddleware` offload pattern.
134
+
135
+ Filters out prior summary messages using the middleware's
136
+ `_filter_summary_messages` to avoid storing summaries-of-summaries.
137
+
138
+ Args:
139
+ messages: Messages to offload.
140
+ middleware: `SummarizationMiddleware` instance for filtering.
141
+ thread_id: Thread identifier used to derive the storage path.
142
+ backend: Backend to persist conversation history to.
143
+
144
+ Returns:
145
+ File path where history was stored, `""` (empty string) if there were no
146
+ non-summary messages to offload (not an error), or `None` if the
147
+ write failed.
148
+ """
149
+ path = f"/conversation_history/{thread_id}.md"
150
+
151
+ # Exclude prior summaries so the offloaded history contains only
152
+ # original messages
153
+ filtered = middleware._filter_summary_messages(messages)
154
+ if not filtered:
155
+ return ""
156
+
157
+ timestamp = datetime.now(UTC).isoformat()
158
+ buf = get_buffer_string(filtered)
159
+ new_section = f"## Offloaded at {timestamp}\n\n{buf}\n\n"
160
+
161
+ existing_content = ""
162
+ try:
163
+ responses = await backend.adownload_files([path])
164
+ resp = responses[0] if responses else None
165
+ if resp and resp.content is not None and resp.error is None:
166
+ existing_content = resp.content.decode("utf-8")
167
+ except Exception as exc: # abort write on read failure
168
+ logger.warning(
169
+ "Failed to read existing history at %s; aborting offload to "
170
+ "avoid overwriting prior history: %s",
171
+ path,
172
+ exc,
173
+ exc_info=True,
174
+ )
175
+ return None
176
+
177
+ combined = existing_content + new_section
178
+
179
+ try:
180
+ result = (
181
+ await backend.aedit(path, existing_content, combined)
182
+ if existing_content
183
+ else await backend.awrite(path, combined)
184
+ )
185
+ if result is None or result.error:
186
+ error_detail = result.error if result else "backend returned None"
187
+ logger.warning(
188
+ "Failed to offload conversation history to %s: %s",
189
+ path,
190
+ error_detail,
191
+ )
192
+ return None
193
+ except Exception as exc: # defensive: surface write failures gracefully
194
+ logger.warning(
195
+ "Exception offloading conversation history to %s: %s",
196
+ path,
197
+ exc,
198
+ exc_info=True,
199
+ )
200
+ return None
201
+
202
+ logger.debug("Offloaded %d messages to %s", len(filtered), path)
203
+ return path
204
+
205
+
206
+ # ---------------------------------------------------------------------------
207
+ # Core offload workflow
208
+ # ---------------------------------------------------------------------------
209
+
210
+
211
+ async def perform_offload(
212
+ *,
213
+ messages: list[Any],
214
+ prior_event: SummarizationEvent | None,
215
+ thread_id: str,
216
+ model_spec: str,
217
+ profile_overrides: dict[str, Any] | None,
218
+ context_limit: int | None,
219
+ total_context_tokens: int,
220
+ backend: BackendProtocol | None,
221
+ ) -> OffloadResult | OffloadThresholdNotMet:
222
+ """Execute the offload workflow: summarize old messages and free context.
223
+
224
+ Args:
225
+ messages: Current conversation messages from agent state.
226
+
227
+ May be LangChain message objects or serialized dicts (the latter
228
+ when read from a remote HTTP state snapshot).
229
+ prior_event: Existing `_summarization_event` if any.
230
+
231
+ In server mode `summary_message` may be a serialized message dict.
232
+ thread_id: Thread identifier for backend storage.
233
+ model_spec: Model specification string (e.g. "openai:gpt-4").
234
+ profile_overrides: Optional profile overrides from CLI flags.
235
+ context_limit: Model context limit from settings.
236
+ total_context_tokens: Current total context token count, or `0` when
237
+ no token tracker is available.
238
+ backend: Backend for persisting offloaded history.
239
+
240
+ Returns:
241
+ `OffloadResult` on success, `OffloadThresholdNotMet` when the
242
+ conversation is within the retention budget.
243
+
244
+ Raises:
245
+ OffloadModelError: If the model cannot be created.
246
+ """
247
+ from deepagents.middleware.summarization import (
248
+ SummarizationMiddleware,
249
+ compute_summarization_defaults,
250
+ )
251
+
252
+ # Remote HTTP state snapshots may surface serialized message dicts instead
253
+ # of LangChain message objects. Normalize them before passing state into
254
+ # summarization middleware helpers. `any(...)` rather than checking index
255
+ # 0 guards against heterogeneous lists (e.g. a snapshot with a streamed
256
+ # append).
257
+ needs_message_conversion = any(isinstance(m, dict) for m in messages)
258
+ needs_summary_conversion = prior_event is not None and isinstance(
259
+ prior_event.get("summary_message"), dict
260
+ )
261
+ if needs_message_conversion or needs_summary_conversion:
262
+ from langchain_core.messages.utils import convert_to_messages
263
+
264
+ if needs_message_conversion:
265
+ messages = cast("list[AnyMessage]", convert_to_messages(messages))
266
+ if needs_summary_conversion and prior_event is not None:
267
+ converted_summary = cast(
268
+ "HumanMessage",
269
+ convert_to_messages([prior_event["summary_message"]])[0],
270
+ )
271
+ prior_event = {
272
+ "cutoff_index": prior_event["cutoff_index"],
273
+ "summary_message": converted_summary,
274
+ "file_path": prior_event["file_path"],
275
+ }
276
+
277
+ try:
278
+ result = create_model(model_spec, profile_overrides=profile_overrides)
279
+ model = result.model
280
+ except Exception as exc:
281
+ msg = f"Offload requires a working model configuration: {exc}"
282
+ raise OffloadModelError(msg) from exc
283
+
284
+ # Patch context limit into model profile when it differs from the native
285
+ # value (e.g. set via --profile-override or runtime config).
286
+ if context_limit is not None:
287
+ profile = getattr(model, "profile", None)
288
+ native = profile.get("max_input_tokens") if isinstance(profile, dict) else None
289
+ if native != context_limit:
290
+ merged = (
291
+ {**profile, "max_input_tokens": context_limit}
292
+ if isinstance(profile, dict)
293
+ else {"max_input_tokens": context_limit}
294
+ )
295
+ try:
296
+ model.profile = merged # ty: ignore[invalid-assignment]
297
+ except (AttributeError, TypeError, ValueError):
298
+ logger.warning(
299
+ "Could not patch context limit (%d) into model profile; "
300
+ "offload budget will use the model's native context window",
301
+ context_limit,
302
+ exc_info=True,
303
+ )
304
+
305
+ defaults = compute_summarization_defaults(model)
306
+ offload_backend = backend
307
+ if offload_backend is None:
308
+ from deepagents.backends.filesystem import FilesystemBackend
309
+
310
+ offload_backend = FilesystemBackend(virtual_mode=False)
311
+ logger.info("Using local FilesystemBackend for offload")
312
+
313
+ middleware = SummarizationMiddleware(
314
+ model=model,
315
+ backend=offload_backend,
316
+ keep=defaults["keep"],
317
+ trim_tokens_to_summarize=None,
318
+ )
319
+
320
+ # Rebuild the message list the model would see, accounting for
321
+ # any prior offload
322
+ effective = middleware._apply_event_to_messages(messages, prior_event)
323
+ cutoff = middleware._determine_cutoff_index(effective)
324
+ budget_str = format_offload_limit(defaults["keep"], context_limit)
325
+
326
+ if cutoff == 0:
327
+ return OffloadThresholdNotMet(
328
+ conversation_tokens=count_tokens_approximately(effective),
329
+ total_context_tokens=total_context_tokens,
330
+ context_limit=context_limit,
331
+ budget_str=budget_str,
332
+ )
333
+
334
+ to_summarize, to_keep = middleware._partition_messages(effective, cutoff)
335
+
336
+ tokens_summarized = count_tokens_approximately(to_summarize)
337
+ tokens_kept = count_tokens_approximately(to_keep)
338
+ tokens_before = tokens_summarized + tokens_kept
339
+
340
+ # Generate summary first so no side effects occur if the LLM fails
341
+ summary = await middleware._acreate_summary(to_summarize)
342
+
343
+ backend_path = await offload_messages_to_backend(
344
+ to_summarize,
345
+ middleware,
346
+ thread_id=thread_id,
347
+ backend=offload_backend,
348
+ )
349
+ offload_warning: str | None = None
350
+ if backend_path is None:
351
+ offload_warning = (
352
+ "Warning: conversation history could not be saved to "
353
+ "storage. Older messages will not be recoverable. "
354
+ "Check logs for details."
355
+ )
356
+ logger.error(
357
+ "Backend write failed for thread %s; offloading will proceed "
358
+ "but messages are not recoverable",
359
+ thread_id,
360
+ )
361
+ file_path = backend_path or None
362
+
363
+ summary_msg = middleware._build_new_messages_with_path(summary, file_path)[0]
364
+
365
+ # Append token savings note so the model is aware of how much context
366
+ # was reclaimed.
367
+ tokens_summary = count_tokens_approximately([summary_msg])
368
+ tokens_after = tokens_summary + tokens_kept
369
+ pct = (
370
+ round((tokens_before - tokens_after) / tokens_before * 100)
371
+ if tokens_before > 0
372
+ else 0
373
+ )
374
+ summarized_before = format_token_count(tokens_summarized)
375
+ summarized_after = format_token_count(tokens_summary)
376
+ savings_note = (
377
+ f"\n\n{len(to_summarize)} messages were offloaded "
378
+ f"({summarized_before} \u2192 {summarized_after} tokens). "
379
+ f"Total context: {format_token_count(tokens_before)} \u2192 "
380
+ f"{format_token_count(tokens_after)} tokens "
381
+ f"({pct}% decrease), "
382
+ f"{len(to_keep)} messages unchanged."
383
+ )
384
+ summary_msg.content += savings_note
385
+
386
+ state_cutoff = middleware._compute_state_cutoff(prior_event, cutoff)
387
+
388
+ new_event: SummarizationEvent = {
389
+ "cutoff_index": state_cutoff,
390
+ "summary_message": summary_msg, # ty: ignore[invalid-argument-type]
391
+ "file_path": file_path,
392
+ }
393
+
394
+ return OffloadResult(
395
+ new_event=new_event,
396
+ messages_offloaded=len(to_summarize),
397
+ messages_kept=len(to_keep),
398
+ tokens_before=tokens_before,
399
+ tokens_after=tokens_after,
400
+ pct_decrease=pct,
401
+ offload_warning=offload_warning,
402
+ )