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/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """SplitAgent - Autonomous dual-team (Purple Team) security framework."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "0.0.3"
6
+ __app_name__ = "splitagent"
7
+
8
+ __all__ = ["__app_name__", "__version__"]
splitagent/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ from __future__ import annotations
2
+
3
+ from splitagent.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
@@ -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)