mitsu 1.4.21__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 (71) hide show
  1. mitsu/__init__.py +3 -0
  2. mitsu/__main__.py +5 -0
  3. mitsu/agents/__init__.py +5 -0
  4. mitsu/agents/runtime.py +470 -0
  5. mitsu/armory/__init__.py +23 -0
  6. mitsu/armory/catalog.py +370 -0
  7. mitsu/assets/pets/chibi-mita.webp +0 -0
  8. mitsu/assets/pets/coding-cat.webp +0 -0
  9. mitsu/assets/pets/doro.webp +0 -0
  10. mitsu/assets/pets/elyndex.webp +0 -0
  11. mitsu/assets/pets/glitchcat.webp +0 -0
  12. mitsu/assets/pets/gugugaga.webp +0 -0
  13. mitsu/assets/pets/kaltsit.webp +0 -0
  14. mitsu/assets/pets/mitsu.webp +0 -0
  15. mitsu/assets/pets/nene.webp +0 -0
  16. mitsu/assets/pets/null-wisp.webp +0 -0
  17. mitsu/assets/pets/pikachu.webp +0 -0
  18. mitsu/assets/pets/rin.webp +0 -0
  19. mitsu/assets/pets/tethys.webp +0 -0
  20. mitsu/assets/pets/yingyu-aima.webp +0 -0
  21. mitsu/assets/pets/yuna.webp +0 -0
  22. mitsu/config/__init__.py +18 -0
  23. mitsu/config/catalog.py +150 -0
  24. mitsu/config/storage.py +255 -0
  25. mitsu/pets/__init__.py +12 -0
  26. mitsu/pets/catalog.py +121 -0
  27. mitsu/providers/__init__.py +29 -0
  28. mitsu/providers/client.py +1344 -0
  29. mitsu/search/__init__.py +19 -0
  30. mitsu/search/engines.py +586 -0
  31. mitsu/search/vulnerabilities.py +311 -0
  32. mitsu/services/__init__.py +1 -0
  33. mitsu/services/docker_installer.py +235 -0
  34. mitsu/services/host_environment.py +148 -0
  35. mitsu/services/redteam_armory.py +619 -0
  36. mitsu/services/searxng.py +311 -0
  37. mitsu/tools/__init__.py +5 -0
  38. mitsu/tools/agent_host.py +1670 -0
  39. mitsu/tools/armory_mcp.py +423 -0
  40. mitsu/tools/artifacts.py +110 -0
  41. mitsu/tools/custom_mcp.py +478 -0
  42. mitsu/tools/redaction.py +62 -0
  43. mitsu/tui/__init__.py +1 -0
  44. mitsu/tui/app.py +230 -0
  45. mitsu/tui/branding.py +13 -0
  46. mitsu/tui/chat_rendering.py +37 -0
  47. mitsu/tui/dialogs/__init__.py +7 -0
  48. mitsu/tui/dialogs/configuration.py +61 -0
  49. mitsu/tui/dialogs/providers.py +305 -0
  50. mitsu/tui/dialogs/search_engines.py +673 -0
  51. mitsu/tui/file_references.py +82 -0
  52. mitsu/tui/images.py +156 -0
  53. mitsu/tui/menu.py +1967 -0
  54. mitsu/tui/onboarding.py +656 -0
  55. mitsu/tui/session.py +6167 -0
  56. mitsu/tui/theme.tcss +2873 -0
  57. mitsu/tui/updates.py +31 -0
  58. mitsu/tui/widgets/__init__.py +5 -0
  59. mitsu/tui/widgets/city_scene.py +141 -0
  60. mitsu/tui/widgets/mitsu.py +425 -0
  61. mitsu/tui/widgets/moon_scene.py +139 -0
  62. mitsu/workspace/__init__.py +5 -0
  63. mitsu/workspace/intel.py +933 -0
  64. mitsu/workspace/surface_map.py +364 -0
  65. mitsu/workspace/undo.py +138 -0
  66. mitsu-1.4.21.dist-info/METADATA +653 -0
  67. mitsu-1.4.21.dist-info/RECORD +71 -0
  68. mitsu-1.4.21.dist-info/WHEEL +5 -0
  69. mitsu-1.4.21.dist-info/entry_points.txt +2 -0
  70. mitsu-1.4.21.dist-info/licenses/LICENSE +21 -0
  71. mitsu-1.4.21.dist-info/top_level.txt +1 -0
mitsu/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Mitsu terminal companion."""
2
+
3
+ __version__ = "1.4.21"
mitsu/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ from mitsu.tui.app import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ main()
@@ -0,0 +1,5 @@
1
+ """Agent orchestration and conversation-history helpers."""
2
+
3
+ from .runtime import AgentEvent, finish_interrupted_tools, history_from_legacy, run_agent_turn
4
+
5
+ __all__ = ["AgentEvent", "finish_interrupted_tools", "history_from_legacy", "run_agent_turn"]
@@ -0,0 +1,470 @@
1
+ """Run agent turns and maintain normalized conversation history."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import re
8
+ import shlex
9
+ import threading
10
+ from dataclasses import dataclass
11
+ from typing import Any, AsyncIterator, Awaitable, Callable
12
+
13
+ from mitsu.tools import AgentToolHost
14
+ from mitsu.providers import ProviderRequestError, ProviderTurn, RequestCancellation, request_agent_turn
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class AgentEvent:
19
+ kind: str
20
+ data: dict[str, Any]
21
+
22
+
23
+ ApprovalHandler = Callable[[str, dict[str, Any]], Awaitable[bool]]
24
+ HistoryHandler = Callable[[], None]
25
+ RequestTurn = Callable[
26
+ [dict[str, Any], list[dict[str, Any]], list[dict[str, Any]]], Awaitable[ProviderTurn]
27
+ ]
28
+ MAX_TOOL_CALLS_PER_TURN = 48
29
+ MAX_BINARY_GREP_SEARCHES_PER_ARTIFACT = 3
30
+
31
+
32
+ def _tool_name(name: str) -> str:
33
+ return name.rsplit("__", 1)[-1]
34
+
35
+
36
+ def _guided_checkpoint_tool(name: str) -> bool:
37
+ local_name = _tool_name(name)
38
+ return name.startswith("mitsu__") and (
39
+ local_name.startswith("intel_")
40
+ or local_name in {"user_dialog", "record_exploit", "remove_exploit", "session_upsert"}
41
+ )
42
+
43
+
44
+ def _guided_flow_action(name: str) -> bool:
45
+ local_name = _tool_name(name)
46
+ bookkeeping = {
47
+ "user_dialog",
48
+ "ask_user",
49
+ "request_mode",
50
+ "session_upsert",
51
+ "record_exploit",
52
+ "remove_exploit",
53
+ "intel_get_map",
54
+ "armory_tool_catalog",
55
+ }
56
+ return not (name.startswith("mitsu__") and (local_name in bookkeeping or local_name.startswith("intel_")))
57
+
58
+
59
+ def finish_interrupted_tools(
60
+ history: list[dict[str, Any]],
61
+ result: str = "Interrupted by the operator. This action has no completed result; do not retry without their direction.",
62
+ ) -> None:
63
+ pending: dict[str, bool] = {}
64
+ repaired: list[dict[str, Any]] = []
65
+
66
+ def complete_pending() -> None:
67
+ results = []
68
+ for call_id, anthropic in pending.items():
69
+ if anthropic:
70
+ results.append({"type": "tool_result", "tool_use_id": call_id, "content": result, "is_error": True})
71
+ else:
72
+ repaired.append({"role": "tool", "tool_call_id": call_id, "content": result})
73
+ if results:
74
+ if repaired and repaired[-1].get("role") == "user" and isinstance(repaired[-1].get("content"), list):
75
+ repaired[-1]["content"].extend(results)
76
+ else:
77
+ repaired.append({"role": "user", "content": results})
78
+ pending.clear()
79
+
80
+ for message in history:
81
+ content = message.get("content")
82
+ result_blocks = [
83
+ block for block in content
84
+ if isinstance(block, dict) and block.get("type") == "tool_result"
85
+ ] if isinstance(content, list) and message.get("role") == "user" else []
86
+ if message.get("role") == "tool":
87
+ pending.pop(message.get("tool_call_id", ""), None)
88
+ elif result_blocks:
89
+ for block in result_blocks:
90
+ pending.pop(block.get("tool_use_id", ""), None)
91
+ else:
92
+ complete_pending()
93
+ repaired.append(message)
94
+ if message.get("role") == "assistant":
95
+ for call in message.get("tool_calls") or []:
96
+ pending[call["id"]] = False
97
+ if isinstance(content, list):
98
+ for block in content:
99
+ if isinstance(block, dict) and block.get("type") == "tool_use":
100
+ pending[block["id"]] = True
101
+ complete_pending()
102
+ history[:] = repaired
103
+
104
+
105
+ def _validated_access_context(intel: dict[str, Any] | None) -> list[dict[str, str]]:
106
+ if not isinstance(intel, dict):
107
+ return []
108
+ records = intel.get("accounts", [])
109
+ if not isinstance(records, list):
110
+ return []
111
+ access_context = []
112
+ for record in records:
113
+ if not isinstance(record, dict) or str(record.get("status", "")).casefold() != "validated":
114
+ continue
115
+ access = str(record.get("access", "")).strip()
116
+ if not access:
117
+ continue
118
+ access_context.append(
119
+ {
120
+ "name": str(record.get("name", "")),
121
+ "target": str(record.get("target", "")),
122
+ "access": access[:240],
123
+ }
124
+ )
125
+ return access_context
126
+
127
+
128
+ def _failed_call_key(name: str, arguments: Any) -> str | None:
129
+ local_name = _tool_name(name)
130
+ if local_name not in {"bash", "armory_run"} or not isinstance(arguments, dict):
131
+ return None
132
+ if local_name == "bash":
133
+ command = str(arguments.get("command", "")).strip()
134
+ return json.dumps([local_name, command], ensure_ascii=False, separators=(",", ":"))
135
+ return json.dumps(
136
+ [local_name, arguments.get("tool_id"), arguments.get("argv")],
137
+ ensure_ascii=False,
138
+ sort_keys=True,
139
+ separators=(",", ":"),
140
+ )
141
+
142
+
143
+ def _binary_grep_key(name: str, arguments: Any) -> str | None:
144
+ if _tool_name(name) != "bash" or not isinstance(arguments, dict):
145
+ return None
146
+ command = str(arguments.get("command", ""))
147
+ if not re.search(r"\bstrings\b", command) or not re.search(r"\b(?:grep|rg)\b", command):
148
+ return None
149
+ match = re.search(r"\bstrings\b([^|;&]+)", command)
150
+ if match is None:
151
+ return None
152
+ try:
153
+ tokens = shlex.split(match.group(1))
154
+ except ValueError:
155
+ return None
156
+ skip_value = False
157
+ for token in tokens:
158
+ if skip_value:
159
+ skip_value = False
160
+ continue
161
+ if token in {"-n", "--bytes"}:
162
+ skip_value = True
163
+ elif token.startswith("-"):
164
+ continue
165
+ else:
166
+ return f"strings-grep:{token}"
167
+ return None
168
+
169
+
170
+ def _request_agent_turn_background(
171
+ profile: dict[str, Any],
172
+ messages: list[dict[str, Any]],
173
+ tools: list[dict[str, Any]],
174
+ on_event: Callable[[str, dict[str, Any]], None] | None = None,
175
+ cancellation: RequestCancellation | None = None,
176
+ ) -> asyncio.Future[ProviderTurn]:
177
+ loop = asyncio.get_running_loop()
178
+ result: asyncio.Future[ProviderTurn] = loop.create_future()
179
+
180
+ def finish(turn: ProviderTurn | None, error: Exception | None) -> None:
181
+ if result.done():
182
+ return
183
+ if error is not None:
184
+ result.set_exception(error)
185
+ elif turn is not None:
186
+ result.set_result(turn)
187
+
188
+ def run() -> None:
189
+ try:
190
+ turn = request_agent_turn(profile, messages, tools, on_event, cancellation)
191
+ except Exception as error:
192
+ turn = None
193
+ failure = error
194
+ else:
195
+ failure = None
196
+ try:
197
+ loop.call_soon_threadsafe(finish, turn, failure)
198
+ except RuntimeError:
199
+ pass
200
+
201
+ threading.Thread(target=run, name="mitsu-provider-request", daemon=True).start()
202
+ return result
203
+
204
+
205
+ async def run_agent_turn(
206
+ profile: dict[str, Any],
207
+ system_prompt: str,
208
+ history: list[dict[str, Any]],
209
+ prompt: str,
210
+ tool_host: AgentToolHost,
211
+ mode: str,
212
+ approve: ApprovalHandler,
213
+ on_history: HistoryHandler,
214
+ request_turn: RequestTurn | None = None,
215
+ get_mode: Callable[[], str] | None = None,
216
+ get_system_prompt: Callable[[], str] | None = None,
217
+ image_paths: list[str] | None = None,
218
+ pause_after_response: bool = False,
219
+ interrupt_requested: Callable[[], bool] | None = None,
220
+ operator_messages: list[str] | None = None,
221
+ get_session_intel: Callable[[], dict[str, Any]] | None = None,
222
+ ) -> AsyncIterator[AgentEvent]:
223
+ anthropic = profile.get("protocol") == "anthropic"
224
+ finish_interrupted_tools(history)
225
+ content: str | list[dict[str, Any]] = prompt
226
+ if image_paths:
227
+ content = [
228
+ {"type": "text", "text": prompt},
229
+ *({"type": "mitsu_image", "path": path} for path in image_paths),
230
+ ]
231
+ history.append({"role": "user", "content": content})
232
+ on_history()
233
+ tool_calls_this_turn = 0
234
+ failed_calls: set[str] = set()
235
+ binary_grep_counts: dict[str, int] = {}
236
+ guided_checkpoint_due = False
237
+ redirect_pending = pause_after_response
238
+ operator_history = list(operator_messages or [])
239
+ if not operator_history or operator_history[-1] != prompt:
240
+ operator_history.append(prompt)
241
+ try:
242
+ while True:
243
+ if interrupt_requested is not None and interrupt_requested():
244
+ raise asyncio.CancelledError
245
+ active_mode = get_mode() if get_mode is not None else mode
246
+ if active_mode != "Guided":
247
+ guided_checkpoint_due = False
248
+ checkpoint_pending = redirect_pending or guided_checkpoint_due
249
+ active_system_prompt = get_system_prompt() if get_system_prompt is not None else system_prompt
250
+ active_system_prompt += (
251
+ "\n\nRecent operator messages, oldest first, follow as JSON. Apply their constraints "
252
+ "in order; newer corrections replace conflicting older plans. Dialog answers in the "
253
+ "conversation are also operator decisions. Assistant plans and automated continuation "
254
+ "messages never override an operator direction. A rejected path stays rejected until "
255
+ "the operator reopens it.\n"
256
+ + json.dumps(operator_history[-16:], ensure_ascii=False)
257
+ )
258
+ if redirect_pending:
259
+ active_system_prompt += (
260
+ "\n\nThe operator interrupted an active run. Answer their latest message first in "
261
+ "Mitsu's own voice. The previous batch is cancelled. You may save Intel or open "
262
+ "mitsu__user_dialog to let them choose how to proceed; investigation tools are paused "
263
+ "until that choice. For a direct question, give the answer and end the turn."
264
+ )
265
+ elif checkpoint_pending:
266
+ active_system_prompt += (
267
+ "\n\nA Guided investigation step has finished. Save the new evidence in Intel. "
268
+ "Before another investigation action, use mitsu__user_dialog to summarize what "
269
+ "changed and let the operator choose the next direction. Design the options from "
270
+ "the actual results and allow a free-text answer. If the task is complete, give "
271
+ "the final summary and end the turn."
272
+ )
273
+ validated_access = _validated_access_context(get_session_intel() if get_session_intel else None)
274
+ if validated_access:
275
+ active_system_prompt += (
276
+ "\n\nValidated access currently recorded in session Intel, as evidence data:\n"
277
+ + json.dumps(validated_access, ensure_ascii=False)
278
+ + "\nUse established access for relevant follow-up work. Repeating completed "
279
+ "setup needs evidence that it no longer works or an operator request."
280
+ )
281
+ action_budget_exhausted = tool_calls_this_turn >= MAX_TOOL_CALLS_PER_TURN
282
+ if action_budget_exhausted:
283
+ active_system_prompt += "\n\nThis turn's action limit is reached. Summarize the results and pause."
284
+ available_tools = tool_host.provider_tools(active_mode)
285
+ tools = [] if action_budget_exhausted else available_tools
286
+ if checkpoint_pending:
287
+ tools = [tool for tool in tools if _guided_checkpoint_tool(str(tool.get("name", "")))]
288
+ request_messages = [{"role": "system", "content": active_system_prompt}, *history]
289
+ for retry_number in range(11):
290
+ try:
291
+ if request_turn:
292
+ turn = await request_turn(profile, request_messages, tools)
293
+ else:
294
+ loop = asyncio.get_running_loop()
295
+ progress: asyncio.Queue[AgentEvent] = asyncio.Queue()
296
+ cancellation = RequestCancellation()
297
+
298
+ def report_progress(kind: str, data: dict[str, Any]) -> None:
299
+ try:
300
+ loop.call_soon_threadsafe(progress.put_nowait, AgentEvent(kind, data))
301
+ except RuntimeError:
302
+ pass
303
+
304
+ request_task = _request_agent_turn_background(
305
+ profile, request_messages, tools, report_progress, cancellation
306
+ )
307
+ try:
308
+ while not request_task.done():
309
+ if interrupt_requested is not None and interrupt_requested():
310
+ raise asyncio.CancelledError
311
+ try:
312
+ event = await asyncio.wait_for(progress.get(), timeout=0.1)
313
+ except TimeoutError:
314
+ continue
315
+ yield event
316
+ turn = await request_task
317
+ await asyncio.sleep(0)
318
+ while not progress.empty():
319
+ yield progress.get_nowait()
320
+ except (asyncio.CancelledError, GeneratorExit):
321
+ cancellation.cancel()
322
+ request_task.cancel()
323
+ raise
324
+ break
325
+ except ProviderRequestError as error:
326
+ if retry_number >= 10 or not _is_transient_provider_error(error):
327
+ raise
328
+ retry_attempt = retry_number + 1
329
+ yield AgentEvent("api_retry", {
330
+ "attempt": retry_attempt, "max_attempts": 10, "delay": 10, "error": str(error),
331
+ })
332
+ await asyncio.sleep(10)
333
+ yield AgentEvent("api_retrying", {"attempt": retry_attempt})
334
+ if interrupt_requested is not None and interrupt_requested():
335
+ raise asyncio.CancelledError
336
+ if turn.usage:
337
+ yield AgentEvent("usage", {"usage": turn.usage})
338
+ history.append(turn.assistant_message)
339
+ on_history()
340
+ yield AgentEvent("assistant_turn", {
341
+ "text": turn.text,
342
+ "tool_calls": len(turn.tool_calls),
343
+ "response_info": turn.response_info,
344
+ })
345
+ if not turn.tool_calls:
346
+ yield AgentEvent("done", {})
347
+ return
348
+ if action_budget_exhausted:
349
+ finish_interrupted_tools(history, "Not run: this turn's action limit was reached.")
350
+ on_history()
351
+ yield AgentEvent("limit", {"message": "Action limit reached. Mitsu is paused."})
352
+ yield AgentEvent("done", {})
353
+ return
354
+ dialog_used = False
355
+ dialog_cancelled = False
356
+ guided_action_seen = False
357
+ for invocation in turn.tool_calls:
358
+ if interrupt_requested is not None and interrupt_requested():
359
+ raise asyncio.CancelledError
360
+ yield AgentEvent("tool_started", {"name": invocation.name, "arguments": invocation.arguments})
361
+ tool_calls_this_turn += 1
362
+ failed_key = _failed_call_key(invocation.name, invocation.arguments)
363
+ binary_grep_key = _binary_grep_key(invocation.name, invocation.arguments)
364
+ invocation_mode = get_mode() if get_mode is not None else mode
365
+ is_error = True
366
+ if dialog_used:
367
+ result = "Not run: review the operator's dialog answer before proposing another action."
368
+ elif (redirect_pending or (invocation_mode == "Guided" and guided_checkpoint_due)) and not _guided_checkpoint_tool(invocation.name):
369
+ result = "Not run: waiting for the operator's next-step choice through mitsu__user_dialog."
370
+ elif invocation_mode == "Guided" and guided_action_seen and _guided_flow_action(invocation.name):
371
+ result = "Not run: Guided mode pauses after one investigation action for the operator's next-step choice."
372
+ elif invocation_mode == "Guided" and guided_action_seen and invocation.name == "mitsu__user_dialog":
373
+ result = "Not run: read this step's results before designing the next-step dialog."
374
+ elif tool_calls_this_turn > MAX_TOOL_CALLS_PER_TURN:
375
+ result = f"Not run: per-message action limit reached ({MAX_TOOL_CALLS_PER_TURN})."
376
+ elif failed_key is not None and failed_key in failed_calls:
377
+ result = "Not run: this identical command already failed during this message."
378
+ elif binary_grep_key is not None and binary_grep_counts.get(binary_grep_key, 0) >= MAX_BINARY_GREP_SEARCHES_PER_ARTIFACT:
379
+ result = f"Not run: printable-string search limit reached for {binary_grep_key.removeprefix('strings-grep:')}."
380
+ else:
381
+ if binary_grep_key is not None:
382
+ binary_grep_counts[binary_grep_key] = binary_grep_counts.get(binary_grep_key, 0) + 1
383
+ if invocation_mode == "Guided" and _guided_flow_action(invocation.name):
384
+ guided_action_seen = True
385
+ needs_approval = (invocation_mode == "Guided" and tool_host.requires_guided_approval(invocation.name)) or (
386
+ invocation_mode == "Recon" and not tool_host.recon_action_is_low_impact(invocation.name, invocation.arguments)
387
+ )
388
+ allowed = not needs_approval or await approve(
389
+ invocation.name, invocation.arguments if isinstance(invocation.arguments, dict) else {}
390
+ )
391
+ if interrupt_requested is not None and interrupt_requested():
392
+ raise asyncio.CancelledError
393
+ if allowed:
394
+ result, is_error, _ = await tool_host.call(invocation.name, invocation.arguments)
395
+ else:
396
+ result = "The operator declined this action. Do not retry it; ask which direction to take."
397
+ redirect_pending = True
398
+ if invocation.name == "mitsu__user_dialog" and not is_error:
399
+ dialog_used = True
400
+ try:
401
+ answer = json.loads(result)
402
+ dialog_cancelled = bool(answer.get("cancelled", False)) or not answer.get("answers")
403
+ except (json.JSONDecodeError, AttributeError, TypeError):
404
+ dialog_cancelled = True
405
+ if not dialog_cancelled:
406
+ redirect_pending = False
407
+ if is_error and failed_key is not None:
408
+ failed_calls.add(failed_key)
409
+ if anthropic:
410
+ block = {"type": "tool_result", "tool_use_id": invocation.id, "content": result, "is_error": is_error}
411
+ if history[-1].get("role") == "user" and isinstance(history[-1].get("content"), list):
412
+ history[-1]["content"].append(block)
413
+ else:
414
+ history.append({"role": "user", "content": [block]})
415
+ else:
416
+ history.append({"role": "tool", "tool_call_id": invocation.id, "content": result})
417
+ on_history()
418
+ yield AgentEvent("tool_finished", {"name": invocation.name, "result": result, "is_error": is_error})
419
+ current_mode = get_mode() if get_mode is not None else mode
420
+ guided_checkpoint_due = current_mode == "Guided" and not dialog_used and (guided_checkpoint_due or guided_action_seen)
421
+ if dialog_cancelled:
422
+ message = "Okayy, paused here~ Tell me what you want to tackle next (˶ᵔ ᵕ ᵔ˶)"
423
+ history.append({"role": "assistant", "content": message})
424
+ on_history()
425
+ yield AgentEvent("assistant_turn", {"text": message, "tool_calls": 0})
426
+ yield AgentEvent("done", {})
427
+ return
428
+ finally:
429
+ finish_interrupted_tools(history)
430
+ on_history()
431
+
432
+
433
+ def history_from_legacy(entries: list[Any]) -> list[dict[str, Any]]:
434
+ history: list[dict[str, Any]] = []
435
+ for entry in entries:
436
+ if not isinstance(entry, dict) or entry.get("role") not in {"user", "assistant"}:
437
+ continue
438
+ content = str(entry.get("content", ""))
439
+ if not content:
440
+ continue
441
+ if entry.get("role") == "user" and content.lstrip().startswith("/"):
442
+ continue
443
+ if history and history[-1].get("role") == entry["role"]:
444
+ history[-1]["content"] += "\n\n" + content
445
+ else:
446
+ history.append({"role": entry["role"], "content": content})
447
+ while history and history[0].get("role") == "assistant":
448
+ history.pop(0)
449
+ if len(history) > 40:
450
+ history = history[-40:]
451
+ if history and history[0].get("role") == "assistant":
452
+ history.pop(0)
453
+ return history
454
+
455
+
456
+ def _is_transient_provider_error(error: ProviderRequestError) -> bool:
457
+ message = str(error).casefold()
458
+ if "provider request failed (429)" in message:
459
+ return True
460
+ for status in range(500, 600):
461
+ if f"provider request failed ({status})" in message:
462
+ return True
463
+ return any(
464
+ marker in message
465
+ for marker in (
466
+ "could not reach the provider",
467
+ "provider stream ended unexpectedly",
468
+ "provider returned an unreadable streamed response",
469
+ )
470
+ )
@@ -0,0 +1,23 @@
1
+ """Catalog of optional security-assessment tools."""
2
+
3
+ from .catalog import (
4
+ CATEGORIES,
5
+ CATEGORY_BY_ID,
6
+ TOOLS,
7
+ TOOLS_BY_CATEGORY,
8
+ TOOL_BY_ID,
9
+ CategorySpec,
10
+ InstallMethod,
11
+ ToolSpec,
12
+ )
13
+
14
+ __all__ = [
15
+ "CATEGORIES",
16
+ "CATEGORY_BY_ID",
17
+ "TOOLS",
18
+ "TOOLS_BY_CATEGORY",
19
+ "TOOL_BY_ID",
20
+ "CategorySpec",
21
+ "InstallMethod",
22
+ "ToolSpec",
23
+ ]