strix-agent 0.4.0__py3-none-any.whl → 0.6.2__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 (117) hide show
  1. strix/agents/StrixAgent/strix_agent.py +3 -3
  2. strix/agents/StrixAgent/system_prompt.jinja +30 -26
  3. strix/agents/base_agent.py +159 -75
  4. strix/agents/state.py +5 -2
  5. strix/config/__init__.py +12 -0
  6. strix/config/config.py +172 -0
  7. strix/interface/assets/tui_styles.tcss +195 -230
  8. strix/interface/cli.py +16 -41
  9. strix/interface/main.py +151 -74
  10. strix/interface/streaming_parser.py +119 -0
  11. strix/interface/tool_components/__init__.py +4 -0
  12. strix/interface/tool_components/agent_message_renderer.py +190 -0
  13. strix/interface/tool_components/agents_graph_renderer.py +54 -38
  14. strix/interface/tool_components/base_renderer.py +68 -36
  15. strix/interface/tool_components/browser_renderer.py +106 -91
  16. strix/interface/tool_components/file_edit_renderer.py +117 -36
  17. strix/interface/tool_components/finish_renderer.py +43 -10
  18. strix/interface/tool_components/notes_renderer.py +63 -38
  19. strix/interface/tool_components/proxy_renderer.py +133 -92
  20. strix/interface/tool_components/python_renderer.py +121 -8
  21. strix/interface/tool_components/registry.py +19 -12
  22. strix/interface/tool_components/reporting_renderer.py +196 -28
  23. strix/interface/tool_components/scan_info_renderer.py +22 -19
  24. strix/interface/tool_components/terminal_renderer.py +270 -90
  25. strix/interface/tool_components/thinking_renderer.py +8 -6
  26. strix/interface/tool_components/todo_renderer.py +225 -0
  27. strix/interface/tool_components/user_message_renderer.py +26 -19
  28. strix/interface/tool_components/web_search_renderer.py +7 -6
  29. strix/interface/tui.py +907 -262
  30. strix/interface/utils.py +236 -4
  31. strix/llm/__init__.py +6 -2
  32. strix/llm/config.py +8 -5
  33. strix/llm/dedupe.py +217 -0
  34. strix/llm/llm.py +209 -356
  35. strix/llm/memory_compressor.py +6 -5
  36. strix/llm/utils.py +17 -8
  37. strix/runtime/__init__.py +12 -3
  38. strix/runtime/docker_runtime.py +121 -202
  39. strix/runtime/tool_server.py +55 -95
  40. strix/skills/README.md +64 -0
  41. strix/skills/__init__.py +110 -0
  42. strix/{prompts → skills}/frameworks/nextjs.jinja +26 -0
  43. strix/skills/scan_modes/deep.jinja +145 -0
  44. strix/skills/scan_modes/quick.jinja +63 -0
  45. strix/skills/scan_modes/standard.jinja +91 -0
  46. strix/telemetry/README.md +38 -0
  47. strix/telemetry/__init__.py +7 -1
  48. strix/telemetry/posthog.py +137 -0
  49. strix/telemetry/tracer.py +194 -54
  50. strix/tools/__init__.py +11 -4
  51. strix/tools/agents_graph/agents_graph_actions.py +20 -21
  52. strix/tools/agents_graph/agents_graph_actions_schema.xml +8 -8
  53. strix/tools/browser/browser_actions.py +10 -6
  54. strix/tools/browser/browser_actions_schema.xml +6 -1
  55. strix/tools/browser/browser_instance.py +96 -48
  56. strix/tools/browser/tab_manager.py +121 -102
  57. strix/tools/context.py +12 -0
  58. strix/tools/executor.py +63 -4
  59. strix/tools/file_edit/file_edit_actions.py +6 -3
  60. strix/tools/file_edit/file_edit_actions_schema.xml +45 -3
  61. strix/tools/finish/finish_actions.py +80 -105
  62. strix/tools/finish/finish_actions_schema.xml +121 -14
  63. strix/tools/notes/notes_actions.py +6 -33
  64. strix/tools/notes/notes_actions_schema.xml +50 -46
  65. strix/tools/proxy/proxy_actions.py +14 -2
  66. strix/tools/proxy/proxy_actions_schema.xml +0 -1
  67. strix/tools/proxy/proxy_manager.py +28 -16
  68. strix/tools/python/python_actions.py +2 -2
  69. strix/tools/python/python_actions_schema.xml +9 -1
  70. strix/tools/python/python_instance.py +39 -37
  71. strix/tools/python/python_manager.py +43 -31
  72. strix/tools/registry.py +73 -12
  73. strix/tools/reporting/reporting_actions.py +218 -31
  74. strix/tools/reporting/reporting_actions_schema.xml +256 -8
  75. strix/tools/terminal/terminal_actions.py +2 -2
  76. strix/tools/terminal/terminal_actions_schema.xml +6 -0
  77. strix/tools/terminal/terminal_manager.py +41 -30
  78. strix/tools/thinking/thinking_actions_schema.xml +27 -25
  79. strix/tools/todo/__init__.py +18 -0
  80. strix/tools/todo/todo_actions.py +568 -0
  81. strix/tools/todo/todo_actions_schema.xml +225 -0
  82. strix/utils/__init__.py +0 -0
  83. strix/utils/resource_paths.py +13 -0
  84. {strix_agent-0.4.0.dist-info → strix_agent-0.6.2.dist-info}/METADATA +90 -65
  85. strix_agent-0.6.2.dist-info/RECORD +134 -0
  86. {strix_agent-0.4.0.dist-info → strix_agent-0.6.2.dist-info}/WHEEL +1 -1
  87. strix/llm/request_queue.py +0 -87
  88. strix/prompts/README.md +0 -64
  89. strix/prompts/__init__.py +0 -109
  90. strix_agent-0.4.0.dist-info/RECORD +0 -118
  91. /strix/{prompts → skills}/cloud/.gitkeep +0 -0
  92. /strix/{prompts → skills}/coordination/root_agent.jinja +0 -0
  93. /strix/{prompts → skills}/custom/.gitkeep +0 -0
  94. /strix/{prompts → skills}/frameworks/fastapi.jinja +0 -0
  95. /strix/{prompts → skills}/protocols/graphql.jinja +0 -0
  96. /strix/{prompts → skills}/reconnaissance/.gitkeep +0 -0
  97. /strix/{prompts → skills}/technologies/firebase_firestore.jinja +0 -0
  98. /strix/{prompts → skills}/technologies/supabase.jinja +0 -0
  99. /strix/{prompts → skills}/vulnerabilities/authentication_jwt.jinja +0 -0
  100. /strix/{prompts → skills}/vulnerabilities/broken_function_level_authorization.jinja +0 -0
  101. /strix/{prompts → skills}/vulnerabilities/business_logic.jinja +0 -0
  102. /strix/{prompts → skills}/vulnerabilities/csrf.jinja +0 -0
  103. /strix/{prompts → skills}/vulnerabilities/idor.jinja +0 -0
  104. /strix/{prompts → skills}/vulnerabilities/information_disclosure.jinja +0 -0
  105. /strix/{prompts → skills}/vulnerabilities/insecure_file_uploads.jinja +0 -0
  106. /strix/{prompts → skills}/vulnerabilities/mass_assignment.jinja +0 -0
  107. /strix/{prompts → skills}/vulnerabilities/open_redirect.jinja +0 -0
  108. /strix/{prompts → skills}/vulnerabilities/path_traversal_lfi_rfi.jinja +0 -0
  109. /strix/{prompts → skills}/vulnerabilities/race_conditions.jinja +0 -0
  110. /strix/{prompts → skills}/vulnerabilities/rce.jinja +0 -0
  111. /strix/{prompts → skills}/vulnerabilities/sql_injection.jinja +0 -0
  112. /strix/{prompts → skills}/vulnerabilities/ssrf.jinja +0 -0
  113. /strix/{prompts → skills}/vulnerabilities/subdomain_takeover.jinja +0 -0
  114. /strix/{prompts → skills}/vulnerabilities/xss.jinja +0 -0
  115. /strix/{prompts → skills}/vulnerabilities/xxe.jinja +0 -0
  116. {strix_agent-0.4.0.dist-info → strix_agent-0.6.2.dist-info}/entry_points.txt +0 -0
  117. {strix_agent-0.4.0.dist-info → strix_agent-0.6.2.dist-info/licenses}/LICENSE +0 -0
strix/interface/main.py CHANGED
@@ -6,10 +6,10 @@ Strix Agent Interface
6
6
  import argparse
7
7
  import asyncio
8
8
  import logging
9
- import os
10
9
  import shutil
11
10
  import sys
12
11
  from pathlib import Path
12
+ from typing import Any
13
13
 
14
14
  import litellm
15
15
  from docker.errors import DockerException
@@ -17,9 +17,14 @@ from rich.console import Console
17
17
  from rich.panel import Panel
18
18
  from rich.text import Text
19
19
 
20
- from strix.interface.cli import run_cli
21
- from strix.interface.tui import run_tui
22
- from strix.interface.utils import (
20
+ from strix.config import Config, apply_saved_config, save_current_config
21
+
22
+
23
+ apply_saved_config()
24
+
25
+ from strix.interface.cli import run_cli # noqa: E402
26
+ from strix.interface.tui import run_tui # noqa: E402
27
+ from strix.interface.utils import ( # noqa: E402
23
28
  assign_workspace_subdirs,
24
29
  build_final_stats_text,
25
30
  check_docker_connection,
@@ -29,10 +34,12 @@ from strix.interface.utils import (
29
34
  image_exists,
30
35
  infer_target_type,
31
36
  process_pull_line,
37
+ rewrite_localhost_targets,
32
38
  validate_llm_response,
33
39
  )
34
- from strix.runtime.docker_runtime import STRIX_IMAGE
35
- from strix.telemetry.tracer import get_global_tracer
40
+ from strix.runtime.docker_runtime import HOST_GATEWAY_HOSTNAME # noqa: E402
41
+ from strix.telemetry import posthog # noqa: E402
42
+ from strix.telemetry.tracer import get_global_tracer # noqa: E402
36
43
 
37
44
 
38
45
  logging.getLogger().setLevel(logging.ERROR)
@@ -43,30 +50,30 @@ def validate_environment() -> None: # noqa: PLR0912, PLR0915
43
50
  missing_required_vars = []
44
51
  missing_optional_vars = []
45
52
 
46
- if not os.getenv("STRIX_LLM"):
53
+ if not Config.get("strix_llm"):
47
54
  missing_required_vars.append("STRIX_LLM")
48
55
 
49
56
  has_base_url = any(
50
57
  [
51
- os.getenv("LLM_API_BASE"),
52
- os.getenv("OPENAI_API_BASE"),
53
- os.getenv("LITELLM_BASE_URL"),
54
- os.getenv("OLLAMA_API_BASE"),
58
+ Config.get("llm_api_base"),
59
+ Config.get("openai_api_base"),
60
+ Config.get("litellm_base_url"),
61
+ Config.get("ollama_api_base"),
55
62
  ]
56
63
  )
57
64
 
58
- if not os.getenv("LLM_API_KEY"):
59
- if not has_base_url:
60
- missing_required_vars.append("LLM_API_KEY")
61
- else:
62
- missing_optional_vars.append("LLM_API_KEY")
65
+ if not Config.get("llm_api_key"):
66
+ missing_optional_vars.append("LLM_API_KEY")
63
67
 
64
68
  if not has_base_url:
65
69
  missing_optional_vars.append("LLM_API_BASE")
66
70
 
67
- if not os.getenv("PERPLEXITY_API_KEY"):
71
+ if not Config.get("perplexity_api_key"):
68
72
  missing_optional_vars.append("PERPLEXITY_API_KEY")
69
73
 
74
+ if not Config.get("strix_reasoning_effort"):
75
+ missing_optional_vars.append("STRIX_REASONING_EFFORT")
76
+
70
77
  if missing_required_vars:
71
78
  error_text = Text()
72
79
  error_text.append("❌ ", style="bold red")
@@ -92,13 +99,6 @@ def validate_environment() -> None: # noqa: PLR0912, PLR0915
92
99
  " - Model name to use with litellm (e.g., 'openai/gpt-5')\n",
93
100
  style="white",
94
101
  )
95
- elif var == "LLM_API_KEY":
96
- error_text.append("• ", style="white")
97
- error_text.append("LLM_API_KEY", style="bold cyan")
98
- error_text.append(
99
- " - API key for the LLM provider (required for cloud providers)\n",
100
- style="white",
101
- )
102
102
 
103
103
  if missing_optional_vars:
104
104
  error_text.append("\nOptional environment variables:\n", style="white")
@@ -106,7 +106,11 @@ def validate_environment() -> None: # noqa: PLR0912, PLR0915
106
106
  if var == "LLM_API_KEY":
107
107
  error_text.append("• ", style="white")
108
108
  error_text.append("LLM_API_KEY", style="bold cyan")
109
- error_text.append(" - API key for the LLM provider\n", style="white")
109
+ error_text.append(
110
+ " - API key for the LLM provider "
111
+ "(not needed for local models, Vertex AI, AWS, etc.)\n",
112
+ style="white",
113
+ )
110
114
  elif var == "LLM_API_BASE":
111
115
  error_text.append("• ", style="white")
112
116
  error_text.append("LLM_API_BASE", style="bold cyan")
@@ -121,18 +125,24 @@ def validate_environment() -> None: # noqa: PLR0912, PLR0915
121
125
  " - API key for Perplexity AI web search (enables real-time research)\n",
122
126
  style="white",
123
127
  )
128
+ elif var == "STRIX_REASONING_EFFORT":
129
+ error_text.append("• ", style="white")
130
+ error_text.append("STRIX_REASONING_EFFORT", style="bold cyan")
131
+ error_text.append(
132
+ " - Reasoning effort level: none, minimal, low, medium, high, xhigh "
133
+ "(default: high)\n",
134
+ style="white",
135
+ )
124
136
 
125
137
  error_text.append("\nExample setup:\n", style="white")
126
138
  error_text.append("export STRIX_LLM='openai/gpt-5'\n", style="dim white")
127
139
 
128
- if "LLM_API_KEY" in missing_required_vars:
129
- error_text.append("export LLM_API_KEY='your-api-key-here'\n", style="dim white")
130
-
131
140
  if missing_optional_vars:
132
141
  for var in missing_optional_vars:
133
142
  if var == "LLM_API_KEY":
134
143
  error_text.append(
135
- "export LLM_API_KEY='your-api-key-here' # optional with local models\n",
144
+ "export LLM_API_KEY='your-api-key-here' "
145
+ "# not needed for local models, Vertex AI, AWS, etc.\n",
136
146
  style="dim white",
137
147
  )
138
148
  elif var == "LLM_API_BASE":
@@ -145,6 +155,11 @@ def validate_environment() -> None: # noqa: PLR0912, PLR0915
145
155
  error_text.append(
146
156
  "export PERPLEXITY_API_KEY='your-perplexity-key-here'\n", style="dim white"
147
157
  )
158
+ elif var == "STRIX_REASONING_EFFORT":
159
+ error_text.append(
160
+ "export STRIX_REASONING_EFFORT='high'\n",
161
+ style="dim white",
162
+ )
148
163
 
149
164
  panel = Panel(
150
165
  error_text,
@@ -187,33 +202,33 @@ async def warm_up_llm() -> None:
187
202
  console = Console()
188
203
 
189
204
  try:
190
- model_name = os.getenv("STRIX_LLM", "openai/gpt-5")
191
- api_key = os.getenv("LLM_API_KEY")
192
-
193
- if api_key:
194
- litellm.api_key = api_key
195
-
205
+ model_name = Config.get("strix_llm")
206
+ api_key = Config.get("llm_api_key")
196
207
  api_base = (
197
- os.getenv("LLM_API_BASE")
198
- or os.getenv("OPENAI_API_BASE")
199
- or os.getenv("LITELLM_BASE_URL")
200
- or os.getenv("OLLAMA_API_BASE")
208
+ Config.get("llm_api_base")
209
+ or Config.get("openai_api_base")
210
+ or Config.get("litellm_base_url")
211
+ or Config.get("ollama_api_base")
201
212
  )
202
- if api_base:
203
- litellm.api_base = api_base
204
213
 
205
214
  test_messages = [
206
215
  {"role": "system", "content": "You are a helpful assistant."},
207
216
  {"role": "user", "content": "Reply with just 'OK'."},
208
217
  ]
209
218
 
210
- llm_timeout = int(os.getenv("LLM_TIMEOUT", "600"))
219
+ llm_timeout = int(Config.get("llm_timeout") or "300")
211
220
 
212
- response = litellm.completion(
213
- model=model_name,
214
- messages=test_messages,
215
- timeout=llm_timeout,
216
- )
221
+ completion_kwargs: dict[str, Any] = {
222
+ "model": model_name,
223
+ "messages": test_messages,
224
+ "timeout": llm_timeout,
225
+ }
226
+ if api_key:
227
+ completion_kwargs["api_key"] = api_key
228
+ if api_base:
229
+ completion_kwargs["api_base"] = api_base
230
+
231
+ response = litellm.completion(**completion_kwargs)
217
232
 
218
233
  validate_llm_response(response)
219
234
 
@@ -240,6 +255,15 @@ async def warm_up_llm() -> None:
240
255
  sys.exit(1)
241
256
 
242
257
 
258
+ def get_version() -> str:
259
+ try:
260
+ from importlib.metadata import version
261
+
262
+ return version("strix-agent")
263
+ except Exception: # noqa: BLE001
264
+ return "unknown"
265
+
266
+
243
267
  def parse_arguments() -> argparse.Namespace:
244
268
  parser = argparse.ArgumentParser(
245
269
  description="Strix Multi-Agent Cybersecurity Penetration Testing Tool",
@@ -270,11 +294,18 @@ Examples:
270
294
  strix --target example.com --instruction "Focus on authentication vulnerabilities"
271
295
 
272
296
  # Custom instructions (from file)
273
- strix --target example.com --instruction ./instructions.txt
274
- strix --target https://app.com --instruction /path/to/detailed_instructions.md
297
+ strix --target example.com --instruction-file ./instructions.txt
298
+ strix --target https://app.com --instruction-file /path/to/detailed_instructions.md
275
299
  """,
276
300
  )
277
301
 
302
+ parser.add_argument(
303
+ "-v",
304
+ "--version",
305
+ action="version",
306
+ version=f"strix {get_version()}",
307
+ )
308
+
278
309
  parser.add_argument(
279
310
  "-t",
280
311
  "--target",
@@ -292,15 +323,15 @@ Examples:
292
323
  "testing approaches (e.g., 'Perform thorough authentication testing'), "
293
324
  "test credentials (e.g., 'Use the following credentials to access the app: "
294
325
  "admin:password123'), "
295
- "or areas of interest (e.g., 'Check login API endpoint for security issues'). "
296
- "You can also provide a path to a file containing detailed instructions "
297
- "(e.g., '--instruction ./instructions.txt').",
326
+ "or areas of interest (e.g., 'Check login API endpoint for security issues').",
298
327
  )
299
328
 
300
329
  parser.add_argument(
301
- "--run-name",
330
+ "--instruction-file",
302
331
  type=str,
303
- help="Custom name for this penetration test run",
332
+ help="Path to a file containing detailed custom instructions for the penetration test. "
333
+ "Use this option when you have lengthy or complex instructions saved in a file "
334
+ "(e.g., '--instruction-file ./detailed_instructions.txt').",
304
335
  )
305
336
 
306
337
  parser.add_argument(
@@ -313,18 +344,37 @@ Examples:
313
344
  ),
314
345
  )
315
346
 
347
+ parser.add_argument(
348
+ "-m",
349
+ "--scan-mode",
350
+ type=str,
351
+ choices=["quick", "standard", "deep"],
352
+ default="deep",
353
+ help=(
354
+ "Scan mode: "
355
+ "'quick' for fast CI/CD checks, "
356
+ "'standard' for routine testing, "
357
+ "'deep' for thorough security reviews (default). "
358
+ "Default: deep."
359
+ ),
360
+ )
361
+
316
362
  args = parser.parse_args()
317
363
 
318
- if args.instruction:
319
- instruction_path = Path(args.instruction)
320
- if instruction_path.exists() and instruction_path.is_file():
321
- try:
322
- with instruction_path.open(encoding="utf-8") as f:
323
- args.instruction = f.read().strip()
324
- if not args.instruction:
325
- parser.error(f"Instruction file '{instruction_path}' is empty")
326
- except Exception as e: # noqa: BLE001
327
- parser.error(f"Failed to read instruction file '{instruction_path}': {e}")
364
+ if args.instruction and args.instruction_file:
365
+ parser.error(
366
+ "Cannot specify both --instruction and --instruction-file. Use one or the other."
367
+ )
368
+
369
+ if args.instruction_file:
370
+ instruction_path = Path(args.instruction_file)
371
+ try:
372
+ with instruction_path.open(encoding="utf-8") as f:
373
+ args.instruction = f.read().strip()
374
+ if not args.instruction:
375
+ parser.error(f"Instruction file '{instruction_path}' is empty")
376
+ except Exception as e: # noqa: BLE001
377
+ parser.error(f"Failed to read instruction file '{instruction_path}': {e}")
328
378
 
329
379
  args.targets_info = []
330
380
  for target in args.target:
@@ -343,6 +393,7 @@ Examples:
343
393
  parser.error(f"Invalid target '{target}'")
344
394
 
345
395
  assign_workspace_subdirs(args.targets_info)
396
+ rewrite_localhost_targets(args.targets_info, HOST_GATEWAY_HOSTNAME)
346
397
 
347
398
  return args
348
399
 
@@ -410,17 +461,20 @@ def display_completion_message(args: argparse.Namespace, results_path: Path) ->
410
461
  console.print("\n")
411
462
  console.print(panel)
412
463
  console.print()
464
+ console.print("[dim]🌐 Website:[/] [cyan]https://strix.ai[/]")
465
+ console.print("[dim]💬 Discord:[/] [cyan]https://discord.gg/YjKFvEZSdZ[/]")
466
+ console.print()
413
467
 
414
468
 
415
469
  def pull_docker_image() -> None:
416
470
  console = Console()
417
471
  client = check_docker_connection()
418
472
 
419
- if image_exists(client, STRIX_IMAGE):
473
+ if image_exists(client, Config.get("strix_image")): # type: ignore[arg-type]
420
474
  return
421
475
 
422
476
  console.print()
423
- console.print(f"[bold cyan]🐳 Pulling Docker image:[/] {STRIX_IMAGE}")
477
+ console.print(f"[bold cyan]🐳 Pulling Docker image:[/] {Config.get('strix_image')}")
424
478
  console.print("[dim yellow]This only happens on first run and may take a few minutes...[/]")
425
479
  console.print()
426
480
 
@@ -429,7 +483,7 @@ def pull_docker_image() -> None:
429
483
  layers_info: dict[str, str] = {}
430
484
  last_update = ""
431
485
 
432
- for line in client.api.pull(STRIX_IMAGE, stream=True, decode=True):
486
+ for line in client.api.pull(Config.get("strix_image"), stream=True, decode=True):
433
487
  last_update = process_pull_line(line, layers_info, status, last_update)
434
488
 
435
489
  except DockerException as e:
@@ -438,7 +492,7 @@ def pull_docker_image() -> None:
438
492
  error_text.append("❌ ", style="bold red")
439
493
  error_text.append("FAILED TO PULL IMAGE", style="bold red")
440
494
  error_text.append("\n\n", style="white")
441
- error_text.append(f"Could not download: {STRIX_IMAGE}\n", style="white")
495
+ error_text.append(f"Could not download: {Config.get('strix_image')}\n", style="white")
442
496
  error_text.append(str(e), style="dim red")
443
497
 
444
498
  panel = Panel(
@@ -470,8 +524,9 @@ def main() -> None:
470
524
  validate_environment()
471
525
  asyncio.run(warm_up_llm())
472
526
 
473
- if not args.run_name:
474
- args.run_name = generate_run_name(args.targets_info)
527
+ save_current_config()
528
+
529
+ args.run_name = generate_run_name(args.targets_info)
475
530
 
476
531
  for target_info in args.targets_info:
477
532
  if target_info["type"] == "repository":
@@ -482,10 +537,32 @@ def main() -> None:
482
537
 
483
538
  args.local_sources = collect_local_sources(args.targets_info)
484
539
 
485
- if args.non_interactive:
486
- asyncio.run(run_cli(args))
487
- else:
488
- asyncio.run(run_tui(args))
540
+ is_whitebox = bool(args.local_sources)
541
+
542
+ posthog.start(
543
+ model=Config.get("strix_llm"),
544
+ scan_mode=args.scan_mode,
545
+ is_whitebox=is_whitebox,
546
+ interactive=not args.non_interactive,
547
+ has_instructions=bool(args.instruction),
548
+ )
549
+
550
+ exit_reason = "user_exit"
551
+ try:
552
+ if args.non_interactive:
553
+ asyncio.run(run_cli(args))
554
+ else:
555
+ asyncio.run(run_tui(args))
556
+ except KeyboardInterrupt:
557
+ exit_reason = "interrupted"
558
+ except Exception as e:
559
+ exit_reason = "error"
560
+ posthog.error("unhandled_exception", str(e))
561
+ raise
562
+ finally:
563
+ tracer = get_global_tracer()
564
+ if tracer:
565
+ posthog.end(tracer, exit_reason=exit_reason)
489
566
 
490
567
  results_path = Path("strix_runs") / args.run_name
491
568
  display_completion_message(args, results_path)
@@ -0,0 +1,119 @@
1
+ import html
2
+ import re
3
+ from dataclasses import dataclass
4
+ from typing import Literal
5
+
6
+
7
+ _FUNCTION_TAG_PREFIX = "<function="
8
+
9
+
10
+ def _get_safe_content(content: str) -> tuple[str, str]:
11
+ if not content:
12
+ return "", ""
13
+
14
+ last_lt = content.rfind("<")
15
+ if last_lt == -1:
16
+ return content, ""
17
+
18
+ suffix = content[last_lt:]
19
+ target = _FUNCTION_TAG_PREFIX # "<function="
20
+
21
+ if target.startswith(suffix):
22
+ return content[:last_lt], suffix
23
+
24
+ return content, ""
25
+
26
+
27
+ @dataclass
28
+ class StreamSegment:
29
+ type: Literal["text", "tool"]
30
+ content: str
31
+ tool_name: str | None = None
32
+ args: dict[str, str] | None = None
33
+ is_complete: bool = False
34
+
35
+
36
+ def parse_streaming_content(content: str) -> list[StreamSegment]:
37
+ if not content:
38
+ return []
39
+
40
+ segments: list[StreamSegment] = []
41
+
42
+ func_pattern = r"<function=([^>]+)>"
43
+ func_matches = list(re.finditer(func_pattern, content))
44
+
45
+ if not func_matches:
46
+ safe_content, _ = _get_safe_content(content)
47
+ text = safe_content.strip()
48
+ if text:
49
+ segments.append(StreamSegment(type="text", content=text))
50
+ return segments
51
+
52
+ first_func_start = func_matches[0].start()
53
+ if first_func_start > 0:
54
+ text_before = content[:first_func_start].strip()
55
+ if text_before:
56
+ segments.append(StreamSegment(type="text", content=text_before))
57
+
58
+ for i, match in enumerate(func_matches):
59
+ tool_name = match.group(1)
60
+ func_start = match.end()
61
+
62
+ func_end_match = re.search(r"</function>", content[func_start:])
63
+
64
+ if func_end_match:
65
+ func_body = content[func_start : func_start + func_end_match.start()]
66
+ is_complete = True
67
+ end_pos = func_start + func_end_match.end()
68
+ else:
69
+ if i + 1 < len(func_matches):
70
+ next_func_start = func_matches[i + 1].start()
71
+ func_body = content[func_start:next_func_start]
72
+ else:
73
+ func_body = content[func_start:]
74
+ is_complete = False
75
+ end_pos = len(content)
76
+
77
+ args = _parse_streaming_params(func_body)
78
+
79
+ segments.append(
80
+ StreamSegment(
81
+ type="tool",
82
+ content=func_body,
83
+ tool_name=tool_name,
84
+ args=args,
85
+ is_complete=is_complete,
86
+ )
87
+ )
88
+
89
+ if is_complete and i + 1 < len(func_matches):
90
+ next_start = func_matches[i + 1].start()
91
+ text_between = content[end_pos:next_start].strip()
92
+ if text_between:
93
+ segments.append(StreamSegment(type="text", content=text_between))
94
+
95
+ return segments
96
+
97
+
98
+ def _parse_streaming_params(func_body: str) -> dict[str, str]:
99
+ args: dict[str, str] = {}
100
+
101
+ complete_pattern = r"<parameter=([^>]+)>(.*?)</parameter>"
102
+ complete_matches = list(re.finditer(complete_pattern, func_body, re.DOTALL))
103
+ complete_end_pos = 0
104
+
105
+ for match in complete_matches:
106
+ param_name = match.group(1)
107
+ param_value = html.unescape(match.group(2).strip())
108
+ args[param_name] = param_value
109
+ complete_end_pos = max(complete_end_pos, match.end())
110
+
111
+ remaining = func_body[complete_end_pos:]
112
+ incomplete_pattern = r"<parameter=([^>]+)>(.*)$"
113
+ incomplete_match = re.search(incomplete_pattern, remaining, re.DOTALL)
114
+ if incomplete_match:
115
+ param_name = incomplete_match.group(1)
116
+ param_value = html.unescape(incomplete_match.group(2).strip())
117
+ args[param_name] = param_value
118
+
119
+ return args
@@ -1,4 +1,5 @@
1
1
  from . import (
2
+ agent_message_renderer,
2
3
  agents_graph_renderer,
3
4
  browser_renderer,
4
5
  file_edit_renderer,
@@ -10,6 +11,7 @@ from . import (
10
11
  scan_info_renderer,
11
12
  terminal_renderer,
12
13
  thinking_renderer,
14
+ todo_renderer,
13
15
  user_message_renderer,
14
16
  web_search_renderer,
15
17
  )
@@ -20,6 +22,7 @@ from .registry import ToolTUIRegistry, get_tool_renderer, register_tool_renderer
20
22
  __all__ = [
21
23
  "BaseToolRenderer",
22
24
  "ToolTUIRegistry",
25
+ "agent_message_renderer",
23
26
  "agents_graph_renderer",
24
27
  "browser_renderer",
25
28
  "file_edit_renderer",
@@ -34,6 +37,7 @@ __all__ = [
34
37
  "scan_info_renderer",
35
38
  "terminal_renderer",
36
39
  "thinking_renderer",
40
+ "todo_renderer",
37
41
  "user_message_renderer",
38
42
  "web_search_renderer",
39
43
  ]