splitagent 0.0.3__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 (56) hide show
  1. splitagent/__init__.py +8 -0
  2. splitagent/__main__.py +6 -0
  3. splitagent/agents/__init__.py +10 -0
  4. splitagent/agents/base.py +477 -0
  5. splitagent/agents/blue.py +57 -0
  6. splitagent/agents/chat.py +60 -0
  7. splitagent/agents/prompts.py +462 -0
  8. splitagent/agents/red.py +75 -0
  9. splitagent/cli.py +701 -0
  10. splitagent/config.py +697 -0
  11. splitagent/core/__init__.py +19 -0
  12. splitagent/core/bus.py +62 -0
  13. splitagent/core/context.py +587 -0
  14. splitagent/core/context_manager.py +381 -0
  15. splitagent/core/engine.py +424 -0
  16. splitagent/core/models.py +310 -0
  17. splitagent/core/proc.py +73 -0
  18. splitagent/core/sandbox.py +184 -0
  19. splitagent/core/toolbox.py +520 -0
  20. splitagent/core/workspace.py +420 -0
  21. splitagent/desktop/__init__.py +7 -0
  22. splitagent/desktop/api.py +525 -0
  23. splitagent/desktop/app.py +1131 -0
  24. splitagent/desktop/web/app.js +3067 -0
  25. splitagent/desktop/web/assets/Inter.ttf +0 -0
  26. splitagent/desktop/web/assets/JetBrainsMonoNerdFontMono-Regular.woff2 +0 -0
  27. splitagent/desktop/web/index.html +760 -0
  28. splitagent/desktop/web/styles.css +1612 -0
  29. splitagent/errors.py +27 -0
  30. splitagent/llm/__init__.py +8 -0
  31. splitagent/llm/client.py +488 -0
  32. splitagent/llm/types.py +172 -0
  33. splitagent/report/__init__.py +9 -0
  34. splitagent/report/cvss.py +93 -0
  35. splitagent/report/generator.py +733 -0
  36. splitagent/tools/__init__.py +8 -0
  37. splitagent/tools/base.py +135 -0
  38. splitagent/tools/defense.py +475 -0
  39. splitagent/tools/exploit.py +318 -0
  40. splitagent/tools/http_pool.py +109 -0
  41. splitagent/tools/knowledge.py +376 -0
  42. splitagent/tools/recon.py +182 -0
  43. splitagent/tools/registry.py +62 -0
  44. splitagent/tools/validate.py +908 -0
  45. splitagent/tools/web.py +386 -0
  46. splitagent/tools/workspace_tools.py +411 -0
  47. splitagent/ui/__init__.py +5 -0
  48. splitagent/ui/app.py +389 -0
  49. splitagent/ui/stream.py +234 -0
  50. splitagent/ui/theme.py +72 -0
  51. splitagent-0.0.3.dist-info/METADATA +987 -0
  52. splitagent-0.0.3.dist-info/RECORD +56 -0
  53. splitagent-0.0.3.dist-info/WHEEL +5 -0
  54. splitagent-0.0.3.dist-info/entry_points.txt +2 -0
  55. splitagent-0.0.3.dist-info/licenses/LICENSE +21 -0
  56. splitagent-0.0.3.dist-info/top_level.txt +1 -0
splitagent/config.py ADDED
@@ -0,0 +1,697 @@
1
+ """Configuration handling for SplitAgent.
2
+
3
+ Two layers of configuration exist:
4
+
5
+ * **Global** (``~/.splitagent/config.yaml`` on POSIX, ``%APPDATA%/splitagent``
6
+ on Windows) stores the LLM provider/API credentials together with UI and
7
+ default sandbox preferences. This is what the user edits from inside the
8
+ program with ``splitagent config``.
9
+ * **Project** (``splitagent.yaml`` in the working directory) describes the
10
+ target, the number of Red/Blue rounds and the report preferences.
11
+
12
+ Only the global layer holds secrets. Project files are safe to commit.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ from dataclasses import asdict, dataclass, field, fields
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ import yaml
23
+
24
+ from splitagent.errors import ConfigError
25
+
26
+ APP_NAME = "splitagent"
27
+ GLOBAL_ENV = "SPLITAGENT_HOME"
28
+
29
+
30
+ # --------------------------------------------------------------------------- #
31
+ # Paths
32
+ # --------------------------------------------------------------------------- #
33
+ def config_home() -> Path:
34
+ """Return the directory holding global SplitAgent state."""
35
+ override = os.environ.get(GLOBAL_ENV)
36
+ if override:
37
+ return Path(override).expanduser()
38
+ if os.name == "nt":
39
+ base = os.environ.get("APPDATA") or str(Path.home())
40
+ return Path(base) / APP_NAME
41
+ base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
42
+ return Path(base) / APP_NAME
43
+
44
+
45
+ def global_config_path() -> Path:
46
+ return config_home() / "config.yaml"
47
+
48
+
49
+ def global_key_path() -> Path:
50
+ return config_home() / "key.bin"
51
+
52
+
53
+ def sessions_dir() -> Path:
54
+ return config_home() / "sessions"
55
+
56
+
57
+ def ensure_home() -> Path:
58
+ home = config_home()
59
+ home.mkdir(parents=True, exist_ok=True)
60
+ sessions_dir().mkdir(parents=True, exist_ok=True)
61
+ return home
62
+
63
+
64
+ def project_config_path(start: Path | None = None) -> Path:
65
+ start = (start or Path.cwd()).resolve()
66
+ for candidate_dir in [start, *start.parents]:
67
+ candidate = candidate_dir / "splitagent.yaml"
68
+ if candidate.exists():
69
+ return candidate
70
+ candidate = candidate_dir / "splitagent.yml"
71
+ if candidate.exists():
72
+ return candidate
73
+ return start / "splitagent.yaml"
74
+
75
+
76
+ # --------------------------------------------------------------------------- #
77
+ # Provider presets
78
+ # --------------------------------------------------------------------------- #
79
+ PROVIDER_PRESETS: dict[str, dict[str, str]] = {
80
+ "openai": {
81
+ "protocol": "openai",
82
+ "base_url": "https://api.openai.com/v1",
83
+ "model": "gpt-4o-mini",
84
+ },
85
+ "opencode-go": {
86
+ "protocol": "openai",
87
+ "base_url": "https://opencode.ai/zen/go/v1",
88
+ "model": "deepseek-v4.1-flash",
89
+ },
90
+ "opencode": {
91
+ "protocol": "openai",
92
+ "base_url": "https://opencode.ai/zen/v1",
93
+ "model": "deepseek-v4-flash",
94
+ },
95
+ "openrouter": {
96
+ "protocol": "openai",
97
+ "base_url": "https://openrouter.ai/api/v1",
98
+ "model": "anthropic/claude-3.5-sonnet",
99
+ },
100
+ "anthropic": {
101
+ "protocol": "anthropic",
102
+ "base_url": "https://api.anthropic.com/v1",
103
+ "model": "claude-3-5-sonnet-latest",
104
+ },
105
+ "groq": {
106
+ "protocol": "openai",
107
+ "base_url": "https://api.groq.com/openai/v1",
108
+ "model": "llama-3.3-70b-versatile",
109
+ },
110
+ "deepseek": {
111
+ "protocol": "openai",
112
+ "base_url": "https://api.deepseek.com/v1",
113
+ "model": "deepseek-chat",
114
+ },
115
+ "together": {
116
+ "protocol": "openai",
117
+ "base_url": "https://api.together.xyz/v1",
118
+ "model": "meta-llama/Llama-3.3-70B-Instruct-Turbo",
119
+ },
120
+ "mistral": {
121
+ "protocol": "openai",
122
+ "base_url": "https://api.mistral.ai/v1",
123
+ "model": "mistral-large-latest",
124
+ },
125
+ "xai": {
126
+ "protocol": "openai",
127
+ "base_url": "https://api.x.ai/v1",
128
+ "model": "grok-2-latest",
129
+ },
130
+ "ollama": {
131
+ "protocol": "openai",
132
+ "base_url": "http://localhost:11434/v1",
133
+ "model": "llama3.1",
134
+ },
135
+ "lmstudio": {
136
+ "protocol": "openai",
137
+ "base_url": "http://localhost:1234/v1",
138
+ "model": "local-model",
139
+ },
140
+ "vllm": {
141
+ "protocol": "openai",
142
+ "base_url": "http://localhost:8000/v1",
143
+ "model": "local-model",
144
+ },
145
+ "custom": {
146
+ "protocol": "openai",
147
+ "base_url": "http://localhost:8000/v1",
148
+ "model": "custom-model",
149
+ },
150
+ }
151
+
152
+ # Local / self-hosted providers that legitimately run without an API key.
153
+ KEYLESS_PROVIDERS = frozenset({"ollama", "lmstudio", "vllm", "custom"})
154
+
155
+ API_KEY_ENV = {
156
+ "openai": ("OPENAI_API_KEY",),
157
+ "opencode-go": ("OPENCODE_API_KEY", "OPENCODE_GO_API_KEY"),
158
+ "opencode": ("OPENCODE_API_KEY",),
159
+ "openrouter": ("OPENROUTER_API_KEY",),
160
+ "anthropic": ("ANTHROPIC_API_KEY",),
161
+ "groq": ("GROQ_API_KEY",),
162
+ "deepseek": ("DEEPSEEK_API_KEY",),
163
+ "together": ("TOGETHER_API_KEY",),
164
+ "mistral": ("MISTRAL_API_KEY",),
165
+ "xai": ("XAI_API_KEY",),
166
+ "custom": ("SPLITAGENT_API_KEY",),
167
+ }
168
+
169
+
170
+ # --------------------------------------------------------------------------- #
171
+ # Dataclasses
172
+ # --------------------------------------------------------------------------- #
173
+ @dataclass
174
+ class LLMSettings:
175
+ """Credentials and behaviour for the model API used by both agents."""
176
+
177
+ provider: str = "openai"
178
+ protocol: str = "openai" # openai | anthropic
179
+ base_url: str = "https://api.openai.com/v1"
180
+ api_key: str = ""
181
+ model: str = "gpt-4o-mini"
182
+ temperature: float = 0.2
183
+ max_tokens: int = 4096
184
+ timeout: int = 120
185
+ stream: bool = True
186
+ # Transient-failure handling. 0 retries disables the retry loop.
187
+ max_retries: int = 3
188
+ retry_initial_delay: float = 1.0
189
+ retry_max_delay: float = 30.0
190
+ session_id: str = ""
191
+ user_agent: str = ""
192
+ # Prompt caching: reuse the stable prefix instead of paying for it every
193
+ # turn. Anthropic/Bedrock/OpenRouter take an explicit marker; OpenAI and
194
+ # OpenAI-compatible gateways cache automatically when the prefix is stable.
195
+ prompt_cache: bool = True
196
+ cache_system_messages: int = 2
197
+ cache_tail_messages: int = 2
198
+ extra_headers: dict[str, str] = field(default_factory=dict)
199
+
200
+ def resolved_api_key(self) -> str:
201
+ if self.api_key:
202
+ return self.api_key
203
+ for env_name in (
204
+ *API_KEY_ENV.get(self.provider, ()),
205
+ "SPLITAGENT_API_KEY",
206
+ "LLM_API_KEY",
207
+ ):
208
+ value = os.environ.get(env_name)
209
+ if value:
210
+ return value
211
+ return ""
212
+
213
+ def redacted(self) -> dict[str, Any]:
214
+ data = asdict(self)
215
+ if data.get("api_key"):
216
+ data["api_key"] = "***"
217
+ return data
218
+
219
+
220
+ @dataclass
221
+ class ModelSpec:
222
+ """Context/output limits for a model, used by the context manager."""
223
+
224
+ context: int = 128_000
225
+ output: int = 32_000
226
+
227
+ def to_dict(self) -> dict[str, int]:
228
+ return {"context": self.context, "output": self.output}
229
+
230
+
231
+ # Known limits; anything else falls back to a conservative default.
232
+ KNOWN_MODEL_LIMITS: dict[str, dict[str, int]] = {
233
+ "deepseek-v4.1-flash": {"context": 1_000_000, "output": 384_000},
234
+ "deepseek-v4-pro": {"context": 1_000_000, "output": 384_000},
235
+ "deepseek-v4-flash": {"context": 1_000_000, "output": 384_000},
236
+ "deepseek-v4-flash-vision-exp": {"context": 1_000_000, "output": 384_000},
237
+ "deepseek-chat": {"context": 128_000, "output": 8_192},
238
+ "gpt-4o": {"context": 128_000, "output": 16_384},
239
+ "gpt-4o-mini": {"context": 128_000, "output": 16_384},
240
+ "claude-3-5-sonnet-latest": {"context": 200_000, "output": 8_192},
241
+ "llama3.1": {"context": 128_000, "output": 8_192},
242
+ "llama-3.3-70b-versatile": {"context": 128_000, "output": 32_768},
243
+ }
244
+
245
+
246
+ def model_spec(model_id: str) -> ModelSpec:
247
+ limits = KNOWN_MODEL_LIMITS.get(model_id)
248
+ if limits:
249
+ return ModelSpec(context=limits["context"], output=limits["output"])
250
+ lowered = model_id.lower()
251
+ if "flash" in lowered or "mini" in lowered:
252
+ return ModelSpec(context=128_000, output=16_384)
253
+ if "pro" in lowered or "sonnet" in lowered:
254
+ return ModelSpec(context=200_000, output=32_000)
255
+ return ModelSpec()
256
+
257
+
258
+ @dataclass
259
+ class ProviderConfig:
260
+ """A configured (connected) provider and its model visibility."""
261
+
262
+ id: str = ""
263
+ base_url: str = ""
264
+ protocol: str = "openai"
265
+ api_key: str = ""
266
+ enabled: bool = True
267
+ models: dict[str, str] = field(default_factory=dict) # model id -> display name
268
+ hidden: list[str] = field(default_factory=list) # model ids toggled off
269
+ limits: dict[str, dict[str, int]] = field(default_factory=dict) # model -> spec
270
+
271
+ def display_name(self, model_id: str) -> str:
272
+ return self.models.get(model_id, model_id)
273
+
274
+ def spec(self, model_id: str) -> ModelSpec:
275
+ """Context/output limits for a model served by this provider."""
276
+ limits = self.limits.get(model_id)
277
+ if limits:
278
+ return ModelSpec(
279
+ context=int(limits.get("context", 128_000)),
280
+ output=int(limits.get("output", 32_000)),
281
+ )
282
+ return model_spec(model_id)
283
+
284
+ def visible(self, model_id: str) -> bool:
285
+ return self.enabled and model_id not in self.hidden
286
+
287
+ def redacted(self) -> dict[str, Any]:
288
+ data = asdict(self)
289
+ if data.get("api_key"):
290
+ data["api_key"] = "***"
291
+ return data
292
+
293
+
294
+ @dataclass
295
+ class UISettings:
296
+ theme: str = "opencode"
297
+ show_thinking: bool = True
298
+ refresh_per_second: int = 12
299
+ # Counter shown on the dashboard. It no longer drives an interface
300
+ # heuristic: there is a single interface.
301
+ audits_completed: int = 0
302
+
303
+
304
+ @dataclass
305
+ class SandboxDefaults:
306
+ engine: str = "docker"
307
+ image: str = "bkimminich/juice-shop:latest"
308
+ network: str = "splitagent-net"
309
+ auto_remove: bool = True
310
+
311
+
312
+ @dataclass
313
+ class GlobalConfig:
314
+ llm: LLMSettings = field(default_factory=LLMSettings)
315
+ ui: UISettings = field(default_factory=UISettings)
316
+ sandbox: SandboxDefaults = field(default_factory=SandboxDefaults)
317
+ providers: dict[str, ProviderConfig] = field(default_factory=dict)
318
+ authorized: bool = False # the user accepted the responsible-use notice
319
+
320
+ @property
321
+ def configured(self) -> bool:
322
+ has_key = bool(self.llm.resolved_api_key()) or self.llm.provider in KEYLESS_PROVIDERS
323
+ return bool(self.llm.model and self.llm.base_url and has_key)
324
+
325
+
326
+ @dataclass
327
+ class TargetConfig:
328
+ kind: str = "web"
329
+ url: str = ""
330
+ hosts: list[str] = field(default_factory=list)
331
+ ports: list[int] = field(default_factory=list)
332
+ scope: list[str] = field(default_factory=list)
333
+ out_of_scope: list[str] = field(default_factory=list)
334
+
335
+ def effective_hosts(self) -> list[str]:
336
+ hosts = list(self.hosts)
337
+ if self.url:
338
+ try:
339
+ from urllib.parse import urlparse
340
+
341
+ host = urlparse(self.url).hostname
342
+ if host and host not in hosts:
343
+ hosts.append(host)
344
+ except ValueError:
345
+ pass
346
+ return hosts
347
+
348
+
349
+ @dataclass
350
+ class SandboxConfig:
351
+ enabled: bool = True
352
+ engine: str = "docker"
353
+ image: str = "bkimminich/juice-shop:latest"
354
+ network: str = "splitagent-net"
355
+ port_map: dict[str, int] = field(default_factory=dict)
356
+ auto_remove: bool = True
357
+
358
+
359
+ @dataclass
360
+ class AuthConfig:
361
+ """Optional credentials handed to the agents for authenticated testing."""
362
+
363
+ username: str = ""
364
+ password: str = ""
365
+ token: str = ""
366
+ cookies: str = ""
367
+ headers: dict[str, str] = field(default_factory=dict)
368
+
369
+ def as_headers(self) -> dict[str, str]:
370
+ headers = dict(self.headers or {})
371
+ if self.token and "Authorization" not in headers:
372
+ headers["Authorization"] = f"Bearer {self.token}"
373
+ if self.cookies:
374
+ headers["Cookie"] = self.cookies
375
+ return headers
376
+
377
+ def describe(self) -> str:
378
+ parts: list[str] = []
379
+ if self.username:
380
+ parts.append(f"username={self.username}")
381
+ parts.append("password=(provided)")
382
+ if self.token:
383
+ parts.append("bearer token (provided)")
384
+ if self.cookies:
385
+ parts.append("cookies (provided)")
386
+ if self.headers:
387
+ parts.append("custom headers: " + ", ".join(sorted(self.headers)))
388
+ return ", ".join(parts) if parts else "(none)"
389
+
390
+ def redacted(self) -> dict[str, Any]:
391
+ """A copy safe to hand to the renderer: secrets are masked."""
392
+ data = asdict(self)
393
+ for key in ("password", "token", "cookies"):
394
+ if data.get(key):
395
+ data[key] = "***"
396
+ data["headers"] = dict.fromkeys(self.headers or {}, "***")
397
+ data["has_password"] = bool(self.password)
398
+ data["has_token"] = bool(self.token)
399
+ data["has_cookies"] = bool(self.cookies)
400
+ return data
401
+
402
+
403
+ @dataclass
404
+ class ExecutionConfig:
405
+ """Where the agents install and run their tooling.
406
+
407
+ ``toolbox`` runs everything inside a disposable Docker container that has
408
+ the security toolset preinstalled, so the host is never modified. ``local``
409
+ runs on the host (the legacy behaviour). ``auto`` prefers the toolbox and
410
+ falls back to local when Docker is unavailable.
411
+ """
412
+
413
+ mode: str = "auto" # auto | toolbox | local
414
+ edition: str = "standard" # standard | kali
415
+ image: str = "splitagent-toolbox:latest"
416
+ container: str = "splitagent-toolbox"
417
+ network: str = "splitagent-net"
418
+ network_mode: str = "bridge" # bridge | host
419
+ auto_start: bool = True # start Docker Desktop / the container silently
420
+ installed: bool = False # the operator approved building the image once
421
+ allow_install: bool = True # allow pip/apt/go installs inside the toolbox
422
+ cpus: str = ""
423
+ memory: str = ""
424
+ keep_alive_minutes: int = 120
425
+
426
+
427
+ @dataclass
428
+ class CompactionConfig:
429
+ """Context-window management, mirroring OpenCode's ``compaction`` block."""
430
+
431
+ auto: bool = True
432
+ prune: bool = True
433
+ # Per-request reduction: drop settled reasoning, blank superseded snapshots.
434
+ optimize: bool = True
435
+ # Reasoning turns to keep in full; older ones are thought, not context.
436
+ keep_reasoning_steps: int = 1
437
+ reserved: int | None = None
438
+ preserve_recent_tokens: int | None = None
439
+ tail_turns: int | None = None
440
+
441
+
442
+ @dataclass
443
+ class RunConfig:
444
+ rounds: int = 3
445
+ max_steps: int = 12
446
+ safe_mode: bool = True
447
+ allow_network: bool = False
448
+ # Independent tool calls inside one step run concurrently, bounded by this.
449
+ tool_concurrency: int = 4
450
+ # Hard ceiling for the whole session. 0 disables the deadline.
451
+ max_duration_minutes: int = 90
452
+ sandbox: SandboxConfig = field(default_factory=SandboxConfig)
453
+ compaction: CompactionConfig = field(default_factory=CompactionConfig)
454
+ execution: ExecutionConfig = field(default_factory=ExecutionConfig)
455
+
456
+
457
+ @dataclass
458
+ class AgentSettings:
459
+ enabled: bool = True
460
+ temperature: float | None = None
461
+ # Steps reserved at the end of a turn for persisting findings and
462
+ # summarising. 0 disables the forced wrap-up.
463
+ wrap_up_at: int = 3
464
+
465
+
466
+ @dataclass
467
+ class AgentsConfig:
468
+ red: AgentSettings = field(default_factory=AgentSettings)
469
+ blue: AgentSettings = field(default_factory=AgentSettings)
470
+
471
+
472
+ @dataclass
473
+ class ReportSettings:
474
+ formats: list[str] = field(default_factory=lambda: ["markdown", "html", "json"])
475
+ output_dir: str = "reports"
476
+ include_patches: bool = True
477
+ include_evidence: bool = True
478
+
479
+
480
+ @dataclass
481
+ class WorkspaceConfig:
482
+ """The agent's own working directory: tools, notes and recon data."""
483
+
484
+ path: str = "" # empty -> <project>/splitagent-workspace
485
+ allow_install: bool = True # let the agents install tooling
486
+ allow_external_tools: bool = True # allow tools outside the workspace
487
+ max_install_seconds: int = 900
488
+ instructions: str = "" # inline operator guidance
489
+ instruction_files: list[str] = field(default_factory=list)
490
+
491
+
492
+ @dataclass
493
+ class ProjectConfig:
494
+ name: str = "splitagent"
495
+ target: TargetConfig = field(default_factory=TargetConfig)
496
+ run: RunConfig = field(default_factory=RunConfig)
497
+ auth: AuthConfig = field(default_factory=AuthConfig)
498
+ workspace: WorkspaceConfig = field(default_factory=WorkspaceConfig)
499
+ agents: AgentsConfig = field(default_factory=AgentsConfig)
500
+ report: ReportSettings = field(default_factory=ReportSettings)
501
+
502
+
503
+ # --------------------------------------------------------------------------- #
504
+ # (De)serialisation helpers
505
+ # --------------------------------------------------------------------------- #
506
+ def _dataclass_from_dict(cls: type, data: dict[str, Any]) -> Any:
507
+ if not isinstance(data, dict):
508
+ return cls()
509
+ kwargs: dict[str, Any] = {}
510
+ known = {f.name: f for f in fields(cls)}
511
+ for name, spec in known.items():
512
+ if name not in data:
513
+ continue
514
+ value = data[name]
515
+ type_name = getattr(spec.type, "__name__", str(spec.type))
516
+ if type_name == "LLMSettings":
517
+ kwargs[name] = _dataclass_from_dict(LLMSettings, value)
518
+ elif type_name == "UISettings":
519
+ kwargs[name] = _dataclass_from_dict(UISettings, value)
520
+ elif type_name == "SandboxDefaults":
521
+ kwargs[name] = _dataclass_from_dict(SandboxDefaults, value)
522
+ elif type_name == "TargetConfig":
523
+ kwargs[name] = _dataclass_from_dict(TargetConfig, value)
524
+ elif type_name == "RunConfig":
525
+ kwargs[name] = _dataclass_from_dict(RunConfig, value)
526
+ elif type_name == "AuthConfig":
527
+ kwargs[name] = _dataclass_from_dict(AuthConfig, value)
528
+ elif type_name == "WorkspaceConfig":
529
+ kwargs[name] = _dataclass_from_dict(WorkspaceConfig, value)
530
+ elif type_name == "SandboxConfig":
531
+ kwargs[name] = _dataclass_from_dict(SandboxConfig, value)
532
+ elif type_name == "CompactionConfig":
533
+ kwargs[name] = _dataclass_from_dict(CompactionConfig, value)
534
+ elif type_name == "ExecutionConfig":
535
+ kwargs[name] = _dataclass_from_dict(ExecutionConfig, value)
536
+ elif type_name == "AgentsConfig":
537
+ kwargs[name] = _dataclass_from_dict(AgentsConfig, value)
538
+ elif type_name == "AgentSettings":
539
+ kwargs[name] = _dataclass_from_dict(AgentSettings, value)
540
+ elif type_name == "ReportSettings":
541
+ kwargs[name] = _dataclass_from_dict(ReportSettings, value)
542
+ else:
543
+ kwargs[name] = value
544
+ return cls(**kwargs)
545
+
546
+
547
+ def _load_yaml(path: Path) -> dict[str, Any]:
548
+ if not path.exists():
549
+ return {}
550
+ try:
551
+ raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
552
+ except yaml.YAMLError as exc: # pragma: no cover - passthrough message
553
+ raise ConfigError(f"Invalid YAML in {path}: {exc}") from exc
554
+ if not isinstance(raw, dict):
555
+ raise ConfigError(f"Top level of {path} must be a mapping")
556
+ return raw
557
+
558
+
559
+ # --------------------------------------------------------------------------- #
560
+ # Global config
561
+ # --------------------------------------------------------------------------- #
562
+ def load_global_config(path: Path | None = None) -> GlobalConfig:
563
+ path = path or global_config_path()
564
+ data = _load_yaml(path)
565
+ cfg = _dataclass_from_dict(GlobalConfig, data)
566
+ if cfg.llm.provider in PROVIDER_PRESETS and not data.get("llm", {}).get("base_url"):
567
+ preset = PROVIDER_PRESETS[cfg.llm.provider]
568
+ cfg.llm.base_url = preset["base_url"]
569
+ cfg.llm.protocol = preset.get("protocol", "openai")
570
+
571
+ raw_providers = data.get("providers")
572
+ cfg.providers = {}
573
+ if isinstance(raw_providers, dict):
574
+ for provider_id, payload in raw_providers.items():
575
+ if not isinstance(payload, dict):
576
+ continue
577
+ provider = _dataclass_from_dict(ProviderConfig, payload)
578
+ provider.id = provider.id or str(provider_id)
579
+ cfg.providers[provider.id] = provider
580
+
581
+ # Migrate a legacy single-key config into the providers map.
582
+ if not cfg.providers and cfg.llm.api_key:
583
+ preset = PROVIDER_PRESETS.get(cfg.llm.provider, {})
584
+ cfg.providers[cfg.llm.provider] = ProviderConfig(
585
+ id=cfg.llm.provider,
586
+ base_url=cfg.llm.base_url,
587
+ protocol=cfg.llm.protocol or preset.get("protocol", "openai"),
588
+ api_key=cfg.llm.api_key,
589
+ enabled=True,
590
+ )
591
+ return cfg
592
+
593
+
594
+ def ensure_provider(config: GlobalConfig, provider_id: str) -> ProviderConfig:
595
+ """Return the provider config, creating a preset-based one if missing."""
596
+ if provider_id not in config.providers:
597
+ preset = PROVIDER_PRESETS.get(provider_id, {})
598
+ config.providers[provider_id] = ProviderConfig(
599
+ id=provider_id,
600
+ base_url=preset.get("base_url", config.llm.base_url),
601
+ protocol=preset.get("protocol", "openai"),
602
+ )
603
+ return config.providers[provider_id]
604
+
605
+
606
+ def provider_visible(config: GlobalConfig, provider_id: str, model_id: str) -> bool:
607
+ provider = config.providers.get(provider_id)
608
+ if provider is None:
609
+ return False
610
+ return provider.visible(model_id)
611
+
612
+
613
+ def settings_for_provider(config: GlobalConfig, provider_id: str, model: str) -> LLMSettings:
614
+ """Build LLM settings for a specific provider/model selection."""
615
+ settings = LLMSettings(**vars(config.llm))
616
+ provider = config.providers.get(provider_id)
617
+ preset = PROVIDER_PRESETS.get(provider_id, {})
618
+ settings.provider = provider_id
619
+ settings.model = model or settings.model
620
+ if provider is not None:
621
+ settings.base_url = provider.base_url or settings.base_url
622
+ settings.protocol = provider.protocol or preset.get("protocol", "openai")
623
+ settings.api_key = provider.api_key
624
+ else:
625
+ settings.base_url = preset.get("base_url", settings.base_url)
626
+ settings.protocol = preset.get("protocol", "openai")
627
+ return settings
628
+
629
+
630
+ def save_global_config(cfg: GlobalConfig, path: Path | None = None) -> Path:
631
+ path = path or global_config_path()
632
+ path.parent.mkdir(parents=True, exist_ok=True)
633
+ data = asdict(cfg)
634
+ path.write_text(yaml.safe_dump(data, sort_keys=False, allow_unicode=True), encoding="utf-8")
635
+ try:
636
+ os.chmod(path, 0o600)
637
+ except OSError: # pragma: no cover - best effort on Windows
638
+ pass
639
+ return path
640
+
641
+
642
+ # --------------------------------------------------------------------------- #
643
+ # Project config
644
+ # --------------------------------------------------------------------------- #
645
+ def load_project_config(path: Path | None = None) -> ProjectConfig:
646
+ path = path or project_config_path()
647
+ data = _load_yaml(path)
648
+ raw_project = data.get("project")
649
+ project_block: dict[str, Any] = raw_project if isinstance(raw_project, dict) else {}
650
+ kwargs: dict[str, Any] = {}
651
+ if project_block.get("name"):
652
+ kwargs["name"] = project_block["name"]
653
+ if "target" in data:
654
+ kwargs["target"] = _dataclass_from_dict(TargetConfig, data["target"])
655
+ if "run" in data:
656
+ kwargs["run"] = _dataclass_from_dict(RunConfig, data["run"])
657
+ if "auth" in data:
658
+ kwargs["auth"] = _dataclass_from_dict(AuthConfig, data["auth"])
659
+ if "workspace" in data:
660
+ kwargs["workspace"] = _dataclass_from_dict(WorkspaceConfig, data["workspace"])
661
+ if "agents" in data:
662
+ kwargs["agents"] = _dataclass_from_dict(AgentsConfig, data["agents"])
663
+ if "report" in data:
664
+ kwargs["report"] = _dataclass_from_dict(ReportSettings, data["report"])
665
+ return ProjectConfig(**kwargs)
666
+
667
+
668
+ def save_project_config(cfg: ProjectConfig, path: Path | None = None) -> Path:
669
+ path = path or Path.cwd() / "splitagent.yaml"
670
+ data = {
671
+ "project": {"name": cfg.name},
672
+ "target": asdict(cfg.target),
673
+ "run": asdict(cfg.run),
674
+ "auth": asdict(cfg.auth),
675
+ "workspace": asdict(cfg.workspace),
676
+ "agents": asdict(cfg.agents),
677
+ "report": asdict(cfg.report),
678
+ }
679
+ path.write_text(yaml.safe_dump(data, sort_keys=False, allow_unicode=True), encoding="utf-8")
680
+ return path
681
+
682
+
683
+ def default_project_config() -> ProjectConfig:
684
+ return ProjectConfig()
685
+
686
+
687
+ def apply_provider_preset(llm: LLMSettings, provider: str) -> LLMSettings:
688
+ """Update the LLM settings in place from a provider preset."""
689
+ preset = PROVIDER_PRESETS.get(provider)
690
+ if not preset:
691
+ raise ConfigError(f"Unknown provider '{provider}'. Known: {', '.join(PROVIDER_PRESETS)}")
692
+ llm.provider = provider
693
+ llm.protocol = preset.get("protocol", "openai")
694
+ llm.base_url = preset["base_url"]
695
+ if not llm.model or llm.model == LLMSettings().model:
696
+ llm.model = preset["model"]
697
+ return llm