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,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
|
+
}
|