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/errors.py ADDED
@@ -0,0 +1,27 @@
1
+ """Custom exception hierarchy for SplitAgent."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class SplitAgentError(Exception):
7
+ """Base error for every SplitAgent failure."""
8
+
9
+
10
+ class ConfigError(SplitAgentError):
11
+ """Raised when configuration is missing or invalid."""
12
+
13
+
14
+ class LLMError(SplitAgentError):
15
+ """Raised when the LLM provider call fails."""
16
+
17
+
18
+ class ToolError(SplitAgentError):
19
+ """Raised when a tool cannot complete its operation."""
20
+
21
+
22
+ class SandboxError(SplitAgentError):
23
+ """Raised when the Docker sandbox cannot be managed."""
24
+
25
+
26
+ class ScopeError(SplitAgentError):
27
+ """Raised when an action would fall outside the authorised target scope."""
@@ -0,0 +1,8 @@
1
+ """LLM provider abstraction."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from splitagent.llm.client import LLMClient
6
+ from splitagent.llm.types import ChatMessage, LLMEvent, ToolCall, ToolSpec
7
+
8
+ __all__ = ["ChatMessage", "LLMClient", "LLMEvent", "ToolCall", "ToolSpec"]
@@ -0,0 +1,488 @@
1
+ """Async LLM client supporting OpenAI-compatible and Anthropic APIs.
2
+
3
+ The framework never assumes a local model: every provider is reached over
4
+ HTTP using credentials stored in the global configuration. A single client
5
+ therefore works with OpenAI, OpenRouter, Groq, DeepSeek, Together, Mistral,
6
+ xAI, vLLM, LM Studio, Ollama (OpenAI compatibility mode), Anthropic and any
7
+ other OpenAI-compatible endpoint.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import json
14
+ import re
15
+ from collections.abc import AsyncIterator
16
+ from typing import Any
17
+
18
+ import httpx
19
+
20
+ from splitagent.config import LLMSettings
21
+ from splitagent.errors import LLMError
22
+ from splitagent.llm.types import ChatMessage, LLMEvent, ToolCall, ToolSpec
23
+
24
+ # Status codes and message shapes worth a second attempt. Everything else
25
+ # (auth, bad request, model not found) fails fast so the operator sees the
26
+ # real cause instead of a slow retry loop.
27
+ _RETRYABLE_STATUS = (408, 409, 425, 429, 500, 502, 503, 504, 522, 524)
28
+ _RETRYABLE_HINTS = (
29
+ "rate limit",
30
+ "rate_limit",
31
+ "too many requests",
32
+ "overloaded",
33
+ "temporarily",
34
+ "timeout",
35
+ "timed out",
36
+ "connection reset",
37
+ "connection aborted",
38
+ "connection error",
39
+ "server error",
40
+ "bad gateway",
41
+ "service unavailable",
42
+ "gateway timeout",
43
+ "internal error",
44
+ "try again",
45
+ )
46
+
47
+
48
+ def _is_retryable(message: str) -> bool:
49
+ """Decide whether an error is transient."""
50
+ if not message:
51
+ return False
52
+ lowered = message.lower()
53
+ status = re.search(r"\bhttp\s+(\d{3})", lowered)
54
+ if status:
55
+ return int(status.group(1)) in _RETRYABLE_STATUS
56
+ return any(hint in lowered for hint in _RETRYABLE_HINTS)
57
+
58
+
59
+ class LLMClient:
60
+ """Thin async wrapper around the two supported wire protocols."""
61
+
62
+ def __init__(self, settings: LLMSettings):
63
+ self.settings = settings
64
+ self._client: httpx.AsyncClient | None = None
65
+ # Populated when a retry happens, so the UI can explain a pause.
66
+ self.last_retry: dict[str, Any] | None = None
67
+
68
+ # -- lifecycle -------------------------------------------------------- #
69
+ async def __aenter__(self) -> LLMClient:
70
+ self._client = httpx.AsyncClient(timeout=self.settings.timeout)
71
+ return self
72
+
73
+ async def __aexit__(self, *exc: object) -> None:
74
+ if self._client is not None:
75
+ await self._client.aclose()
76
+ self._client = None
77
+
78
+ def _http(self) -> httpx.AsyncClient:
79
+ if self._client is None:
80
+ self._client = httpx.AsyncClient(timeout=self.settings.timeout)
81
+ return self._client
82
+
83
+ # -- headers ---------------------------------------------------------- #
84
+ def _headers(self) -> dict[str, str]:
85
+ from splitagent import __version__
86
+
87
+ headers = {
88
+ "Content-Type": "application/json",
89
+ # Identify the client, as requested by gateway providers (e.g. OpenCode Go).
90
+ "User-Agent": self.settings.user_agent or f"SplitAgent/{__version__}",
91
+ }
92
+ key = self.settings.resolved_api_key()
93
+ if self.settings.protocol == "anthropic":
94
+ if key:
95
+ headers["x-api-key"] = key
96
+ headers["anthropic-version"] = "2023-06-01"
97
+ else:
98
+ if key:
99
+ headers["Authorization"] = f"Bearer {key}"
100
+ # Stable per-conversation id for routing and prompt caching.
101
+ if self.settings.session_id:
102
+ headers["x-opencode-session"] = self.settings.session_id
103
+ headers.update(self.settings.extra_headers or {})
104
+ return headers
105
+
106
+ def _endpoint(self) -> str:
107
+ base = (self.settings.base_url or "").rstrip("/")
108
+ if self.settings.protocol == "anthropic":
109
+ if base.endswith("/messages"):
110
+ return base
111
+ return f"{base}/messages"
112
+ if base.endswith("/chat/completions"):
113
+ return base
114
+ return f"{base}/chat/completions"
115
+
116
+ # -- public API ------------------------------------------------------- #
117
+ def _with_cache_points(self, messages: list[ChatMessage]) -> list[ChatMessage]:
118
+ """Mark cache breakpoints the way OpenCode does.
119
+
120
+ The system prompt plus the last exchanged messages are the stable
121
+ prefix a provider can reuse. This is a no-op when ``prompt_cache`` is
122
+ disabled or the provider has no explicit marker.
123
+ """
124
+ if not self.settings.prompt_cache:
125
+ return messages
126
+ system = [m for m in messages if m.role == "system"][: self.settings.cache_system_messages]
127
+ tail = [m for m in messages if m.role != "system"][-self.settings.cache_tail_messages :]
128
+ for message in {id(m): m for m in [*system, *tail]}.values():
129
+ message.cache = True
130
+ return messages
131
+
132
+ async def stream(
133
+ self, messages: list[ChatMessage], tools: list[ToolSpec] | None = None
134
+ ) -> AsyncIterator[LLMEvent]:
135
+ """Yield incremental events for a completion request.
136
+
137
+ Transient provider failures are retried with exponential backoff. A
138
+ streaming response that fails *after* emitting content is not retried
139
+ blindly - only failures seen before any text arrives are, so a long
140
+ answer is never duplicated.
141
+ """
142
+ messages = self._with_cache_points(messages)
143
+ attempts = max(1, self.settings.max_retries + 1)
144
+ delay = self.settings.retry_initial_delay
145
+
146
+ for attempt in range(1, attempts + 1):
147
+ emitted = False
148
+ retryable = ""
149
+ try:
150
+ stream = (
151
+ self._stream_anthropic(messages, tools or [])
152
+ if self.settings.protocol == "anthropic"
153
+ else self._stream_openai(messages, tools or [])
154
+ )
155
+ async for event in stream:
156
+ if event.type in ("text", "tool_call", "reasoning"):
157
+ emitted = True
158
+ if event.type == "error":
159
+ if not emitted and _is_retryable(event.error or ""):
160
+ retryable = event.error or ""
161
+ break
162
+ yield event
163
+ return
164
+ yield event
165
+ if not retryable:
166
+ return
167
+ except httpx.HTTPError as exc:
168
+ if emitted or not _is_retryable(str(exc)):
169
+ yield LLMEvent(type="error", error=f"Connection error: {exc}")
170
+ return
171
+ retryable = str(exc)
172
+
173
+ if not retryable or attempt == attempts:
174
+ yield LLMEvent(
175
+ type="error",
176
+ error=(
177
+ f"{retryable} (gave up after {attempts} attempts)"
178
+ if retryable
179
+ else "Unknown LLM error"
180
+ ),
181
+ )
182
+ return
183
+
184
+ yield self._emit_retry(attempt, attempts, delay, retryable)
185
+ await asyncio.sleep(delay)
186
+ delay = min(delay * 2, self.settings.retry_max_delay)
187
+
188
+ def _emit_retry(self, attempt: int, attempts: int, delay: float, reason: str) -> LLMEvent:
189
+ """Surface the retry so the UI can show it instead of seeming stuck."""
190
+ self.last_retry = {
191
+ "attempt": attempt,
192
+ "of": attempts,
193
+ "delay": delay,
194
+ "reason": reason[:300],
195
+ }
196
+ return LLMEvent(type="retry", data=dict(self.last_retry))
197
+
198
+ async def complete(
199
+ self, messages: list[ChatMessage], tools: list[ToolSpec] | None = None
200
+ ) -> tuple[ChatMessage, dict[str, Any]]:
201
+ """Run a full completion, gathering the streamed events."""
202
+ content_parts: list[str] = []
203
+ reasoning_parts: list[str] = []
204
+ calls: dict[str, ToolCall] = {}
205
+ usage: dict[str, Any] = {}
206
+
207
+ async for event in self.stream(messages, tools):
208
+ if event.type == "text":
209
+ content_parts.append(event.text)
210
+ elif event.type == "reasoning":
211
+ reasoning_parts.append(event.text)
212
+ elif event.type == "tool_call" and event.tool_call is not None:
213
+ calls[event.tool_call.id] = event.tool_call
214
+ elif event.type == "usage" and event.usage:
215
+ usage.update(event.usage)
216
+ elif event.type == "error":
217
+ raise LLMError(event.error or "Unknown LLM error")
218
+
219
+ message = ChatMessage(
220
+ role="assistant",
221
+ content="".join(content_parts),
222
+ reasoning="".join(reasoning_parts),
223
+ tool_calls=list(calls.values()),
224
+ )
225
+ return message, usage
226
+
227
+ # -- OpenAI-compatible ------------------------------------------------- #
228
+ async def _stream_openai(
229
+ self, messages: list[ChatMessage], tools: list[ToolSpec]
230
+ ) -> AsyncIterator[LLMEvent]:
231
+ body: dict[str, Any] = {
232
+ "model": self.settings.model,
233
+ "messages": [m.to_openai(cache=m.cache) for m in messages],
234
+ "temperature": self.settings.temperature,
235
+ "max_tokens": self.settings.max_tokens,
236
+ "stream": bool(self.settings.stream),
237
+ }
238
+ if tools:
239
+ body["tools"] = [t.to_openai() for t in tools]
240
+ body["tool_choice"] = "auto"
241
+ if self.settings.stream:
242
+ body["stream_options"] = {"include_usage": True}
243
+
244
+ if not self.settings.stream:
245
+ message, nonstream_usage = await self._openai_nonstream(body)
246
+ if message.content:
247
+ yield LLMEvent(type="text", text=message.content)
248
+ for call in message.tool_calls:
249
+ yield LLMEvent(type="tool_call", tool_call=call)
250
+ yield LLMEvent(type="usage", usage=nonstream_usage)
251
+ yield LLMEvent(type="done")
252
+ return
253
+
254
+ partial_calls: dict[int, dict[str, str]] = {}
255
+ usage: dict[str, Any] = {}
256
+ try:
257
+ async with self._http().stream(
258
+ "POST", self._endpoint(), json=body, headers=self._headers()
259
+ ) as response:
260
+ if response.status_code >= 400:
261
+ detail = (await response.aread()).decode("utf-8", "replace")
262
+ yield LLMEvent(
263
+ type="error",
264
+ error=f"HTTP {response.status_code}: {detail[:800]}",
265
+ )
266
+ return
267
+ async for line in response.aiter_lines():
268
+ if not line or not line.startswith("data:"):
269
+ continue
270
+ payload = line[5:].strip()
271
+ if payload == "[DONE]":
272
+ break
273
+ try:
274
+ chunk = json.loads(payload)
275
+ except json.JSONDecodeError:
276
+ continue
277
+ if chunk.get("usage"):
278
+ usage.update(chunk["usage"])
279
+ choices = chunk.get("choices") or []
280
+ if not choices:
281
+ continue
282
+ delta = choices[0].get("delta") or {}
283
+ text = delta.get("content")
284
+ if text:
285
+ yield LLMEvent(type="text", text=text)
286
+ reasoning = delta.get("reasoning_content") or delta.get("reasoning")
287
+ if isinstance(reasoning, str) and reasoning:
288
+ yield LLMEvent(type="reasoning", text=reasoning)
289
+ for tc in delta.get("tool_calls") or []:
290
+ index = tc.get("index", 0)
291
+ slot = partial_calls.setdefault(
292
+ index, {"id": "", "name": "", "arguments": ""}
293
+ )
294
+ if tc.get("id"):
295
+ slot["id"] = tc["id"]
296
+ fn = tc.get("function") or {}
297
+ if fn.get("name"):
298
+ slot["name"] = fn["name"]
299
+ if fn.get("arguments"):
300
+ slot["arguments"] += fn["arguments"]
301
+ except httpx.HTTPError as exc:
302
+ yield LLMEvent(type="error", error=f"Connection error: {exc}")
303
+ return
304
+
305
+ for index in sorted(partial_calls):
306
+ slot = partial_calls[index]
307
+ call = ToolCall(
308
+ id=slot["id"] or f"call_{index}",
309
+ name=slot["name"],
310
+ arguments=slot["arguments"] or "{}",
311
+ )
312
+ if call.name:
313
+ yield LLMEvent(type="tool_call", tool_call=call)
314
+ if usage:
315
+ yield LLMEvent(type="usage", usage=usage)
316
+ yield LLMEvent(type="done")
317
+
318
+ async def _openai_nonstream(self, body: dict[str, Any]) -> tuple[ChatMessage, dict[str, Any]]:
319
+ body = {**body, "stream": False}
320
+ try:
321
+ response = await self._http().post(self._endpoint(), json=body, headers=self._headers())
322
+ except httpx.HTTPError as exc:
323
+ raise LLMError(f"Connection error: {exc}") from exc
324
+ if response.status_code >= 400:
325
+ raise LLMError(f"HTTP {response.status_code}: {response.text[:800]}")
326
+ data = response.json()
327
+ usage = data.get("usage") or {}
328
+ choice = (data.get("choices") or [{}])[0]
329
+ message = choice.get("message") or {}
330
+ calls = [
331
+ ToolCall(
332
+ id=call.get("id", f"call_{i}"),
333
+ name=(call.get("function") or {}).get("name", ""),
334
+ arguments=(call.get("function") or {}).get("arguments", "{}"),
335
+ )
336
+ for i, call in enumerate(message.get("tool_calls") or [])
337
+ ]
338
+ return (
339
+ ChatMessage(
340
+ role="assistant",
341
+ content=message.get("content") or "",
342
+ reasoning=message.get("reasoning_content") or message.get("reasoning") or "",
343
+ tool_calls=calls,
344
+ ),
345
+ usage,
346
+ )
347
+
348
+ # -- Anthropic --------------------------------------------------------- #
349
+ async def _stream_anthropic(
350
+ self, messages: list[ChatMessage], tools: list[ToolSpec]
351
+ ) -> AsyncIterator[LLMEvent]:
352
+ system_parts = [m.content for m in messages if m.role == "system"]
353
+ conversation = [m for m in messages if m.role != "system"]
354
+ body: dict[str, Any] = {
355
+ "model": self.settings.model,
356
+ "messages": [m.to_anthropic(cache=m.cache) for m in conversation],
357
+ "max_tokens": self.settings.max_tokens,
358
+ "temperature": self.settings.temperature,
359
+ "stream": True,
360
+ }
361
+ if system_parts:
362
+ # Anthropic caches the system block explicitly; this is the largest
363
+ # and most stable part of the prefix.
364
+ block: dict[str, Any] = {"type": "text", "text": "\n\n".join(system_parts)}
365
+ if self.settings.prompt_cache:
366
+ block["cache_control"] = {"type": "ephemeral"}
367
+ body["system"] = [block]
368
+ if tools:
369
+ body["tools"] = [t.to_anthropic() for t in tools]
370
+
371
+ current_tool: dict[str, Any] | None = None
372
+ usage: dict[str, Any] = {}
373
+ try:
374
+ async with self._http().stream(
375
+ "POST", self._endpoint(), json=body, headers=self._headers()
376
+ ) as response:
377
+ if response.status_code >= 400:
378
+ detail = (await response.aread()).decode("utf-8", "replace")
379
+ yield LLMEvent(
380
+ type="error",
381
+ error=f"HTTP {response.status_code}: {detail[:800]}",
382
+ )
383
+ return
384
+ async for line in response.aiter_lines():
385
+ if not line or not line.startswith("data:"):
386
+ continue
387
+ payload = line[5:].strip()
388
+ if not payload:
389
+ continue
390
+ try:
391
+ event = json.loads(payload)
392
+ except json.JSONDecodeError:
393
+ continue
394
+ etype = event.get("type")
395
+ if etype == "message_start":
396
+ # Anthropic reports the input token count here, not in
397
+ # message_delta; without this the prompt side of the
398
+ # usage was always missing.
399
+ start_usage = (event.get("message") or {}).get("usage") or {}
400
+ usage.update(start_usage)
401
+ elif etype == "content_block_start":
402
+ block = event.get("content_block") or {}
403
+ if block.get("type") == "tool_use":
404
+ current_tool = {
405
+ "id": block.get("id", ""),
406
+ "name": block.get("name", ""),
407
+ "arguments": "",
408
+ }
409
+ elif etype == "content_block_delta":
410
+ delta = event.get("delta") or {}
411
+ if delta.get("type") == "text_delta":
412
+ yield LLMEvent(type="text", text=delta.get("text", ""))
413
+ elif delta.get("type") == "input_json_delta" and current_tool:
414
+ current_tool["arguments"] += delta.get("partial_json", "")
415
+ elif etype == "content_block_stop":
416
+ if current_tool:
417
+ call = ToolCall(
418
+ id=current_tool["id"] or "call_0",
419
+ name=current_tool["name"],
420
+ arguments=current_tool["arguments"] or "{}",
421
+ )
422
+ if call.name:
423
+ yield LLMEvent(type="tool_call", tool_call=call)
424
+ current_tool = None
425
+ elif etype == "message_delta":
426
+ if event.get("usage"):
427
+ usage.update(event["usage"])
428
+ elif etype == "error":
429
+ error = event.get("error") or {}
430
+ yield LLMEvent(
431
+ type="error",
432
+ error=str(error.get("message") or error),
433
+ )
434
+ return
435
+ except httpx.HTTPError as exc:
436
+ yield LLMEvent(type="error", error=f"Connection error: {exc}")
437
+ return
438
+
439
+ if usage:
440
+ yield LLMEvent(type="usage", usage=usage)
441
+ yield LLMEvent(type="done")
442
+
443
+ # -- utility ----------------------------------------------------------- #
444
+ async def list_models(self) -> list[dict[str, str]]:
445
+ """Fetch the model catalogue from an OpenAI-compatible ``/models``."""
446
+ base = (self.settings.base_url or "").rstrip("/")
447
+ url = f"{base}/models"
448
+ headers = self._headers()
449
+ headers.pop("Content-Type", None)
450
+ try:
451
+ async with httpx.AsyncClient(timeout=25.0) as client:
452
+ response = await client.get(url, headers=headers)
453
+ except httpx.HTTPError as exc:
454
+ raise LLMError(f"Connection error: {exc}") from exc
455
+ if response.status_code >= 400:
456
+ raise LLMError(f"HTTP {response.status_code}: {response.text[:300]}")
457
+ data = response.json()
458
+ items = data.get("data") if isinstance(data, dict) else None
459
+ if items is None and isinstance(data, dict):
460
+ items = data.get("models")
461
+ if not isinstance(items, list):
462
+ return []
463
+ models: list[dict[str, str]] = []
464
+ for item in items:
465
+ if isinstance(item, str):
466
+ models.append({"id": item, "name": item})
467
+ continue
468
+ if not isinstance(item, dict):
469
+ continue
470
+ model_id = item.get("id") or item.get("name") or ""
471
+ if not model_id:
472
+ continue
473
+ models.append({"id": str(model_id), "name": str(item.get("name") or model_id)})
474
+ return models
475
+
476
+ async def test_connection(self) -> tuple[bool, str]:
477
+ """Cheap round-trip used by the setup wizard to validate credentials."""
478
+ if not self.settings.session_id:
479
+ self.settings.session_id = "splitagent-connectivity"
480
+ try:
481
+ message, _ = await self.complete(
482
+ [ChatMessage(role="user", content="Reply with the single word: ok")]
483
+ )
484
+ except LLMError as exc:
485
+ return False, str(exc)
486
+ except Exception as exc: # pragma: no cover - defensive
487
+ return False, f"{type(exc).__name__}: {exc}"
488
+ return True, (message.content or "").strip()[:200]