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,585 @@
1
+ """LangChain middleware that rewrites large tool outputs through tool_output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import threading
7
+ import time
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from langchain.agents.middleware import AgentMiddleware, AgentState
12
+ from langchain_core.messages import ToolMessage
13
+ from langgraph.types import Command
14
+
15
+ from synapse.runtime.execute_capture import begin_execute_capture, end_execute_capture
16
+ from synapse.runtime.interaction_ledger import current_position
17
+ from synapse.tool_output.models import CompressionStageEvent, TransformContext, TransformEvent
18
+ from synapse.tool_output.pipeline import ToolOutputTransformPipeline
19
+ from synapse.tool_output.repository import ToolOutputRepository, content_to_text
20
+
21
+
22
+ def _estimate_tokens(content: str) -> int:
23
+ """Estimate model-visible tokens using the same approximation as compaction."""
24
+ try:
25
+ from langchain_core.messages import ToolMessage
26
+ from langchain_core.messages.utils import count_tokens_approximately
27
+
28
+ return max(
29
+ 0,
30
+ int(
31
+ count_tokens_approximately(
32
+ [ToolMessage(content=content, tool_call_id="estimate", name="tool")]
33
+ )
34
+ ),
35
+ )
36
+ except Exception: # noqa: BLE001
37
+ # Conservative fallback for environments without LangChain utilities.
38
+ return max(0, (len(content) + 3) // 4)
39
+
40
+
41
+
42
+ def build_tool_output_transform_middleware(
43
+ repository: ToolOutputRepository,
44
+ *,
45
+ threshold_bytes: int = 512,
46
+ pipeline: ToolOutputTransformPipeline | None = None,
47
+ enabled: bool = True,
48
+ ):
49
+ """Rewrite large outputs once, preserving originals only when needed.
50
+
51
+ The middleware owns all result rewriting for regular, async, and Command
52
+ result paths. ``execute_capture`` remains in use so an already-truncated
53
+ backend message can be transformed from its complete captured output.
54
+ """
55
+ threshold = max(0, int(threshold_bytes))
56
+ pipeline = pipeline or ToolOutputTransformPipeline()
57
+ excluded = frozenset({"read_tool_result", "compact_conversation"})
58
+ protected_source_reads: set[tuple[str, str, str, int, int]] = set()
59
+ protected_source_reads_lock = threading.Lock()
60
+
61
+ def runtime_identity(request: Any) -> tuple[str, str]:
62
+ config = dict(getattr(request.runtime, "config", None) or {})
63
+ try:
64
+ from langchain_core.runnables.config import get_config
65
+
66
+ active = get_config()
67
+ if active:
68
+ config = dict(active)
69
+ except (RuntimeError, ImportError):
70
+ pass
71
+ configurable = dict(config.get("configurable") or {})
72
+ return (
73
+ str(configurable.get("thread_id") or "unknown-thread"),
74
+ str(configurable.get("checkpoint_ns") or ""),
75
+ )
76
+
77
+ def call_value(request: Any, key: str, default: str = "") -> str:
78
+ call = getattr(request, "tool_call", None)
79
+ if isinstance(call, dict):
80
+ return str(call.get(key) or default)
81
+ return str(getattr(call, key, None) or default)
82
+
83
+ def call_args(request: Any) -> dict[str, Any]:
84
+ call = getattr(request, "tool_call", None)
85
+ value = call.get("args") if isinstance(call, dict) else getattr(call, "args", None)
86
+ return dict(value) if isinstance(value, dict) else {}
87
+
88
+ def summarized_args(args: dict[str, Any]) -> dict[str, Any]:
89
+ summary: dict[str, Any] = {}
90
+ for key, value in args.items():
91
+ if key in {"content", "new_string", "old_string"}:
92
+ text = str(value or "")
93
+ summary[key] = {
94
+ "bytes": len(text.encode("utf-8")),
95
+ "sha256": hashlib.sha256(text.encode("utf-8")).hexdigest()[:16],
96
+ }
97
+ elif isinstance(value, str):
98
+ summary[key] = value if len(value) <= 500 else value[:500] + "..."
99
+ elif isinstance(value, int | float | bool | type(None)):
100
+ summary[key] = value
101
+ else:
102
+ summary[key] = str(value)[:500]
103
+ return summary
104
+
105
+ def current_query(request: Any) -> str:
106
+ """Best-effort latest human text without changing graph state."""
107
+ runtime = getattr(request, "runtime", None)
108
+ state = getattr(runtime, "state", None)
109
+ messages = state.get("messages") if isinstance(state, dict) else None
110
+ if not isinstance(messages, list):
111
+ return ""
112
+ for message in reversed(messages):
113
+ role = getattr(message, "type", None) or getattr(message, "role", None)
114
+ if role not in {"human", "user"}:
115
+ continue
116
+ content = content_to_text(getattr(message, "content", ""))
117
+ if content.strip():
118
+ return content
119
+ return ""
120
+
121
+ def rewrite_message(
122
+ request: Any,
123
+ message: ToolMessage,
124
+ *,
125
+ original_content: str | None = None,
126
+ execute_output_truncated: bool = False,
127
+ ) -> ToolMessage:
128
+ name = str(message.name or call_value(request, "name", "tool"))
129
+ if name in excluded:
130
+ return message
131
+ original = (
132
+ original_content if original_content is not None else content_to_text(message.content)
133
+ )
134
+ original_bytes = len(original.encode("utf-8"))
135
+ original_tokens = _estimate_tokens(original)
136
+ status = str(message.status or "success")
137
+ thread_id, checkpoint_ns = runtime_identity(request)
138
+ tool_call_id = str(message.tool_call_id or call_value(request, "id"))
139
+ message_id = str(getattr(message, "id", None) or "")
140
+ content_sha256 = hashlib.sha256(original.encode("utf-8")).hexdigest()
141
+ started = time.perf_counter()
142
+
143
+ def record_decision(
144
+ *,
145
+ decision: str,
146
+ reason_code: str,
147
+ content_type: str = "unknown",
148
+ transformer: str = "none",
149
+ visible_content: str | None = None,
150
+ algorithm_output: str | None = None,
151
+ eligible: bool = False,
152
+ ref: str | None = None,
153
+ critical_total: int = 0,
154
+ critical_retained: int = 0,
155
+ execution_path: str = "not_run",
156
+ confidence: float = 0.0,
157
+ reason_detail: str = "",
158
+ stages: tuple[CompressionStageEvent, ...] = (),
159
+ ) -> TransformEvent:
160
+ visible = original if visible_content is None else visible_content
161
+ algorithm = visible if algorithm_output is None else algorithm_output
162
+ event = TransformEvent(
163
+ content_type=content_type,
164
+ transformer=transformer,
165
+ outcome="transformed" if decision == "transformed" else "passthrough",
166
+ original_bytes=original_bytes,
167
+ visible_bytes=len(visible.encode("utf-8")),
168
+ duration_ms=(time.perf_counter() - started) * 1000,
169
+ critical_total=critical_total,
170
+ critical_retained=critical_retained,
171
+ ref_created=bool(ref),
172
+ execution_path=execution_path,
173
+ estimated_original_tokens=original_tokens,
174
+ estimated_visible_tokens=_estimate_tokens(visible),
175
+ decision=decision,
176
+ reason_code=reason_code,
177
+ reason_detail=reason_detail,
178
+ eligible=eligible,
179
+ detection_confidence=confidence,
180
+ threshold_bytes=threshold,
181
+ tool_call_id=tool_call_id,
182
+ tool_name=name,
183
+ status=status,
184
+ checkpoint_ns=checkpoint_ns,
185
+ message_id=message_id,
186
+ algorithm_output_bytes=len(algorithm.encode("utf-8")),
187
+ algorithm_output_tokens=_estimate_tokens(algorithm),
188
+ content_sha256=content_sha256,
189
+ stages=stages,
190
+ )
191
+ repository.record_event(thread_id, event, ref=ref)
192
+ return event
193
+
194
+ if not enabled:
195
+ record_decision(
196
+ decision="skipped",
197
+ reason_code="global_disabled",
198
+ reason_detail="tool-output transformation is disabled by configuration",
199
+ stages=(
200
+ CompressionStageEvent(
201
+ phase="eligibility",
202
+ algorithm="feature-flag-policy",
203
+ applied=False,
204
+ reason_code="global_disabled",
205
+ input_bytes=original_bytes,
206
+ output_bytes=original_bytes,
207
+ input_tokens=original_tokens,
208
+ output_tokens=original_tokens,
209
+ ),
210
+ ),
211
+ )
212
+ return message
213
+ compress_error_output = status == "error" and original_bytes > threshold
214
+ if status == "error" and not compress_error_output:
215
+ record_decision(
216
+ decision="skipped",
217
+ reason_code="error_output_protected",
218
+ reason_detail="small failed tool result remains intact for diagnostics",
219
+ stages=(
220
+ CompressionStageEvent(
221
+ phase="eligibility",
222
+ algorithm="tool-status-policy",
223
+ applied=False,
224
+ reason_code="error_output_protected",
225
+ input_bytes=original_bytes,
226
+ output_bytes=original_bytes,
227
+ input_tokens=original_tokens,
228
+ output_tokens=original_tokens,
229
+ ),
230
+ ),
231
+ )
232
+ return message
233
+ if original_bytes <= threshold and not execute_output_truncated:
234
+ record_decision(
235
+ decision="skipped",
236
+ reason_code="below_threshold",
237
+ reason_detail=f"{original_bytes} bytes <= {threshold} byte threshold",
238
+ stages=(
239
+ CompressionStageEvent(
240
+ phase="eligibility",
241
+ algorithm="byte-threshold-v1",
242
+ applied=False,
243
+ reason_code="below_threshold",
244
+ input_bytes=original_bytes,
245
+ output_bytes=original_bytes,
246
+ input_tokens=original_tokens,
247
+ output_tokens=original_tokens,
248
+ metadata={"threshold_bytes": threshold},
249
+ ),
250
+ ),
251
+ )
252
+ return message
253
+
254
+ args = call_args(request)
255
+ file_path = str(args.get("file_path") or args.get("path") or "")
256
+ file_suffix = Path(file_path).suffix.casefold() if file_path else ""
257
+ source_read_key: tuple[str, str, str, int, int] | None = None
258
+ if name == "read_file" and file_path and thread_id != "unknown-thread":
259
+ try:
260
+ offset = max(0, int(args.get("offset", 0) or 0))
261
+ except (TypeError, ValueError):
262
+ offset = 0
263
+ try:
264
+ limit = max(0, int(args.get("limit", 0) or 0))
265
+ except (TypeError, ValueError):
266
+ limit = 0
267
+ source_read_key = (thread_id, checkpoint_ns, file_path, offset, limit)
268
+ with protected_source_reads_lock:
269
+ fresh_read_source = source_read_key not in protected_source_reads
270
+ try:
271
+ transformed = pipeline.transform(
272
+ original,
273
+ TransformContext(
274
+ tool_name=name,
275
+ status=status,
276
+ query=current_query(request),
277
+ tool_args=args,
278
+ file_path=file_path,
279
+ file_suffix=file_suffix,
280
+ fresh_read_source=fresh_read_source,
281
+ ),
282
+ )
283
+ if (
284
+ source_read_key is not None
285
+ and transformed.metadata.get("fallback") == "fresh_read_source_protected"
286
+ ):
287
+ with protected_source_reads_lock:
288
+ protected_source_reads.add(source_read_key)
289
+ except Exception as exc: # noqa: BLE001
290
+ record_decision(
291
+ decision="fallback",
292
+ reason_code="transform_error",
293
+ eligible=True,
294
+ reason_detail=type(exc).__name__,
295
+ stages=(
296
+ CompressionStageEvent(
297
+ phase="transform",
298
+ algorithm="tool-output-pipeline",
299
+ applied=False,
300
+ reason_code="transform_error",
301
+ input_bytes=original_bytes,
302
+ output_bytes=original_bytes,
303
+ input_tokens=original_tokens,
304
+ output_tokens=original_tokens,
305
+ metadata={"error": type(exc).__name__},
306
+ ),
307
+ ),
308
+ )
309
+ return message
310
+ confidence = float(transformed.metadata.get("detection_confidence", 0.0) or 0.0)
311
+ execution_path = str(transformed.metadata.get("execution_path", "python_only"))
312
+ fallback_reason = str(transformed.metadata.get("fallback") or "no_byte_savings")
313
+ if transformed.content == original:
314
+ skipped_reasons = {"disabled", "fresh_read_source_protected"}
315
+ decision = "skipped" if fallback_reason in skipped_reasons else "fallback"
316
+ reason_code = (
317
+ "disabled_content_type" if fallback_reason == "disabled" else fallback_reason
318
+ )
319
+ record_decision(
320
+ decision=decision,
321
+ reason_code=reason_code,
322
+ content_type=transformed.content_type.value,
323
+ transformer=transformed.transformer,
324
+ eligible=decision == "fallback",
325
+ critical_total=transformed.critical_total,
326
+ critical_retained=transformed.critical_retained,
327
+ execution_path=execution_path,
328
+ confidence=confidence,
329
+ stages=transformed.stages,
330
+ )
331
+ return message
332
+
333
+ algorithm_bytes = len(transformed.content.encode("utf-8"))
334
+ provisional_ref = "tool-output://" + ("0" * 32)
335
+ envelope_template = (
336
+ "[tool output transformed]\n"
337
+ f"tool: {name}\n"
338
+ f"type: {transformed.content_type.value}\n"
339
+ f"transformer: {transformed.transformer}\n"
340
+ f"ref: {provisional_ref}\n"
341
+ f"original_bytes: {original_bytes}\n"
342
+ f"visible_bytes: {algorithm_bytes}\n"
343
+ f"content:\n{transformed.content}\n\n"
344
+ "Use read_tool_result(ref=..., query=...) for targeted retrieval, "
345
+ "or offset/limit for exact lines."
346
+ )
347
+ envelope_tokens = _estimate_tokens(envelope_template)
348
+ saved_tokens = max(0, original_tokens - envelope_tokens)
349
+ savings_ratio = saved_tokens / original_tokens if original_tokens else 0.0
350
+ diff_effective = (
351
+ transformed.content_type.value != "diff"
352
+ or saved_tokens >= 128
353
+ or (saved_tokens >= 32 and savings_ratio >= 0.05)
354
+ )
355
+ token_guard_accepted = envelope_tokens < original_tokens and diff_effective
356
+ token_guard_reason = (
357
+ "accepted"
358
+ if token_guard_accepted
359
+ else (
360
+ "insufficient_effective_savings"
361
+ if envelope_tokens < original_tokens
362
+ else "envelope_erased_savings"
363
+ )
364
+ )
365
+ token_guard_stage = CompressionStageEvent(
366
+ phase="token-guard",
367
+ algorithm="langchain-approximate-envelope-v2",
368
+ applied=token_guard_accepted,
369
+ reason_code=token_guard_reason,
370
+ input_bytes=original_bytes,
371
+ output_bytes=len(envelope_template.encode("utf-8")),
372
+ input_tokens=original_tokens,
373
+ output_tokens=envelope_tokens,
374
+ metadata={
375
+ "saved_tokens": saved_tokens,
376
+ "savings_ratio": round(savings_ratio, 4),
377
+ "min_absolute_tokens": 128,
378
+ "min_conditional_tokens": 32,
379
+ "min_conditional_ratio": 0.05,
380
+ },
381
+ )
382
+ stages = (*transformed.stages, token_guard_stage)
383
+ if not token_guard_accepted:
384
+ record_decision(
385
+ decision="fallback",
386
+ reason_code=token_guard_reason,
387
+ reason_detail=(
388
+ "final diff envelope savings did not clear the effective savings floor"
389
+ if token_guard_reason == "insufficient_effective_savings"
390
+ else "final model-visible wrapper did not reduce estimated tokens"
391
+ ),
392
+ content_type=transformed.content_type.value,
393
+ transformer=transformed.transformer,
394
+ algorithm_output=transformed.content,
395
+ eligible=True,
396
+ critical_total=transformed.critical_total,
397
+ critical_retained=transformed.critical_retained,
398
+ execution_path=execution_path,
399
+ confidence=confidence,
400
+ stages=stages,
401
+ )
402
+ return message
403
+ try:
404
+ record = repository.put(
405
+ thread_id=thread_id,
406
+ checkpoint_ns=checkpoint_ns,
407
+ tool_call_id=tool_call_id,
408
+ tool_name=name,
409
+ status=status,
410
+ content=original,
411
+ )
412
+ except Exception as exc: # noqa: BLE001
413
+ record_decision(
414
+ decision="fallback",
415
+ reason_code="storage_error",
416
+ reason_detail=type(exc).__name__,
417
+ content_type=transformed.content_type.value,
418
+ transformer=transformed.transformer,
419
+ algorithm_output=transformed.content,
420
+ eligible=True,
421
+ critical_total=transformed.critical_total,
422
+ critical_retained=transformed.critical_retained,
423
+ execution_path=execution_path,
424
+ confidence=confidence,
425
+ stages=stages,
426
+ )
427
+ return message
428
+
429
+ final_content = envelope_template.replace(provisional_ref, record.ref, 1)
430
+ event = record_decision(
431
+ decision="transformed",
432
+ reason_code="compressed",
433
+ content_type=transformed.content_type.value,
434
+ transformer=transformed.transformer,
435
+ visible_content=final_content,
436
+ algorithm_output=transformed.content,
437
+ eligible=True,
438
+ ref=record.ref,
439
+ critical_total=transformed.critical_total,
440
+ critical_retained=transformed.critical_retained,
441
+ execution_path=execution_path,
442
+ confidence=confidence,
443
+ stages=stages,
444
+ )
445
+ metadata = dict(message.artifact) if isinstance(message.artifact, dict) else {}
446
+ metadata["tool_output_transform"] = {
447
+ **event.as_dict(),
448
+ "ref": record.ref,
449
+ "sha256": record.sha256,
450
+ **transformed.metadata,
451
+ }
452
+ if execute_output_truncated:
453
+ metadata["tool_output_contains_untruncated_execute_output"] = True
454
+ message.artifact = metadata
455
+ message.content = final_content
456
+ return message
457
+
458
+ def rewrite_result(
459
+ request: Any,
460
+ result: Any,
461
+ *,
462
+ original_content: str | None = None,
463
+ execute_output_truncated: bool = False,
464
+ ) -> Any:
465
+ if isinstance(result, ToolMessage):
466
+ return rewrite_message(
467
+ request,
468
+ result,
469
+ original_content=original_content,
470
+ execute_output_truncated=execute_output_truncated,
471
+ )
472
+ if isinstance(result, Command):
473
+ update = result.update
474
+ messages = update.get("messages") if isinstance(update, dict) else None
475
+ if not isinstance(messages, list):
476
+ return result
477
+ rewritten = [
478
+ rewrite_message(request, item) if isinstance(item, ToolMessage) else item
479
+ for item in messages
480
+ ]
481
+ return Command(
482
+ graph=result.graph,
483
+ update={**update, "messages": rewritten},
484
+ resume=result.resume,
485
+ goto=result.goto,
486
+ )
487
+ if isinstance(result, list):
488
+ return [rewrite_result(request, item) for item in result]
489
+ return result
490
+
491
+ def is_execute(request: Any) -> bool:
492
+ return call_value(request, "name") == "execute"
493
+
494
+ def record_tool_interaction(request: Any, result: Any, *, started: float) -> None:
495
+ thread_id, checkpoint_ns = runtime_identity(request)
496
+ name = call_value(request, "name", "tool")
497
+ args = call_args(request)
498
+ position = current_position(thread_id)
499
+ messages: list[ToolMessage] = []
500
+ if isinstance(result, ToolMessage):
501
+ messages = [result]
502
+ elif isinstance(result, Command) and isinstance(result.update, dict):
503
+ messages = [m for m in result.update.get("messages", []) if isinstance(m, ToolMessage)]
504
+ elif isinstance(result, list):
505
+ messages = [m for m in result if isinstance(m, ToolMessage)]
506
+ message = messages[0] if messages else None
507
+ artifact = getattr(message, "artifact", None) if message is not None else None
508
+ transform = artifact.get("tool_output_transform") if isinstance(artifact, dict) else None
509
+ output = content_to_text(message.content) if message is not None else ""
510
+ repository.record_interaction(
511
+ thread_id=thread_id,
512
+ event={
513
+ "event_type": "tool_call",
514
+ "turn_id": position.turn_id,
515
+ "turn_index": position.turn_index,
516
+ "model_call_index": position.model_call_index,
517
+ "tool_call_id": str(
518
+ getattr(message, "tool_call_id", None) or call_value(request, "id")
519
+ ),
520
+ "tool_name": name,
521
+ "tool_args": summarized_args(args),
522
+ "checkpoint_ns": checkpoint_ns,
523
+ "status": str(getattr(message, "status", None) or "success"),
524
+ "output_bytes": len(output.encode("utf-8")),
525
+ "compression_managed": name not in excluded,
526
+ "compression_decision": (
527
+ str(transform.get("decision")) if isinstance(transform, dict) else ""
528
+ ),
529
+ "compression_reason": (
530
+ str(transform.get("reason_code")) if isinstance(transform, dict) else ""
531
+ ),
532
+ "duration_ms": (time.perf_counter() - started) * 1000,
533
+ },
534
+ )
535
+
536
+ def wrap_tool_call(self, request, handler): # noqa: ANN001, ARG001
537
+ started = time.perf_counter()
538
+ if not is_execute(request):
539
+ result = rewrite_result(request, handler(request))
540
+ record_tool_interaction(request, result, started=started)
541
+ return result
542
+ capture, token = begin_execute_capture()
543
+ try:
544
+ result = handler(request)
545
+ finally:
546
+ end_execute_capture(token)
547
+ rewritten = rewrite_result(
548
+ request,
549
+ result,
550
+ original_content=capture.full_output,
551
+ execute_output_truncated=capture.truncated,
552
+ )
553
+ record_tool_interaction(request, rewritten, started=started)
554
+ return rewritten
555
+
556
+ async def awrap_tool_call(self, request, handler): # noqa: ANN001, ARG001
557
+ started = time.perf_counter()
558
+ if not is_execute(request):
559
+ result = rewrite_result(request, await handler(request))
560
+ record_tool_interaction(request, result, started=started)
561
+ return result
562
+ capture, token = begin_execute_capture()
563
+ try:
564
+ result = await handler(request)
565
+ finally:
566
+ end_execute_capture(token)
567
+ rewritten = rewrite_result(
568
+ request,
569
+ result,
570
+ original_content=capture.full_output,
571
+ execute_output_truncated=capture.truncated,
572
+ )
573
+ record_tool_interaction(request, rewritten, started=started)
574
+ return rewritten
575
+
576
+ return type(
577
+ "transform_tool_outputs",
578
+ (AgentMiddleware,),
579
+ {
580
+ "state_schema": AgentState,
581
+ "tools": [],
582
+ "wrap_tool_call": wrap_tool_call,
583
+ "awrap_tool_call": awrap_tool_call,
584
+ },
585
+ )()
@@ -0,0 +1,60 @@
1
+ """Record estimated token savings when transformed tool outputs reach a model call."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from langchain.agents.middleware import AgentMiddleware, AgentState
8
+
9
+ from synapse.tool_output.repository import ToolOutputRepository
10
+
11
+
12
+ def _thread_id(request: Any) -> str:
13
+ config = getattr(getattr(request, "runtime", None), "config", None) or {}
14
+ configurable = config.get("configurable") if isinstance(config, dict) else {}
15
+ return str((configurable or {}).get("thread_id") or "")
16
+
17
+
18
+ def _avoided_tokens(request: Any) -> int:
19
+ state = getattr(request, "state", None) or {}
20
+ messages = state.get("messages") if isinstance(state, dict) else None
21
+ total = 0
22
+ for message in messages or []:
23
+ artifact = getattr(message, "artifact", None)
24
+ if not isinstance(artifact, dict):
25
+ continue
26
+ transform = artifact.get("tool_output_transform")
27
+ if not isinstance(transform, dict):
28
+ continue
29
+ total += max(0, int(transform.get("estimated_saved_tokens", 0) or 0))
30
+ return total
31
+
32
+
33
+ def build_tool_output_usage_middleware(repository: ToolOutputRepository) -> Any:
34
+ """Record transformed-output savings each time those outputs enter a model call.
35
+
36
+ This is an approximation: it counts model-visible transformed ToolMessages in
37
+ the request state, not provider-tokenizer exact token deltas.
38
+ """
39
+
40
+ class _ToolOutputUsageMiddleware(AgentMiddleware):
41
+ state_schema = AgentState
42
+
43
+ def _record(self, request: Any) -> None:
44
+ thread_id = _thread_id(request)
45
+ avoided = _avoided_tokens(request)
46
+ if thread_id and avoided:
47
+ repository.record_model_reuse(
48
+ thread_id=thread_id,
49
+ estimated_avoided_tokens=avoided,
50
+ )
51
+
52
+ def wrap_model_call(self, request: Any, handler: Any) -> Any:
53
+ self._record(request)
54
+ return handler(request)
55
+
56
+ async def awrap_model_call(self, request: Any, handler: Any) -> Any:
57
+ self._record(request)
58
+ return await handler(request)
59
+
60
+ return _ToolOutputUsageMiddleware()
@@ -0,0 +1,31 @@
1
+ """Session metadata, binding, and persistence services."""
2
+
3
+ from synapse.sessions.store import (
4
+ ModelBinding,
5
+ SessionInfo,
6
+ SessionStore,
7
+ allocate_thread_id,
8
+ apply_binding_to_settings,
9
+ binding_from_settings,
10
+ default_sessions_path,
11
+ format_session_table,
12
+ is_default_session_title,
13
+ pick_startup_thread_id,
14
+ resolve_startup_binding,
15
+ title_from_user_message,
16
+ )
17
+
18
+ __all__ = [
19
+ "ModelBinding",
20
+ "SessionInfo",
21
+ "SessionStore",
22
+ "allocate_thread_id",
23
+ "apply_binding_to_settings",
24
+ "binding_from_settings",
25
+ "default_sessions_path",
26
+ "format_session_table",
27
+ "is_default_session_title",
28
+ "pick_startup_thread_id",
29
+ "resolve_startup_binding",
30
+ "title_from_user_message",
31
+ ]