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,146 @@
|
|
|
1
|
+
"""Workspace path helpers for virtual-mode file tools."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
# Common filesystem tool argument keys used by deepagents / our tools.
|
|
11
|
+
_PATH_KEYS = (
|
|
12
|
+
"path",
|
|
13
|
+
"file_path",
|
|
14
|
+
"filename",
|
|
15
|
+
"file",
|
|
16
|
+
"target",
|
|
17
|
+
"target_file",
|
|
18
|
+
"source",
|
|
19
|
+
"src",
|
|
20
|
+
"dst",
|
|
21
|
+
"destination",
|
|
22
|
+
"directory",
|
|
23
|
+
"dir",
|
|
24
|
+
"glob",
|
|
25
|
+
"pattern_path",
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
_WIN_DRIVE = re.compile(r"^[A-Za-z]:[\\/]")
|
|
30
|
+
_WIN_EXTENDED_PREFIX = re.compile(r"^(?:\\\\\?\\|//\?/)", re.IGNORECASE)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def is_windows_absolute(path: str) -> bool:
|
|
34
|
+
s = _strip_windows_extended_prefix(str(path))
|
|
35
|
+
return bool(_WIN_DRIVE.match(s)) or s.startswith("\\\\")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _strip_windows_extended_prefix(path: str) -> str:
|
|
39
|
+
r"""Convert Windows ``\\?\`` paths to their normal drive / UNC form."""
|
|
40
|
+
s = str(path).strip()
|
|
41
|
+
if not _WIN_EXTENDED_PREFIX.match(s):
|
|
42
|
+
return s
|
|
43
|
+
remainder = _WIN_EXTENDED_PREFIX.sub("", s, count=1)
|
|
44
|
+
if remainder.upper().startswith("UNC\\"):
|
|
45
|
+
return "\\\\" + remainder[4:]
|
|
46
|
+
return remainder
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def is_virtual_path(path: str) -> bool:
|
|
50
|
+
"""True for POSIX-style virtual paths like ``/src/a.py`` (not ``C:/...``)."""
|
|
51
|
+
s = str(path).strip()
|
|
52
|
+
if not s.startswith("/"):
|
|
53
|
+
return False
|
|
54
|
+
# Reject /C:/... style mistakes
|
|
55
|
+
if len(s) >= 3 and s[2] == ":" and s[1].isalpha():
|
|
56
|
+
return False
|
|
57
|
+
return True
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def to_virtual_path(path: str | os.PathLike[str] | None, workspace: Path) -> str | None:
|
|
61
|
+
"""Map host paths under workspace to virtual paths starting with ``/``.
|
|
62
|
+
|
|
63
|
+
Examples (workspace = F:/project/repo):
|
|
64
|
+
- ``F:/project/repo/src/a.py`` -> ``/src/a.py``
|
|
65
|
+
- ``src/a.py`` -> ``/src/a.py``
|
|
66
|
+
- ``/src/a.py`` -> ``/src/a.py`` (unchanged)
|
|
67
|
+
- ``/`` -> ``/``
|
|
68
|
+
"""
|
|
69
|
+
if path is None:
|
|
70
|
+
return None
|
|
71
|
+
s = _strip_windows_extended_prefix(str(path))
|
|
72
|
+
if not s:
|
|
73
|
+
return s
|
|
74
|
+
|
|
75
|
+
if is_virtual_path(s):
|
|
76
|
+
return s if s.startswith("/") else f"/{s}"
|
|
77
|
+
|
|
78
|
+
root = Path(workspace).resolve()
|
|
79
|
+
|
|
80
|
+
# Windows absolute / UNC / host absolute under workspace root
|
|
81
|
+
if is_windows_absolute(s) or os.path.isabs(s):
|
|
82
|
+
try:
|
|
83
|
+
cand = Path(s)
|
|
84
|
+
# resolve() may fail for non-existing; still ok for relative_to with abs paths
|
|
85
|
+
try:
|
|
86
|
+
cand_res = cand.resolve()
|
|
87
|
+
except OSError:
|
|
88
|
+
cand_res = cand
|
|
89
|
+
try:
|
|
90
|
+
rel = cand_res.relative_to(root)
|
|
91
|
+
rel_s = rel.as_posix()
|
|
92
|
+
return "/" if rel_s in {"", "."} else f"/{rel_s}"
|
|
93
|
+
except ValueError:
|
|
94
|
+
# Outside workspace: leave as-is
|
|
95
|
+
return s
|
|
96
|
+
except Exception: # noqa: BLE001
|
|
97
|
+
return s
|
|
98
|
+
|
|
99
|
+
# Relative host path -> virtual
|
|
100
|
+
rel = s.replace("\\", "/").lstrip("./")
|
|
101
|
+
return "/" + rel if rel else "/"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def rewrite_tool_args_paths(args: dict[str, Any], workspace: Path) -> dict[str, Any]:
|
|
105
|
+
"""Return a shallow-copied args dict with path-like fields virtualized."""
|
|
106
|
+
out = dict(args)
|
|
107
|
+
for key in _PATH_KEYS:
|
|
108
|
+
if key not in out or out[key] is None:
|
|
109
|
+
continue
|
|
110
|
+
val = out[key]
|
|
111
|
+
if isinstance(val, str):
|
|
112
|
+
out[key] = to_virtual_path(val, workspace)
|
|
113
|
+
elif isinstance(val, list):
|
|
114
|
+
out[key] = [
|
|
115
|
+
to_virtual_path(v, workspace) if isinstance(v, str) else v for v in val
|
|
116
|
+
]
|
|
117
|
+
return out
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def summarize_tool_result(content: Any, *, limit: int = 80) -> str:
|
|
121
|
+
"""Compact tool-result status for CLI (no file body dump)."""
|
|
122
|
+
try:
|
|
123
|
+
if content is None:
|
|
124
|
+
text = ""
|
|
125
|
+
elif isinstance(content, str):
|
|
126
|
+
text = content
|
|
127
|
+
elif isinstance(content, (bytes, bytearray)):
|
|
128
|
+
text = bytes(content).decode("utf-8", errors="replace")
|
|
129
|
+
else:
|
|
130
|
+
text = str(content)
|
|
131
|
+
except Exception: # noqa: BLE001
|
|
132
|
+
text = ""
|
|
133
|
+
stripped = text.strip() if isinstance(text, str) else ""
|
|
134
|
+
if not stripped:
|
|
135
|
+
return "ok"
|
|
136
|
+
lower = stripped.lower()
|
|
137
|
+
if lower.startswith("error") or "not supported" in lower or "traceback" in lower:
|
|
138
|
+
# Keep short error reason only
|
|
139
|
+
one = stripped.replace("\n", " ")
|
|
140
|
+
if len(one) > limit:
|
|
141
|
+
one = one[: limit - 3] + "..."
|
|
142
|
+
return one
|
|
143
|
+
# Success: size only, never dump body.
|
|
144
|
+
n = len(stripped)
|
|
145
|
+
lines = stripped.count("\n") + 1
|
|
146
|
+
return f"ok ({n} chars, {lines} lines)"
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Command safety helpers and named safety profiles.
|
|
2
|
+
|
|
3
|
+
Default product behavior: **no approval gate**, auto-pass (dev-autopass).
|
|
4
|
+
Profiles make the product posture explicit:
|
|
5
|
+
|
|
6
|
+
- dev-autopass: full tools, no HITL (default)
|
|
7
|
+
- dev-approve: HITL on execute/write/edit
|
|
8
|
+
- readonly: exclude write/execute tools via harness
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Any, Literal
|
|
16
|
+
|
|
17
|
+
SafetyProfileName = Literal["dev-autopass", "dev-approve", "readonly"]
|
|
18
|
+
|
|
19
|
+
# Patterns considered dangerous on developer machines.
|
|
20
|
+
DANGEROUS_PATTERNS: list[re.Pattern[str]] = [
|
|
21
|
+
re.compile(r"\brm\s+(-[a-zA-Z]*r[a-zA-Z]*f|-[a-zA-Z]*f[a-zA-Z]*r)\b", re.I),
|
|
22
|
+
re.compile(r"\brm\s+-rf\s+/", re.I),
|
|
23
|
+
re.compile(r"\b(del|rmdir)\s+/s\b", re.I),
|
|
24
|
+
re.compile(r"\bformat\s+[a-z]:", re.I),
|
|
25
|
+
re.compile(r"\bmkfs\b", re.I),
|
|
26
|
+
re.compile(r"\bdd\s+if=", re.I),
|
|
27
|
+
re.compile(r":\s*\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;", re.I), # fork bomb
|
|
28
|
+
re.compile(r"\bshutdown\b", re.I),
|
|
29
|
+
re.compile(r"\breboot\b", re.I),
|
|
30
|
+
re.compile(r"\bgit\s+push\s+.*--force\b", re.I),
|
|
31
|
+
re.compile(r"\bgit\s+reset\s+--hard\b", re.I),
|
|
32
|
+
re.compile(r"\bgit\s+clean\s+-fdx\b", re.I),
|
|
33
|
+
re.compile(r">\s*/dev/sd[a-z]", re.I),
|
|
34
|
+
re.compile(r"\bRemove-Item\b.*-Recurse\b.*-Force\b", re.I),
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class SafetyVerdict:
|
|
40
|
+
allowed: bool
|
|
41
|
+
reason: str | None = None
|
|
42
|
+
matched_pattern: str | None = None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class SafetyProfile:
|
|
47
|
+
"""Named product posture for approval / readonly / blacklist."""
|
|
48
|
+
|
|
49
|
+
name: SafetyProfileName
|
|
50
|
+
require_approval: bool
|
|
51
|
+
readonly: bool
|
|
52
|
+
auto_approve: bool
|
|
53
|
+
enable_command_blacklist: bool
|
|
54
|
+
description: str
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
SAFETY_PROFILES: dict[str, SafetyProfile] = {
|
|
58
|
+
"dev-autopass": SafetyProfile(
|
|
59
|
+
name="dev-autopass",
|
|
60
|
+
require_approval=False,
|
|
61
|
+
readonly=False,
|
|
62
|
+
auto_approve=True,
|
|
63
|
+
enable_command_blacklist=True,
|
|
64
|
+
description="Local dev default: full tools, no HITL, blacklist advisory.",
|
|
65
|
+
),
|
|
66
|
+
"dev-approve": SafetyProfile(
|
|
67
|
+
name="dev-approve",
|
|
68
|
+
require_approval=True,
|
|
69
|
+
readonly=False,
|
|
70
|
+
auto_approve=False,
|
|
71
|
+
enable_command_blacklist=True,
|
|
72
|
+
description="Local dev with HITL on execute/write/edit.",
|
|
73
|
+
),
|
|
74
|
+
"readonly": SafetyProfile(
|
|
75
|
+
name="readonly",
|
|
76
|
+
require_approval=False,
|
|
77
|
+
readonly=True,
|
|
78
|
+
auto_approve=True,
|
|
79
|
+
enable_command_blacklist=True,
|
|
80
|
+
description="Read-only: exclude write_file/edit_file/execute via harness.",
|
|
81
|
+
),
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def get_safety_profile(name: str | None) -> SafetyProfile:
|
|
86
|
+
key = (name or "dev-autopass").strip().casefold()
|
|
87
|
+
if key in SAFETY_PROFILES:
|
|
88
|
+
return SAFETY_PROFILES[key]
|
|
89
|
+
# aliases
|
|
90
|
+
if key in {"approve", "hitl", "dev_approve"}:
|
|
91
|
+
return SAFETY_PROFILES["dev-approve"]
|
|
92
|
+
if key in {"ro", "read-only", "read_only"}:
|
|
93
|
+
return SAFETY_PROFILES["readonly"]
|
|
94
|
+
if key in {"auto", "autopass", "dev_autopass", "off"}:
|
|
95
|
+
return SAFETY_PROFILES["dev-autopass"]
|
|
96
|
+
return SAFETY_PROFILES["dev-autopass"]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def apply_safety_to_settings(settings: Any, profile: SafetyProfile | str) -> list[str]:
|
|
100
|
+
"""Mutate settings fields from a named profile. Returns change notes."""
|
|
101
|
+
prof = get_safety_profile(profile if isinstance(profile, str) else profile.name)
|
|
102
|
+
notes: list[str] = []
|
|
103
|
+
mapping = {
|
|
104
|
+
"require_approval": prof.require_approval,
|
|
105
|
+
"auto_approve": prof.auto_approve,
|
|
106
|
+
"readonly": prof.readonly,
|
|
107
|
+
"enable_command_blacklist": prof.enable_command_blacklist,
|
|
108
|
+
"safety_profile": prof.name,
|
|
109
|
+
}
|
|
110
|
+
for key, val in mapping.items():
|
|
111
|
+
if not hasattr(settings, key):
|
|
112
|
+
continue
|
|
113
|
+
old = getattr(settings, key)
|
|
114
|
+
if old != val:
|
|
115
|
+
try:
|
|
116
|
+
setattr(settings, key, val)
|
|
117
|
+
except Exception: # noqa: BLE001
|
|
118
|
+
# pydantic Settings may be frozen-ish; try model_copy pattern
|
|
119
|
+
pass
|
|
120
|
+
else:
|
|
121
|
+
notes.append(f"{key}: {old!r} -> {val!r}")
|
|
122
|
+
# pydantic BaseSettings often allows setattr on instance
|
|
123
|
+
if hasattr(settings, "model_copy") and not notes:
|
|
124
|
+
# try replace via object.__setattr__ for model fields
|
|
125
|
+
for key, val in mapping.items():
|
|
126
|
+
if hasattr(settings, key) and getattr(settings, key) != val:
|
|
127
|
+
object.__setattr__(settings, key, val)
|
|
128
|
+
notes.append(f"{key} -> {val!r}")
|
|
129
|
+
if not notes:
|
|
130
|
+
notes.append(f"profile already active: {prof.name}")
|
|
131
|
+
else:
|
|
132
|
+
notes.insert(0, f"safety profile: {prof.name}")
|
|
133
|
+
notes.append(prof.description)
|
|
134
|
+
return notes
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def check_command(command: str, *, enable_blacklist: bool = True) -> SafetyVerdict:
|
|
138
|
+
"""Return whether a shell command is considered safe enough to run."""
|
|
139
|
+
if not enable_blacklist:
|
|
140
|
+
return SafetyVerdict(allowed=True)
|
|
141
|
+
|
|
142
|
+
text = command.strip()
|
|
143
|
+
if not text:
|
|
144
|
+
return SafetyVerdict(allowed=False, reason="empty command")
|
|
145
|
+
|
|
146
|
+
for pattern in DANGEROUS_PATTERNS:
|
|
147
|
+
if pattern.search(text):
|
|
148
|
+
return SafetyVerdict(
|
|
149
|
+
allowed=False,
|
|
150
|
+
reason="command matches dangerous pattern",
|
|
151
|
+
matched_pattern=pattern.pattern,
|
|
152
|
+
)
|
|
153
|
+
return SafetyVerdict(allowed=True)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def build_interrupt_on(*, require_approval: bool) -> dict[str, bool] | None:
|
|
157
|
+
"""Map product settings to deepagents `interrupt_on`.
|
|
158
|
+
|
|
159
|
+
Default: require_approval=False => no interrupt middleware.
|
|
160
|
+
"""
|
|
161
|
+
if not require_approval:
|
|
162
|
+
return None
|
|
163
|
+
return {
|
|
164
|
+
"execute": True,
|
|
165
|
+
"write_file": True,
|
|
166
|
+
"edit_file": True,
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def format_safety_status(settings: Any) -> list[str]:
|
|
171
|
+
profile = getattr(settings, "safety_profile", None) or (
|
|
172
|
+
"dev-approve"
|
|
173
|
+
if getattr(settings, "require_approval", False)
|
|
174
|
+
else ("readonly" if getattr(settings, "readonly", False) else "dev-autopass")
|
|
175
|
+
)
|
|
176
|
+
return [
|
|
177
|
+
f"safety profile: {profile}",
|
|
178
|
+
f" require_approval: {getattr(settings, 'require_approval', False)}",
|
|
179
|
+
f" auto_approve: {getattr(settings, 'auto_approve', True)}",
|
|
180
|
+
f" readonly: {getattr(settings, 'readonly', False)}",
|
|
181
|
+
f" command_blacklist: {getattr(settings, 'enable_command_blacklist', True)}",
|
|
182
|
+
"profiles: dev-autopass | dev-approve | readonly",
|
|
183
|
+
"switch: /safety <profile> (rebuilds agent)",
|
|
184
|
+
]
|
synapse/runtime/steer.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""Mid-run user guidance queue (type-B HITL / steer).
|
|
2
|
+
|
|
3
|
+
While the agent loop is busy, the user can enqueue short instructions.
|
|
4
|
+
They are drained at the next model step (after the current tool batch) and
|
|
5
|
+
injected as HumanMessages so the LLM can change direction without Esc-cancel.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import threading
|
|
11
|
+
from collections import deque
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
STEER_PREFIX = "[Mid-run user guidance]"
|
|
16
|
+
# UI-only label used for silent follow-up turns (should never paint in transcript).
|
|
17
|
+
STEER_FOLLOWUP_PREFIX = "[steer follow-up]"
|
|
18
|
+
|
|
19
|
+
SteerListener = Callable[[list[str]], None]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def is_steer_message(msg: Any = None, *, text: str | None = None) -> bool:
|
|
23
|
+
"""True if a LangChain message / plain text is mid-run guidance (model-only).
|
|
24
|
+
|
|
25
|
+
Used to keep steer chrome out of the transcript and status bar.
|
|
26
|
+
"""
|
|
27
|
+
if text is None and msg is not None:
|
|
28
|
+
# Prefer kwargs flag set by middleware.
|
|
29
|
+
ak = getattr(msg, "additional_kwargs", None)
|
|
30
|
+
if isinstance(ak, dict) and ak.get("coding_steer"):
|
|
31
|
+
return True
|
|
32
|
+
if isinstance(msg, dict):
|
|
33
|
+
ak2 = msg.get("additional_kwargs") or {}
|
|
34
|
+
if isinstance(ak2, dict) and ak2.get("coding_steer"):
|
|
35
|
+
return True
|
|
36
|
+
content = msg.get("content")
|
|
37
|
+
else:
|
|
38
|
+
content = getattr(msg, "content", None)
|
|
39
|
+
if isinstance(content, str):
|
|
40
|
+
text = content
|
|
41
|
+
elif isinstance(content, list):
|
|
42
|
+
parts: list[str] = []
|
|
43
|
+
for block in content:
|
|
44
|
+
if isinstance(block, str):
|
|
45
|
+
parts.append(block)
|
|
46
|
+
elif isinstance(block, dict) and block.get("text"):
|
|
47
|
+
parts.append(str(block.get("text")))
|
|
48
|
+
text = "\n".join(parts)
|
|
49
|
+
else:
|
|
50
|
+
text = str(content or "")
|
|
51
|
+
body = (text or "").strip()
|
|
52
|
+
if not body:
|
|
53
|
+
return False
|
|
54
|
+
if body.startswith(STEER_PREFIX) or STEER_PREFIX in body[:80]:
|
|
55
|
+
return True
|
|
56
|
+
if body.startswith(STEER_FOLLOWUP_PREFIX):
|
|
57
|
+
return True
|
|
58
|
+
return False
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class SteerQueue:
|
|
62
|
+
"""Thread-safe FIFO of mid-run guidance strings."""
|
|
63
|
+
|
|
64
|
+
def __init__(self) -> None:
|
|
65
|
+
self._items: deque[str] = deque()
|
|
66
|
+
self._lock = threading.Lock()
|
|
67
|
+
self._listeners: list[SteerListener] = []
|
|
68
|
+
self._pending_notifications: deque[
|
|
69
|
+
tuple[list[str], list[SteerListener]]
|
|
70
|
+
] = deque()
|
|
71
|
+
self._notifying = False
|
|
72
|
+
|
|
73
|
+
def add_listener(self, callback: SteerListener) -> None:
|
|
74
|
+
"""Register a callback invoked with a snapshot after each change."""
|
|
75
|
+
if callback is None:
|
|
76
|
+
return
|
|
77
|
+
with self._lock:
|
|
78
|
+
if callback not in self._listeners:
|
|
79
|
+
self._listeners.append(callback)
|
|
80
|
+
|
|
81
|
+
def remove_listener(self, callback: SteerListener) -> None:
|
|
82
|
+
with self._lock:
|
|
83
|
+
self._listeners = [cb for cb in self._listeners if cb is not callback]
|
|
84
|
+
|
|
85
|
+
def _snapshot(self) -> list[str]:
|
|
86
|
+
return list(self._items)
|
|
87
|
+
|
|
88
|
+
def _enqueue_notification_unlocked(self) -> bool:
|
|
89
|
+
self._pending_notifications.append(
|
|
90
|
+
(self._snapshot(), list(self._listeners))
|
|
91
|
+
)
|
|
92
|
+
if self._notifying:
|
|
93
|
+
return False
|
|
94
|
+
self._notifying = True
|
|
95
|
+
return True
|
|
96
|
+
|
|
97
|
+
def _dispatch_notifications(self) -> None:
|
|
98
|
+
# Preserve mutation order without invoking application code under _lock.
|
|
99
|
+
while True:
|
|
100
|
+
with self._lock:
|
|
101
|
+
if not self._pending_notifications:
|
|
102
|
+
self._notifying = False
|
|
103
|
+
return
|
|
104
|
+
snap, listeners = self._pending_notifications.popleft()
|
|
105
|
+
for callback in listeners:
|
|
106
|
+
try:
|
|
107
|
+
callback(snap)
|
|
108
|
+
except Exception: # noqa: BLE001
|
|
109
|
+
pass
|
|
110
|
+
|
|
111
|
+
def push(self, text: str) -> int:
|
|
112
|
+
"""Enqueue guidance. Returns pending count after push (0 if empty text)."""
|
|
113
|
+
body = (text or "").strip()
|
|
114
|
+
if not body:
|
|
115
|
+
return 0
|
|
116
|
+
with self._lock:
|
|
117
|
+
self._items.append(body)
|
|
118
|
+
n = len(self._items)
|
|
119
|
+
should_dispatch = self._enqueue_notification_unlocked()
|
|
120
|
+
if should_dispatch:
|
|
121
|
+
self._dispatch_notifications()
|
|
122
|
+
return n
|
|
123
|
+
|
|
124
|
+
def drain(self) -> list[str]:
|
|
125
|
+
"""Pop all pending guidance (order preserved)."""
|
|
126
|
+
with self._lock:
|
|
127
|
+
items = list(self._items)
|
|
128
|
+
self._items.clear()
|
|
129
|
+
should_dispatch = bool(items) and self._enqueue_notification_unlocked()
|
|
130
|
+
if should_dispatch:
|
|
131
|
+
self._dispatch_notifications()
|
|
132
|
+
return items
|
|
133
|
+
|
|
134
|
+
def peek_count(self) -> int:
|
|
135
|
+
with self._lock:
|
|
136
|
+
return len(self._items)
|
|
137
|
+
|
|
138
|
+
def peek_items(self) -> list[str]:
|
|
139
|
+
"""Return a copy of pending items (order preserved)."""
|
|
140
|
+
with self._lock:
|
|
141
|
+
return self._snapshot()
|
|
142
|
+
|
|
143
|
+
def remove_at(self, index: int) -> str | None:
|
|
144
|
+
"""Remove one item by index. Returns removed text or None."""
|
|
145
|
+
with self._lock:
|
|
146
|
+
if index < 0 or index >= len(self._items):
|
|
147
|
+
return None
|
|
148
|
+
# deque has no pop(index); rebuild
|
|
149
|
+
items = list(self._items)
|
|
150
|
+
removed = items.pop(index)
|
|
151
|
+
self._items = deque(items)
|
|
152
|
+
should_dispatch = self._enqueue_notification_unlocked()
|
|
153
|
+
if should_dispatch:
|
|
154
|
+
self._dispatch_notifications()
|
|
155
|
+
return removed
|
|
156
|
+
|
|
157
|
+
def clear(self) -> list[str]:
|
|
158
|
+
"""Drop all pending items. Returns the previous list."""
|
|
159
|
+
with self._lock:
|
|
160
|
+
if not self._items:
|
|
161
|
+
return []
|
|
162
|
+
items = list(self._items)
|
|
163
|
+
self._items.clear()
|
|
164
|
+
should_dispatch = self._enqueue_notification_unlocked()
|
|
165
|
+
if should_dispatch:
|
|
166
|
+
self._dispatch_notifications()
|
|
167
|
+
return items
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def format_steer_message(items: list[str]) -> str:
|
|
171
|
+
"""Render queued guidance for the model."""
|
|
172
|
+
cleaned = [str(x).strip() for x in items if str(x).strip()]
|
|
173
|
+
if not cleaned:
|
|
174
|
+
return ""
|
|
175
|
+
header = (
|
|
176
|
+
f"{STEER_PREFIX}\n"
|
|
177
|
+
"The user is steering the current task. Incorporate the guidance below "
|
|
178
|
+
"into your next actions. Prefer adjusting the plan over restarting "
|
|
179
|
+
"unrelated work.\n"
|
|
180
|
+
)
|
|
181
|
+
if len(cleaned) == 1:
|
|
182
|
+
return f"{header}\n{cleaned[0]}"
|
|
183
|
+
body = "\n".join(f"{i}. {line}" for i, line in enumerate(cleaned, 1))
|
|
184
|
+
return f"{header}\nMultiple notes (in order):\n{body}"
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def format_steer_panel(items: list[str], *, max_preview: int = 72) -> str:
|
|
188
|
+
"""Render a multi-line panel for TUI (human-facing)."""
|
|
189
|
+
cleaned = [str(x).strip() for x in items if str(x).strip()]
|
|
190
|
+
if not cleaned:
|
|
191
|
+
return ""
|
|
192
|
+
n = len(cleaned)
|
|
193
|
+
lines = [f" ▸ steer queue · {n} pending · applies before next model step"]
|
|
194
|
+
for i, text in enumerate(cleaned, 1):
|
|
195
|
+
one = " ".join(text.split())
|
|
196
|
+
if len(one) > max_preview:
|
|
197
|
+
one = one[: max(0, max_preview - 1)] + "…"
|
|
198
|
+
lines.append(f" {i}. {one}")
|
|
199
|
+
return "\n".join(lines)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def build_steer_middleware(queue: SteerQueue):
|
|
203
|
+
"""Inject drained queue messages before each model call (post-tool boundary)."""
|
|
204
|
+
from langchain.agents.middleware import AgentMiddleware, AgentState
|
|
205
|
+
from langchain_core.messages import HumanMessage
|
|
206
|
+
|
|
207
|
+
def _logic(state: Any, runtime: Any) -> dict[str, Any] | None: # noqa: ARG001
|
|
208
|
+
items = queue.drain()
|
|
209
|
+
if not items:
|
|
210
|
+
return None
|
|
211
|
+
content = format_steer_message(items)
|
|
212
|
+
if not content:
|
|
213
|
+
return None
|
|
214
|
+
msg = HumanMessage(
|
|
215
|
+
content=content,
|
|
216
|
+
additional_kwargs={"coding_steer": True, "steer_count": len(items)},
|
|
217
|
+
)
|
|
218
|
+
return {"messages": [msg]}
|
|
219
|
+
|
|
220
|
+
def before_model(self, state: Any, runtime: Any) -> dict[str, Any] | None: # noqa: ANN001
|
|
221
|
+
return _logic(state, runtime)
|
|
222
|
+
|
|
223
|
+
async def abefore_model(self, state: Any, runtime: Any) -> dict[str, Any] | None: # noqa: ANN001
|
|
224
|
+
return _logic(state, runtime)
|
|
225
|
+
|
|
226
|
+
return type(
|
|
227
|
+
"inject_steer_queue",
|
|
228
|
+
(AgentMiddleware,),
|
|
229
|
+
{
|
|
230
|
+
"state_schema": AgentState,
|
|
231
|
+
"tools": [],
|
|
232
|
+
"before_model": before_model,
|
|
233
|
+
"abefore_model": abefore_model,
|
|
234
|
+
},
|
|
235
|
+
)()
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def get_agent_steer_queue(agent: Any) -> SteerQueue | None:
|
|
239
|
+
q = getattr(agent, "_coding_steer_queue", None)
|
|
240
|
+
return q if isinstance(q, SteerQueue) else None
|