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.
- splitagent/__init__.py +8 -0
- splitagent/__main__.py +6 -0
- splitagent/agents/__init__.py +10 -0
- splitagent/agents/base.py +477 -0
- splitagent/agents/blue.py +57 -0
- splitagent/agents/chat.py +60 -0
- splitagent/agents/prompts.py +462 -0
- splitagent/agents/red.py +75 -0
- splitagent/cli.py +701 -0
- splitagent/config.py +697 -0
- splitagent/core/__init__.py +19 -0
- splitagent/core/bus.py +62 -0
- splitagent/core/context.py +587 -0
- splitagent/core/context_manager.py +381 -0
- splitagent/core/engine.py +424 -0
- splitagent/core/models.py +310 -0
- splitagent/core/proc.py +73 -0
- splitagent/core/sandbox.py +184 -0
- splitagent/core/toolbox.py +520 -0
- splitagent/core/workspace.py +420 -0
- splitagent/desktop/__init__.py +7 -0
- splitagent/desktop/api.py +525 -0
- splitagent/desktop/app.py +1131 -0
- splitagent/desktop/web/app.js +3067 -0
- splitagent/desktop/web/assets/Inter.ttf +0 -0
- splitagent/desktop/web/assets/JetBrainsMonoNerdFontMono-Regular.woff2 +0 -0
- splitagent/desktop/web/index.html +760 -0
- splitagent/desktop/web/styles.css +1612 -0
- splitagent/errors.py +27 -0
- splitagent/llm/__init__.py +8 -0
- splitagent/llm/client.py +488 -0
- splitagent/llm/types.py +172 -0
- splitagent/report/__init__.py +9 -0
- splitagent/report/cvss.py +93 -0
- splitagent/report/generator.py +733 -0
- splitagent/tools/__init__.py +8 -0
- splitagent/tools/base.py +135 -0
- splitagent/tools/defense.py +475 -0
- splitagent/tools/exploit.py +318 -0
- splitagent/tools/http_pool.py +109 -0
- splitagent/tools/knowledge.py +376 -0
- splitagent/tools/recon.py +182 -0
- splitagent/tools/registry.py +62 -0
- splitagent/tools/validate.py +908 -0
- splitagent/tools/web.py +386 -0
- splitagent/tools/workspace_tools.py +411 -0
- splitagent/ui/__init__.py +5 -0
- splitagent/ui/app.py +389 -0
- splitagent/ui/stream.py +234 -0
- splitagent/ui/theme.py +72 -0
- splitagent-0.0.3.dist-info/METADATA +987 -0
- splitagent-0.0.3.dist-info/RECORD +56 -0
- splitagent-0.0.3.dist-info/WHEEL +5 -0
- splitagent-0.0.3.dist-info/entry_points.txt +2 -0
- splitagent-0.0.3.dist-info/licenses/LICENSE +21 -0
- splitagent-0.0.3.dist-info/top_level.txt +1 -0
splitagent/__init__.py
ADDED
splitagent/__main__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Red and Blue agents."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from splitagent.agents.base import AgentResult, BaseAgent
|
|
6
|
+
from splitagent.agents.blue import BlueAgent
|
|
7
|
+
from splitagent.agents.chat import ChatAgent
|
|
8
|
+
from splitagent.agents.red import RedAgent
|
|
9
|
+
|
|
10
|
+
__all__ = ["AgentResult", "BaseAgent", "BlueAgent", "ChatAgent", "RedAgent"]
|
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
"""Shared agent runtime: a streaming tool-calling loop."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from splitagent.core.bus import Event, EventBus
|
|
10
|
+
from splitagent.core.context import SharedContext
|
|
11
|
+
from splitagent.core.context_manager import (
|
|
12
|
+
ContextPolicy,
|
|
13
|
+
compact,
|
|
14
|
+
context_report,
|
|
15
|
+
is_overflow,
|
|
16
|
+
optimize,
|
|
17
|
+
prune_tool_output,
|
|
18
|
+
)
|
|
19
|
+
from splitagent.errors import LLMError
|
|
20
|
+
from splitagent.llm.client import LLMClient
|
|
21
|
+
from splitagent.llm.types import ChatMessage
|
|
22
|
+
from splitagent.tools.base import ToolContext
|
|
23
|
+
from splitagent.tools.registry import ToolRegistry
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class AgentResult:
|
|
28
|
+
text: str = ""
|
|
29
|
+
steps: int = 0
|
|
30
|
+
usage: dict[str, Any] = field(default_factory=dict)
|
|
31
|
+
tool_calls: int = 0
|
|
32
|
+
transcript: list[ChatMessage] = field(default_factory=list)
|
|
33
|
+
trace: list[dict[str, Any]] = field(default_factory=list)
|
|
34
|
+
pruned: int = 0
|
|
35
|
+
saved: int = 0
|
|
36
|
+
compactions: int = 0
|
|
37
|
+
wrapped_up: bool = False
|
|
38
|
+
hit_step_limit: bool = False
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# Injected when the agent is about to run out of steps. The goal is to make it
|
|
42
|
+
# persist what it already knows and write a usable summary instead of silently
|
|
43
|
+
# dying with no output - which is exactly what happened in production runs.
|
|
44
|
+
WRAP_UP_PROMPT = """You are almost out of steps ({remaining} left of {total}).
|
|
45
|
+
|
|
46
|
+
STOP exploring. Do this now, in this order:
|
|
47
|
+
1. Persist every weakness you have already confirmed with `record_finding`, \
|
|
48
|
+
including the evidence you gathered and a CVSS v3.1 vector. If you have no \
|
|
49
|
+
evidence for something, do not record it.
|
|
50
|
+
2. Save your working state with `workspace_write` to `notes/<scope>-state.md` \
|
|
51
|
+
so the next round can continue: what you tested, what you found, what is left.
|
|
52
|
+
3. Reply with a concise markdown summary of what you tested, what you found, \
|
|
53
|
+
and what remains untested.
|
|
54
|
+
|
|
55
|
+
Do not start new scans. Answer in plain text with no further tool calls after \
|
|
56
|
+
the writes."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class BaseAgent:
|
|
60
|
+
"""A model-driven agent that can call tools until it produces an answer."""
|
|
61
|
+
|
|
62
|
+
name = "agent"
|
|
63
|
+
role = "assistant"
|
|
64
|
+
color = "white"
|
|
65
|
+
|
|
66
|
+
def __init__(
|
|
67
|
+
self,
|
|
68
|
+
client: LLMClient,
|
|
69
|
+
context: SharedContext,
|
|
70
|
+
tool_context: ToolContext,
|
|
71
|
+
registry: ToolRegistry,
|
|
72
|
+
bus: EventBus,
|
|
73
|
+
system_prompt: str,
|
|
74
|
+
max_steps: int = 12,
|
|
75
|
+
temperature: float | None = None,
|
|
76
|
+
history_limit: int = 16,
|
|
77
|
+
policy: ContextPolicy | None = None,
|
|
78
|
+
wrap_up_at: int = 3,
|
|
79
|
+
volatile_builder: Any = None,
|
|
80
|
+
tool_concurrency: int = 4,
|
|
81
|
+
) -> None:
|
|
82
|
+
self.client = client
|
|
83
|
+
self.context = context
|
|
84
|
+
self.tool_context = tool_context
|
|
85
|
+
self.registry = registry
|
|
86
|
+
self.bus = bus
|
|
87
|
+
self.system_prompt = system_prompt
|
|
88
|
+
self.max_steps = max_steps
|
|
89
|
+
self.temperature = temperature
|
|
90
|
+
self.history_limit = history_limit
|
|
91
|
+
self.history: list[ChatMessage] = []
|
|
92
|
+
self.usage_total: dict[str, int] = {}
|
|
93
|
+
self.policy = policy or context.policy
|
|
94
|
+
# Steps reserved for persisting findings and summarising. 0 disables.
|
|
95
|
+
self.wrap_up_at = max(0, min(wrap_up_at, max_steps - 1))
|
|
96
|
+
# Produces the per-turn context that must stay out of the cached
|
|
97
|
+
# system prefix (findings, task list, round counter, notes).
|
|
98
|
+
self.volatile_builder = volatile_builder
|
|
99
|
+
# Bounded parallelism for independent tools in the same step.
|
|
100
|
+
self.tool_concurrency = max(1, tool_concurrency)
|
|
101
|
+
self.trace: list[dict[str, Any]] = []
|
|
102
|
+
self.compactions = 0
|
|
103
|
+
self.pruned_tokens = 0
|
|
104
|
+
self.saved_tokens = 0
|
|
105
|
+
self.wrapped_up = False
|
|
106
|
+
self.last_usage: dict[str, Any] = {}
|
|
107
|
+
|
|
108
|
+
# -- trace ------------------------------------------------------------- #
|
|
109
|
+
def _trace(self, kind: str, **data: Any) -> None:
|
|
110
|
+
import time
|
|
111
|
+
|
|
112
|
+
entry = {"t": round(time.time(), 3), "kind": kind, **data}
|
|
113
|
+
self.trace.append(entry)
|
|
114
|
+
|
|
115
|
+
async def _maintain_context(self, messages: list[ChatMessage]) -> list[ChatMessage]:
|
|
116
|
+
"""Optimise, then compact, before sending the next request."""
|
|
117
|
+
# Cheap, per-request projection first: reasoning of settled steps,
|
|
118
|
+
# superseded snapshots and old tool output. This is what keeps the
|
|
119
|
+
# billed prompt small even on models with a huge window.
|
|
120
|
+
_, stats = optimize(messages, self.policy)
|
|
121
|
+
saved = stats["reasoning"] + stats["stateful"] + stats["pruned"]
|
|
122
|
+
if saved:
|
|
123
|
+
self.saved_tokens += saved
|
|
124
|
+
self._trace("optimize", **stats)
|
|
125
|
+
await self._emit("context.optimize", **stats, saved=saved)
|
|
126
|
+
|
|
127
|
+
before = context_report(messages, self.policy, self.last_usage)
|
|
128
|
+
overflow = is_overflow(self.policy, self.last_usage)
|
|
129
|
+
if overflow or before["percent"] >= 100:
|
|
130
|
+
messages, result = compact(messages, self.policy)
|
|
131
|
+
if result.compacted:
|
|
132
|
+
self.compactions += 1
|
|
133
|
+
self._trace(
|
|
134
|
+
"compaction",
|
|
135
|
+
removed=result.removed,
|
|
136
|
+
preserved=result.preserved,
|
|
137
|
+
)
|
|
138
|
+
await self._emit(
|
|
139
|
+
"context.compaction",
|
|
140
|
+
removed=result.removed,
|
|
141
|
+
preserved=result.preserved,
|
|
142
|
+
summary=result.summary[:4000],
|
|
143
|
+
overflow=overflow,
|
|
144
|
+
)
|
|
145
|
+
await self.context.add_checkpoint(
|
|
146
|
+
self.name, result.summary, result.removed, result.preserved
|
|
147
|
+
)
|
|
148
|
+
# Rewrite history so future turns use the compacted view. The
|
|
149
|
+
# task-injected "current state" blocks are scaffolding, not part
|
|
150
|
+
# of the conversation, so they are dropped here too.
|
|
151
|
+
self.history = [
|
|
152
|
+
m for m in messages if m.role != "system" and not getattr(m, "_injected", False)
|
|
153
|
+
]
|
|
154
|
+
|
|
155
|
+
report = context_report(messages, self.policy, self.last_usage)
|
|
156
|
+
self._trace("context", **report)
|
|
157
|
+
await self._emit("context.usage", **report)
|
|
158
|
+
return messages
|
|
159
|
+
|
|
160
|
+
# -- helpers ----------------------------------------------------------- #
|
|
161
|
+
def _build_messages(self, task: str) -> list[ChatMessage]:
|
|
162
|
+
"""System prompt (stable, cacheable) + history + current task.
|
|
163
|
+
|
|
164
|
+
The volatile context is regenerated every turn and merged into the
|
|
165
|
+
task message, so the system prefix stays byte-identical and the
|
|
166
|
+
provider's prompt cache keeps hitting.
|
|
167
|
+
"""
|
|
168
|
+
messages = [ChatMessage(role="system", content=self.system_prompt)]
|
|
169
|
+
messages.extend(self.history[-self.history_limit :])
|
|
170
|
+
message = ChatMessage(role="user", content=self._task_with_context(task))
|
|
171
|
+
message._task_base = task # type: ignore[attr-defined]
|
|
172
|
+
message._injected = True # type: ignore[attr-defined]
|
|
173
|
+
messages.append(message)
|
|
174
|
+
return messages
|
|
175
|
+
|
|
176
|
+
def _task_with_context(self, task: str) -> str:
|
|
177
|
+
if not self.volatile_builder:
|
|
178
|
+
return task
|
|
179
|
+
try:
|
|
180
|
+
extra = self.volatile_builder()
|
|
181
|
+
except Exception:
|
|
182
|
+
return task
|
|
183
|
+
if not extra.strip():
|
|
184
|
+
return task
|
|
185
|
+
return f"{task}\n\n=== CURRENT STATE ===\n{extra}"
|
|
186
|
+
|
|
187
|
+
def _refresh_context_message(self, messages: list[ChatMessage]) -> None:
|
|
188
|
+
"""Rewrite the newest user turn with fresh volatile context.
|
|
189
|
+
|
|
190
|
+
Called each step so findings recorded moments ago are visible without
|
|
191
|
+
touching the cached prefix.
|
|
192
|
+
"""
|
|
193
|
+
if not self.volatile_builder or not messages:
|
|
194
|
+
return
|
|
195
|
+
for message in reversed(messages):
|
|
196
|
+
if message.role != "user":
|
|
197
|
+
continue
|
|
198
|
+
base = getattr(message, "_task_base", None)
|
|
199
|
+
if base is None:
|
|
200
|
+
return
|
|
201
|
+
message.content = self._task_with_context(base)
|
|
202
|
+
return
|
|
203
|
+
|
|
204
|
+
def _accumulate_usage(self, usage: dict[str, Any]) -> None:
|
|
205
|
+
"""Sum token counters, including the ones providers nest.
|
|
206
|
+
|
|
207
|
+
OpenAI-compatible APIs report cache hits as
|
|
208
|
+
``prompt_tokens_details.cached_tokens`` and reasoning as
|
|
209
|
+
``completion_tokens_details.reasoning_tokens``. Flattening them here
|
|
210
|
+
means the session totals and the UI report the truth instead of zero.
|
|
211
|
+
"""
|
|
212
|
+
for key, value in usage.items():
|
|
213
|
+
if isinstance(value, int):
|
|
214
|
+
self.usage_total[key] = self.usage_total.get(key, 0) + value
|
|
215
|
+
continue
|
|
216
|
+
if not isinstance(value, dict):
|
|
217
|
+
continue
|
|
218
|
+
for nested_key, nested_value in value.items():
|
|
219
|
+
if not isinstance(nested_value, int):
|
|
220
|
+
continue
|
|
221
|
+
# ``cached_tokens`` -> ``cached_tokens``; keep a flat alias so
|
|
222
|
+
# existing readers (report, UI) pick it up without changes.
|
|
223
|
+
flat = "cached_tokens" if nested_key == "cached_tokens" else nested_key
|
|
224
|
+
self.usage_total[flat] = self.usage_total.get(flat, 0) + nested_value
|
|
225
|
+
|
|
226
|
+
async def _emit(self, type_: str, **data: Any) -> None:
|
|
227
|
+
await self.bus.emit(Event(type=type_, agent=self.name, data=data))
|
|
228
|
+
|
|
229
|
+
async def _execute_tools(self, tool_calls: list[Any]) -> list[tuple[Any, str, str]]:
|
|
230
|
+
"""Run the step's tool calls, in parallel when it is safe.
|
|
231
|
+
|
|
232
|
+
Independent probes (a port scan and a headers audit, or several
|
|
233
|
+
injection tests on different parameters) used to run one after the
|
|
234
|
+
other, so a step cost the sum of every timeout. Read-only tools now run
|
|
235
|
+
concurrently; anything that mutates state, installs or shells out keeps
|
|
236
|
+
the sequential path and stays ordered.
|
|
237
|
+
|
|
238
|
+
Results are always reassembled in the model's original call order, so
|
|
239
|
+
the conversation stays deterministic.
|
|
240
|
+
"""
|
|
241
|
+
for call in tool_calls:
|
|
242
|
+
await self._emit(
|
|
243
|
+
"agent.tool_call",
|
|
244
|
+
tool=call.name,
|
|
245
|
+
arguments=call.parsed_arguments(),
|
|
246
|
+
call_id=call.id,
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
tools = [self.registry.get(call.name) for call in tool_calls]
|
|
250
|
+
parallel = [i for i, tool in enumerate(tools) if tool is not None and tool.parallel_safe]
|
|
251
|
+
sequential = [i for i, tool in enumerate(tools) if tool is None or not tool.parallel_safe]
|
|
252
|
+
outputs: list[str] = ["" for _ in tool_calls]
|
|
253
|
+
|
|
254
|
+
async def invoke(index: int) -> None:
|
|
255
|
+
call = tool_calls[index]
|
|
256
|
+
tool = tools[index]
|
|
257
|
+
try:
|
|
258
|
+
if tool is None:
|
|
259
|
+
outputs[index] = f"error: unknown tool '{call.name}'"
|
|
260
|
+
else:
|
|
261
|
+
outputs[index] = await tool.run(call.parsed_arguments())
|
|
262
|
+
except Exception as exc:
|
|
263
|
+
outputs[index] = f"error: {type(exc).__name__}: {exc}"
|
|
264
|
+
|
|
265
|
+
if parallel:
|
|
266
|
+
# A single hard tool cannot block the whole step: cap concurrency
|
|
267
|
+
# so we stay polite to the target and avoid tripping rate limits.
|
|
268
|
+
guard = asyncio.Semaphore(self.tool_concurrency)
|
|
269
|
+
|
|
270
|
+
async def guarded(index: int) -> None:
|
|
271
|
+
async with guard:
|
|
272
|
+
await invoke(index)
|
|
273
|
+
|
|
274
|
+
await asyncio.gather(*(guarded(i) for i in parallel))
|
|
275
|
+
for index in sequential:
|
|
276
|
+
await invoke(index)
|
|
277
|
+
|
|
278
|
+
assembled: list[tuple[Any, str, str]] = []
|
|
279
|
+
for index, call in enumerate(tool_calls):
|
|
280
|
+
output = outputs[index]
|
|
281
|
+
await self._emit(
|
|
282
|
+
"agent.tool_result",
|
|
283
|
+
tool=call.name,
|
|
284
|
+
call_id=call.id,
|
|
285
|
+
output=output[:2000],
|
|
286
|
+
)
|
|
287
|
+
bounded = prune_tool_output(output)
|
|
288
|
+
self._trace(
|
|
289
|
+
"tool_result",
|
|
290
|
+
tool=call.name,
|
|
291
|
+
call_id=call.id,
|
|
292
|
+
full_chars=len(output),
|
|
293
|
+
sent_chars=len(bounded),
|
|
294
|
+
parallel=index in parallel,
|
|
295
|
+
)
|
|
296
|
+
assembled.append((call, output, bounded))
|
|
297
|
+
return assembled
|
|
298
|
+
|
|
299
|
+
# -- main loop --------------------------------------------------------- #
|
|
300
|
+
async def run(self, task: str) -> AgentResult:
|
|
301
|
+
messages = self._build_messages(task)
|
|
302
|
+
result = AgentResult()
|
|
303
|
+
final_text = ""
|
|
304
|
+
|
|
305
|
+
if self.temperature is not None:
|
|
306
|
+
self.client.settings.temperature = self.temperature
|
|
307
|
+
# Stable per-conversation id (required by gateway providers such as
|
|
308
|
+
# OpenCode Go for routing and prompt caching).
|
|
309
|
+
self.client.settings.session_id = f"{self.context.state.id}-{self.name}"
|
|
310
|
+
|
|
311
|
+
for step in range(1, self.max_steps + 1):
|
|
312
|
+
result.steps = step
|
|
313
|
+
await self._emit("agent.step", step=step, max_steps=self.max_steps)
|
|
314
|
+
|
|
315
|
+
messages = await self._maintain_context(messages)
|
|
316
|
+
self._refresh_context_message(messages)
|
|
317
|
+
|
|
318
|
+
# Reserve the last steps for persisting and summarising. Without
|
|
319
|
+
# this the agent burns its budget exploring and returns nothing.
|
|
320
|
+
remaining = self.max_steps - step
|
|
321
|
+
if self.wrap_up_at and not self.wrapped_up and remaining <= self.wrap_up_at:
|
|
322
|
+
self.wrapped_up = True
|
|
323
|
+
result.wrapped_up = True
|
|
324
|
+
await self._emit(
|
|
325
|
+
"agent.wrap_up",
|
|
326
|
+
remaining=remaining,
|
|
327
|
+
total=self.max_steps,
|
|
328
|
+
)
|
|
329
|
+
messages.append(
|
|
330
|
+
ChatMessage(
|
|
331
|
+
role="user",
|
|
332
|
+
pinned=True,
|
|
333
|
+
content=WRAP_UP_PROMPT.format(remaining=remaining, total=self.max_steps),
|
|
334
|
+
)
|
|
335
|
+
)
|
|
336
|
+
self._trace("wrap_up", step=step, remaining=remaining)
|
|
337
|
+
|
|
338
|
+
await self._emit("agent.working", step=step, max_steps=self.max_steps)
|
|
339
|
+
|
|
340
|
+
text_parts: list[str] = []
|
|
341
|
+
reasoning_parts: list[str] = []
|
|
342
|
+
tool_calls = []
|
|
343
|
+
try:
|
|
344
|
+
async for event in self.client.stream(messages, self.registry.specs()):
|
|
345
|
+
if event.type == "text":
|
|
346
|
+
text_parts.append(event.text)
|
|
347
|
+
await self._emit("agent.text", text=event.text, step=step)
|
|
348
|
+
elif event.type == "reasoning":
|
|
349
|
+
reasoning_parts.append(event.text)
|
|
350
|
+
elif event.type == "tool_call" and event.tool_call is not None:
|
|
351
|
+
tool_calls.append(event.tool_call)
|
|
352
|
+
elif event.type == "usage" and event.usage:
|
|
353
|
+
self._accumulate_usage(event.usage)
|
|
354
|
+
self.last_usage = dict(event.usage)
|
|
355
|
+
result.usage = dict(self.usage_total)
|
|
356
|
+
self._trace("usage", **event.usage)
|
|
357
|
+
await self._emit("usage", **event.usage)
|
|
358
|
+
elif event.type == "retry":
|
|
359
|
+
retry_data = event.data or {}
|
|
360
|
+
self._trace("retry", **retry_data)
|
|
361
|
+
await self._emit("agent.retry", **retry_data)
|
|
362
|
+
elif event.type == "error":
|
|
363
|
+
await self._emit("error", text=event.error or "LLM error")
|
|
364
|
+
raise LLMError(event.error or "LLM error")
|
|
365
|
+
except LLMError as exc:
|
|
366
|
+
await self._emit("error", text=str(exc))
|
|
367
|
+
result.text = final_text or f"[{self.name}] LLM error: {exc}"
|
|
368
|
+
return result
|
|
369
|
+
|
|
370
|
+
assistant_text = "".join(text_parts)
|
|
371
|
+
reasoning_text = "".join(reasoning_parts)
|
|
372
|
+
if reasoning_text:
|
|
373
|
+
await self._emit("agent.thinking", text=reasoning_text[:4000], step=step)
|
|
374
|
+
assistant_message = ChatMessage(
|
|
375
|
+
role="assistant",
|
|
376
|
+
content=assistant_text,
|
|
377
|
+
reasoning=reasoning_text,
|
|
378
|
+
tool_calls=tool_calls,
|
|
379
|
+
)
|
|
380
|
+
messages.append(assistant_message)
|
|
381
|
+
self.history.append(assistant_message)
|
|
382
|
+
|
|
383
|
+
if not tool_calls:
|
|
384
|
+
final_text = assistant_text
|
|
385
|
+
break
|
|
386
|
+
|
|
387
|
+
outputs = await self._execute_tools(tool_calls)
|
|
388
|
+
result.tool_calls += len(tool_calls)
|
|
389
|
+
for call, _output, bounded in outputs:
|
|
390
|
+
# The full output is kept out of band so it can still be
|
|
391
|
+
# audited; only a bounded slice enters the conversation.
|
|
392
|
+
tool_message = ChatMessage(
|
|
393
|
+
role="tool",
|
|
394
|
+
content=bounded,
|
|
395
|
+
tool_call_id=call.id,
|
|
396
|
+
name=call.name,
|
|
397
|
+
)
|
|
398
|
+
messages.append(tool_message)
|
|
399
|
+
self.history.append(tool_message)
|
|
400
|
+
|
|
401
|
+
# Record the user's own words in the durable history (the task message
|
|
402
|
+
# also carried the volatile "current state" block). Rebuilding a clean
|
|
403
|
+
# history every turn keeps a continuous conversation from accumulating a
|
|
404
|
+
# duplicated system/state prompt on each request.
|
|
405
|
+
self.history = self._clean_history(messages)
|
|
406
|
+
|
|
407
|
+
if not final_text:
|
|
408
|
+
result.hit_step_limit = True
|
|
409
|
+
final_text = self._fallback_summary()
|
|
410
|
+
result.text = final_text
|
|
411
|
+
result.transcript = messages
|
|
412
|
+
result.trace = self.trace
|
|
413
|
+
result.pruned = self.pruned_tokens
|
|
414
|
+
result.saved = self.saved_tokens
|
|
415
|
+
result.compactions = self.compactions
|
|
416
|
+
await self._emit(
|
|
417
|
+
"agent.done",
|
|
418
|
+
text=final_text[:4000],
|
|
419
|
+
steps=result.steps,
|
|
420
|
+
pruned=self.pruned_tokens,
|
|
421
|
+
saved=self.saved_tokens,
|
|
422
|
+
compactions=self.compactions,
|
|
423
|
+
hit_step_limit=result.hit_step_limit,
|
|
424
|
+
)
|
|
425
|
+
return result
|
|
426
|
+
|
|
427
|
+
def _clean_history(self, messages: list[ChatMessage]) -> list[ChatMessage]:
|
|
428
|
+
"""The durable conversation: no system prompt, no injected state blocks.
|
|
429
|
+
|
|
430
|
+
The task message is replaced by its user-written base so the next turn
|
|
431
|
+
re-injects fresh volatile context instead of replaying a stale copy.
|
|
432
|
+
"""
|
|
433
|
+
cleaned: list[ChatMessage] = []
|
|
434
|
+
for message in messages:
|
|
435
|
+
if message.role == "system":
|
|
436
|
+
continue
|
|
437
|
+
base = getattr(message, "_task_base", None)
|
|
438
|
+
if getattr(message, "_injected", False) and base is not None:
|
|
439
|
+
message.content = base
|
|
440
|
+
cleaned.append(message)
|
|
441
|
+
elif getattr(message, "_injected", False):
|
|
442
|
+
continue
|
|
443
|
+
else:
|
|
444
|
+
cleaned.append(message)
|
|
445
|
+
return cleaned
|
|
446
|
+
|
|
447
|
+
def _fallback_summary(self) -> str:
|
|
448
|
+
"""Last resort when even the wrap-up turn produced no text.
|
|
449
|
+
|
|
450
|
+
Anything persisted still stands, so the summary reports the real state
|
|
451
|
+
instead of a bare "[agent] reached the step limit".
|
|
452
|
+
"""
|
|
453
|
+
findings = self.context.state.findings
|
|
454
|
+
mine = [f for f in findings if f.discovered_by == self.name]
|
|
455
|
+
lines = [
|
|
456
|
+
f"## {self.name.capitalize()} agent - automatic summary",
|
|
457
|
+
"",
|
|
458
|
+
f"Ran out of steps ({self.max_steps}) after {len(self.trace)} recorded "
|
|
459
|
+
f"events. The wrap-up turn produced no text, so this summary is built "
|
|
460
|
+
f"from persisted state.",
|
|
461
|
+
"",
|
|
462
|
+
f"- Tool calls executed: {sum(1 for t in self.trace if t['kind'] == 'tool_result')}",
|
|
463
|
+
f"- Findings persisted: {len(mine)}",
|
|
464
|
+
]
|
|
465
|
+
for finding in mine:
|
|
466
|
+
lines.append(
|
|
467
|
+
f" - [{finding.severity}] {finding.title} "
|
|
468
|
+
f"({finding.cvss_score:.1f}) @ {finding.endpoint or finding.target}"
|
|
469
|
+
)
|
|
470
|
+
notes = self.context.todo_summary()
|
|
471
|
+
if self.context.state.todos:
|
|
472
|
+
lines += ["", "Task list left behind:", notes]
|
|
473
|
+
lines += [
|
|
474
|
+
"",
|
|
475
|
+
"Untested surface remains - increase `run.max_steps` or continue in the next round.",
|
|
476
|
+
]
|
|
477
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""The Blue Agent: telemetry triage, mitigation and verification."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from splitagent.agents.base import BaseAgent
|
|
8
|
+
from splitagent.agents.prompts import build_blue_prompt, volatile_context
|
|
9
|
+
from splitagent.agents.red import build_policy
|
|
10
|
+
from splitagent.config import ProjectConfig
|
|
11
|
+
from splitagent.core.bus import EventBus
|
|
12
|
+
from splitagent.core.context import SharedContext
|
|
13
|
+
from splitagent.llm.client import LLMClient
|
|
14
|
+
from splitagent.tools.base import ToolContext
|
|
15
|
+
from splitagent.tools.registry import build_registry
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class BlueAgent(BaseAgent):
|
|
19
|
+
name = "blue"
|
|
20
|
+
color = "blue"
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
client: LLMClient,
|
|
25
|
+
context: SharedContext,
|
|
26
|
+
project: ProjectConfig,
|
|
27
|
+
bus: EventBus,
|
|
28
|
+
round_index: int = 1,
|
|
29
|
+
total_rounds: int = 1,
|
|
30
|
+
settings: dict[str, Any] | None = None,
|
|
31
|
+
) -> None:
|
|
32
|
+
tool_context = ToolContext(
|
|
33
|
+
target=project.target,
|
|
34
|
+
run=project.run,
|
|
35
|
+
context=context,
|
|
36
|
+
round=round_index,
|
|
37
|
+
agent="blue",
|
|
38
|
+
settings=settings or {},
|
|
39
|
+
)
|
|
40
|
+
registry = build_registry(tool_context, "blue")
|
|
41
|
+
prompt = build_blue_prompt(project, context, round_index, total_rounds)
|
|
42
|
+
super().__init__(
|
|
43
|
+
client=client,
|
|
44
|
+
context=context,
|
|
45
|
+
tool_context=tool_context,
|
|
46
|
+
registry=registry,
|
|
47
|
+
bus=bus,
|
|
48
|
+
system_prompt=prompt,
|
|
49
|
+
max_steps=project.run.max_steps,
|
|
50
|
+
temperature=project.agents.blue.temperature,
|
|
51
|
+
policy=build_policy(project, client.settings),
|
|
52
|
+
wrap_up_at=project.agents.blue.wrap_up_at,
|
|
53
|
+
volatile_builder=lambda: volatile_context(
|
|
54
|
+
project, context, round_index, total_rounds, role="blue"
|
|
55
|
+
),
|
|
56
|
+
tool_concurrency=project.run.tool_concurrency,
|
|
57
|
+
)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""The copilot: an interactive pentest assistant with the full toolset."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from splitagent.agents.base import BaseAgent
|
|
8
|
+
from splitagent.agents.prompts import build_chat_prompt, volatile_context
|
|
9
|
+
from splitagent.agents.red import build_policy
|
|
10
|
+
from splitagent.config import ProjectConfig
|
|
11
|
+
from splitagent.core.bus import EventBus
|
|
12
|
+
from splitagent.core.context import SharedContext
|
|
13
|
+
from splitagent.llm.client import LLMClient
|
|
14
|
+
from splitagent.llm.types import ChatMessage
|
|
15
|
+
from splitagent.tools.base import ToolContext
|
|
16
|
+
from splitagent.tools.registry import build_registry
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ChatAgent(BaseAgent):
|
|
20
|
+
"""Free-form assistant that can inspect the target and advise the operator."""
|
|
21
|
+
|
|
22
|
+
name = "assistant"
|
|
23
|
+
color = "accent"
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
client: LLMClient,
|
|
28
|
+
context: SharedContext,
|
|
29
|
+
project: ProjectConfig,
|
|
30
|
+
bus: EventBus,
|
|
31
|
+
settings: dict[str, Any] | None = None,
|
|
32
|
+
history: list[ChatMessage] | None = None,
|
|
33
|
+
) -> None:
|
|
34
|
+
tool_context = ToolContext(
|
|
35
|
+
target=project.target,
|
|
36
|
+
run=project.run,
|
|
37
|
+
context=context,
|
|
38
|
+
round=0,
|
|
39
|
+
agent="chat",
|
|
40
|
+
settings=settings or {},
|
|
41
|
+
)
|
|
42
|
+
registry = build_registry(tool_context, "chat")
|
|
43
|
+
prompt = build_chat_prompt(project, context)
|
|
44
|
+
super().__init__(
|
|
45
|
+
client=client,
|
|
46
|
+
context=context,
|
|
47
|
+
tool_context=tool_context,
|
|
48
|
+
registry=registry,
|
|
49
|
+
bus=bus,
|
|
50
|
+
system_prompt=prompt,
|
|
51
|
+
max_steps=project.run.max_steps,
|
|
52
|
+
temperature=project.agents.red.temperature,
|
|
53
|
+
history_limit=40,
|
|
54
|
+
policy=build_policy(project, client.settings),
|
|
55
|
+
wrap_up_at=0, # the copilot is interactive, never wrap it up
|
|
56
|
+
volatile_builder=lambda: volatile_context(project, context, role="red"),
|
|
57
|
+
tool_concurrency=project.run.tool_concurrency,
|
|
58
|
+
)
|
|
59
|
+
if history:
|
|
60
|
+
self.history = list(history)
|