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,207 @@
1
+ """Default subagent specs for create_deep_agent(subagents=...).
2
+
3
+ Note: deepagents FilesystemPermission is incompatible with backends that expose
4
+ command execution (LocalShellBackend / SandboxBackendProtocol). Isolation for
5
+ our product uses tool-exclusion middleware + system prompts instead of
6
+ permissions.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from synapse.runtime.middleware import build_tool_error_recovery_middleware
15
+ from synapse.runtime.tool_output_middleware import build_tool_output_transform_middleware
16
+ from synapse.tool_output.pipeline import ToolOutputTransformPipeline
17
+ from synapse.tool_output.repository import ToolOutputRepository
18
+ from synapse.tool_output.transformers import load_transformer_plugins
19
+ from synapse.tools import build_tool_result_reader_tool
20
+
21
+
22
+ def _intent_middleware() -> list[Any]:
23
+ """Inject ``intent`` field into every tool arg schema (main-agent parity)."""
24
+ from synapse.runtime.middleware import build_intent_schema_middleware
25
+
26
+ return list(build_intent_schema_middleware())
27
+
28
+
29
+ _TODO_TOOL_NAMES = {"write_todos", "todo_write", "todos"}
30
+
31
+
32
+ def _tool_exclusion_middleware(excluded: set[str], *, allow_execute: bool = False) -> list[Any]:
33
+ """Hide restricted tools from subagents with one middleware instance."""
34
+ from synapse.runtime.middleware import build_tool_exclusion_middleware
35
+
36
+ blocked = set(excluded) | _TODO_TOOL_NAMES
37
+ if not allow_execute:
38
+ blocked.add("execute")
39
+ return [build_tool_exclusion_middleware(blocked)]
40
+
41
+
42
+ _READONLY_TOOL_NAMES = {"write_file", "edit_file"}
43
+
44
+
45
+ _PARALLEL_HINT = (
46
+ "- Run independent tool calls in parallel when they do not depend on each other.\n"
47
+ "- Every tool call must include a short English ``intent`` describing its purpose.\n"
48
+ " Example: ``inspect pytest config`` / ``locate login failure``.\n"
49
+ "- Do not use generic intent values such as ``run tool`` or ``read_file``.\n"
50
+ )
51
+
52
+
53
+ def build_default_subagents(
54
+ *,
55
+ enabled: bool = True,
56
+ tester_model: str | None = None,
57
+ reviewer_model: str | None = None,
58
+ researcher_model: str | None = None,
59
+ isolate_tools: bool = True,
60
+ tool_output_db_path: Path | str | None = None,
61
+ tool_output_transform_threshold_bytes: int = 512,
62
+ tool_output_disabled_types: list[str] | None = None,
63
+ tool_output_transform_plugins: list[str] | None = None,
64
+ enable_native_tool_output_compression: bool = True,
65
+ ) -> list[dict[str, Any]] | None:
66
+ """Return declarative SubAgent specs, or None when disabled.
67
+
68
+ deepagents exposes these via the built-in `task` tool. The main agent
69
+ routes by reading each subagent's description.
70
+
71
+ When ``isolate_tools`` is True (LocalShell-safe):
72
+ - researcher: exclude write_file/edit_file/execute
73
+ - reviewer: exclude write_file/edit_file
74
+ - tester: uses built-in ``execute`` and project ``AGENTS.md`` commands
75
+ """
76
+ if not enabled:
77
+ return None
78
+
79
+ tester: dict[str, Any] = {
80
+ "name": "tester",
81
+ "description": (
82
+ "Run focused tests, diagnose failures, and propose minimal fixes. "
83
+ "Use for pytest failures, regressions, and verification after edits."
84
+ ),
85
+ "system_prompt": (
86
+ "You are a testing specialist for a Python coding agent.\n"
87
+ "- Prefer the narrowest useful pytest invocation first.\n"
88
+ "- Follow the project's AGENTS.md for test steps and conventions.\n"
89
+ "- Report failing tests, root cause, and exact commands run.\n"
90
+ "- Do not expand scope beyond verifying the requested behavior.\n"
91
+ "- Reply in Chinese when the parent conversation is Chinese.\n"
92
+ "- Do not use emoji in any output.\n" + _PARALLEL_HINT
93
+ ),
94
+ }
95
+ if tester_model:
96
+ tester["model"] = tester_model
97
+ if isolate_tools:
98
+ # Keep the tester on built-in tools; project commands belong in AGENTS.md.
99
+ tester["tools"] = []
100
+
101
+ tester["middleware"] = _tool_exclusion_middleware(set(), allow_execute=True)
102
+
103
+ reviewer: dict[str, Any] = {
104
+ "name": "reviewer",
105
+ "description": (
106
+ "Review code changes for correctness, regressions, security, and "
107
+ "style. Use after substantive edits or before summarizing a fix."
108
+ ),
109
+ "system_prompt": (
110
+ "You are a code reviewer for a local coding agent.\n"
111
+ "- Inspect diffs and related tests.\n"
112
+ "- Prioritize bugs, edge cases, and unsafe shell/file operations.\n"
113
+ "- Be concise: findings first, then residual risks.\n"
114
+ "- Do not rewrite large modules unless asked.\n"
115
+ "- Prefer read-only inspection; do not modify files unless required.\n"
116
+ "- Reply in Chinese when the parent conversation is Chinese.\n"
117
+ "- Do not use emoji in any output.\n" + _PARALLEL_HINT
118
+ ),
119
+ }
120
+ if reviewer_model:
121
+ reviewer["model"] = reviewer_model
122
+ # Reviewer may run read-only shell (git diff, pytest -q) but not write.
123
+ reviewer["middleware"] = _tool_exclusion_middleware(
124
+ _READONLY_TOOL_NAMES if isolate_tools else set(), allow_execute=True
125
+ )
126
+
127
+ researcher: dict[str, Any] = {
128
+ "name": "researcher",
129
+ "description": (
130
+ "Explore the codebase to answer questions: locate symbols, map "
131
+ "call chains, and summarize relevant files without making edits."
132
+ ),
133
+ "system_prompt": (
134
+ "You are a codebase researcher.\n"
135
+ "- Prefer read_file/glob over broad shell scans.\n"
136
+ "- Do not modify files.\n"
137
+ "- Do not run destructive shell commands.\n"
138
+ "- Return concrete file paths and short evidence snippets.\n"
139
+ "- Reply in Chinese when the parent conversation is Chinese.\n"
140
+ "- Do not use emoji in any output.\n" + _PARALLEL_HINT
141
+ ),
142
+ }
143
+ if researcher_model:
144
+ researcher["model"] = researcher_model
145
+ # Researcher is read-only and cannot run shell commands when isolated.
146
+ researcher["middleware"] = _tool_exclusion_middleware(
147
+ _READONLY_TOOL_NAMES if isolate_tools else set(),
148
+ allow_execute=not isolate_tools,
149
+ )
150
+
151
+ # Subagents use the exact same reversible transformation policy as the
152
+ # parent, scoped by their checkpoint namespace.
153
+ result_middleware: list[Any] = []
154
+ result_reader: Any | None = None
155
+ if tool_output_db_path is not None:
156
+ try:
157
+ output_pipeline = ToolOutputTransformPipeline(
158
+ transformers=load_transformer_plugins(tool_output_transform_plugins or []),
159
+ disabled_types=set(tool_output_disabled_types or []),
160
+ use_native=enable_native_tool_output_compression,
161
+ )
162
+ except Exception: # noqa: BLE001
163
+ output_pipeline = ToolOutputTransformPipeline(
164
+ disabled_types=set(tool_output_disabled_types or []),
165
+ use_native=enable_native_tool_output_compression,
166
+ )
167
+ result_middleware = [
168
+ build_tool_output_transform_middleware(
169
+ ToolOutputRepository(tool_output_db_path),
170
+ threshold_bytes=tool_output_transform_threshold_bytes,
171
+ pipeline=output_pipeline,
172
+ ),
173
+ build_tool_error_recovery_middleware(),
174
+ ]
175
+ result_reader = build_tool_result_reader_tool(tool_output_db_path)
176
+ for spec in (researcher, tester, reviewer):
177
+ existing = list(spec.get("middleware") or [])
178
+ spec["middleware"] = result_middleware + _intent_middleware() + existing
179
+ if result_reader is not None:
180
+ spec["tools"] = [*list(spec.get("tools") or []), result_reader]
181
+
182
+ return [researcher, tester, reviewer]
183
+
184
+
185
+ def format_subagents_lines(specs: list[dict[str, Any]] | None) -> list[str]:
186
+ if not specs:
187
+ return ["subagents: disabled"]
188
+ lines = [f"subagents: {len(specs)}"]
189
+ for spec in specs:
190
+ name = spec.get("name") or "?"
191
+ model = spec.get("model") or "(inherit)"
192
+ tools = spec.get("tools") or []
193
+ tool_names = [getattr(t, "name", getattr(t, "__name__", str(t))) for t in tools]
194
+ mw = spec.get("middleware") or []
195
+ isolation = "tool-exclude" if mw else ("tools+" if tools else "default")
196
+ if spec.get("permissions"):
197
+ isolation = "permissions(unsupported-with-shell)"
198
+ lines.append(f" - {name} model={model} isolate={isolation}")
199
+ if tool_names:
200
+ lines.append(f" tools+: {', '.join(str(n) for n in tool_names)}")
201
+ desc = str(spec.get("description") or "")
202
+ if desc:
203
+ one = " ".join(desc.split())
204
+ if len(one) > 90:
205
+ one = one[:89] + "…"
206
+ lines.append(f" {one}")
207
+ return lines
@@ -0,0 +1,221 @@
1
+ """Gitignore-based path filters for filesystem tools.
2
+
3
+ Used by ``CodingLocalShellBackend`` so ``glob`` / ``grep`` skip paths that
4
+ developers already marked ignored (``.venv``, build caches, agent state, ...).
5
+
6
+ Practical gitwildmatch subset (not a full git clone):
7
+ - root ``.gitignore`` only
8
+ - ``*``, ``?``, ``**``, trailing ``/`` directory rules, ``!`` negation
9
+ - extra deny patterns OR-ed via config
10
+
11
+ Not a security sandbox: ``execute`` can still touch ignored paths.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import re
17
+ from dataclasses import dataclass
18
+ from pathlib import Path, PurePosixPath
19
+
20
+
21
+ def _strip_comment(line: str) -> str:
22
+ out: list[str] = []
23
+ escaped = False
24
+ for ch in line:
25
+ if escaped:
26
+ out.append(ch)
27
+ escaped = False
28
+ continue
29
+ if ch == "\\":
30
+ escaped = True
31
+ out.append(ch)
32
+ continue
33
+ if ch == "#":
34
+ break
35
+ out.append(ch)
36
+ return "".join(out).rstrip().replace("\\ ", " ").replace("\\#", "#")
37
+
38
+
39
+ def _gitignore_pattern_to_regex(pattern: str) -> re.Pattern[str]:
40
+ """Compile one gitignore pattern to regex over relative POSIX paths."""
41
+ pat = pattern
42
+ dir_only = pat.endswith("/")
43
+ if dir_only:
44
+ pat = pat[:-1]
45
+
46
+ anchored = pat.startswith("/")
47
+ if anchored:
48
+ pat = pat[1:]
49
+ elif "/" in pat:
50
+ anchored = True
51
+
52
+ parts: list[str] = ["^"]
53
+ if not anchored:
54
+ parts.append("(?:.*/)?")
55
+
56
+ i = 0
57
+ n = len(pat)
58
+ while i < n:
59
+ c = pat[i]
60
+ if c == "*":
61
+ if i + 1 < n and pat[i + 1] == "*":
62
+ i += 2
63
+ if i < n and pat[i] == "/":
64
+ i += 1
65
+ parts.append("(?:.*/)?")
66
+ else:
67
+ parts.append(".*")
68
+ else:
69
+ parts.append("[^/]*")
70
+ i += 1
71
+ elif c == "?":
72
+ parts.append("[^/]")
73
+ i += 1
74
+ elif c == "[":
75
+ j = i + 1
76
+ if j < n and pat[j] in {"!", "]"}:
77
+ j += 1
78
+ while j < n and pat[j] != "]":
79
+ j += 1
80
+ if j >= n:
81
+ parts.append(re.escape(c))
82
+ i += 1
83
+ else:
84
+ class_body = pat[i + 1 : j]
85
+ if class_body.startswith("!"):
86
+ class_body = "^" + class_body[1:]
87
+ parts.append("[" + class_body + "]")
88
+ i = j + 1
89
+ else:
90
+ parts.append(re.escape(c))
91
+ i += 1
92
+
93
+ # Directory-only and plain-name rules both exclude trees under the name.
94
+ parts.append("(?:/.*)?")
95
+ parts.append("$")
96
+ return re.compile("".join(parts))
97
+
98
+
99
+ @dataclass(frozen=True)
100
+ class _Rule:
101
+ regex: re.Pattern[str]
102
+ negate: bool
103
+ raw: str
104
+
105
+
106
+ _BUILTIN_DENY_PATTERNS = (".git/",)
107
+ _BUILTIN_DENY_RULES = tuple(
108
+ _Rule(regex=_gitignore_pattern_to_regex(pattern), negate=False, raw=pattern)
109
+ for pattern in _BUILTIN_DENY_PATTERNS
110
+ )
111
+
112
+
113
+ class ToolIgnoreMatcher:
114
+ """Match workspace-relative paths against built-in, gitignore, and deny rules."""
115
+
116
+ def __init__(self, rules: list[_Rule]) -> None:
117
+ self._rules = list(rules)
118
+
119
+ @property
120
+ def rule_count(self) -> int:
121
+ """Number of configured gitignore or extra-deny rules."""
122
+ return len(self._rules)
123
+
124
+ @property
125
+ def has_rules(self) -> bool:
126
+ """Whether built-in or configured filters can exclude a path."""
127
+ return bool(_BUILTIN_DENY_RULES or self._rules)
128
+
129
+ @classmethod
130
+ def from_workspace(
131
+ cls,
132
+ root: Path | str,
133
+ *,
134
+ extra_deny: list[str] | None = None,
135
+ ) -> ToolIgnoreMatcher:
136
+ root_path = Path(root).expanduser().resolve()
137
+ rules: list[_Rule] = []
138
+ gi = root_path / ".gitignore"
139
+ if gi.is_file():
140
+ try:
141
+ text = gi.read_text(encoding="utf-8", errors="replace")
142
+ except OSError:
143
+ text = ""
144
+ rules.extend(cls._parse_lines(text.splitlines()))
145
+ for raw in extra_deny or []:
146
+ rules.extend(cls._parse_lines([str(raw)]))
147
+ return cls(rules)
148
+
149
+ @classmethod
150
+ def from_patterns(cls, patterns: list[str]) -> ToolIgnoreMatcher:
151
+ rules: list[_Rule] = []
152
+ for raw in patterns:
153
+ rules.extend(cls._parse_lines([raw]))
154
+ return cls(rules)
155
+
156
+ @staticmethod
157
+ def _parse_lines(lines: list[str]) -> list[_Rule]:
158
+ out: list[_Rule] = []
159
+ for line in lines:
160
+ raw = line.strip()
161
+ if not raw:
162
+ continue
163
+ body = _strip_comment(raw)
164
+ if not body:
165
+ continue
166
+ negate = body.startswith("!")
167
+ if negate:
168
+ body = body[1:].strip()
169
+ if not body:
170
+ continue
171
+ try:
172
+ regex = _gitignore_pattern_to_regex(body)
173
+ except re.error:
174
+ continue
175
+ out.append(_Rule(regex=regex, negate=negate, raw=raw))
176
+ return out
177
+
178
+ @staticmethod
179
+ def normalize(path: str | Path) -> str:
180
+ text = str(path).replace("\\", "/").strip()
181
+ if not text or text in {".", "./"}:
182
+ return ""
183
+ while text.startswith("./"):
184
+ text = text[2:]
185
+ if text.startswith("/"):
186
+ text = text[1:]
187
+ if len(text) >= 2 and text[1] == ":":
188
+ text = text[2:].lstrip("/")
189
+ text = PurePosixPath(text).as_posix()
190
+ return "" if text == "." else text.rstrip("/")
191
+
192
+ def is_ignored(self, path: str | Path, *, is_dir: bool = False) -> bool:
193
+ del is_dir # tree match is encoded in regex via (?:/.*)?
194
+ rel = self.normalize(path)
195
+ if not rel:
196
+ return False
197
+ if any(rule.regex.match(rel) for rule in _BUILTIN_DENY_RULES):
198
+ return True
199
+ if not self._rules:
200
+ return False
201
+ ignored = False
202
+ matched = False
203
+ for rule in self._rules:
204
+ if rule.regex.match(rel):
205
+ matched = True
206
+ ignored = not rule.negate
207
+ return ignored if matched else False
208
+
209
+
210
+ def relative_to_root(path: str | Path, root: Path) -> str:
211
+ """Best-effort workspace-relative POSIX path for ignore checks."""
212
+ text = str(path).replace("\\", "/")
213
+ try:
214
+ p = Path(path)
215
+ if p.is_absolute():
216
+ return p.resolve().relative_to(root.resolve()).as_posix()
217
+ except Exception: # noqa: BLE001
218
+ pass
219
+ if text.startswith("/"):
220
+ return text[1:]
221
+ return text
@@ -0,0 +1,118 @@
1
+ """Deterministic offline evaluation for tool-output transformers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from synapse.tool_output.models import TransformContext
11
+ from synapse.tool_output.pipeline import ToolOutputTransformPipeline
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class ToolOutputEvalCase:
16
+ case_id: str
17
+ content: str
18
+ tool_name: str = "execute"
19
+ query: str = ""
20
+ required: tuple[str, ...] = ()
21
+
22
+ @classmethod
23
+ def from_dict(cls, value: dict[str, Any]) -> ToolOutputEvalCase:
24
+ return cls(
25
+ case_id=str(value["id"]),
26
+ content=str(value["content"]),
27
+ tool_name=str(value.get("tool_name") or "execute"),
28
+ query=str(value.get("query") or ""),
29
+ required=tuple(str(item) for item in value.get("required", [])),
30
+ )
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class ToolOutputEvalResult:
35
+ case_id: str
36
+ content_type: str
37
+ transformer: str
38
+ original_bytes: int
39
+ visible_bytes: int
40
+ savings_ratio: float
41
+ required_total: int
42
+ required_retained: int
43
+
44
+ @property
45
+ def passed(self) -> bool:
46
+ return (
47
+ self.required_retained == self.required_total
48
+ and self.visible_bytes < self.original_bytes
49
+ )
50
+
51
+
52
+ def load_cases(path: Path | str) -> list[ToolOutputEvalCase]:
53
+ value = json.loads(Path(path).read_text(encoding="utf-8"))
54
+ if not isinstance(value, list):
55
+ raise ValueError("tool-output eval fixture must be a JSON array")
56
+ return [ToolOutputEvalCase.from_dict(item) for item in value if isinstance(item, dict)]
57
+
58
+
59
+ def evaluate_cases(
60
+ cases: list[ToolOutputEvalCase], *, pipeline: ToolOutputTransformPipeline | None = None
61
+ ) -> list[ToolOutputEvalResult]:
62
+ active = pipeline or ToolOutputTransformPipeline()
63
+ results: list[ToolOutputEvalResult] = []
64
+ for case in cases:
65
+ transformed = active.transform(
66
+ case.content,
67
+ TransformContext(tool_name=case.tool_name, status="success", query=case.query),
68
+ )
69
+ original_bytes = len(case.content.encode("utf-8"))
70
+ visible_bytes = len(transformed.content.encode("utf-8"))
71
+ retained = sum(item in transformed.content for item in case.required)
72
+ results.append(
73
+ ToolOutputEvalResult(
74
+ case_id=case.case_id,
75
+ content_type=transformed.content_type.value,
76
+ transformer=transformed.transformer,
77
+ original_bytes=original_bytes,
78
+ visible_bytes=visible_bytes,
79
+ savings_ratio=round(1 - visible_bytes / original_bytes, 4)
80
+ if original_bytes
81
+ else 0.0,
82
+ required_total=len(case.required),
83
+ required_retained=retained,
84
+ )
85
+ )
86
+ return results
87
+
88
+
89
+ def summarize_results(results: list[ToolOutputEvalResult]) -> dict[str, Any]:
90
+ original = sum(item.original_bytes for item in results)
91
+ visible = sum(item.visible_bytes for item in results)
92
+ required_total = sum(item.required_total for item in results)
93
+ required_retained = sum(item.required_retained for item in results)
94
+ return {
95
+ "cases": len(results),
96
+ "passed": sum(item.passed for item in results),
97
+ "original_bytes": original,
98
+ "visible_bytes": visible,
99
+ "savings_ratio": round(1 - visible / original, 4) if original else 0.0,
100
+ "required_retention": (
101
+ round(required_retained / required_total, 4) if required_total else 1.0
102
+ ),
103
+ "results": [
104
+ {
105
+ "id": item.case_id,
106
+ "type": item.content_type,
107
+ "transformer": item.transformer,
108
+ "savings_ratio": item.savings_ratio,
109
+ "required_retention": (
110
+ round(item.required_retained / item.required_total, 4)
111
+ if item.required_total
112
+ else 1.0
113
+ ),
114
+ "passed": item.passed,
115
+ }
116
+ for item in results
117
+ ],
118
+ }