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.
- deepagents_code/__init__.py +42 -0
- deepagents_code/__main__.py +6 -0
- deepagents_code/_ask_user_types.py +90 -0
- deepagents_code/_cli_context.py +96 -0
- deepagents_code/_constants.py +41 -0
- deepagents_code/_debug.py +148 -0
- deepagents_code/_debug_buffer.py +204 -0
- deepagents_code/_env_vars.py +411 -0
- deepagents_code/_fake_models.py +66 -0
- deepagents_code/_git.py +521 -0
- deepagents_code/_paths.py +69 -0
- deepagents_code/_server_config.py +576 -0
- deepagents_code/_session_stats.py +235 -0
- deepagents_code/_startup_error.py +45 -0
- deepagents_code/_testing_models.py +305 -0
- deepagents_code/_textual_patches.py +420 -0
- deepagents_code/_tool_stream.py +694 -0
- deepagents_code/_version.py +46 -0
- deepagents_code/agent.py +1976 -0
- deepagents_code/app.py +19239 -0
- deepagents_code/app.tcss +438 -0
- deepagents_code/approval_mode.py +131 -0
- deepagents_code/ask_user.py +306 -0
- deepagents_code/auth_display.py +185 -0
- deepagents_code/auth_store.py +545 -0
- deepagents_code/built_in_skills/__init__.py +5 -0
- deepagents_code/built_in_skills/remember/SKILL.md +118 -0
- deepagents_code/built_in_skills/skill-creator/SKILL.md +383 -0
- deepagents_code/built_in_skills/skill-creator/scripts/init_skill.py +366 -0
- deepagents_code/built_in_skills/skill-creator/scripts/quick_validate.py +158 -0
- deepagents_code/client/__init__.py +1 -0
- deepagents_code/client/commands/__init__.py +1 -0
- deepagents_code/client/commands/auth.py +541 -0
- deepagents_code/client/commands/config.py +714 -0
- deepagents_code/client/commands/mcp.py +250 -0
- deepagents_code/client/commands/tools.py +416 -0
- deepagents_code/client/launch/__init__.py +1 -0
- deepagents_code/client/launch/server.py +978 -0
- deepagents_code/client/launch/server_manager.py +540 -0
- deepagents_code/client/non_interactive.py +1758 -0
- deepagents_code/client/remote_client.py +794 -0
- deepagents_code/clipboard.py +217 -0
- deepagents_code/command_registry.py +450 -0
- deepagents_code/config.py +4829 -0
- deepagents_code/config_manifest.py +1451 -0
- deepagents_code/configurable_model.py +577 -0
- deepagents_code/default_agent_prompt.md +12 -0
- deepagents_code/doctor.py +559 -0
- deepagents_code/editor.py +142 -0
- deepagents_code/event_bus.py +411 -0
- deepagents_code/extras_info.py +661 -0
- deepagents_code/file_ops.py +576 -0
- deepagents_code/formatting.py +135 -0
- deepagents_code/goal_rubric.py +497 -0
- deepagents_code/goal_tools.py +459 -0
- deepagents_code/hooks.py +348 -0
- deepagents_code/input.py +1041 -0
- deepagents_code/integrations/__init__.py +1 -0
- deepagents_code/integrations/openai_codex.py +551 -0
- deepagents_code/integrations/sandbox_config.py +198 -0
- deepagents_code/integrations/sandbox_factory.py +1124 -0
- deepagents_code/integrations/sandbox_provider.py +137 -0
- deepagents_code/integrations/sandbox_registry.py +350 -0
- deepagents_code/iterm_cursor_guide.py +176 -0
- deepagents_code/local_context.py +926 -0
- deepagents_code/main.py +3985 -0
- deepagents_code/managed_tools.py +642 -0
- deepagents_code/mcp_auth.py +1950 -0
- deepagents_code/mcp_config.py +176 -0
- deepagents_code/mcp_disabled.py +212 -0
- deepagents_code/mcp_login_service.py +281 -0
- deepagents_code/mcp_oauth_ui.py +199 -0
- deepagents_code/mcp_providers/__init__.py +23 -0
- deepagents_code/mcp_providers/_registry.py +39 -0
- deepagents_code/mcp_providers/base.py +133 -0
- deepagents_code/mcp_providers/github.py +102 -0
- deepagents_code/mcp_providers/slack.py +175 -0
- deepagents_code/mcp_tools.py +2427 -0
- deepagents_code/mcp_trust.py +207 -0
- deepagents_code/media_utils.py +826 -0
- deepagents_code/memory_guard.py +474 -0
- deepagents_code/model_config.py +4156 -0
- deepagents_code/notifications.py +247 -0
- deepagents_code/offload.py +402 -0
- deepagents_code/onboarding.py +223 -0
- deepagents_code/output.py +69 -0
- deepagents_code/paste_collapse.py +103 -0
- deepagents_code/project_utils.py +231 -0
- deepagents_code/py.typed +0 -0
- deepagents_code/reasoning_effort.py +641 -0
- deepagents_code/reliable_rubric.py +97 -0
- deepagents_code/resume_state.py +237 -0
- deepagents_code/server_graph.py +310 -0
- deepagents_code/session_end_summary.py +329 -0
- deepagents_code/sessions.py +1716 -0
- deepagents_code/skills/__init__.py +18 -0
- deepagents_code/skills/commands.py +1252 -0
- deepagents_code/skills/invocation.py +112 -0
- deepagents_code/skills/load.py +196 -0
- deepagents_code/skills/trust.py +547 -0
- deepagents_code/state_migration.py +136 -0
- deepagents_code/subagents.py +278 -0
- deepagents_code/system_prompt.md +204 -0
- deepagents_code/terminal_capabilities.py +115 -0
- deepagents_code/terminal_escape.py +287 -0
- deepagents_code/theme.py +891 -0
- deepagents_code/todo_list_prompt.md +12 -0
- deepagents_code/tool_catalog.py +509 -0
- deepagents_code/tool_display.py +367 -0
- deepagents_code/tools.py +516 -0
- deepagents_code/tui/__init__.py +1 -0
- deepagents_code/tui/textual_adapter.py +2553 -0
- deepagents_code/tui/widgets/__init__.py +9 -0
- deepagents_code/tui/widgets/_js_eval_display.py +139 -0
- deepagents_code/tui/widgets/_links.py +261 -0
- deepagents_code/tui/widgets/agent_selector.py +420 -0
- deepagents_code/tui/widgets/approval.py +602 -0
- deepagents_code/tui/widgets/ask_user.py +515 -0
- deepagents_code/tui/widgets/auth.py +1997 -0
- deepagents_code/tui/widgets/autocomplete.py +890 -0
- deepagents_code/tui/widgets/chat_input.py +3181 -0
- deepagents_code/tui/widgets/codex_auth.py +452 -0
- deepagents_code/tui/widgets/cwd_switch.py +242 -0
- deepagents_code/tui/widgets/debug_console.py +868 -0
- deepagents_code/tui/widgets/diff.py +252 -0
- deepagents_code/tui/widgets/effort_selector.py +189 -0
- deepagents_code/tui/widgets/goal_review.py +390 -0
- deepagents_code/tui/widgets/goal_status.py +50 -0
- deepagents_code/tui/widgets/history.py +194 -0
- deepagents_code/tui/widgets/install_confirm.py +248 -0
- deepagents_code/tui/widgets/launch_init.py +482 -0
- deepagents_code/tui/widgets/loading.py +227 -0
- deepagents_code/tui/widgets/mcp_login.py +539 -0
- deepagents_code/tui/widgets/mcp_reconnect.py +212 -0
- deepagents_code/tui/widgets/mcp_viewer.py +1634 -0
- deepagents_code/tui/widgets/message_store.py +999 -0
- deepagents_code/tui/widgets/messages.py +3846 -0
- deepagents_code/tui/widgets/model_selector.py +2343 -0
- deepagents_code/tui/widgets/notification_center.py +456 -0
- deepagents_code/tui/widgets/notification_detail.py +257 -0
- deepagents_code/tui/widgets/notification_settings.py +170 -0
- deepagents_code/tui/widgets/restart_prompt.py +158 -0
- deepagents_code/tui/widgets/skill_trust.py +131 -0
- deepagents_code/tui/widgets/startup_tip.py +91 -0
- deepagents_code/tui/widgets/status.py +781 -0
- deepagents_code/tui/widgets/subagent_panel.py +969 -0
- deepagents_code/tui/widgets/theme_selector.py +401 -0
- deepagents_code/tui/widgets/thread_selector.py +2564 -0
- deepagents_code/tui/widgets/tool_renderers.py +190 -0
- deepagents_code/tui/widgets/tool_widgets.py +304 -0
- deepagents_code/tui/widgets/update_available.py +311 -0
- deepagents_code/tui/widgets/update_confirm.py +184 -0
- deepagents_code/tui/widgets/update_progress.py +327 -0
- deepagents_code/tui/widgets/welcome.py +508 -0
- deepagents_code/turn_end_summary.py +522 -0
- deepagents_code/ui.py +858 -0
- deepagents_code/unicode_security.py +563 -0
- deepagents_code/update_check.py +3124 -0
- zjcode-0.0.1.data/data/deepagents_code/default_agent_prompt.md +12 -0
- zjcode-0.0.1.dist-info/METADATA +220 -0
- zjcode-0.0.1.dist-info/RECORD +163 -0
- zjcode-0.0.1.dist-info/WHEEL +4 -0
- 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
|
+
)
|