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,184 @@
|
|
|
1
|
+
"""Layered synapse configuration roots.
|
|
2
|
+
|
|
3
|
+
Two primary layers (later overrides earlier on merge):
|
|
4
|
+
|
|
5
|
+
1. User global: ``~/.synapse/``
|
|
6
|
+
2. Project local: ``<workspace>/.synapse/``
|
|
7
|
+
|
|
8
|
+
Optional portable layer: directory next to a frozen / non-python exe.
|
|
9
|
+
|
|
10
|
+
File layout (either layer)::
|
|
11
|
+
|
|
12
|
+
.synapse/
|
|
13
|
+
models.json # model profiles + api_key (preferred over .env)
|
|
14
|
+
mcp.json # MCP servers
|
|
15
|
+
settings.json # non-secret Settings overrides (includes theme)
|
|
16
|
+
themes.json # optional custom UI themes (merged user → project)
|
|
17
|
+
system_prompt.md # coding agent system prompt (user/project override)
|
|
18
|
+
sessions.sqlite # project layer typically
|
|
19
|
+
checkpoints.sqlite
|
|
20
|
+
history
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import sys
|
|
27
|
+
from collections.abc import Iterable
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
SYNAPSE_DIRNAME = ".synapse"
|
|
32
|
+
MODELS_FILENAME = "models.json"
|
|
33
|
+
MCP_FILENAME = "mcp.json"
|
|
34
|
+
SETTINGS_FILENAME = "settings.json"
|
|
35
|
+
THEMES_FILENAME = "themes.json"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def user_config_dir() -> Path:
|
|
39
|
+
return (Path.home() / SYNAPSE_DIRNAME).expanduser().resolve()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def project_config_dir(workspace: Path | str | None = None) -> Path:
|
|
43
|
+
base = Path(workspace).expanduser().resolve() if workspace is not None else Path.cwd().resolve()
|
|
44
|
+
return (base / SYNAPSE_DIRNAME).resolve()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def executable_config_dirs() -> list[Path]:
|
|
48
|
+
"""Dirs next to the running binary (frozen exe / non-python entrypoint)."""
|
|
49
|
+
out: list[Path] = []
|
|
50
|
+
try:
|
|
51
|
+
if getattr(sys, "frozen", False):
|
|
52
|
+
out.append(Path(sys.executable).resolve().parent)
|
|
53
|
+
meipass = getattr(sys, "_MEIPASS", None)
|
|
54
|
+
if meipass:
|
|
55
|
+
out.append(Path(meipass).resolve())
|
|
56
|
+
return out
|
|
57
|
+
exe = Path(sys.executable).resolve()
|
|
58
|
+
if exe.suffix.lower() == ".exe" and not exe.stem.lower().startswith("python"):
|
|
59
|
+
out.append(exe.parent)
|
|
60
|
+
if sys.argv:
|
|
61
|
+
argv0 = Path(sys.argv[0]).resolve()
|
|
62
|
+
if argv0.suffix.lower() == ".exe":
|
|
63
|
+
out.append(argv0.parent)
|
|
64
|
+
except Exception: # noqa: BLE001
|
|
65
|
+
return out
|
|
66
|
+
return out
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def layered_config_dirs(
|
|
70
|
+
workspace: Path | str | None = None,
|
|
71
|
+
*,
|
|
72
|
+
include_exe: bool = True,
|
|
73
|
+
) -> list[Path]:
|
|
74
|
+
"""Ordered config dirs: user → (exe) → project.
|
|
75
|
+
|
|
76
|
+
Merge rule: later entries override earlier ones for the same keys/profiles.
|
|
77
|
+
"""
|
|
78
|
+
dirs: list[Path] = [user_config_dir()]
|
|
79
|
+
if include_exe:
|
|
80
|
+
for d in executable_config_dirs():
|
|
81
|
+
# Treat portable bundle as a layer between user and project.
|
|
82
|
+
dirs.append(d / SYNAPSE_DIRNAME if d.name != SYNAPSE_DIRNAME else d)
|
|
83
|
+
dirs.append(project_config_dir(workspace))
|
|
84
|
+
seen: set[Path] = set()
|
|
85
|
+
ordered: list[Path] = []
|
|
86
|
+
for d in dirs:
|
|
87
|
+
try:
|
|
88
|
+
key = d.resolve()
|
|
89
|
+
except Exception: # noqa: BLE001
|
|
90
|
+
key = d
|
|
91
|
+
if key in seen:
|
|
92
|
+
continue
|
|
93
|
+
seen.add(key)
|
|
94
|
+
ordered.append(key)
|
|
95
|
+
return ordered
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def config_search_roots(start: Path | None = None) -> list[Path]:
|
|
99
|
+
"""Roots used for legacy `.env` discovery (workspace/cwd/exe upward)."""
|
|
100
|
+
roots: list[Path] = []
|
|
101
|
+
if start is not None:
|
|
102
|
+
try:
|
|
103
|
+
roots.append(Path(start).expanduser().resolve())
|
|
104
|
+
except Exception: # noqa: BLE001
|
|
105
|
+
roots.append(Path(start))
|
|
106
|
+
try:
|
|
107
|
+
roots.append(Path.cwd().resolve())
|
|
108
|
+
except Exception: # noqa: BLE001
|
|
109
|
+
roots.append(Path.cwd())
|
|
110
|
+
roots.extend(executable_config_dirs())
|
|
111
|
+
roots.append(Path.home())
|
|
112
|
+
seen: set[Path] = set()
|
|
113
|
+
ordered: list[Path] = []
|
|
114
|
+
for r in roots:
|
|
115
|
+
try:
|
|
116
|
+
key = r.resolve()
|
|
117
|
+
except Exception: # noqa: BLE001
|
|
118
|
+
key = r
|
|
119
|
+
if key in seen:
|
|
120
|
+
continue
|
|
121
|
+
seen.add(key)
|
|
122
|
+
ordered.append(key)
|
|
123
|
+
return ordered
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def existing_files(dirs: Iterable[Path], filename: str) -> list[Path]:
|
|
127
|
+
out: list[Path] = []
|
|
128
|
+
for d in dirs:
|
|
129
|
+
p = Path(d) / filename
|
|
130
|
+
try:
|
|
131
|
+
if p.is_file():
|
|
132
|
+
out.append(p.resolve())
|
|
133
|
+
except OSError:
|
|
134
|
+
continue
|
|
135
|
+
return out
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def load_json_object(path: Path) -> dict[str, Any]:
|
|
139
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
140
|
+
if not isinstance(data, dict):
|
|
141
|
+
raise ValueError(f"{path} root must be a JSON object")
|
|
142
|
+
return data
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def deep_merge_dict(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
|
|
146
|
+
"""Recursive dict merge; override wins. Lists are replaced, not concatenated."""
|
|
147
|
+
out = dict(base)
|
|
148
|
+
for key, value in override.items():
|
|
149
|
+
if (
|
|
150
|
+
key in out
|
|
151
|
+
and isinstance(out[key], dict)
|
|
152
|
+
and isinstance(value, dict)
|
|
153
|
+
):
|
|
154
|
+
out[key] = deep_merge_dict(out[key], value)
|
|
155
|
+
else:
|
|
156
|
+
out[key] = value
|
|
157
|
+
return out
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def load_layered_json(
|
|
161
|
+
filename: str,
|
|
162
|
+
workspace: Path | str | None = None,
|
|
163
|
+
) -> tuple[dict[str, Any] | None, list[Path]]:
|
|
164
|
+
"""Load and merge JSON objects from layered dirs. Returns (merged, paths_used)."""
|
|
165
|
+
paths = existing_files(layered_config_dirs(workspace), filename)
|
|
166
|
+
if not paths:
|
|
167
|
+
return None, []
|
|
168
|
+
merged: dict[str, Any] = {}
|
|
169
|
+
for path in paths:
|
|
170
|
+
merged = deep_merge_dict(merged, load_json_object(path))
|
|
171
|
+
return merged, paths
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def load_layered_settings_file(workspace: Path | str | None = None) -> dict[str, Any]:
|
|
175
|
+
data, _ = load_layered_json(SETTINGS_FILENAME, workspace)
|
|
176
|
+
return dict(data or {})
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def models_config_paths(workspace: Path | str | None = None) -> list[Path]:
|
|
180
|
+
return existing_files(layered_config_dirs(workspace), MODELS_FILENAME)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def mcp_config_paths(workspace: Path | str | None = None) -> list[Path]:
|
|
184
|
+
return existing_files(layered_config_dirs(workspace), MCP_FILENAME)
|
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
"""Application settings for the coding agent."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Literal
|
|
7
|
+
|
|
8
|
+
from dotenv import load_dotenv
|
|
9
|
+
from pydantic import Field, field_validator
|
|
10
|
+
from pydantic_settings import (
|
|
11
|
+
BaseSettings,
|
|
12
|
+
PydanticBaseSettingsSource,
|
|
13
|
+
SettingsConfigDict,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
from synapse.settings.config_paths import (
|
|
17
|
+
config_search_roots,
|
|
18
|
+
executable_config_dirs,
|
|
19
|
+
load_layered_settings_file,
|
|
20
|
+
project_config_dir,
|
|
21
|
+
user_config_dir,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
# Re-export for callers/tests
|
|
25
|
+
__all__ = [
|
|
26
|
+
"Settings",
|
|
27
|
+
"bootstrap_project_env",
|
|
28
|
+
"config_search_roots",
|
|
29
|
+
"executable_config_dirs",
|
|
30
|
+
"find_dotenv",
|
|
31
|
+
"load_settings",
|
|
32
|
+
"project_config_dir",
|
|
33
|
+
"user_config_dir",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def find_dotenv(start: Path | None = None) -> Path | None:
|
|
38
|
+
"""Search for a `.env` file (legacy; prefer models.json api_key).
|
|
39
|
+
|
|
40
|
+
Looks under workspace/cwd/exe/home upward for a few levels.
|
|
41
|
+
"""
|
|
42
|
+
seen: set[Path] = set()
|
|
43
|
+
for base in config_search_roots(start):
|
|
44
|
+
cur = base
|
|
45
|
+
for _ in range(6):
|
|
46
|
+
if cur in seen:
|
|
47
|
+
break
|
|
48
|
+
seen.add(cur)
|
|
49
|
+
env_path = cur / ".env"
|
|
50
|
+
if env_path.is_file():
|
|
51
|
+
return env_path
|
|
52
|
+
if cur.parent == cur:
|
|
53
|
+
break
|
|
54
|
+
cur = cur.parent
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def bootstrap_project_env(project_root: Path | None = None) -> Path | None:
|
|
59
|
+
"""Optionally load legacy `.env` (override process env).
|
|
60
|
+
|
|
61
|
+
Preferred secret location is ``api_key`` inside layered ``models.json``.
|
|
62
|
+
``.env`` remains supported for migration/CI only.
|
|
63
|
+
"""
|
|
64
|
+
env_path = find_dotenv(project_root)
|
|
65
|
+
if env_path is None:
|
|
66
|
+
return None
|
|
67
|
+
load_dotenv(dotenv_path=env_path, override=True, encoding="utf-8")
|
|
68
|
+
return env_path
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class Settings(BaseSettings):
|
|
72
|
+
"""Runtime configuration from layered JSON + optional env / legacy `.env`."""
|
|
73
|
+
|
|
74
|
+
model_config = SettingsConfigDict(
|
|
75
|
+
env_file=".env",
|
|
76
|
+
env_file_encoding="utf-8",
|
|
77
|
+
extra="ignore",
|
|
78
|
+
case_sensitive=False,
|
|
79
|
+
env_ignore_empty=True,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# Model
|
|
83
|
+
model: str = Field(default="openai:gpt-4.1", validation_alias="MODEL")
|
|
84
|
+
openai_api_key: str | None = Field(default=None, validation_alias="OPENAI_API_KEY")
|
|
85
|
+
openai_base_url: str | None = Field(default=None, validation_alias="OPENAI_BASE_URL")
|
|
86
|
+
openai_websocket: bool = Field(default=False, validation_alias="OPENAI_WEBSOCKET")
|
|
87
|
+
anthropic_api_key: str | None = Field(default=None, validation_alias="ANTHROPIC_API_KEY")
|
|
88
|
+
# Multi-model catalog (JSON file or inline JSON). Empty => legacy single model.
|
|
89
|
+
models_config_path: Path | None = Field(default=None, validation_alias="AGENT_MODELS_CONFIG")
|
|
90
|
+
models_json: str | None = Field(default=None, validation_alias="MODELS_JSON")
|
|
91
|
+
# Selected profile alias (optional; falls back to model / registry default)
|
|
92
|
+
active_model: str | None = Field(default=None, validation_alias="AGENT_ACTIVE_MODEL")
|
|
93
|
+
# Independent OpenAI-compatible image-to-text model configuration.
|
|
94
|
+
# Example: {"model": "qwen-vl-max", "base_url": "...", "api_key_env": "VISION_API_KEY"}
|
|
95
|
+
vision_model: dict[str, Any] | None = Field(default=None, validation_alias="VISION_MODEL")
|
|
96
|
+
|
|
97
|
+
# Workspace
|
|
98
|
+
workspace: Path = Field(default_factory=Path.cwd, validation_alias="WORKSPACE")
|
|
99
|
+
shell_timeout: int = Field(default=120, validation_alias="SHELL_TIMEOUT")
|
|
100
|
+
max_output_bytes: int = Field(default=100_000, validation_alias="MAX_OUTPUT_BYTES")
|
|
101
|
+
inherit_env: bool = Field(default=True, validation_alias="INHERIT_ENV")
|
|
102
|
+
virtual_mode: bool = Field(default=True, validation_alias="VIRTUAL_MODE")
|
|
103
|
+
# Shell executable. None => auto-detect (pwsh on Windows, bash elsewhere).
|
|
104
|
+
# Values: pwsh | powershell | cmd | bash | system | absolute path | None (auto)
|
|
105
|
+
shell_executable: str | None = Field(default=None, validation_alias="SHELL_EXECUTABLE")
|
|
106
|
+
# Decode shell stdout/stderr with this codec (avoids GBK UnicodeDecodeError on Windows).
|
|
107
|
+
shell_encoding: str = Field(default="utf-8", validation_alias="SHELL_ENCODING")
|
|
108
|
+
shell_encoding_errors: str = Field(default="replace", validation_alias="SHELL_ENCODING_ERRORS")
|
|
109
|
+
|
|
110
|
+
# Approval: default OFF, auto-pass (user requirement)
|
|
111
|
+
require_approval: bool = Field(default=False, validation_alias="AGENT_REQUIRE_APPROVAL")
|
|
112
|
+
auto_approve: bool = Field(default=True, validation_alias="AGENT_AUTO_APPROVE")
|
|
113
|
+
safety_profile: str = Field(default="dev-autopass", validation_alias="AGENT_SAFETY_PROFILE")
|
|
114
|
+
|
|
115
|
+
# Safety blacklist is advisory when auto_approve=True; still used for warnings
|
|
116
|
+
enable_command_blacklist: bool = Field(
|
|
117
|
+
default=True, validation_alias="ENABLE_COMMAND_BLACKLIST"
|
|
118
|
+
)
|
|
119
|
+
enable_compact_tool: bool = Field(default=True, validation_alias="AGENT_ENABLE_COMPACT_TOOL")
|
|
120
|
+
# Rewrite large tool results through a content-aware, reversible pipeline.
|
|
121
|
+
# Originals are stored in a content-addressed SQLite database only after a
|
|
122
|
+
# successful rewrite; small results bypass both transformation and storage.
|
|
123
|
+
enable_tool_output_transform: bool = Field(
|
|
124
|
+
default=True, validation_alias="AGENT_ENABLE_TOOL_OUTPUT_TRANSFORM"
|
|
125
|
+
)
|
|
126
|
+
tool_output_transform_threshold_bytes: int = Field(
|
|
127
|
+
default=512, validation_alias="AGENT_TOOL_OUTPUT_TRANSFORM_THRESHOLD_BYTES"
|
|
128
|
+
)
|
|
129
|
+
tool_output_disabled_types: list[str] = Field(
|
|
130
|
+
default_factory=list, validation_alias="AGENT_TOOL_OUTPUT_DISABLED_TYPES"
|
|
131
|
+
)
|
|
132
|
+
tool_output_transform_plugins: list[str] = Field(
|
|
133
|
+
default_factory=list, validation_alias="AGENT_TOOL_OUTPUT_TRANSFORM_PLUGINS"
|
|
134
|
+
)
|
|
135
|
+
# Prefer the optional prebuilt native compression wheel when installed.
|
|
136
|
+
# Python transformers remain the automatic fallback on unsupported platforms.
|
|
137
|
+
enable_native_tool_output_compression: bool = Field(
|
|
138
|
+
default=True, validation_alias="AGENT_ENABLE_NATIVE_TOOL_OUTPUT_COMPRESSION"
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
# Session / checkpoint
|
|
142
|
+
checkpoint_backend: Literal["memory", "sqlite"] = Field(
|
|
143
|
+
default="sqlite", validation_alias="CHECKPOINT_BACKEND"
|
|
144
|
+
)
|
|
145
|
+
checkpoint_path: Path = Field(
|
|
146
|
+
default=Path(".synapse/checkpoints.sqlite"),
|
|
147
|
+
validation_alias="CHECKPOINT_PATH",
|
|
148
|
+
)
|
|
149
|
+
sessions_path: Path | None = Field(default=None, validation_alias="SESSIONS_PATH")
|
|
150
|
+
|
|
151
|
+
# Project memory / skills (paths relative to project root or absolute)
|
|
152
|
+
enable_memory: bool = Field(default=False, validation_alias="AGENT_ENABLE_MEMORY")
|
|
153
|
+
memory_paths: list[str] = Field(
|
|
154
|
+
default_factory=lambda: ["MEMORY.md", ".synapse/MEMORY.md"]
|
|
155
|
+
)
|
|
156
|
+
skills_paths: list[str] = Field(default_factory=lambda: ["skills"])
|
|
157
|
+
|
|
158
|
+
# Framework wiring (deepagents native)
|
|
159
|
+
enable_subagents: bool = Field(default=False, validation_alias="AGENT_ENABLE_SUBAGENTS")
|
|
160
|
+
subagent_tester_model: str | None = Field(
|
|
161
|
+
default=None, validation_alias="AGENT_SUBAGENT_TESTER_MODEL"
|
|
162
|
+
)
|
|
163
|
+
subagent_reviewer_model: str | None = Field(
|
|
164
|
+
default=None, validation_alias="AGENT_SUBAGENT_REVIEWER_MODEL"
|
|
165
|
+
)
|
|
166
|
+
readonly: bool = Field(default=False, validation_alias="AGENT_READONLY")
|
|
167
|
+
excluded_tools: list[str] = Field(default_factory=list, validation_alias="AGENT_EXCLUDED_TOOLS")
|
|
168
|
+
enable_fs_permissions: bool = Field(
|
|
169
|
+
default=False, validation_alias="AGENT_ENABLE_FS_PERMISSIONS"
|
|
170
|
+
)
|
|
171
|
+
deny_fs_paths: list[str] = Field(default_factory=list, validation_alias="AGENT_DENY_FS_PATHS")
|
|
172
|
+
|
|
173
|
+
# MCP extension (tools= injection)
|
|
174
|
+
enable_mcp: bool = Field(default=True, validation_alias="AGENT_ENABLE_MCP")
|
|
175
|
+
# When False (default), skip MCP connect during agent build; TUI attaches later.
|
|
176
|
+
mcp_eager: bool = Field(default=False, validation_alias="AGENT_MCP_EAGER")
|
|
177
|
+
# TUI: show UI first, build agent in a background thread.
|
|
178
|
+
tui_defer_agent: bool = Field(default=True, validation_alias="AGENT_TUI_DEFER_AGENT")
|
|
179
|
+
mcp_config_path: Path | None = Field(default=None, validation_alias="AGENT_MCP_CONFIG")
|
|
180
|
+
|
|
181
|
+
mcp_servers_json: str | None = Field(default=None, validation_alias="MCP_SERVERS_JSON")
|
|
182
|
+
|
|
183
|
+
# Observability
|
|
184
|
+
langsmith_tracing: bool = Field(default=False, validation_alias="LANGSMITH_TRACING")
|
|
185
|
+
langsmith_api_key: str | None = Field(default=None, validation_alias="LANGSMITH_API_KEY")
|
|
186
|
+
langsmith_project: str = Field(default="coding-agent", validation_alias="LANGSMITH_PROJECT")
|
|
187
|
+
|
|
188
|
+
# Debug
|
|
189
|
+
debug: bool = Field(default=False, validation_alias="AGENT_DEBUG")
|
|
190
|
+
|
|
191
|
+
# Streaming / concurrency
|
|
192
|
+
token_stream: bool = Field(default=True, validation_alias="TOKEN_STREAM")
|
|
193
|
+
parallel_tool_calls: bool = Field(default=True, validation_alias="PARALLEL_TOOL_CALLS")
|
|
194
|
+
max_concurrency: int = Field(default=8, validation_alias="MAX_CONCURRENCY")
|
|
195
|
+
# langchain-openai async per-chunk silence timeout (seconds).
|
|
196
|
+
# Default None disables StreamChunkTimeoutError (library default is 120s), which
|
|
197
|
+
# otherwise aborts long reasoning / slow OpenAI-compatible gateways mid-turn.
|
|
198
|
+
# Set a positive number to re-enable; 0 also disables. Profile params can override.
|
|
199
|
+
stream_chunk_timeout: float | None = Field(
|
|
200
|
+
default=None, validation_alias="STREAM_CHUNK_TIMEOUT"
|
|
201
|
+
)
|
|
202
|
+
# TUI / CLI appearance (see synapse.ui.theme).
|
|
203
|
+
# Terminal inherit (transparent): ansi (aliases: inherit, terminal, auto)
|
|
204
|
+
# Built-in dark: cursor-dark github-dark dracula nord solarized-dark
|
|
205
|
+
# catppuccin-mocha one-dark
|
|
206
|
+
# Built-in light: solarized-light github-light one-light gruvbox-light
|
|
207
|
+
# catppuccin-latte tokyo-night-light ayu-light nord-light
|
|
208
|
+
# Custom palettes: layered .coding-agent/themes.json
|
|
209
|
+
theme: str = Field(default="cursor-dark", validation_alias="AGENT_THEME")
|
|
210
|
+
|
|
211
|
+
# TUI tool timeline: keep tool-detail rows expanded under group headers.
|
|
212
|
+
# Set false to auto-collapse batches after they finish (summary only).
|
|
213
|
+
tool_details_expanded: bool = Field(
|
|
214
|
+
default=True, validation_alias="AGENT_TOOL_DETAILS_EXPANDED"
|
|
215
|
+
)
|
|
216
|
+
# TUI session recap: after idle following a completed turn, show one-line summary.
|
|
217
|
+
session_recap_enabled: bool = Field(default=True, validation_alias="AGENT_SESSION_RECAP")
|
|
218
|
+
session_recap_idle_seconds: float = Field(
|
|
219
|
+
default=180.0, validation_alias="AGENT_SESSION_RECAP_IDLE_SECONDS"
|
|
220
|
+
)
|
|
221
|
+
session_recap_min_turns: int = Field(
|
|
222
|
+
default=3, validation_alias="AGENT_SESSION_RECAP_MIN_TURNS"
|
|
223
|
+
)
|
|
224
|
+
# DeepSeek V4 thinking / other reasoning models via OpenAI-compatible API
|
|
225
|
+
enable_thinking: bool = Field(default=True, validation_alias="ENABLE_THINKING")
|
|
226
|
+
reasoning_effort: str = Field(default="high", validation_alias="REASONING_EFFORT")
|
|
227
|
+
|
|
228
|
+
@classmethod
|
|
229
|
+
def settings_customise_sources(
|
|
230
|
+
cls,
|
|
231
|
+
settings_cls: type[BaseSettings],
|
|
232
|
+
init_settings: PydanticBaseSettingsSource,
|
|
233
|
+
env_settings: PydanticBaseSettingsSource,
|
|
234
|
+
dotenv_settings: PydanticBaseSettingsSource,
|
|
235
|
+
file_secret_settings: PydanticBaseSettingsSource,
|
|
236
|
+
) -> tuple[PydanticBaseSettingsSource, ...]:
|
|
237
|
+
"""Prefer project `.env` over process/user environment variables.
|
|
238
|
+
|
|
239
|
+
Default pydantic-settings order lets a stale system OPENAI_API_KEY
|
|
240
|
+
override the project-local key and cause 401 against private gateways.
|
|
241
|
+
"""
|
|
242
|
+
return (
|
|
243
|
+
init_settings,
|
|
244
|
+
dotenv_settings,
|
|
245
|
+
env_settings,
|
|
246
|
+
file_secret_settings,
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
@field_validator("openai_api_key", "anthropic_api_key", "openai_base_url", mode="before")
|
|
250
|
+
@classmethod
|
|
251
|
+
def _strip_secret_like(cls, value: object) -> object:
|
|
252
|
+
if isinstance(value, str):
|
|
253
|
+
text = value.strip().strip("\"'")
|
|
254
|
+
return text or None
|
|
255
|
+
return value
|
|
256
|
+
|
|
257
|
+
@field_validator("model", "active_model", "theme", mode="before")
|
|
258
|
+
@classmethod
|
|
259
|
+
def _strip_model(cls, value: object) -> object:
|
|
260
|
+
if isinstance(value, str):
|
|
261
|
+
return value.strip().strip("\"'")
|
|
262
|
+
return value
|
|
263
|
+
|
|
264
|
+
@field_validator("workspace", "checkpoint_path", mode="before")
|
|
265
|
+
@classmethod
|
|
266
|
+
def _coerce_required_path(cls, value: object) -> Path:
|
|
267
|
+
if value is None or value == "":
|
|
268
|
+
return Path.cwd() if value is None else Path(value)
|
|
269
|
+
return Path(value).expanduser().resolve()
|
|
270
|
+
|
|
271
|
+
@field_validator(
|
|
272
|
+
"sessions_path",
|
|
273
|
+
"models_config_path",
|
|
274
|
+
"mcp_config_path",
|
|
275
|
+
mode="before",
|
|
276
|
+
)
|
|
277
|
+
@classmethod
|
|
278
|
+
def _coerce_optional_path(cls, value: object) -> Path | None:
|
|
279
|
+
if value is None or value == "":
|
|
280
|
+
return None
|
|
281
|
+
# Keep relative paths relative so load_settings can resolve against workspace.
|
|
282
|
+
return Path(str(value)).expanduser()
|
|
283
|
+
|
|
284
|
+
@field_validator(
|
|
285
|
+
"excluded_tools",
|
|
286
|
+
"deny_fs_paths",
|
|
287
|
+
"tool_output_disabled_types",
|
|
288
|
+
"tool_output_transform_plugins",
|
|
289
|
+
mode="before",
|
|
290
|
+
)
|
|
291
|
+
@classmethod
|
|
292
|
+
def _split_csv(cls, value: object) -> object:
|
|
293
|
+
if value is None:
|
|
294
|
+
return []
|
|
295
|
+
if isinstance(value, str):
|
|
296
|
+
return [part.strip() for part in value.split(",") if part.strip()]
|
|
297
|
+
return value
|
|
298
|
+
|
|
299
|
+
@field_validator("stream_chunk_timeout", mode="before")
|
|
300
|
+
@classmethod
|
|
301
|
+
def _coerce_stream_chunk_timeout(cls, value: object) -> object:
|
|
302
|
+
if value is None or value == "":
|
|
303
|
+
return None
|
|
304
|
+
if isinstance(value, str):
|
|
305
|
+
text = value.strip().casefold()
|
|
306
|
+
if text in {"none", "null", "off", "disable", "disabled"}:
|
|
307
|
+
return None
|
|
308
|
+
return float(text)
|
|
309
|
+
return value
|
|
310
|
+
|
|
311
|
+
def ensure_dirs(self) -> None:
|
|
312
|
+
"""Create local state directories if needed."""
|
|
313
|
+
self.checkpoint_path.parent.mkdir(parents=True, exist_ok=True)
|
|
314
|
+
if self.sessions_path is not None:
|
|
315
|
+
self.sessions_path.parent.mkdir(parents=True, exist_ok=True)
|
|
316
|
+
self.resolved_tool_output_db_path().parent.mkdir(parents=True, exist_ok=True)
|
|
317
|
+
|
|
318
|
+
def resolved_sessions_path(self) -> Path:
|
|
319
|
+
if self.sessions_path is not None:
|
|
320
|
+
return Path(self.sessions_path).expanduser().resolve()
|
|
321
|
+
return self.checkpoint_path.parent / "sessions.sqlite"
|
|
322
|
+
|
|
323
|
+
def resolved_tool_output_db_path(self) -> Path:
|
|
324
|
+
"""SQLite database for reversible transformed tool outputs."""
|
|
325
|
+
return self.resolved_sessions_path().parent / "tool-outputs.sqlite"
|
|
326
|
+
|
|
327
|
+
def resolved_memory_paths(self, project_root: Path | None = None) -> list[str]:
|
|
328
|
+
root = project_root or Path.cwd()
|
|
329
|
+
resolved: list[str] = []
|
|
330
|
+
for p in self.memory_paths:
|
|
331
|
+
path = Path(p) if Path(p).is_absolute() else (root / p)
|
|
332
|
+
resolved.append(str(path.resolve()))
|
|
333
|
+
return resolved
|
|
334
|
+
|
|
335
|
+
def resolved_skills_paths(self, project_root: Path | None = None) -> list[str]:
|
|
336
|
+
root = project_root or Path.cwd()
|
|
337
|
+
paths: list[str] = []
|
|
338
|
+
for p in self.skills_paths:
|
|
339
|
+
path = Path(p) if Path(p).is_absolute() else (root / p)
|
|
340
|
+
if path.exists():
|
|
341
|
+
paths.append(str(path.resolve()))
|
|
342
|
+
return paths
|
|
343
|
+
|
|
344
|
+
def mask_openai_key(self) -> str:
|
|
345
|
+
key = self.openai_api_key or ""
|
|
346
|
+
if not key:
|
|
347
|
+
return "<empty>"
|
|
348
|
+
if len(key) <= 8:
|
|
349
|
+
return "***"
|
|
350
|
+
return f"len={len(key)} head={key[:4]}***tail=***{key[-4:]}"
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def load_settings(**overrides: Any) -> Settings:
|
|
354
|
+
"""Load settings from layered config + optional env/legacy `.env`.
|
|
355
|
+
|
|
356
|
+
Layers (later wins for settings.json / models / mcp):
|
|
357
|
+
1. ``~/.coding-agent/``
|
|
358
|
+
2. exe-adjacent ``.coding-agent/`` (portable bundle)
|
|
359
|
+
3. ``<workspace>/.coding-agent/``
|
|
360
|
+
|
|
361
|
+
Secrets: prefer ``api_key`` in ``models.json``. ``.env`` is legacy only.
|
|
362
|
+
"""
|
|
363
|
+
project_root = overrides.get("workspace")
|
|
364
|
+
root_path = Path(project_root).expanduser().resolve() if project_root is not None else None
|
|
365
|
+
# Legacy: optional .env for migration
|
|
366
|
+
bootstrap_project_env(root_path)
|
|
367
|
+
|
|
368
|
+
env_path = find_dotenv(root_path)
|
|
369
|
+
if env_path is not None:
|
|
370
|
+
settings = Settings(_env_file=str(env_path))
|
|
371
|
+
else:
|
|
372
|
+
settings = Settings(_env_file=None)
|
|
373
|
+
|
|
374
|
+
# Layered settings.json (user → project). Applied before CLI overrides.
|
|
375
|
+
workspace_hint = root_path or settings.workspace
|
|
376
|
+
file_cfg = load_layered_settings_file(workspace_hint)
|
|
377
|
+
if file_cfg:
|
|
378
|
+
allowed = set(Settings.model_fields.keys())
|
|
379
|
+
updates: dict[str, Any] = {}
|
|
380
|
+
for key, value in file_cfg.items():
|
|
381
|
+
if key in allowed and value is not None:
|
|
382
|
+
updates[key] = value
|
|
383
|
+
if updates:
|
|
384
|
+
# Path-like coercion for known path fields
|
|
385
|
+
for pk in (
|
|
386
|
+
"workspace",
|
|
387
|
+
"checkpoint_path",
|
|
388
|
+
"sessions_path",
|
|
389
|
+
"models_config_path",
|
|
390
|
+
"mcp_config_path",
|
|
391
|
+
):
|
|
392
|
+
if pk in updates and updates[pk] is not None:
|
|
393
|
+
updates[pk] = Path(str(updates[pk])).expanduser()
|
|
394
|
+
settings = settings.model_copy(update=updates)
|
|
395
|
+
|
|
396
|
+
if overrides:
|
|
397
|
+
# Non-path fields: ignore None (means "leave default / env").
|
|
398
|
+
normal = {
|
|
399
|
+
k: v
|
|
400
|
+
for k, v in overrides.items()
|
|
401
|
+
if v is not None
|
|
402
|
+
and k
|
|
403
|
+
not in {
|
|
404
|
+
"models_config_path",
|
|
405
|
+
"mcp_config_path",
|
|
406
|
+
"sessions_path",
|
|
407
|
+
"workspace",
|
|
408
|
+
"checkpoint_path",
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
if normal:
|
|
412
|
+
settings = settings.model_copy(update=normal)
|
|
413
|
+
# Optional paths may be explicitly cleared with None.
|
|
414
|
+
pathish = {
|
|
415
|
+
k: overrides[k]
|
|
416
|
+
for k in ("models_config_path", "mcp_config_path", "sessions_path")
|
|
417
|
+
if k in overrides
|
|
418
|
+
}
|
|
419
|
+
if pathish:
|
|
420
|
+
coerced: dict[str, Any] = {}
|
|
421
|
+
for k, v in pathish.items():
|
|
422
|
+
if v is None or v == "":
|
|
423
|
+
coerced[k] = None
|
|
424
|
+
else:
|
|
425
|
+
p = Path(v).expanduser()
|
|
426
|
+
coerced[k] = p.resolve() if p.is_absolute() else p
|
|
427
|
+
settings = settings.model_copy(update=coerced)
|
|
428
|
+
if "workspace" in overrides and overrides["workspace"] is not None:
|
|
429
|
+
settings.workspace = Path(overrides["workspace"]).expanduser().resolve()
|
|
430
|
+
if "checkpoint_path" in overrides and overrides["checkpoint_path"] is not None:
|
|
431
|
+
settings.checkpoint_path = Path(overrides["checkpoint_path"]).expanduser().resolve()
|
|
432
|
+
|
|
433
|
+
# Default state files live under project .synapse when possible.
|
|
434
|
+
proj = project_config_dir(settings.workspace)
|
|
435
|
+
path_updates: dict[str, Any] = {}
|
|
436
|
+
try:
|
|
437
|
+
default_ckpt = (Path.cwd() / ".synapse" / "checkpoints.sqlite").resolve()
|
|
438
|
+
except Exception: # noqa: BLE001
|
|
439
|
+
default_ckpt = None
|
|
440
|
+
if default_ckpt is not None and settings.checkpoint_path == default_ckpt:
|
|
441
|
+
path_updates["checkpoint_path"] = proj / "checkpoints.sqlite"
|
|
442
|
+
if settings.sessions_path is None:
|
|
443
|
+
path_updates["sessions_path"] = proj / "sessions.sqlite"
|
|
444
|
+
|
|
445
|
+
# Resolve relative models/mcp config paths against workspace (not process cwd).
|
|
446
|
+
if settings.models_config_path is not None:
|
|
447
|
+
mp = Path(settings.models_config_path).expanduser()
|
|
448
|
+
if not mp.is_absolute():
|
|
449
|
+
path_updates["models_config_path"] = (Path(settings.workspace) / mp).resolve()
|
|
450
|
+
if settings.mcp_config_path is not None:
|
|
451
|
+
cp = Path(settings.mcp_config_path).expanduser()
|
|
452
|
+
if not cp.is_absolute():
|
|
453
|
+
path_updates["mcp_config_path"] = (Path(settings.workspace) / cp).resolve()
|
|
454
|
+
if path_updates:
|
|
455
|
+
settings = settings.model_copy(update=path_updates)
|
|
456
|
+
|
|
457
|
+
settings.ensure_dirs()
|
|
458
|
+
|
|
459
|
+
# Layered models.json → selected profile (api_key / base_url / thinking).
|
|
460
|
+
# Model profile resolution is runtime configuration, while UI activation is
|
|
461
|
+
# intentionally left to the concrete frontend bootstrap.
|
|
462
|
+
from synapse.models.registry import apply_models_config_to_settings
|
|
463
|
+
|
|
464
|
+
return apply_models_config_to_settings(settings)
|