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.
Files changed (131) hide show
  1. synapse/__init__.py +13 -0
  2. synapse/__main__.py +6 -0
  3. synapse/app/__init__.py +1 -0
  4. synapse/app/agent.py +492 -0
  5. synapse/app/agent_md.py +107 -0
  6. synapse/cli.py +750 -0
  7. synapse/commands/__init__.py +1 -0
  8. synapse/commands/compression.py +573 -0
  9. synapse/commands/helpers.py +22 -0
  10. synapse/commands/mcp.py +406 -0
  11. synapse/commands/model.py +173 -0
  12. synapse/commands/result.py +34 -0
  13. synapse/commands/sessions.py +443 -0
  14. synapse/commands/slash_cmds.py +521 -0
  15. synapse/commands/slash_complete.py +816 -0
  16. synapse/commands/theme.py +99 -0
  17. synapse/config.py +27 -0
  18. synapse/content/__init__.py +1 -0
  19. synapse/content/input_history.py +122 -0
  20. synapse/content/multimodal.py +733 -0
  21. synapse/content/prompts.py +249 -0
  22. synapse/content/skills_catalog.py +128 -0
  23. synapse/integrations/__init__.py +1 -0
  24. synapse/integrations/checkpoint_seed.py +281 -0
  25. synapse/integrations/codex_history.py +375 -0
  26. synapse/integrations/codex_import.py +393 -0
  27. synapse/integrations/codex_sessions.py +629 -0
  28. synapse/integrations/describe_image.py +370 -0
  29. synapse/integrations/http_clients.py +199 -0
  30. synapse/integrations/llm_openai_compat.py +90 -0
  31. synapse/integrations/llm_openai_websocket.py +187 -0
  32. synapse/integrations/mcp_client.py +646 -0
  33. synapse/integrations/vision_middleware.py +62 -0
  34. synapse/models/__init__.py +5 -0
  35. synapse/models/config.py +240 -0
  36. synapse/models/helpers.py +206 -0
  37. synapse/models/profile.py +59 -0
  38. synapse/models/registry.py +722 -0
  39. synapse/models_registry.py +7 -0
  40. synapse/observability/__init__.py +1 -0
  41. synapse/observability/startup_trace.py +127 -0
  42. synapse/runtime/__init__.py +1 -0
  43. synapse/runtime/async_runtime.py +176 -0
  44. synapse/runtime/backends.py +458 -0
  45. synapse/runtime/context_compact.py +249 -0
  46. synapse/runtime/execute_capture.py +48 -0
  47. synapse/runtime/fs_permissions.py +79 -0
  48. synapse/runtime/harness.py +57 -0
  49. synapse/runtime/hitl.py +197 -0
  50. synapse/runtime/interaction_ledger.py +82 -0
  51. synapse/runtime/middleware.py +802 -0
  52. synapse/runtime/model_request_compression_middleware.py +745 -0
  53. synapse/runtime/pathing.py +146 -0
  54. synapse/runtime/safety.py +184 -0
  55. synapse/runtime/steer.py +240 -0
  56. synapse/runtime/subagents.py +207 -0
  57. synapse/runtime/tool_ignore.py +221 -0
  58. synapse/runtime/tool_output_eval.py +118 -0
  59. synapse/runtime/tool_output_middleware.py +585 -0
  60. synapse/runtime/tool_output_usage_middleware.py +60 -0
  61. synapse/sessions/__init__.py +31 -0
  62. synapse/sessions/cancel_repair.py +208 -0
  63. synapse/sessions/session_recap.py +174 -0
  64. synapse/sessions/store.py +695 -0
  65. synapse/sessions/transcript.py +754 -0
  66. synapse/settings/__init__.py +5 -0
  67. synapse/settings/config_paths.py +184 -0
  68. synapse/settings/schema.py +464 -0
  69. synapse/tool_output/__init__.py +59 -0
  70. synapse/tool_output/detection.py +170 -0
  71. synapse/tool_output/metrics.py +32 -0
  72. synapse/tool_output/models.py +173 -0
  73. synapse/tool_output/pipeline.py +330 -0
  74. synapse/tool_output/repository.py +721 -0
  75. synapse/tool_output/transformers.py +648 -0
  76. synapse/tools/__init__.py +5 -0
  77. synapse/tools/session_tools.py +204 -0
  78. synapse/ui/__init__.py +10 -0
  79. synapse/ui/bottombar/__init__.py +73 -0
  80. synapse/ui/bottombar/components/__init__.py +143 -0
  81. synapse/ui/bottombar/components/key_hints.py +30 -0
  82. synapse/ui/bottombar/components/mcp.py +64 -0
  83. synapse/ui/bottombar/components/mode.py +24 -0
  84. synapse/ui/bottombar/components/model.py +28 -0
  85. synapse/ui/bottombar/components/thread.py +29 -0
  86. synapse/ui/bottombar/context.py +36 -0
  87. synapse/ui/bottombar/core.py +74 -0
  88. synapse/ui/dialogs/__init__.py +25 -0
  89. synapse/ui/dialogs/base.py +362 -0
  90. synapse/ui/dialogs/codex_session_list.py +84 -0
  91. synapse/ui/dialogs/compression_diagnostics.py +210 -0
  92. synapse/ui/dialogs/git_explore.py +702 -0
  93. synapse/ui/dialogs/mcp_panel.py +407 -0
  94. synapse/ui/dialogs/model_picker.py +128 -0
  95. synapse/ui/dialogs/safety_panel.py +63 -0
  96. synapse/ui/dialogs/session_list.py +98 -0
  97. synapse/ui/dialogs/theme_designer.py +863 -0
  98. synapse/ui/dialogs/theme_picker.py +113 -0
  99. synapse/ui/git_explore/__init__.py +31 -0
  100. synapse/ui/git_explore/engine.py +82 -0
  101. synapse/ui/git_explore/provider.py +242 -0
  102. synapse/ui/git_explore/unified.py +85 -0
  103. synapse/ui/rendering.py +350 -0
  104. synapse/ui/sink.py +70 -0
  105. synapse/ui/steer_widget.py +367 -0
  106. synapse/ui/stream.py +1207 -0
  107. synapse/ui/stream_events.py +421 -0
  108. synapse/ui/stream_runtime.py +252 -0
  109. synapse/ui/theme.py +1154 -0
  110. synapse/ui/timeline.py +621 -0
  111. synapse/ui/topbar/__init__.py +97 -0
  112. synapse/ui/topbar/components/__init__.py +150 -0
  113. synapse/ui/topbar/components/branch.py +41 -0
  114. synapse/ui/topbar/components/title.py +24 -0
  115. synapse/ui/topbar/components/tool_output.py +24 -0
  116. synapse/ui/topbar/components/usage.py +24 -0
  117. synapse/ui/topbar/components/workspace.py +32 -0
  118. synapse/ui/topbar/context.py +32 -0
  119. synapse/ui/topbar/core.py +979 -0
  120. synapse/ui/topbar/git_changes_popover.py +178 -0
  121. synapse/ui/topbar/git_chrome.py +475 -0
  122. synapse/ui/topbar/tool_output_popover.py +84 -0
  123. synapse/ui/topbar/widget.py +474 -0
  124. synapse/ui/tui.py +5717 -0
  125. synapse/ui/turn_rail.py +71 -0
  126. synapse/ui/user_turn.py +83 -0
  127. synapse/ui/welcome.py +261 -0
  128. synapse_cli_agent-0.1.13.dist-info/METADATA +412 -0
  129. synapse_cli_agent-0.1.13.dist-info/RECORD +131 -0
  130. synapse_cli_agent-0.1.13.dist-info/WHEEL +4 -0
  131. 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