synapse-cli-agent 0.1.13__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.
- synapse/__init__.py +13 -0
- synapse/__main__.py +6 -0
- synapse/app/__init__.py +1 -0
- synapse/app/agent.py +492 -0
- synapse/app/agent_md.py +107 -0
- synapse/cli.py +750 -0
- synapse/commands/__init__.py +1 -0
- synapse/commands/compression.py +573 -0
- synapse/commands/helpers.py +22 -0
- synapse/commands/mcp.py +406 -0
- synapse/commands/model.py +173 -0
- synapse/commands/result.py +34 -0
- synapse/commands/sessions.py +443 -0
- synapse/commands/slash_cmds.py +521 -0
- synapse/commands/slash_complete.py +816 -0
- synapse/commands/theme.py +99 -0
- synapse/config.py +27 -0
- synapse/content/__init__.py +1 -0
- synapse/content/input_history.py +122 -0
- synapse/content/multimodal.py +733 -0
- synapse/content/prompts.py +249 -0
- synapse/content/skills_catalog.py +128 -0
- synapse/integrations/__init__.py +1 -0
- synapse/integrations/checkpoint_seed.py +281 -0
- synapse/integrations/codex_history.py +375 -0
- synapse/integrations/codex_import.py +393 -0
- synapse/integrations/codex_sessions.py +629 -0
- synapse/integrations/describe_image.py +370 -0
- synapse/integrations/http_clients.py +199 -0
- synapse/integrations/llm_openai_compat.py +90 -0
- synapse/integrations/llm_openai_websocket.py +187 -0
- synapse/integrations/mcp_client.py +646 -0
- synapse/integrations/vision_middleware.py +62 -0
- synapse/models/__init__.py +5 -0
- synapse/models/config.py +240 -0
- synapse/models/helpers.py +206 -0
- synapse/models/profile.py +59 -0
- synapse/models/registry.py +722 -0
- synapse/models_registry.py +7 -0
- synapse/observability/__init__.py +1 -0
- synapse/observability/startup_trace.py +127 -0
- synapse/runtime/__init__.py +1 -0
- synapse/runtime/async_runtime.py +176 -0
- synapse/runtime/backends.py +458 -0
- synapse/runtime/context_compact.py +249 -0
- synapse/runtime/execute_capture.py +48 -0
- synapse/runtime/fs_permissions.py +79 -0
- synapse/runtime/harness.py +57 -0
- synapse/runtime/hitl.py +197 -0
- synapse/runtime/interaction_ledger.py +82 -0
- synapse/runtime/middleware.py +802 -0
- synapse/runtime/model_request_compression_middleware.py +745 -0
- synapse/runtime/pathing.py +146 -0
- synapse/runtime/safety.py +184 -0
- synapse/runtime/steer.py +240 -0
- synapse/runtime/subagents.py +207 -0
- synapse/runtime/tool_ignore.py +221 -0
- synapse/runtime/tool_output_eval.py +118 -0
- synapse/runtime/tool_output_middleware.py +585 -0
- synapse/runtime/tool_output_usage_middleware.py +60 -0
- synapse/sessions/__init__.py +31 -0
- synapse/sessions/cancel_repair.py +208 -0
- synapse/sessions/session_recap.py +174 -0
- synapse/sessions/store.py +695 -0
- synapse/sessions/transcript.py +754 -0
- synapse/settings/__init__.py +5 -0
- synapse/settings/config_paths.py +184 -0
- synapse/settings/schema.py +464 -0
- synapse/tool_output/__init__.py +59 -0
- synapse/tool_output/detection.py +170 -0
- synapse/tool_output/metrics.py +32 -0
- synapse/tool_output/models.py +173 -0
- synapse/tool_output/pipeline.py +330 -0
- synapse/tool_output/repository.py +721 -0
- synapse/tool_output/transformers.py +648 -0
- synapse/tools/__init__.py +5 -0
- synapse/tools/session_tools.py +204 -0
- synapse/ui/__init__.py +10 -0
- synapse/ui/bottombar/__init__.py +73 -0
- synapse/ui/bottombar/components/__init__.py +143 -0
- synapse/ui/bottombar/components/key_hints.py +30 -0
- synapse/ui/bottombar/components/mcp.py +64 -0
- synapse/ui/bottombar/components/mode.py +24 -0
- synapse/ui/bottombar/components/model.py +28 -0
- synapse/ui/bottombar/components/thread.py +29 -0
- synapse/ui/bottombar/context.py +36 -0
- synapse/ui/bottombar/core.py +74 -0
- synapse/ui/dialogs/__init__.py +25 -0
- synapse/ui/dialogs/base.py +362 -0
- synapse/ui/dialogs/codex_session_list.py +84 -0
- synapse/ui/dialogs/compression_diagnostics.py +210 -0
- synapse/ui/dialogs/git_explore.py +702 -0
- synapse/ui/dialogs/mcp_panel.py +407 -0
- synapse/ui/dialogs/model_picker.py +128 -0
- synapse/ui/dialogs/safety_panel.py +63 -0
- synapse/ui/dialogs/session_list.py +98 -0
- synapse/ui/dialogs/theme_designer.py +863 -0
- synapse/ui/dialogs/theme_picker.py +113 -0
- synapse/ui/git_explore/__init__.py +31 -0
- synapse/ui/git_explore/engine.py +82 -0
- synapse/ui/git_explore/provider.py +242 -0
- synapse/ui/git_explore/unified.py +85 -0
- synapse/ui/rendering.py +350 -0
- synapse/ui/sink.py +70 -0
- synapse/ui/steer_widget.py +367 -0
- synapse/ui/stream.py +1207 -0
- synapse/ui/stream_events.py +421 -0
- synapse/ui/stream_runtime.py +252 -0
- synapse/ui/theme.py +1154 -0
- synapse/ui/timeline.py +621 -0
- synapse/ui/topbar/__init__.py +97 -0
- synapse/ui/topbar/components/__init__.py +150 -0
- synapse/ui/topbar/components/branch.py +41 -0
- synapse/ui/topbar/components/title.py +24 -0
- synapse/ui/topbar/components/tool_output.py +24 -0
- synapse/ui/topbar/components/usage.py +24 -0
- synapse/ui/topbar/components/workspace.py +32 -0
- synapse/ui/topbar/context.py +32 -0
- synapse/ui/topbar/core.py +979 -0
- synapse/ui/topbar/git_changes_popover.py +178 -0
- synapse/ui/topbar/git_chrome.py +475 -0
- synapse/ui/topbar/tool_output_popover.py +84 -0
- synapse/ui/topbar/widget.py +474 -0
- synapse/ui/tui.py +5717 -0
- synapse/ui/turn_rail.py +71 -0
- synapse/ui/user_turn.py +83 -0
- synapse/ui/welcome.py +261 -0
- synapse_cli_agent-0.1.13.dist-info/METADATA +412 -0
- synapse_cli_agent-0.1.13.dist-info/RECORD +131 -0
- synapse_cli_agent-0.1.13.dist-info/WHEEL +4 -0
- synapse_cli_agent-0.1.13.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,802 @@
|
|
|
1
|
+
"""Agent middleware helpers — retry + transit path normalisation.
|
|
2
|
+
|
|
3
|
+
Exports:
|
|
4
|
+
- ``should_retry_transient_model_error``: classifier for retry middleware
|
|
5
|
+
- ``build_model_retry_middleware``: factory returning a ModelRetryMiddleware
|
|
6
|
+
- ``set_retry_notifier`` / ``clear_retry_notifier``: bridge so the UI can
|
|
7
|
+
show retry updates without a hard coupling to the middleware
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import contextvars
|
|
14
|
+
import time
|
|
15
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from langchain.agents.middleware import (
|
|
20
|
+
AgentMiddleware,
|
|
21
|
+
AgentState,
|
|
22
|
+
ModelRetryMiddleware,
|
|
23
|
+
)
|
|
24
|
+
from langchain.agents.middleware._retry import calculate_delay, should_retry_exception
|
|
25
|
+
from langchain.agents.middleware.types import ModelRequest, ModelResponse
|
|
26
|
+
from langchain_core.messages import ToolMessage
|
|
27
|
+
|
|
28
|
+
from synapse.runtime.pathing import rewrite_tool_args_paths
|
|
29
|
+
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
# Transient error markers (text-based, no status code)
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
_TRANSIENT_MODEL_ERROR_MARKERS = (
|
|
34
|
+
"empty model output",
|
|
35
|
+
"overloaded",
|
|
36
|
+
"temporarily unavailable",
|
|
37
|
+
"service unavailable",
|
|
38
|
+
"upstream timeout",
|
|
39
|
+
"upstream request timeout",
|
|
40
|
+
"rate limit",
|
|
41
|
+
"rate_limit",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
# Transient *server* errors: 5xx status codes whose body indicates a
|
|
46
|
+
# recoverable infra hiccup (not a hard client / auth error).
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
_RETRYABLE_5XX_MARKERS = (
|
|
49
|
+
"auth_unavailable",
|
|
50
|
+
"overloaded",
|
|
51
|
+
"temporarily unavailable",
|
|
52
|
+
"service unavailable",
|
|
53
|
+
"upstream timeout",
|
|
54
|
+
"upstream request timeout",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
_RETRYABLE_5XX_STATUSES = frozenset({429, 502, 503, 504})
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
# Module-level retry notifier (set by stream / TUI before each turn)
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
_retry_notifier: Callable[[int, float, str], None] | None = None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def set_retry_notifier(fn: Callable[[int, float, str], None] | None) -> None:
|
|
67
|
+
"""Install a callback invoked before each retry delay.
|
|
68
|
+
|
|
69
|
+
``fn(attempt, delay, reason)`` where *attempt* is 1-indexed,
|
|
70
|
+
*delay* is seconds about to be slept, and *reason* summarises
|
|
71
|
+
the exception that triggered the retry.
|
|
72
|
+
``None`` clears the notifier.
|
|
73
|
+
"""
|
|
74
|
+
global _retry_notifier
|
|
75
|
+
_retry_notifier = fn
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def clear_retry_notifier() -> None:
|
|
79
|
+
"""Remove any installed retry notifier."""
|
|
80
|
+
set_retry_notifier(None)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _model_error_text(exc: Exception) -> str:
|
|
84
|
+
"""Collect provider error text, including nested SSE error bodies."""
|
|
85
|
+
|
|
86
|
+
parts = [str(exc)]
|
|
87
|
+
body = getattr(exc, "body", None)
|
|
88
|
+
|
|
89
|
+
def _collect(value: Any) -> None:
|
|
90
|
+
if isinstance(value, Mapping):
|
|
91
|
+
for nested in value.values():
|
|
92
|
+
_collect(nested)
|
|
93
|
+
elif isinstance(value, (list, tuple)):
|
|
94
|
+
for nested in value:
|
|
95
|
+
_collect(nested)
|
|
96
|
+
elif value is not None:
|
|
97
|
+
parts.append(str(value))
|
|
98
|
+
|
|
99
|
+
_collect(body)
|
|
100
|
+
return " ".join(parts).lower()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def should_retry_transient_model_error(exc: Exception) -> bool:
|
|
104
|
+
"""Return ``True`` when *exc* is a recoverable transient model error.
|
|
105
|
+
|
|
106
|
+
Retried:
|
|
107
|
+
- Provider errors **without** an HTTP status code whose error text
|
|
108
|
+
matches a known transient marker (e.g. ``overloaded``).
|
|
109
|
+
- 5xx server errors (502 / 503 / 504) when the body text contains an
|
|
110
|
+
explicitly-recognised infrastructure marker such as
|
|
111
|
+
``auth_unavailable`` — these are short-lived auth-infra hiccups that
|
|
112
|
+
recover within seconds.
|
|
113
|
+
|
|
114
|
+
**Not** retried:
|
|
115
|
+
- Any error carrying a 4xx status code (401, 429, …).
|
|
116
|
+
- 5xx errors whose body does not match a known marker (non-transient).
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
status_code = getattr(exc, "status_code", None)
|
|
120
|
+
if isinstance(status_code, int):
|
|
121
|
+
if status_code in _RETRYABLE_5XX_STATUSES:
|
|
122
|
+
text = _model_error_text(exc)
|
|
123
|
+
if any(marker in text for marker in _RETRYABLE_5XX_MARKERS):
|
|
124
|
+
return True
|
|
125
|
+
return False
|
|
126
|
+
text = _model_error_text(exc)
|
|
127
|
+
return any(marker in text for marker in _TRANSIENT_MODEL_ERROR_MARKERS)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _format_retry_reason(exc: Exception) -> str:
|
|
131
|
+
"""One-line summary of *exc* suitable for the status bar."""
|
|
132
|
+
status_code = getattr(exc, "status_code", None)
|
|
133
|
+
msg = str(exc)
|
|
134
|
+
body = getattr(exc, "body", None)
|
|
135
|
+
if isinstance(body, Mapping):
|
|
136
|
+
err = body.get("error") or {}
|
|
137
|
+
if isinstance(err, Mapping):
|
|
138
|
+
inner = err.get("message") or ""
|
|
139
|
+
if inner:
|
|
140
|
+
msg = str(inner)
|
|
141
|
+
short = msg.split("\n")[0].strip()
|
|
142
|
+
if len(short) > 120:
|
|
143
|
+
short = short[:117] + "..."
|
|
144
|
+
if isinstance(status_code, int):
|
|
145
|
+
return f"[{status_code}] {short}"
|
|
146
|
+
return short
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class NotifyingModelRetryMiddleware(ModelRetryMiddleware):
|
|
150
|
+
"""``ModelRetryMiddleware`` subclass that fires a notifier on each retry."""
|
|
151
|
+
|
|
152
|
+
def _notify_retry(self, attempt: int, delay: float, exc: Exception) -> None:
|
|
153
|
+
if _retry_notifier is not None:
|
|
154
|
+
try:
|
|
155
|
+
reason = _format_retry_reason(exc)
|
|
156
|
+
_retry_notifier(attempt, delay, reason)
|
|
157
|
+
except Exception: # noqa: BLE001
|
|
158
|
+
pass
|
|
159
|
+
|
|
160
|
+
# -- sync ----------------------------------------------------------------
|
|
161
|
+
def wrap_model_call(
|
|
162
|
+
self,
|
|
163
|
+
request: ModelRequest,
|
|
164
|
+
handler: Callable[[ModelRequest], ModelResponse],
|
|
165
|
+
) -> ModelResponse:
|
|
166
|
+
for attempt in range(self.max_retries + 1):
|
|
167
|
+
try:
|
|
168
|
+
return handler(request)
|
|
169
|
+
except Exception as exc:
|
|
170
|
+
attempts_made = attempt + 1
|
|
171
|
+
if not should_retry_exception(exc, self.retry_on):
|
|
172
|
+
return self._handle_failure(exc, attempts_made)
|
|
173
|
+
if attempt < self.max_retries:
|
|
174
|
+
delay = calculate_delay(
|
|
175
|
+
attempt,
|
|
176
|
+
backoff_factor=self.backoff_factor,
|
|
177
|
+
initial_delay=self.initial_delay,
|
|
178
|
+
max_delay=self.max_delay,
|
|
179
|
+
jitter=self.jitter,
|
|
180
|
+
)
|
|
181
|
+
self._notify_retry(attempts_made, delay, exc)
|
|
182
|
+
if delay > 0:
|
|
183
|
+
time.sleep(delay)
|
|
184
|
+
else:
|
|
185
|
+
return self._handle_failure(exc, attempts_made)
|
|
186
|
+
msg = "Unexpected: retry loop completed without returning"
|
|
187
|
+
raise RuntimeError(msg)
|
|
188
|
+
|
|
189
|
+
# -- async ---------------------------------------------------------------
|
|
190
|
+
async def awrap_model_call(
|
|
191
|
+
self,
|
|
192
|
+
request: ModelRequest,
|
|
193
|
+
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
|
|
194
|
+
) -> ModelResponse:
|
|
195
|
+
for attempt in range(self.max_retries + 1):
|
|
196
|
+
try:
|
|
197
|
+
return await handler(request)
|
|
198
|
+
except Exception as exc:
|
|
199
|
+
attempts_made = attempt + 1
|
|
200
|
+
if not should_retry_exception(exc, self.retry_on):
|
|
201
|
+
return self._handle_failure(exc, attempts_made)
|
|
202
|
+
if attempt < self.max_retries:
|
|
203
|
+
delay = calculate_delay(
|
|
204
|
+
attempt,
|
|
205
|
+
backoff_factor=self.backoff_factor,
|
|
206
|
+
initial_delay=self.initial_delay,
|
|
207
|
+
max_delay=self.max_delay,
|
|
208
|
+
jitter=self.jitter,
|
|
209
|
+
)
|
|
210
|
+
self._notify_retry(attempts_made, delay, exc)
|
|
211
|
+
if delay > 0:
|
|
212
|
+
await asyncio.sleep(delay)
|
|
213
|
+
else:
|
|
214
|
+
return self._handle_failure(exc, attempts_made)
|
|
215
|
+
msg = "Unexpected: retry loop completed without returning"
|
|
216
|
+
raise RuntimeError(msg)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def build_model_retry_middleware() -> NotifyingModelRetryMiddleware:
|
|
220
|
+
"""Retry recoverable model failures (stream + HTTP 5xx) with backoff."""
|
|
221
|
+
return NotifyingModelRetryMiddleware(
|
|
222
|
+
max_retries=999,
|
|
223
|
+
retry_on=should_retry_transient_model_error,
|
|
224
|
+
on_failure="error",
|
|
225
|
+
initial_delay=1.0,
|
|
226
|
+
backoff_factor=2.0,
|
|
227
|
+
max_delay=8.0,
|
|
228
|
+
jitter=True,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _dual_wrap_model_call(*, name: str, apply):
|
|
233
|
+
"""Build model-call middleware with both sync and async hooks.
|
|
234
|
+
|
|
235
|
+
``apply(request) -> request`` mutates/overrides the request; the handler is
|
|
236
|
+
always invoked (sync or async). Required for ``astream`` / ``ainvoke``.
|
|
237
|
+
"""
|
|
238
|
+
|
|
239
|
+
def wrap_model_call(self, request, handler): # noqa: ANN001, ARG001
|
|
240
|
+
return handler(apply(request))
|
|
241
|
+
|
|
242
|
+
async def awrap_model_call(self, request, handler): # noqa: ANN001, ARG001
|
|
243
|
+
return await handler(apply(request))
|
|
244
|
+
|
|
245
|
+
return type(
|
|
246
|
+
name,
|
|
247
|
+
(AgentMiddleware,),
|
|
248
|
+
{
|
|
249
|
+
"state_schema": AgentState,
|
|
250
|
+
"tools": [],
|
|
251
|
+
"wrap_model_call": wrap_model_call,
|
|
252
|
+
"awrap_model_call": awrap_model_call,
|
|
253
|
+
},
|
|
254
|
+
)()
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _dual_wrap_tool_call(*, name: str, apply):
|
|
258
|
+
"""Build tool-call middleware with both sync and async hooks."""
|
|
259
|
+
|
|
260
|
+
def wrap_tool_call(self, request, handler): # noqa: ANN001, ARG001
|
|
261
|
+
return handler(apply(request))
|
|
262
|
+
|
|
263
|
+
async def awrap_tool_call(self, request, handler): # noqa: ANN001, ARG001
|
|
264
|
+
return await handler(apply(request))
|
|
265
|
+
|
|
266
|
+
return type(
|
|
267
|
+
name,
|
|
268
|
+
(AgentMiddleware,),
|
|
269
|
+
{
|
|
270
|
+
"state_schema": AgentState,
|
|
271
|
+
"tools": [],
|
|
272
|
+
"wrap_tool_call": wrap_tool_call,
|
|
273
|
+
"awrap_tool_call": awrap_tool_call,
|
|
274
|
+
},
|
|
275
|
+
)()
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def build_task_namespace_middleware():
|
|
279
|
+
"""Give each concurrent ``task`` invocation a distinct subgraph namespace."""
|
|
280
|
+
|
|
281
|
+
def _call_id(request: Any) -> str:
|
|
282
|
+
call = request.tool_call
|
|
283
|
+
if isinstance(call, dict):
|
|
284
|
+
return str(call.get("id") or "")
|
|
285
|
+
return str(getattr(call, "id", None) or "")
|
|
286
|
+
|
|
287
|
+
def _name(request: Any) -> str:
|
|
288
|
+
call = request.tool_call
|
|
289
|
+
if isinstance(call, dict):
|
|
290
|
+
return str(call.get("name") or "")
|
|
291
|
+
return str(getattr(call, "name", None) or "")
|
|
292
|
+
|
|
293
|
+
def _config(request: Any, call_id: str) -> dict[str, Any]:
|
|
294
|
+
config = dict(request.runtime.config or {})
|
|
295
|
+
configurable = dict(config.get("configurable") or {})
|
|
296
|
+
parent_ns = str(configurable.get("checkpoint_ns") or "")
|
|
297
|
+
segment = f"task_call:{call_id}"
|
|
298
|
+
configurable["checkpoint_ns"] = f"{parent_ns}|{segment}" if parent_ns else segment
|
|
299
|
+
config["configurable"] = configurable
|
|
300
|
+
return config
|
|
301
|
+
|
|
302
|
+
def wrap_tool_call(self, request, handler): # noqa: ANN001, ARG001
|
|
303
|
+
call_id = _call_id(request)
|
|
304
|
+
if _name(request) != "task" or not call_id:
|
|
305
|
+
return handler(request)
|
|
306
|
+
from langchain_core.runnables.config import set_config_context
|
|
307
|
+
|
|
308
|
+
with set_config_context(_config(request, call_id)) as ctx:
|
|
309
|
+
return ctx.run(handler, request)
|
|
310
|
+
|
|
311
|
+
async def awrap_tool_call(self, request, handler): # noqa: ANN001, ARG001
|
|
312
|
+
call_id = _call_id(request)
|
|
313
|
+
if _name(request) != "task" or not call_id:
|
|
314
|
+
return await handler(request)
|
|
315
|
+
from langchain_core.runnables.config import set_config_context
|
|
316
|
+
|
|
317
|
+
with set_config_context(_config(request, call_id)) as ctx:
|
|
318
|
+
task = ctx.run(asyncio.create_task, handler(request))
|
|
319
|
+
return await task
|
|
320
|
+
|
|
321
|
+
return type(
|
|
322
|
+
"scope_task_subgraphs",
|
|
323
|
+
(AgentMiddleware,),
|
|
324
|
+
{
|
|
325
|
+
"state_schema": AgentState,
|
|
326
|
+
"tools": [],
|
|
327
|
+
"wrap_tool_call": wrap_tool_call,
|
|
328
|
+
"awrap_tool_call": awrap_tool_call,
|
|
329
|
+
},
|
|
330
|
+
)()
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
# Required model-facing field: short purpose shown in the timeline UI.
|
|
334
|
+
TOOL_INTENT_KEY = "intent"
|
|
335
|
+
TOOL_INTENT_DESCRIPTION = (
|
|
336
|
+
"Required. One short sentence describing WHY this tool is being called "
|
|
337
|
+
"(user-facing intent for the timeline UI). Prefer Chinese. "
|
|
338
|
+
"Example: 'inspect pytest config' / 'locate login failure'. "
|
|
339
|
+
"Do not dump raw args or only restate the tool name."
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def build_path_normalize_middleware(workspace: Path):
|
|
344
|
+
"""Rewrite host/Windows paths in tool args to virtual ``/`` paths."""
|
|
345
|
+
|
|
346
|
+
root = Path(workspace).resolve()
|
|
347
|
+
|
|
348
|
+
def _apply(request): # type: ignore[no-untyped-def]
|
|
349
|
+
tool_call = request.tool_call
|
|
350
|
+
# tool_call may be dict-like
|
|
351
|
+
if isinstance(tool_call, dict):
|
|
352
|
+
args = dict(tool_call.get("args") or {})
|
|
353
|
+
new_args = rewrite_tool_args_paths(args, root)
|
|
354
|
+
if new_args != args:
|
|
355
|
+
new_call = {**tool_call, "args": new_args}
|
|
356
|
+
return request.override(tool_call=new_call)
|
|
357
|
+
return request
|
|
358
|
+
args = dict(getattr(tool_call, "args", None) or {})
|
|
359
|
+
new_args = rewrite_tool_args_paths(args, root)
|
|
360
|
+
if new_args != args:
|
|
361
|
+
# Best-effort for object-style tool_call
|
|
362
|
+
try:
|
|
363
|
+
new_call = {
|
|
364
|
+
"name": getattr(tool_call, "name", None),
|
|
365
|
+
"args": new_args,
|
|
366
|
+
"id": getattr(tool_call, "id", None),
|
|
367
|
+
"type": getattr(tool_call, "type", "tool_call"),
|
|
368
|
+
}
|
|
369
|
+
return request.override(tool_call=new_call)
|
|
370
|
+
except Exception: # noqa: BLE001
|
|
371
|
+
return request
|
|
372
|
+
return request
|
|
373
|
+
|
|
374
|
+
return _dual_wrap_tool_call(name="normalize_virtual_paths", apply=_apply)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def build_tool_error_recovery_middleware():
|
|
378
|
+
"""Return tool failures to the model instead of terminating the agent graph."""
|
|
379
|
+
|
|
380
|
+
def _error_message(request, exc: Exception) -> ToolMessage: # type: ignore[no-untyped-def]
|
|
381
|
+
tool_call = request.tool_call
|
|
382
|
+
if isinstance(tool_call, dict):
|
|
383
|
+
name = str(tool_call.get("name") or "tool")
|
|
384
|
+
call_id = str(tool_call.get("id") or "unknown")
|
|
385
|
+
else:
|
|
386
|
+
name = str(getattr(tool_call, "name", None) or "tool")
|
|
387
|
+
call_id = str(getattr(tool_call, "id", None) or "unknown")
|
|
388
|
+
return ToolMessage(
|
|
389
|
+
content=(
|
|
390
|
+
f"Error: {name} failed ({type(exc).__name__}): {exc}\n"
|
|
391
|
+
"The tool call failed. Continue the task by correcting the arguments "
|
|
392
|
+
"or choosing another safe tool."
|
|
393
|
+
),
|
|
394
|
+
tool_call_id=call_id,
|
|
395
|
+
name=name,
|
|
396
|
+
status="error",
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
def wrap_tool_call(self, request, handler): # noqa: ANN001, ARG001
|
|
400
|
+
try:
|
|
401
|
+
return handler(request)
|
|
402
|
+
except Exception as exc: # noqa: BLE001
|
|
403
|
+
return _error_message(request, exc)
|
|
404
|
+
|
|
405
|
+
async def awrap_tool_call(self, request, handler): # noqa: ANN001, ARG001
|
|
406
|
+
try:
|
|
407
|
+
return await handler(request)
|
|
408
|
+
except Exception as exc: # noqa: BLE001
|
|
409
|
+
return _error_message(request, exc)
|
|
410
|
+
|
|
411
|
+
return type(
|
|
412
|
+
"recover_tool_errors",
|
|
413
|
+
(AgentMiddleware,),
|
|
414
|
+
{
|
|
415
|
+
"state_schema": AgentState,
|
|
416
|
+
"tools": [],
|
|
417
|
+
"wrap_tool_call": wrap_tool_call,
|
|
418
|
+
"awrap_tool_call": awrap_tool_call,
|
|
419
|
+
},
|
|
420
|
+
)()
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _tool_name(tool: Any) -> str:
|
|
424
|
+
name = getattr(tool, "name", None)
|
|
425
|
+
if name:
|
|
426
|
+
return str(name)
|
|
427
|
+
return str(getattr(tool, "__name__", tool))
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def build_tool_exclusion_middleware(excluded: set[str] | frozenset[str] | list[str]):
|
|
431
|
+
"""Hide tools from the model request (LocalShell-safe alternative to permissions).
|
|
432
|
+
|
|
433
|
+
deepagents ``FilesystemPermission`` cannot be combined with backends that
|
|
434
|
+
implement command execution. Use this middleware for product isolation.
|
|
435
|
+
"""
|
|
436
|
+
blocked = frozenset(str(x) for x in excluded if x)
|
|
437
|
+
|
|
438
|
+
def _apply(request): # type: ignore[no-untyped-def]
|
|
439
|
+
if not blocked:
|
|
440
|
+
return request
|
|
441
|
+
tools = getattr(request, "tools", None) or []
|
|
442
|
+
filtered = [t for t in tools if _tool_name(t) not in blocked]
|
|
443
|
+
if len(filtered) != len(tools):
|
|
444
|
+
return request.override(tools=filtered)
|
|
445
|
+
return request
|
|
446
|
+
|
|
447
|
+
return _dual_wrap_model_call(name="exclude_tools", apply=_apply)
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _strip_intent_from_tool_call(tool_call: Any) -> Any:
|
|
451
|
+
"""Remove intent from args before the real tool schema validates."""
|
|
452
|
+
if isinstance(tool_call, dict):
|
|
453
|
+
args = dict(tool_call.get("args") or {})
|
|
454
|
+
if TOOL_INTENT_KEY not in args:
|
|
455
|
+
return tool_call
|
|
456
|
+
args.pop(TOOL_INTENT_KEY, None)
|
|
457
|
+
return {**tool_call, "args": args}
|
|
458
|
+
|
|
459
|
+
args = dict(getattr(tool_call, "args", None) or {})
|
|
460
|
+
if TOOL_INTENT_KEY not in args:
|
|
461
|
+
return tool_call
|
|
462
|
+
args.pop(TOOL_INTENT_KEY, None)
|
|
463
|
+
try:
|
|
464
|
+
return {
|
|
465
|
+
"name": getattr(tool_call, "name", None),
|
|
466
|
+
"args": args,
|
|
467
|
+
"id": getattr(tool_call, "id", None),
|
|
468
|
+
"type": getattr(tool_call, "type", "tool_call"),
|
|
469
|
+
}
|
|
470
|
+
except Exception: # noqa: BLE001
|
|
471
|
+
return tool_call
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _field_definitions_with_intent(schema: Any) -> dict[str, Any] | None:
|
|
475
|
+
"""Build create_model field map with required intent first."""
|
|
476
|
+
from pydantic import Field
|
|
477
|
+
from pydantic_core import PydanticUndefined
|
|
478
|
+
|
|
479
|
+
fields = getattr(schema, "model_fields", None)
|
|
480
|
+
if not fields:
|
|
481
|
+
return None
|
|
482
|
+
if TOOL_INTENT_KEY in fields:
|
|
483
|
+
return None
|
|
484
|
+
|
|
485
|
+
defs: dict[str, Any] = {
|
|
486
|
+
TOOL_INTENT_KEY: (
|
|
487
|
+
str,
|
|
488
|
+
Field(..., description=TOOL_INTENT_DESCRIPTION),
|
|
489
|
+
)
|
|
490
|
+
}
|
|
491
|
+
for name, finfo in fields.items():
|
|
492
|
+
ann = finfo.annotation
|
|
493
|
+
desc = finfo.description or name
|
|
494
|
+
if finfo.is_required():
|
|
495
|
+
defs[name] = (ann, Field(..., description=desc))
|
|
496
|
+
continue
|
|
497
|
+
default = finfo.default
|
|
498
|
+
default_factory = getattr(finfo, "default_factory", None)
|
|
499
|
+
if default_factory is not None and default is PydanticUndefined:
|
|
500
|
+
defs[name] = (ann, Field(default_factory=default_factory, description=desc))
|
|
501
|
+
elif default is PydanticUndefined:
|
|
502
|
+
defs[name] = (ann, Field(default=None, description=desc))
|
|
503
|
+
else:
|
|
504
|
+
defs[name] = (ann, Field(default=default, description=desc))
|
|
505
|
+
return defs
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def add_intent_to_tool(tool: Any, *, cache: dict[int, Any] | None = None) -> Any:
|
|
509
|
+
"""Return a model-facing tool clone with required ``intent`` in args schema.
|
|
510
|
+
|
|
511
|
+
Tools may include injected runtime args (e.g. ``ToolRuntime`` / ``BaseStore``).
|
|
512
|
+
Those annotations are not JSON-serializable; create the schema with
|
|
513
|
+
``arbitrary_types_allowed=True`` so wrapping does not crash the agent.
|
|
514
|
+
LangChain's ``tool_call_schema`` still hides injected fields from the model.
|
|
515
|
+
"""
|
|
516
|
+
if cache is not None:
|
|
517
|
+
cached = cache.get(id(tool))
|
|
518
|
+
if cached is not None:
|
|
519
|
+
return cached
|
|
520
|
+
|
|
521
|
+
get_schema = getattr(tool, "get_input_schema", None)
|
|
522
|
+
if not callable(get_schema):
|
|
523
|
+
return tool
|
|
524
|
+
try:
|
|
525
|
+
schema = get_schema()
|
|
526
|
+
except Exception: # noqa: BLE001
|
|
527
|
+
return tool
|
|
528
|
+
|
|
529
|
+
defs = _field_definitions_with_intent(schema)
|
|
530
|
+
if defs is None:
|
|
531
|
+
return tool
|
|
532
|
+
|
|
533
|
+
from langchain_core.tools import StructuredTool
|
|
534
|
+
from pydantic import ConfigDict, create_model
|
|
535
|
+
|
|
536
|
+
title = getattr(schema, "__name__", None) or f"{_tool_name(tool)}Schema"
|
|
537
|
+
try:
|
|
538
|
+
# compact_conversation etc. carry ToolRuntime (contains BaseStore).
|
|
539
|
+
NewModel = create_model(
|
|
540
|
+
f"{title}WithIntent",
|
|
541
|
+
__config__=ConfigDict(arbitrary_types_allowed=True),
|
|
542
|
+
**defs,
|
|
543
|
+
)
|
|
544
|
+
except Exception: # noqa: BLE001
|
|
545
|
+
# Never break the whole turn because one tool schema is exotic.
|
|
546
|
+
return tool
|
|
547
|
+
|
|
548
|
+
def _sync(**kwargs: Any) -> Any:
|
|
549
|
+
data = dict(kwargs)
|
|
550
|
+
data.pop(TOOL_INTENT_KEY, None)
|
|
551
|
+
return tool.invoke(data)
|
|
552
|
+
|
|
553
|
+
async def _async(**kwargs: Any) -> Any:
|
|
554
|
+
data = dict(kwargs)
|
|
555
|
+
data.pop(TOOL_INTENT_KEY, None)
|
|
556
|
+
return await tool.ainvoke(data)
|
|
557
|
+
|
|
558
|
+
try:
|
|
559
|
+
wrapped = StructuredTool.from_function(
|
|
560
|
+
func=_sync,
|
|
561
|
+
coroutine=_async,
|
|
562
|
+
name=_tool_name(tool),
|
|
563
|
+
description=getattr(tool, "description", None) or _tool_name(tool),
|
|
564
|
+
args_schema=NewModel,
|
|
565
|
+
)
|
|
566
|
+
except Exception: # noqa: BLE001
|
|
567
|
+
return tool
|
|
568
|
+
|
|
569
|
+
if cache is not None:
|
|
570
|
+
cache[id(tool)] = wrapped
|
|
571
|
+
return wrapped
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def build_intent_schema_middleware():
|
|
575
|
+
"""Inject required ``intent`` into every tool schema the model sees.
|
|
576
|
+
|
|
577
|
+
Execution path strips ``intent`` so original deepagents tools keep working.
|
|
578
|
+
The stream/UI still sees ``intent`` on AI tool_calls and can render it.
|
|
579
|
+
"""
|
|
580
|
+
cache: dict[int, Any] = {}
|
|
581
|
+
|
|
582
|
+
def _apply_inject(request): # type: ignore[no-untyped-def]
|
|
583
|
+
tools = list(getattr(request, "tools", None) or [])
|
|
584
|
+
if not tools:
|
|
585
|
+
return request
|
|
586
|
+
rewritten = [add_intent_to_tool(t, cache=cache) for t in tools]
|
|
587
|
+
if rewritten != tools:
|
|
588
|
+
return request.override(tools=rewritten)
|
|
589
|
+
return request
|
|
590
|
+
|
|
591
|
+
def _apply_strip(request): # type: ignore[no-untyped-def]
|
|
592
|
+
tool_call = getattr(request, "tool_call", None)
|
|
593
|
+
if tool_call is None:
|
|
594
|
+
return request
|
|
595
|
+
new_call = _strip_intent_from_tool_call(tool_call)
|
|
596
|
+
if new_call is not tool_call:
|
|
597
|
+
try:
|
|
598
|
+
return request.override(tool_call=new_call)
|
|
599
|
+
except Exception: # noqa: BLE001
|
|
600
|
+
return request
|
|
601
|
+
return request
|
|
602
|
+
|
|
603
|
+
# Stack as a list: model-facing schema rewrite + tool-exec intent strip.
|
|
604
|
+
return [
|
|
605
|
+
_dual_wrap_model_call(name="require_tool_intent", apply=_apply_inject),
|
|
606
|
+
_dual_wrap_tool_call(name="strip_tool_intent", apply=_apply_strip),
|
|
607
|
+
]
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
_prompt_cleanup_saved_tokens: contextvars.ContextVar[int] = contextvars.ContextVar(
|
|
611
|
+
"synapse_prompt_cleanup_saved_tokens", default=0
|
|
612
|
+
)
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def current_prompt_cleanup_saved_tokens() -> int:
|
|
616
|
+
"""Return prompt-cleanup savings for the current model-call context."""
|
|
617
|
+
return max(0, int(_prompt_cleanup_saved_tokens.get() or 0))
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
# ---------------------------------------------------------------------------
|
|
621
|
+
# Strip redundant prompt blocks injected by deepagents built-in middleware
|
|
622
|
+
# ---------------------------------------------------------------------------
|
|
623
|
+
|
|
624
|
+
# Text block prefixes injected by deepagents middleware. Each block
|
|
625
|
+
# duplicates content already present in the corresponding tool definition
|
|
626
|
+
# (e.g. ``write_todos`` description, filesystem tool docs). Stripping them
|
|
627
|
+
# saves ~720 tokens per model call without losing any capability.
|
|
628
|
+
|
|
629
|
+
_REDUNDANT_BLOCK_PREFIXES: tuple[str, ...] = (
|
|
630
|
+
"\n\n## `write_todos`",
|
|
631
|
+
"\n\n## Skills System",
|
|
632
|
+
"\n\n## Following Conventions",
|
|
633
|
+
)
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def build_strip_redundant_prompt_blocks():
|
|
637
|
+
"""Remove middleware-injected prompt blocks that duplicate tool definitions."""
|
|
638
|
+
|
|
639
|
+
def _apply(request): # type: ignore[no-untyped-def]
|
|
640
|
+
_prompt_cleanup_saved_tokens.set(0)
|
|
641
|
+
msg = getattr(request, "system_message", None)
|
|
642
|
+
if msg is None or not hasattr(msg, "content_blocks"):
|
|
643
|
+
return request
|
|
644
|
+
blocks = msg.content_blocks
|
|
645
|
+
if not blocks:
|
|
646
|
+
return request
|
|
647
|
+
|
|
648
|
+
filtered = []
|
|
649
|
+
removed_chars = 0
|
|
650
|
+
changed = False
|
|
651
|
+
for block in blocks:
|
|
652
|
+
text = block.get("text", "") if isinstance(block, dict) else ""
|
|
653
|
+
if any(text.startswith(p) for p in _REDUNDANT_BLOCK_PREFIXES):
|
|
654
|
+
changed = True
|
|
655
|
+
removed_chars += len(text)
|
|
656
|
+
continue
|
|
657
|
+
filtered.append(block)
|
|
658
|
+
|
|
659
|
+
if not changed:
|
|
660
|
+
return request
|
|
661
|
+
|
|
662
|
+
new_msg = msg.__class__(content_blocks=filtered)
|
|
663
|
+
_prompt_cleanup_saved_tokens.set(max(0, (removed_chars + 3) // 4))
|
|
664
|
+
return request.override(system_message=new_msg)
|
|
665
|
+
|
|
666
|
+
return _dual_wrap_model_call(name="strip_redundant_prompt", apply=_apply)
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
# ---------------------------------------------------------------------------
|
|
670
|
+
# Compact tool descriptions — replace verbose upstream descriptions with
|
|
671
|
+
# concise alternatives to reduce prompt-cache-polluting tool-schema tokens.
|
|
672
|
+
# ---------------------------------------------------------------------------
|
|
673
|
+
|
|
674
|
+
# Per-tool short descriptions. The upstream LangChain / deepagents defaults
|
|
675
|
+
# embed full-blown usage guides inside tool descriptions (3-13 KB each),
|
|
676
|
+
# pushing tool-schema tokens to ~134K per request. Replacing them with
|
|
677
|
+
# one-sentence summaries keeps the essential signal while saving ~30K+ tokens
|
|
678
|
+
# of schema overhead (most of which is cached, but shorter schemas still
|
|
679
|
+
# reduce bandwidth and cache-eviction pressure).
|
|
680
|
+
_COMPACT_TOOL_DESCRIPTIONS: dict[str, str] = {
|
|
681
|
+
"write_todos": (
|
|
682
|
+
"Create and manage a structured task list for your current work session. "
|
|
683
|
+
"Each todo has content and status: pending, in_progress, or completed. "
|
|
684
|
+
"Only use for complex multi-step tasks (3+ steps); skip for trivial tasks."
|
|
685
|
+
),
|
|
686
|
+
"execute": (
|
|
687
|
+
"Execute a shell command in a sandbox environment. "
|
|
688
|
+
"Returns combined stdout/stderr with exit code. "
|
|
689
|
+
"Use timeout=SECONDS for long commands. "
|
|
690
|
+
"Join multiple commands with && or ; (not newlines). "
|
|
691
|
+
"Use glob/grep/read_file instead of find/grep/cat."
|
|
692
|
+
),
|
|
693
|
+
"read_file": (
|
|
694
|
+
"Read a file from the filesystem and return content with cat -n line numbers. "
|
|
695
|
+
"Use pagination (offset/limit) for large files: read_file(path, offset=0, limit=100). "
|
|
696
|
+
"Always read a file before editing it. "
|
|
697
|
+
"Supports images, audio, video, and PDF via multimodal reads."
|
|
698
|
+
),
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def build_compact_tool_descriptions(
|
|
703
|
+
overrides: dict[str, str] | None = None,
|
|
704
|
+
) -> AgentMiddleware:
|
|
705
|
+
"""Build middleware that replaces verbose upstream tool descriptions.
|
|
706
|
+
|
|
707
|
+
Args:
|
|
708
|
+
overrides: Additional per-tool short descriptions merged on top of
|
|
709
|
+
the built-in ``_COMPACT_TOOL_DESCRIPTIONS`` map.
|
|
710
|
+
"""
|
|
711
|
+
descriptions = dict(_COMPACT_TOOL_DESCRIPTIONS)
|
|
712
|
+
if overrides:
|
|
713
|
+
descriptions.update(overrides)
|
|
714
|
+
|
|
715
|
+
if not descriptions:
|
|
716
|
+
return _dual_wrap_model_call(name="compact_tool_desc_noop", apply=lambda r: r)
|
|
717
|
+
|
|
718
|
+
_compact_tool_desc_saved_tokens = contextvars.ContextVar[int](
|
|
719
|
+
"compact_tool_desc_saved_tokens", default=0
|
|
720
|
+
)
|
|
721
|
+
|
|
722
|
+
def _apply(request): # type: ignore[no-untyped-def]
|
|
723
|
+
tools = getattr(request, "tools", None)
|
|
724
|
+
if not tools:
|
|
725
|
+
return request
|
|
726
|
+
|
|
727
|
+
saved_chars = 0
|
|
728
|
+
changed = False
|
|
729
|
+
new_tools: list[Any] = []
|
|
730
|
+
|
|
731
|
+
for tool in tools:
|
|
732
|
+
if _is_tool_dict(tool):
|
|
733
|
+
name, updated, chars = _compact_dict_tool(tool, descriptions)
|
|
734
|
+
elif hasattr(tool, "name") and hasattr(tool, "description"):
|
|
735
|
+
name, updated, chars = _compact_base_tool(tool, descriptions)
|
|
736
|
+
else:
|
|
737
|
+
new_tools.append(tool)
|
|
738
|
+
continue
|
|
739
|
+
|
|
740
|
+
if updated:
|
|
741
|
+
changed = True
|
|
742
|
+
saved_chars += chars
|
|
743
|
+
new_tools.append(updated)
|
|
744
|
+
else:
|
|
745
|
+
new_tools.append(tool)
|
|
746
|
+
|
|
747
|
+
if not changed:
|
|
748
|
+
return request
|
|
749
|
+
|
|
750
|
+
_compact_tool_desc_saved_tokens.set(
|
|
751
|
+
max(0, (saved_chars + 3) // 4)
|
|
752
|
+
)
|
|
753
|
+
return request.override(tools=new_tools)
|
|
754
|
+
|
|
755
|
+
return _dual_wrap_model_call(
|
|
756
|
+
name="compact_tool_descriptions", apply=_apply
|
|
757
|
+
)
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
def _is_tool_dict(obj: Any) -> bool:
|
|
761
|
+
return isinstance(obj, dict) and (
|
|
762
|
+
"function" in obj or "name" in obj
|
|
763
|
+
)
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def _compact_dict_tool(
|
|
767
|
+
tool: dict[str, Any],
|
|
768
|
+
descriptions: dict[str, str],
|
|
769
|
+
) -> tuple[str, Any, int]:
|
|
770
|
+
"""Replace description for OpenAI-function-format tools."""
|
|
771
|
+
# OpenAI format: {"type": "function", "function": {"name": "...", "description": "..."}}
|
|
772
|
+
fn = tool.get("function") if isinstance(tool.get("function"), dict) else tool
|
|
773
|
+
name = str(fn.get("name", ""))
|
|
774
|
+
short = descriptions.get(name)
|
|
775
|
+
if short is None:
|
|
776
|
+
return name, None, 0
|
|
777
|
+
old_desc = str(fn.get("description", ""))
|
|
778
|
+
if old_desc == short:
|
|
779
|
+
return name, None, 0
|
|
780
|
+
saved = max(0, len(old_desc.encode("utf-8")) - len(short.encode("utf-8")))
|
|
781
|
+
if isinstance(tool.get("function"), dict):
|
|
782
|
+
new_fn = dict(fn, description=short)
|
|
783
|
+
return name, {**tool, "function": new_fn}, saved
|
|
784
|
+
else:
|
|
785
|
+
return name, {**tool, "description": short}, saved
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
def _compact_base_tool(
|
|
789
|
+
tool: Any,
|
|
790
|
+
descriptions: dict[str, str],
|
|
791
|
+
) -> tuple[str, Any, int]:
|
|
792
|
+
"""Replace description for LangChain BaseTool objects."""
|
|
793
|
+
name = getattr(tool, "name", "")
|
|
794
|
+
short = descriptions.get(name)
|
|
795
|
+
if short is None:
|
|
796
|
+
return name, None, 0
|
|
797
|
+
old_desc = getattr(tool, "description", "")
|
|
798
|
+
if old_desc == short:
|
|
799
|
+
return name, None, 0
|
|
800
|
+
saved = max(0, len(old_desc.encode("utf-8")) - len(short.encode("utf-8")))
|
|
801
|
+
new_tool = tool.model_copy(update={"description": short})
|
|
802
|
+
return name, new_tool, saved
|