hackagent 0.3.1__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 (183) hide show
  1. hackagent/__init__.py +12 -0
  2. hackagent/agent.py +214 -0
  3. hackagent/api/__init__.py +1 -0
  4. hackagent/api/agent/__init__.py +1 -0
  5. hackagent/api/agent/agent_create.py +347 -0
  6. hackagent/api/agent/agent_destroy.py +140 -0
  7. hackagent/api/agent/agent_list.py +242 -0
  8. hackagent/api/agent/agent_partial_update.py +361 -0
  9. hackagent/api/agent/agent_retrieve.py +235 -0
  10. hackagent/api/agent/agent_update.py +361 -0
  11. hackagent/api/apilogs/__init__.py +1 -0
  12. hackagent/api/apilogs/apilogs_list.py +170 -0
  13. hackagent/api/apilogs/apilogs_retrieve.py +162 -0
  14. hackagent/api/attack/__init__.py +1 -0
  15. hackagent/api/attack/attack_create.py +275 -0
  16. hackagent/api/attack/attack_destroy.py +146 -0
  17. hackagent/api/attack/attack_list.py +254 -0
  18. hackagent/api/attack/attack_partial_update.py +289 -0
  19. hackagent/api/attack/attack_retrieve.py +247 -0
  20. hackagent/api/attack/attack_update.py +289 -0
  21. hackagent/api/checkout/__init__.py +1 -0
  22. hackagent/api/checkout/checkout_create.py +225 -0
  23. hackagent/api/generate/__init__.py +1 -0
  24. hackagent/api/generate/generate_create.py +253 -0
  25. hackagent/api/judge/__init__.py +1 -0
  26. hackagent/api/judge/judge_create.py +253 -0
  27. hackagent/api/key/__init__.py +1 -0
  28. hackagent/api/key/key_create.py +179 -0
  29. hackagent/api/key/key_destroy.py +103 -0
  30. hackagent/api/key/key_list.py +170 -0
  31. hackagent/api/key/key_retrieve.py +162 -0
  32. hackagent/api/organization/__init__.py +1 -0
  33. hackagent/api/organization/organization_create.py +208 -0
  34. hackagent/api/organization/organization_destroy.py +104 -0
  35. hackagent/api/organization/organization_list.py +170 -0
  36. hackagent/api/organization/organization_me_retrieve.py +126 -0
  37. hackagent/api/organization/organization_partial_update.py +222 -0
  38. hackagent/api/organization/organization_retrieve.py +163 -0
  39. hackagent/api/organization/organization_update.py +222 -0
  40. hackagent/api/prompt/__init__.py +1 -0
  41. hackagent/api/prompt/prompt_create.py +171 -0
  42. hackagent/api/prompt/prompt_destroy.py +104 -0
  43. hackagent/api/prompt/prompt_list.py +185 -0
  44. hackagent/api/prompt/prompt_partial_update.py +185 -0
  45. hackagent/api/prompt/prompt_retrieve.py +163 -0
  46. hackagent/api/prompt/prompt_update.py +185 -0
  47. hackagent/api/result/__init__.py +1 -0
  48. hackagent/api/result/result_create.py +175 -0
  49. hackagent/api/result/result_destroy.py +106 -0
  50. hackagent/api/result/result_list.py +249 -0
  51. hackagent/api/result/result_partial_update.py +193 -0
  52. hackagent/api/result/result_retrieve.py +167 -0
  53. hackagent/api/result/result_trace_create.py +177 -0
  54. hackagent/api/result/result_update.py +189 -0
  55. hackagent/api/run/__init__.py +1 -0
  56. hackagent/api/run/run_create.py +187 -0
  57. hackagent/api/run/run_destroy.py +112 -0
  58. hackagent/api/run/run_list.py +291 -0
  59. hackagent/api/run/run_partial_update.py +201 -0
  60. hackagent/api/run/run_result_create.py +177 -0
  61. hackagent/api/run/run_retrieve.py +179 -0
  62. hackagent/api/run/run_run_tests_create.py +187 -0
  63. hackagent/api/run/run_update.py +201 -0
  64. hackagent/api/user/__init__.py +1 -0
  65. hackagent/api/user/user_create.py +212 -0
  66. hackagent/api/user/user_destroy.py +106 -0
  67. hackagent/api/user/user_list.py +174 -0
  68. hackagent/api/user/user_me_retrieve.py +126 -0
  69. hackagent/api/user/user_me_update.py +196 -0
  70. hackagent/api/user/user_partial_update.py +226 -0
  71. hackagent/api/user/user_retrieve.py +167 -0
  72. hackagent/api/user/user_update.py +226 -0
  73. hackagent/attacks/AdvPrefix/__init__.py +41 -0
  74. hackagent/attacks/AdvPrefix/completions.py +416 -0
  75. hackagent/attacks/AdvPrefix/config.py +259 -0
  76. hackagent/attacks/AdvPrefix/evaluation.py +745 -0
  77. hackagent/attacks/AdvPrefix/evaluators.py +564 -0
  78. hackagent/attacks/AdvPrefix/generate.py +711 -0
  79. hackagent/attacks/AdvPrefix/utils.py +307 -0
  80. hackagent/attacks/__init__.py +35 -0
  81. hackagent/attacks/advprefix.py +507 -0
  82. hackagent/attacks/base.py +106 -0
  83. hackagent/attacks/strategies.py +906 -0
  84. hackagent/cli/__init__.py +19 -0
  85. hackagent/cli/commands/__init__.py +20 -0
  86. hackagent/cli/commands/agent.py +100 -0
  87. hackagent/cli/commands/attack.py +417 -0
  88. hackagent/cli/commands/config.py +301 -0
  89. hackagent/cli/commands/results.py +327 -0
  90. hackagent/cli/config.py +249 -0
  91. hackagent/cli/main.py +515 -0
  92. hackagent/cli/tui/__init__.py +31 -0
  93. hackagent/cli/tui/actions_logger.py +200 -0
  94. hackagent/cli/tui/app.py +288 -0
  95. hackagent/cli/tui/base.py +137 -0
  96. hackagent/cli/tui/logger.py +318 -0
  97. hackagent/cli/tui/views/__init__.py +33 -0
  98. hackagent/cli/tui/views/agents.py +488 -0
  99. hackagent/cli/tui/views/attacks.py +624 -0
  100. hackagent/cli/tui/views/config.py +244 -0
  101. hackagent/cli/tui/views/dashboard.py +307 -0
  102. hackagent/cli/tui/views/results.py +1210 -0
  103. hackagent/cli/tui/widgets/__init__.py +24 -0
  104. hackagent/cli/tui/widgets/actions.py +346 -0
  105. hackagent/cli/tui/widgets/logs.py +435 -0
  106. hackagent/cli/utils.py +276 -0
  107. hackagent/client.py +286 -0
  108. hackagent/errors.py +37 -0
  109. hackagent/logger.py +83 -0
  110. hackagent/models/__init__.py +109 -0
  111. hackagent/models/agent.py +223 -0
  112. hackagent/models/agent_request.py +129 -0
  113. hackagent/models/api_token_log.py +184 -0
  114. hackagent/models/attack.py +154 -0
  115. hackagent/models/attack_request.py +82 -0
  116. hackagent/models/checkout_session_request_request.py +76 -0
  117. hackagent/models/checkout_session_response.py +59 -0
  118. hackagent/models/choice.py +81 -0
  119. hackagent/models/choice_message.py +67 -0
  120. hackagent/models/evaluation_status_enum.py +14 -0
  121. hackagent/models/generate_error_response.py +59 -0
  122. hackagent/models/generate_request_request.py +212 -0
  123. hackagent/models/generate_success_response.py +115 -0
  124. hackagent/models/generic_error_response.py +70 -0
  125. hackagent/models/message_request.py +67 -0
  126. hackagent/models/organization.py +102 -0
  127. hackagent/models/organization_minimal.py +68 -0
  128. hackagent/models/organization_request.py +71 -0
  129. hackagent/models/paginated_agent_list.py +123 -0
  130. hackagent/models/paginated_api_token_log_list.py +123 -0
  131. hackagent/models/paginated_attack_list.py +123 -0
  132. hackagent/models/paginated_organization_list.py +123 -0
  133. hackagent/models/paginated_prompt_list.py +123 -0
  134. hackagent/models/paginated_result_list.py +123 -0
  135. hackagent/models/paginated_run_list.py +123 -0
  136. hackagent/models/paginated_user_api_key_list.py +123 -0
  137. hackagent/models/paginated_user_profile_list.py +123 -0
  138. hackagent/models/patched_agent_request.py +128 -0
  139. hackagent/models/patched_attack_request.py +92 -0
  140. hackagent/models/patched_organization_request.py +71 -0
  141. hackagent/models/patched_prompt_request.py +125 -0
  142. hackagent/models/patched_result_request.py +237 -0
  143. hackagent/models/patched_run_request.py +138 -0
  144. hackagent/models/patched_user_profile_request.py +99 -0
  145. hackagent/models/prompt.py +220 -0
  146. hackagent/models/prompt_request.py +126 -0
  147. hackagent/models/result.py +294 -0
  148. hackagent/models/result_list_evaluation_status.py +14 -0
  149. hackagent/models/result_request.py +232 -0
  150. hackagent/models/run.py +233 -0
  151. hackagent/models/run_list_status.py +12 -0
  152. hackagent/models/run_request.py +133 -0
  153. hackagent/models/status_enum.py +12 -0
  154. hackagent/models/step_type_enum.py +14 -0
  155. hackagent/models/trace.py +121 -0
  156. hackagent/models/trace_request.py +94 -0
  157. hackagent/models/usage.py +75 -0
  158. hackagent/models/user_api_key.py +201 -0
  159. hackagent/models/user_api_key_request.py +73 -0
  160. hackagent/models/user_profile.py +135 -0
  161. hackagent/models/user_profile_minimal.py +76 -0
  162. hackagent/models/user_profile_request.py +99 -0
  163. hackagent/router/__init__.py +25 -0
  164. hackagent/router/adapters/__init__.py +20 -0
  165. hackagent/router/adapters/base.py +63 -0
  166. hackagent/router/adapters/google_adk.py +671 -0
  167. hackagent/router/adapters/litellm_adapter.py +524 -0
  168. hackagent/router/adapters/openai_adapter.py +426 -0
  169. hackagent/router/router.py +969 -0
  170. hackagent/router/types.py +54 -0
  171. hackagent/tracking/__init__.py +42 -0
  172. hackagent/tracking/context.py +163 -0
  173. hackagent/tracking/decorators.py +299 -0
  174. hackagent/tracking/tracker.py +441 -0
  175. hackagent/types.py +54 -0
  176. hackagent/utils.py +194 -0
  177. hackagent/vulnerabilities/__init__.py +13 -0
  178. hackagent/vulnerabilities/prompts.py +81 -0
  179. hackagent-0.3.1.dist-info/METADATA +122 -0
  180. hackagent-0.3.1.dist-info/RECORD +183 -0
  181. hackagent-0.3.1.dist-info/WHEEL +4 -0
  182. hackagent-0.3.1.dist-info/entry_points.txt +2 -0
  183. hackagent-0.3.1.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,1210 @@
1
+ # Copyright 2025 - AI4I. All rights reserved.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """
16
+ Results Tab
17
+
18
+ View and analyze attack results.
19
+ """
20
+
21
+ from datetime import datetime
22
+ import json
23
+ from typing import Any
24
+ from uuid import UUID
25
+
26
+ from textual.app import ComposeResult
27
+ from textual.binding import Binding
28
+ from textual.containers import Container, Horizontal, VerticalScroll
29
+ from textual.widgets import Button, DataTable, Label, Select, Static
30
+
31
+ from hackagent.cli.config import CLIConfig
32
+
33
+
34
+ class ResultsTab(Container):
35
+ """Results tab for viewing attack results with split view."""
36
+
37
+ DEFAULT_CSS = """
38
+ ResultsTab {
39
+ layout: horizontal;
40
+ }
41
+
42
+ ResultsTab #results-left-panel {
43
+ width: 30%;
44
+ border-right: solid $primary;
45
+ }
46
+
47
+ ResultsTab #results-right-panel {
48
+ width: 70%;
49
+ }
50
+
51
+ ResultsTab #results-table {
52
+ height: 100%;
53
+ }
54
+ """
55
+
56
+ BINDINGS = [
57
+ Binding("enter", "view_result", "View Details"),
58
+ Binding("s", "show_summary", "Summary"),
59
+ ]
60
+
61
+ def __init__(self, cli_config: CLIConfig):
62
+ """Initialize results tab.
63
+
64
+ Args:
65
+ cli_config: CLI configuration object
66
+ """
67
+ super().__init__()
68
+ self.cli_config = cli_config
69
+ self.results_data: list[Any] = []
70
+ self.selected_result: Any = None
71
+
72
+ def compose(self) -> ComposeResult:
73
+ """Compose the results layout with horizontal split."""
74
+ # Left side - Results list (30%)
75
+ with VerticalScroll(id="results-left-panel"):
76
+ yield Static(
77
+ "[bold cyan]🎯 Attack Results[/bold cyan]",
78
+ classes="section-header",
79
+ )
80
+
81
+ with Horizontal(classes="toolbar"):
82
+ yield Button("🔄 Refresh", id="refresh-results", variant="primary")
83
+ yield Button("📊 CSV", id="export-csv", variant="default")
84
+ yield Button("📄 JSON", id="export-json", variant="default")
85
+
86
+ with Horizontal(classes="toolbar"):
87
+ yield Label("Filter:")
88
+ yield Select(
89
+ [
90
+ ("All", "all"),
91
+ ("Pending", "pending"),
92
+ ("Running", "running"),
93
+ ("Completed", "completed"),
94
+ ("Failed", "failed"),
95
+ ],
96
+ id="status-filter",
97
+ value="all",
98
+ )
99
+ yield Label("Limit:")
100
+ yield Select(
101
+ [("10", "10"), ("25", "25"), ("50", "50"), ("100", "100")],
102
+ id="limit-select",
103
+ value="25",
104
+ )
105
+
106
+ # Results table
107
+ yield DataTable(zebra_stripes=True, cursor_type="row", id="results-table")
108
+
109
+ # Right side - Details view (70%)
110
+ with VerticalScroll(id="results-right-panel"):
111
+ yield Static(
112
+ "[bold cyan]📋 Result Details[/bold cyan]",
113
+ classes="section-header",
114
+ )
115
+ yield Static(
116
+ "[dim]💡 Select a result from the list to view full details and logs[/dim]",
117
+ id="result-details",
118
+ )
119
+
120
+ def on_mount(self) -> None:
121
+ """Called when the tab is mounted."""
122
+ # Initialize table columns
123
+ try:
124
+ table = self.query_one("#results-table", DataTable)
125
+ table.clear(columns=True)
126
+ table.add_columns("ID", "Status", "Agent", "Created", "Results")
127
+ except Exception as e:
128
+ self.app.notify(f"Failed to initialize table: {str(e)}", severity="error")
129
+
130
+ # Show loading message immediately
131
+ try:
132
+ details_widget = self.query_one("#result-details", Static)
133
+ details_widget.update("[cyan]Loading results from API...[/cyan]")
134
+ except Exception:
135
+ pass
136
+
137
+ # Initial load - call refresh_data directly to populate initial state
138
+ try:
139
+ self.refresh_data()
140
+ except Exception as e:
141
+ # If initial load fails, show error
142
+ try:
143
+ details_widget = self.query_one("#result-details", Static)
144
+ details_widget.update(
145
+ f"[red]Failed to load data: {str(e)}[/red]\n\n[dim]Press 🔄 Refresh button or F5 to retry[/dim]"
146
+ )
147
+ except Exception:
148
+ pass
149
+
150
+ def on_button_pressed(self, event: Button.Pressed) -> None:
151
+ """Handle button press events."""
152
+ if event.button.id == "refresh-results":
153
+ self.refresh_data()
154
+ elif event.button.id == "export-csv":
155
+ self._export_results_csv()
156
+ elif event.button.id == "export-json":
157
+ self._export_results_json()
158
+
159
+ def on_select_changed(self, event: Select.Changed) -> None:
160
+ """Handle select dropdown changes."""
161
+ if event.select.id in ["status-filter", "limit-select"]:
162
+ self.refresh_data()
163
+
164
+ def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
165
+ """Handle row selection in the results table."""
166
+ table = self.query_one(DataTable)
167
+ row_key = event.row_key
168
+ row_index = table.get_row_index(row_key)
169
+
170
+ if row_index < len(self.results_data):
171
+ self.selected_result = self.results_data[row_index]
172
+ self._show_result_details()
173
+
174
+ def refresh_data(self) -> None:
175
+ """Refresh results data from API."""
176
+ try:
177
+ from hackagent.api.run import run_list
178
+ from hackagent.client import AuthenticatedClient
179
+
180
+ # Get filter values
181
+ status_sel = self.query_one("#status-filter", Select).value
182
+ limit_sel = self.query_one("#limit-select", Select).value
183
+
184
+ # Ensure we have strings (Select.value can be None/NoSelection)
185
+ status_filter = str(status_sel) if status_sel is not None else "all"
186
+ limit = 25
187
+ if limit_sel is not None:
188
+ try:
189
+ limit = int(str(limit_sel))
190
+ except (ValueError, TypeError):
191
+ limit = 25
192
+
193
+ # Validate configuration
194
+ if not self.cli_config.api_key:
195
+ self._show_empty_state("API key not configured")
196
+ return
197
+
198
+ import httpx
199
+
200
+ client = AuthenticatedClient(
201
+ base_url=self.cli_config.base_url,
202
+ token=self.cli_config.api_key,
203
+ prefix="Bearer",
204
+ timeout=httpx.Timeout(5.0, connect=5.0), # 5 second timeout
205
+ )
206
+
207
+ # Build query parameters with status filter if not "all"
208
+ kwargs = {"client": client, "page_size": limit}
209
+ if status_filter and status_filter != "all":
210
+ # Map filter values to API enum
211
+ from hackagent.models.run_list_status import RunListStatus
212
+
213
+ status_map = {
214
+ "pending": RunListStatus.PENDING,
215
+ "running": RunListStatus.RUNNING,
216
+ "completed": RunListStatus.COMPLETED,
217
+ "failed": RunListStatus.FAILED,
218
+ }
219
+ if status_filter.lower() in status_map:
220
+ kwargs["status"] = status_map[status_filter.lower()]
221
+
222
+ response = run_list.sync_detailed(**kwargs)
223
+
224
+ if response.status_code == 200 and response.parsed:
225
+ # Get all runs - these contain agent_name, attack info, etc.
226
+ all_runs = response.parsed.results if response.parsed.results else []
227
+
228
+ self.results_data = all_runs if all_runs else []
229
+
230
+ if not self.results_data:
231
+ self._show_empty_state(
232
+ "No runs found. Execute an attack to see results here."
233
+ )
234
+ else:
235
+ self._update_table()
236
+ elif response.status_code == 401:
237
+ self._show_empty_state("Authentication failed")
238
+ elif response.status_code == 403:
239
+ self._show_empty_state("Access forbidden")
240
+ else:
241
+ self._show_empty_state(
242
+ f"Failed to fetch results: {response.status_code}"
243
+ )
244
+
245
+ except Exception as e:
246
+ error_type = type(e).__name__
247
+ error_msg = str(e)
248
+
249
+ # Provide helpful error messages
250
+ if "timeout" in error_msg.lower() or "TimeoutException" in error_type:
251
+ self._show_empty_state(
252
+ f"⚠️ Connection Timeout\n\n"
253
+ f"Cannot reach API: {self.cli_config.base_url}\n"
254
+ f"Check your network connection and retry."
255
+ )
256
+ elif "401" in error_msg or "authentication" in error_msg.lower():
257
+ self._show_empty_state(
258
+ "🔒 Authentication Failed\n\nYour API key is invalid.\nRun: hackagent config set --api-key YOUR_KEY"
259
+ )
260
+ else:
261
+ self._show_empty_state(
262
+ f"Error loading results: {error_type}\n{error_msg}"
263
+ )
264
+
265
+ def _show_empty_state(self, message: str) -> None:
266
+ """Show an empty state message when no data is available.
267
+
268
+ Args:
269
+ message: Message to display
270
+ """
271
+ table = self.query_one("#results-table", DataTable)
272
+ table.clear()
273
+
274
+ # Show message in details area
275
+ details_widget = self.query_one("#result-details", Static)
276
+ details_widget.update(
277
+ f"[yellow]{message}[/yellow]\n\n[dim]💡 Tip: Press F5 or click 🔄 Refresh to retry[/dim]"
278
+ )
279
+
280
+ def _update_table(self) -> None:
281
+ """Update the results table with current data."""
282
+ try:
283
+ table = self.query_one("#results-table", DataTable)
284
+ table.clear()
285
+
286
+ for run in self.results_data:
287
+ # Get status with color coding from Run.status
288
+ status_display = "Unknown"
289
+ if hasattr(run, "status"):
290
+ status_val = run.status
291
+ if hasattr(status_val, "value"):
292
+ status_display = status_val.value
293
+ else:
294
+ status_display = str(status_val)
295
+
296
+ # Color code based on status
297
+ status_upper = status_display.upper()
298
+ if status_upper == "COMPLETED":
299
+ status_display = f"[green]✅ {status_display}[/green]"
300
+ elif status_upper == "RUNNING":
301
+ status_display = f"[cyan]🔄 {status_display}[/cyan]"
302
+ elif status_upper == "FAILED":
303
+ status_display = f"[red]❌ {status_display}[/red]"
304
+ elif status_upper == "PENDING":
305
+ status_display = f"[yellow]⏳ {status_display}[/yellow]"
306
+ else:
307
+ status_display = f"❓ {status_display}"
308
+
309
+ # Get agent name - directly available in Run model
310
+ agent_name = run.agent_name if hasattr(run, "agent_name") else "Unknown"
311
+
312
+ # Get created time from timestamp
313
+ created_time = "N/A"
314
+ if hasattr(run, "timestamp") and run.timestamp:
315
+ try:
316
+ dt = (
317
+ run.timestamp
318
+ if isinstance(run.timestamp, datetime)
319
+ else datetime.fromisoformat(
320
+ str(run.timestamp).replace("Z", "+00:00")
321
+ )
322
+ )
323
+ created_time = dt.strftime("%Y-%m-%d %H:%M")
324
+ except Exception:
325
+ created_time = str(run.timestamp)[:16]
326
+
327
+ # Get results count as a metric
328
+ results_count = (
329
+ len(run.results) if hasattr(run, "results") and run.results else 0
330
+ )
331
+ results_display = f"{results_count} results"
332
+
333
+ # Add row with all columns: ID, Status, Agent, Created, Results
334
+ table.add_row(
335
+ str(run.id)[:8] + "...",
336
+ status_display,
337
+ agent_name,
338
+ created_time,
339
+ results_display,
340
+ )
341
+
342
+ # Show success message
343
+ details_widget = self.query_one("#result-details", Static)
344
+ details_widget.update(
345
+ f"[green]✅ Loaded {len(self.results_data)} run(s)[/green]\n\n"
346
+ f"[dim]💡 Click any row to view full details including:\n"
347
+ f" • Agent: {self.results_data[0].agent_name if self.results_data else 'N/A'}\n"
348
+ f" • Organization: {self.results_data[0].organization_name if self.results_data else 'N/A'}\n"
349
+ f" • Run configuration\n"
350
+ f" • All result evaluations\n"
351
+ f" • Execution traces & logs[/dim]"
352
+ )
353
+
354
+ except Exception as e:
355
+ # If table update fails, show error
356
+ details_widget = self.query_one("#result-details", Static)
357
+ details_widget.update(f"[red]❌ Error updating table: {str(e)}[/red]")
358
+
359
+ def _parse_agent_actions(self, logs_str: str) -> list[dict[str, Any]]:
360
+ """Parse agent actions from log strings.
361
+
362
+ Args:
363
+ logs_str: Raw log string
364
+
365
+ Returns:
366
+ List of parsed action dictionaries
367
+ """
368
+ import re
369
+
370
+ actions = []
371
+ lines = logs_str.split("\n")
372
+
373
+ for i, line in enumerate(lines):
374
+ # HTTP requests
375
+ if "HTTP" in line and (
376
+ "POST" in line or "GET" in line or "PUT" in line or "DELETE" in line
377
+ ):
378
+ method_match = re.search(r"(GET|POST|PUT|DELETE|PATCH)", line)
379
+ url_match = re.search(r"(https?://[^\s]+)", line)
380
+ if method_match and url_match:
381
+ actions.append(
382
+ {
383
+ "type": "http_request",
384
+ "method": method_match.group(1),
385
+ "url": url_match.group(1),
386
+ "line_num": i + 1,
387
+ }
388
+ )
389
+
390
+ # Tool/Function calls
391
+ elif "Tool:" in line or "Function:" in line or "🔧" in line:
392
+ tool_match = re.search(r"(?:Tool|Function):\s*([\w_]+)", line)
393
+ if tool_match:
394
+ tool_name = tool_match.group(1)
395
+ # Look for arguments in next few lines
396
+ args = ""
397
+ for j in range(i + 1, min(i + 5, len(lines))):
398
+ if "Arguments:" in lines[j] or "Input:" in lines[j]:
399
+ args = lines[j]
400
+ break
401
+ actions.append(
402
+ {
403
+ "type": "tool_call",
404
+ "tool_name": tool_name,
405
+ "arguments": args,
406
+ "line_num": i + 1,
407
+ }
408
+ )
409
+
410
+ # ADK events
411
+ elif "ADK" in line and (
412
+ "tool_call" in line.lower() or "tool_result" in line.lower()
413
+ ):
414
+ if "tool_call" in line.lower():
415
+ actions.append(
416
+ {"type": "adk_tool_call", "content": line, "line_num": i + 1}
417
+ )
418
+ elif "tool_result" in line.lower():
419
+ actions.append(
420
+ {"type": "adk_tool_result", "content": line, "line_num": i + 1}
421
+ )
422
+
423
+ # Model queries
424
+ elif "Querying model" in line or "LLM" in line:
425
+ model_match = re.search(r"model[\s:]+([\w-]+)", line)
426
+ if model_match:
427
+ actions.append(
428
+ {
429
+ "type": "llm_query",
430
+ "model": model_match.group(1),
431
+ "line_num": i + 1,
432
+ }
433
+ )
434
+
435
+ return actions
436
+
437
+ def _show_result_details(self) -> None:
438
+ """Show details of the selected run and its results."""
439
+ if not self.selected_result:
440
+ return
441
+
442
+ run = self.selected_result # This is a Run object now
443
+ details_widget = self.query_one("#result-details", Static)
444
+
445
+ # Fetch full run details from API including all results and traces
446
+ try:
447
+ import httpx
448
+
449
+ from hackagent.api.run import run_retrieve
450
+ from hackagent.client import AuthenticatedClient
451
+
452
+ client = AuthenticatedClient(
453
+ base_url=self.cli_config.base_url,
454
+ token=self.cli_config.api_key,
455
+ prefix="Bearer",
456
+ timeout=httpx.Timeout(
457
+ 10.0, connect=10.0
458
+ ), # 10 second timeout for detailed data
459
+ )
460
+
461
+ run_id = run.id if isinstance(run.id, UUID) else UUID(str(run.id))
462
+ response = run_retrieve.sync_detailed(client=client, id=run_id)
463
+
464
+ if response.status_code == 200 and response.parsed:
465
+ run = (
466
+ response.parsed
467
+ ) # Use full run with all details, results, and traces
468
+ except Exception as e:
469
+ # If fetch fails, continue with cached run but show warning
470
+ details_widget.update(
471
+ f"[yellow]⚠️ Could not fetch full details: {str(e)}[/yellow]\n\n[dim]Showing cached data...[/dim]"
472
+ )
473
+ return
474
+
475
+ # Format creation date
476
+ created = "Unknown"
477
+ if hasattr(run, "timestamp") and run.timestamp:
478
+ try:
479
+ if isinstance(run.timestamp, datetime):
480
+ created = run.timestamp.strftime("%Y-%m-%d %H:%M:%S")
481
+ else:
482
+ created = str(run.timestamp)
483
+ except (AttributeError, ValueError, TypeError):
484
+ created = str(run.timestamp)
485
+
486
+ # Get status from Run
487
+ status_display = "Unknown"
488
+ if hasattr(run, "status"):
489
+ status_val = run.status
490
+ if hasattr(status_val, "value"):
491
+ status_display = status_val.value
492
+ else:
493
+ status_display = str(status_val)
494
+
495
+ # Status color and icon based on status
496
+ status_color = "yellow"
497
+ status_icon = "🔄"
498
+ if status_display.upper() == "COMPLETED":
499
+ status_color = "green"
500
+ status_icon = "✅"
501
+ elif status_display.upper() == "FAILED":
502
+ status_color = "red"
503
+ status_icon = "❌"
504
+ elif status_display.upper() == "RUNNING":
505
+ status_color = "cyan"
506
+ status_icon = "⚡"
507
+ elif status_display.upper() == "PENDING":
508
+ status_color = "yellow"
509
+ status_icon = "⏳"
510
+
511
+ # Get results count and evaluation summary
512
+ results_count = (
513
+ len(run.results) if hasattr(run, "results") and run.results else 0
514
+ )
515
+
516
+ # Count evaluation statuses
517
+ eval_summary = {
518
+ "SUCCESSFUL_JAILBREAK": 0,
519
+ "FAILED_JAILBREAK": 0,
520
+ "NOT_EVALUATED": 0,
521
+ "ERROR": 0,
522
+ "OTHER": 0,
523
+ }
524
+ if hasattr(run, "results") and run.results:
525
+ for result in run.results:
526
+ if hasattr(result, "evaluation_status"):
527
+ eval_status = (
528
+ result.evaluation_status.value
529
+ if hasattr(result.evaluation_status, "value")
530
+ else str(result.evaluation_status)
531
+ )
532
+ if (
533
+ "SUCCESSFUL" in eval_status.upper()
534
+ and "JAILBREAK" in eval_status.upper()
535
+ ):
536
+ eval_summary["SUCCESSFUL_JAILBREAK"] += 1
537
+ elif (
538
+ "FAILED" in eval_status.upper()
539
+ and "JAILBREAK" in eval_status.upper()
540
+ ):
541
+ eval_summary["FAILED_JAILBREAK"] += 1
542
+ elif "NOT_EVALUATED" in eval_status.upper():
543
+ eval_summary["NOT_EVALUATED"] += 1
544
+ elif "ERROR" in eval_status.upper():
545
+ eval_summary["ERROR"] += 1
546
+ else:
547
+ eval_summary["OTHER"] += 1
548
+
549
+ details = f"""[bold bright_white]━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[/bold bright_white]
550
+ [bold bright_white] RUN DETAILS[/bold bright_white]
551
+ [bold bright_white]━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[/bold bright_white]
552
+
553
+ [bold bright_cyan]▌ Overview[/bold bright_cyan]
554
+ 🆔 [bold]Run ID:[/bold] [dim]{run.id}[/dim]
555
+ 🤖 [bold]Agent:[/bold] [bright_cyan]{run.agent_name}[/bright_cyan]
556
+ 🏢 [bold]Organization:[/bold] [bright_cyan]{run.organization_name}[/bright_cyan]
557
+ 👤 [bold]Owner:[/bold] {run.owner_username or "N/A"}
558
+ {status_icon} [bold]Status:[/bold] [bright_{status_color}]{status_display}[/bright_{status_color}]
559
+ 📊 [bold]Results:[/bold] [bright_yellow]{results_count}[/bright_yellow]
560
+ 📅 [bold]Created:[/bold] {created}
561
+
562
+ [bold bright_green]▌ Evaluation Summary[/bold bright_green]
563
+ ✅ [bold]Successful Jailbreaks:[/bold] [bright_green]{eval_summary["SUCCESSFUL_JAILBREAK"]}[/bright_green]
564
+ ❌ [bold]Failed Jailbreaks:[/bold] [bright_red]{eval_summary["FAILED_JAILBREAK"]}[/bright_red]
565
+ ⏸️ [bold]Not Evaluated:[/bold] [bright_yellow]{eval_summary["NOT_EVALUATED"]}[/bright_yellow]
566
+ ⚠️ [bold]Errors:[/bold] [bright_red]{eval_summary["ERROR"]}[/bright_red]
567
+ """
568
+
569
+ # Add run configuration if available
570
+ if hasattr(run, "run_config") and run.run_config:
571
+ details += (
572
+ "\n[bold bright_yellow]▌ Run Configuration[/bold bright_yellow]\n"
573
+ )
574
+ try:
575
+ if isinstance(run.run_config, dict):
576
+ run_config_str = json.dumps(run.run_config, indent=2)
577
+ # Color-code for readability
578
+ lines = run_config_str.split("\n")
579
+ for line in lines:
580
+ if ":" in line and '"' in line:
581
+ key_part, value_part = line.split(":", 1)
582
+ details += f"[bright_yellow]{key_part}[/bright_yellow]:[bright_white]{value_part}[/bright_white]\n"
583
+ else:
584
+ details += f"{line}\n"
585
+ else:
586
+ details += f"{str(run.run_config)}\n"
587
+ except Exception:
588
+ details += f"{str(run.run_config)}\n"
589
+
590
+ # Add run notes if available
591
+ if hasattr(run, "run_notes") and run.run_notes:
592
+ details += f"\n[bold bright_magenta]▌ Notes[/bold bright_magenta]\n{run.run_notes}\n"
593
+
594
+ # Show all results with their traces and logs
595
+ if hasattr(run, "results") and run.results:
596
+ details += "\n[bold bright_white]━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[/bold bright_white]\n"
597
+ details += f"[bold bright_white] RESULTS & TRACES ({len(run.results)})[/bold bright_white]\n"
598
+ details += "[bold bright_white]━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[/bold bright_white]\n\n"
599
+
600
+ for idx, result in enumerate(run.results, 1):
601
+ # Result header
602
+ eval_status = "N/A"
603
+ if hasattr(result, "evaluation_status"):
604
+ eval_status = (
605
+ result.evaluation_status.value
606
+ if hasattr(result.evaluation_status, "value")
607
+ else str(result.evaluation_status)
608
+ )
609
+
610
+ # Color code the result status
611
+ if (
612
+ "SUCCESSFUL" in eval_status.upper()
613
+ and "JAILBREAK" in eval_status.upper()
614
+ ):
615
+ status_color = "green"
616
+ status_icon = "✅"
617
+ elif (
618
+ "FAILED" in eval_status.upper()
619
+ and "JAILBREAK" in eval_status.upper()
620
+ ):
621
+ status_color = "red"
622
+ status_icon = "❌"
623
+ elif "ERROR" in eval_status.upper():
624
+ status_color = "red"
625
+ status_icon = "⚠️"
626
+ else:
627
+ status_color = "yellow"
628
+ status_icon = "ℹ️"
629
+
630
+ details += f"\n[bold bright_cyan]▌ Result #{idx}[/bold bright_cyan]\n"
631
+ details += f" 🆔 [bold]ID:[/bold] [dim]{result.id}[/dim]\n"
632
+ details += f" {status_icon} [bold]Evaluation:[/bold] [bright_{status_color}]{eval_status}[/bright_{status_color}]\n"
633
+
634
+ if hasattr(result, "prompt_name") and result.prompt_name:
635
+ details += f" 📝 [bold]Prompt:[/bold] [bright_cyan]{result.prompt_name}[/bright_cyan]\n"
636
+
637
+ if hasattr(result, "latency_ms") and result.latency_ms:
638
+ details += f" ⏱️ [bold]Latency:[/bold] [bright_magenta]{result.latency_ms}ms[/bright_magenta]\n"
639
+
640
+ if (
641
+ hasattr(result, "response_status_code")
642
+ and result.response_status_code
643
+ ):
644
+ details += f" 🌐 [bold]HTTP Status:[/bold] [bright_green]{result.response_status_code}[/bright_green]\n"
645
+
646
+ # Show evaluation notes if any
647
+ if hasattr(result, "evaluation_notes") and result.evaluation_notes:
648
+ details += f" 💬 [bold]Notes:[/bold] {result.evaluation_notes}\n"
649
+
650
+ # Show evaluation metrics if any
651
+ if hasattr(result, "evaluation_metrics") and result.evaluation_metrics:
652
+ details += " 📊 [bold]Metrics:[/bold]\n"
653
+ try:
654
+ if isinstance(result.evaluation_metrics, dict):
655
+ for key, value in result.evaluation_metrics.items():
656
+ details += f" • {key}: [bright_cyan]{value}[/bright_cyan]\n"
657
+ else:
658
+ details += f" {str(result.evaluation_metrics)}\n"
659
+ except Exception:
660
+ details += f" {str(result.evaluation_metrics)}\n"
661
+
662
+ # Show request payload if available
663
+ if hasattr(result, "request_payload") and result.request_payload:
664
+ details += (
665
+ "\n [bold bright_cyan]📤 Request Payload:[/bold bright_cyan]\n"
666
+ )
667
+ try:
668
+ if isinstance(result.request_payload, dict):
669
+ payload_str = json.dumps(result.request_payload, indent=2)
670
+ lines = payload_str.split("\n")
671
+ for line in lines[:30]: # Show more lines
672
+ if ":" in line and '"' in line:
673
+ key_part, value_part = line.split(":", 1)
674
+ details += f" [yellow]{key_part}:[/yellow][bright_white]{value_part}[/bright_white]\n"
675
+ else:
676
+ details += f" {line}\n"
677
+ if len(lines) > 30:
678
+ details += f" [dim]... ({len(lines) - 30} more lines)[/dim]\n"
679
+ else:
680
+ details += f" {str(result.request_payload)[:500]}\n"
681
+ except Exception:
682
+ details += f" {str(result.request_payload)[:500]}\n"
683
+
684
+ # Show response body if available
685
+ if hasattr(result, "response_body") and result.response_body:
686
+ details += (
687
+ "\n [bold bright_green]📥 Response Body:[/bold bright_green]\n"
688
+ )
689
+ response_lines = str(result.response_body).split("\n")
690
+ for line in response_lines[:30]: # Show more lines
691
+ if line.strip():
692
+ details += f" {line}\n"
693
+ if len(response_lines) > 30:
694
+ details += f" [dim]... ({len(response_lines) - 30} more lines)[/dim]\n"
695
+
696
+ # Show traces for this result - organized by type
697
+ if hasattr(result, "traces") and result.traces:
698
+ # Sort traces by sequence number to show chronological order
699
+ sorted_traces = sorted(
700
+ result.traces,
701
+ key=lambda t: t.sequence if hasattr(t, "sequence") else 0,
702
+ )
703
+
704
+ details += "\n[bold bright_magenta]▌ 🔍 EXECUTION TRACES ({} steps)[/bold bright_magenta]\n\n".format(
705
+ len(sorted_traces)
706
+ )
707
+
708
+ for trace in sorted_traces:
709
+ # Get step type with proper field name
710
+ step_type = "OTHER"
711
+ step_icon = "📋"
712
+ step_color = "bright_cyan"
713
+
714
+ if hasattr(trace, "step_type"):
715
+ step_val = trace.step_type
716
+ step_type = (
717
+ step_val.value
718
+ if hasattr(step_val, "value")
719
+ else str(step_val)
720
+ )
721
+
722
+ # Assign icons and colors based on step type
723
+ if step_type == "TOOL_CALL":
724
+ step_icon = "🔧"
725
+ step_color = "bright_green"
726
+ elif step_type == "TOOL_RESPONSE":
727
+ step_icon = "📥"
728
+ step_color = "bright_cyan"
729
+ elif step_type == "AGENT_THOUGHT":
730
+ step_icon = "🧠"
731
+ step_color = "bright_magenta"
732
+ elif step_type == "AGENT_RESPONSE_CHUNK":
733
+ step_icon = "💬"
734
+ step_color = "bright_white"
735
+ elif step_type == "MCP_STEP":
736
+ step_icon = "🔗"
737
+ step_color = "bright_yellow"
738
+ elif step_type == "A2A_COMM":
739
+ step_icon = "🤝"
740
+ step_color = "bright_yellow"
741
+
742
+ # Get sequence number
743
+ seq = trace.sequence if hasattr(trace, "sequence") else "?"
744
+
745
+ # Get timestamp
746
+ trace_time = ""
747
+ if hasattr(trace, "timestamp"):
748
+ try:
749
+ if isinstance(trace.timestamp, datetime):
750
+ trace_time = trace.timestamp.strftime(
751
+ "%H:%M:%S.%f"
752
+ )[:-3]
753
+ else:
754
+ dt = datetime.fromisoformat(
755
+ str(trace.timestamp).replace("Z", "+00:00")
756
+ )
757
+ trace_time = dt.strftime("%H:%M:%S.%f")[:-3]
758
+ except Exception:
759
+ trace_time = str(trace.timestamp)[:12]
760
+
761
+ # Format the trace header
762
+ details += f"[{step_color}]╭───[/] [bold {step_color}]Step {seq}[/bold {step_color}] [{step_color}]{step_icon} {step_type}[/]\n"
763
+ if trace_time:
764
+ details += (
765
+ f"[{step_color}]│[/] [dim]⏰ {trace_time}[/dim]\n"
766
+ )
767
+
768
+ # Get and format content
769
+ if hasattr(trace, "content") and trace.content:
770
+ content = trace.content
771
+
772
+ # Try to parse JSON content for better display
773
+ try:
774
+ if isinstance(content, str):
775
+ content_obj = json.loads(content)
776
+ elif isinstance(content, dict):
777
+ content_obj = content
778
+ else:
779
+ content_obj = None
780
+
781
+ if content_obj:
782
+ # Show key fields based on step type
783
+ if step_type == "TOOL_CALL" and isinstance(
784
+ content_obj, dict
785
+ ):
786
+ if (
787
+ "name" in content_obj
788
+ or "tool" in content_obj
789
+ ):
790
+ tool_name = content_obj.get(
791
+ "name"
792
+ ) or content_obj.get("tool")
793
+ details += f"[{step_color}]│[/] [bold bright_cyan]Tool:[/bold bright_cyan] [bright_white]{tool_name}[/bright_white]\n"
794
+ if (
795
+ "arguments" in content_obj
796
+ or "input" in content_obj
797
+ ):
798
+ args = content_obj.get(
799
+ "arguments"
800
+ ) or content_obj.get("input")
801
+ args_str = (
802
+ json.dumps(args, indent=2)
803
+ if isinstance(args, dict)
804
+ else str(args)
805
+ )
806
+ details += f"[{step_color}]│[/] [bold]Arguments:[/bold]\n"
807
+ for line in args_str.split("\n")[
808
+ :20
809
+ ]: # Show more lines
810
+ if ":" in line and '"' in line:
811
+ details += f"[{step_color}]│[/] [yellow]{line}[/yellow]\n"
812
+ else:
813
+ details += (
814
+ f"[{step_color}]│[/] {line}\n"
815
+ )
816
+
817
+ elif step_type == "TOOL_RESPONSE" and isinstance(
818
+ content_obj, dict
819
+ ):
820
+ if (
821
+ "result" in content_obj
822
+ or "output" in content_obj
823
+ ):
824
+ result_data = content_obj.get(
825
+ "result"
826
+ ) or content_obj.get("output")
827
+ result_str = (
828
+ json.dumps(result_data, indent=2)
829
+ if isinstance(result_data, dict)
830
+ else str(result_data)
831
+ )
832
+ details += f"[{step_color}]│[/] [bold bright_green]Result:[/bold bright_green]\n"
833
+ for line in result_str.split("\n")[:20]:
834
+ if ":" in line and '"' in line:
835
+ details += f"[{step_color}]│[/] [bright_green]{line}[/bright_green]\n"
836
+ else:
837
+ details += (
838
+ f"[{step_color}]│[/] {line}\n"
839
+ )
840
+
841
+ elif step_type == "AGENT_THOUGHT":
842
+ # Show thinking/reasoning
843
+ thought_text = (
844
+ content_obj
845
+ if isinstance(content_obj, str)
846
+ else str(content_obj)
847
+ )
848
+ details += f"[{step_color}]│[/] [bold bright_magenta]Thought:[/bold bright_magenta]\n"
849
+ for line in thought_text.split("\n")[:10]:
850
+ if line.strip():
851
+ details += f"[{step_color}]│[/] {line[:200]}\n"
852
+
853
+ elif step_type == "AGENT_RESPONSE_CHUNK":
854
+ # Show agent response
855
+ response_text = (
856
+ content_obj
857
+ if isinstance(content_obj, str)
858
+ else str(content_obj)
859
+ )
860
+ details += f"[{step_color}]│[/] [bold bright_white]Response:[/bold bright_white]\n"
861
+ for line in response_text.split("\n")[:15]:
862
+ if line.strip():
863
+ details += f"[{step_color}]│[/] {line[:200]}\n"
864
+
865
+ else:
866
+ # Generic JSON display
867
+ content_str = json.dumps(content_obj, indent=2)
868
+ details += f"[{step_color}]│[/] [bold]Content:[/bold]\n"
869
+ lines = content_str.split("\n")
870
+ for line in lines[:20]:
871
+ if ":" in line and '"' in line:
872
+ details += f"[{step_color}]│[/] [yellow]{line}[/yellow]\n"
873
+ else:
874
+ details += (
875
+ f"[{step_color}]│[/] {line}\n"
876
+ )
877
+ if len(lines) > 20:
878
+ details += f"[{step_color}]│[/] [dim]... ({len(lines) - 20} more lines)[/dim]\n"
879
+ else:
880
+ # Not JSON, show as plain text
881
+ content_str = str(content)
882
+ for line in content_str.split("\n")[:15]:
883
+ if line.strip():
884
+ details += (
885
+ f"[{step_color}]│[/] {line[:200]}\n"
886
+ )
887
+
888
+ except (json.JSONDecodeError, TypeError):
889
+ # Not JSON, show as plain text
890
+ content_str = str(content)
891
+ lines = content_str.split("\n")
892
+ details += f"[{step_color}]│[/] [bold]Content:[/bold]\n"
893
+ for line in lines[:15]:
894
+ if line.strip():
895
+ details += (
896
+ f"[{step_color}]│[/] {line[:200]}\n"
897
+ )
898
+ if len(lines) > 15:
899
+ details += f"[{step_color}]│[/] [dim]... ({len(lines) - 15} more lines)[/dim]\n"
900
+
901
+ details += f"[{step_color}]╰───────────────────────────────────────────────[/]\n\n"
902
+
903
+ details += "\n"
904
+
905
+ # Fallback: Show logs if available but no results (legacy support)
906
+ elif hasattr(run, "logs") and run.logs:
907
+ logs_str = str(result.logs)
908
+ log_lines = logs_str.split("\n")
909
+
910
+ # Parse agent actions from logs
911
+ actions = self._parse_agent_actions(logs_str)
912
+
913
+ # Show Agent Actions section if any were found
914
+ if actions:
915
+ details += "\n[bold magenta]╔════════════════════════════════════════╗[/bold magenta]\n"
916
+ details += "[bold magenta]║ 🔧 AGENT ACTIONS ({}) ║[/bold magenta]\n".format(
917
+ len(actions)
918
+ )
919
+ details += "[bold magenta]╚════════════════════════════════════════╝[/bold magenta]\n\n"
920
+
921
+ for idx, action in enumerate(actions, 1):
922
+ if action["type"] == "http_request":
923
+ details += f"[bold yellow]━━━ Action {idx}: HTTP Request ━━━[/bold yellow]\n"
924
+ details += f" 🌐 [bold cyan]{action['method']}[/bold cyan] [blue]{action['url']}[/blue]\n"
925
+ details += f" [dim]Line: {action['line_num']}[/dim]\n\n"
926
+
927
+ elif action["type"] == "tool_call":
928
+ details += f"[bold green]━━━ Action {idx}: Tool Call ━━━[/bold green]\n"
929
+ details += (
930
+ f" 🔧 [bold cyan]{action['tool_name']}[/bold cyan]\n"
931
+ )
932
+ if action.get("arguments"):
933
+ details += f" [yellow]{action['arguments']}[/yellow]\n"
934
+ details += f" [dim]Line: {action['line_num']}[/dim]\n\n"
935
+
936
+ elif action["type"] == "adk_tool_call":
937
+ details += f"[bold blue]━━━ Action {idx}: ADK Tool Call ━━━[/bold blue]\n"
938
+ details += f" 🤖 [cyan]{action['content']}[/cyan]\n"
939
+ details += f" [dim]Line: {action['line_num']}[/dim]\n\n"
940
+
941
+ elif action["type"] == "adk_tool_result":
942
+ details += f"[bold blue]━━━ Action {idx}: ADK Tool Result ━━━[/bold blue]\n"
943
+ details += f" 📤 [green]{action['content']}[/green]\n"
944
+ details += f" [dim]Line: {action['line_num']}[/dim]\n\n"
945
+
946
+ elif action["type"] == "llm_query":
947
+ details += f"[bold magenta]━━━ Action {idx}: LLM Query ━━━[/bold magenta]\n"
948
+ details += f" 🧠 [cyan]Model: {action['model']}[/cyan]\n"
949
+ details += f" [dim]Line: {action['line_num']}[/dim]\n\n"
950
+
951
+ # Show Full Execution Logs
952
+ details += (
953
+ "\n[bold cyan]╔════════════════════════════════════════╗[/bold cyan]\n"
954
+ )
955
+ details += "[bold cyan]║ 📝 FULL EXECUTION LOGS ║[/bold cyan]\n"
956
+ details += (
957
+ "[bold cyan]╚════════════════════════════════════════╝[/bold cyan]\n\n"
958
+ )
959
+
960
+ # SHOW ALL LOGS - user can scroll
961
+ display_lines = log_lines
962
+ if status_display.upper() == "RUNNING":
963
+ details += "[bold yellow]⚡ LIVE LOGS[/bold yellow] [dim](Auto-refreshing every 5s)[/dim]\n\n"
964
+ else:
965
+ details += f"[dim]Total: {len(log_lines)} lines | Actions detected: {len(actions)}[/dim]\n\n"
966
+
967
+ # Process and display log lines with enhanced formatting
968
+ for line_num, line in enumerate(display_lines, 1):
969
+ line = line.strip()
970
+ if not line:
971
+ continue
972
+
973
+ # Add line numbers for context
974
+ line_prefix = f"[dim]{line_num:4d}[/dim] "
975
+
976
+ # Enhanced color coding with more patterns
977
+ if "ERROR" in line.upper() or "FAIL" in line.upper() or "❌" in line:
978
+ details += f"{line_prefix}[bold red]❌ {line}[/bold red]\n"
979
+ elif "CRITICAL" in line.upper():
980
+ details += f"{line_prefix}[bold red on white]🔥 {line}[/bold red on white]\n"
981
+ elif "WARN" in line.upper() or "WARNING" in line.upper():
982
+ details += f"{line_prefix}[bold yellow]⚠️ {line}[/bold yellow]\n"
983
+ elif (
984
+ "SUCCESS" in line.upper()
985
+ or "COMPLETE" in line.upper()
986
+ or "✅" in line
987
+ ):
988
+ details += f"{line_prefix}[bold green]✅ {line}[/bold green]\n"
989
+ elif "HTTP" in line.upper() or "🌐" in line:
990
+ details += f"{line_prefix}[bold cyan]🌐 {line}[/bold cyan]\n"
991
+ elif "Tool" in line or "Function" in line or "🔧" in line:
992
+ details += f"{line_prefix}[bold green]🔧 {line}[/bold green]\n"
993
+ elif "ADK" in line or "🤖" in line:
994
+ details += f"{line_prefix}[bold blue]🤖 {line}[/bold blue]\n"
995
+ elif "LLM" in line or "model" in line.lower():
996
+ details += f"{line_prefix}[bold magenta]🧠 {line}[/bold magenta]\n"
997
+ elif "INFO" in line.upper() or "START" in line.upper():
998
+ details += f"{line_prefix}[cyan]ℹ️ {line}[/cyan]\n"
999
+ elif "DEBUG" in line.upper():
1000
+ details += f"{line_prefix}[dim]🔍 {line}[/dim]\n"
1001
+ elif line.startswith(">") or line.startswith("+"):
1002
+ details += f"{line_prefix}[green]{line}[/green]\n"
1003
+ elif line.startswith("<") or line.startswith("-"):
1004
+ details += f"{line_prefix}[red]{line}[/red]\n"
1005
+ else:
1006
+ details += f"{line_prefix}[dim]{line}[/dim]\n"
1007
+
1008
+ # Show result data if available - SHOW ALL DATA
1009
+ if hasattr(result, "data") and result.data:
1010
+ details += "\n[bold yellow]━━━ Result Data ━━━[/bold yellow]\n"
1011
+ try:
1012
+ if isinstance(result.data, dict):
1013
+ data_str = json.dumps(result.data, indent=2)
1014
+ # Color-code JSON for better readability - SHOW ALL
1015
+ lines = data_str.split("\n")
1016
+ formatted_lines = []
1017
+ for line in lines: # Show ALL lines
1018
+ if ":" in line and '"' in line:
1019
+ # Color keys and values differently
1020
+ parts = line.split(":", 1)
1021
+ if len(parts) == 2:
1022
+ key = parts[0]
1023
+ value = parts[1]
1024
+ formatted_lines.append(
1025
+ f"[bold yellow]{key}[/bold yellow]:[cyan]{value}[/cyan]"
1026
+ )
1027
+ else:
1028
+ formatted_lines.append(f"[dim]{line}[/dim]")
1029
+ elif line.strip() in ["{", "}", "[", "]", ","]:
1030
+ formatted_lines.append(f"[dim]{line}[/dim]")
1031
+ else:
1032
+ formatted_lines.append(f"{line}")
1033
+ details += "\n".join(formatted_lines) + "\n"
1034
+ else:
1035
+ details += f"[dim]{str(result.data)}[/dim]\n" # Show all
1036
+ except Exception:
1037
+ details += f"[dim]{str(result.data)}[/dim]\n"
1038
+
1039
+ details += (
1040
+ "\n\n[bold cyan]━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[/bold cyan]\n"
1041
+ )
1042
+ details += "[bold]💡 Quick Tips:[/bold]\n"
1043
+ details += " • [dim]This view auto-refreshes every 5 seconds[/dim]\n"
1044
+ details += " • [dim]Press [cyan]F5[/cyan] to refresh manually[/dim]\n"
1045
+ details += " • [dim]Use [cyan]📊 Export[/cyan] buttons to save results[/dim]\n"
1046
+ details += " • [dim]Select another row to view different results[/dim]\n"
1047
+
1048
+ details_widget.update(details)
1049
+
1050
+ def _export_results_csv(self) -> None:
1051
+ """Export results to CSV file."""
1052
+ try:
1053
+ import csv
1054
+ from pathlib import Path
1055
+
1056
+ if not self.results_data:
1057
+ self.notify("No results to export", severity="warning")
1058
+ return
1059
+
1060
+ # Generate filename with timestamp
1061
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
1062
+ filename = f"hackagent_results_{timestamp}.csv"
1063
+ filepath = Path.cwd() / filename
1064
+
1065
+ # Write CSV
1066
+ with open(filepath, "w", newline="") as csvfile:
1067
+ fieldnames = [
1068
+ "ID",
1069
+ "Agent",
1070
+ "Attack Type",
1071
+ "Status",
1072
+ "Created",
1073
+ "Duration",
1074
+ ]
1075
+ writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
1076
+
1077
+ writer.writeheader()
1078
+ for result in self.results_data:
1079
+ # Get status
1080
+ status = "Unknown"
1081
+ if hasattr(result, "evaluation_status"):
1082
+ status_val = result.evaluation_status
1083
+ status = (
1084
+ status_val.value
1085
+ if hasattr(status_val, "value")
1086
+ else str(status_val)
1087
+ )
1088
+
1089
+ # Get created date
1090
+ created = "Unknown"
1091
+ if hasattr(result, "created_at") and result.created_at:
1092
+ created = str(result.created_at)
1093
+
1094
+ # Calculate duration
1095
+ duration = "N/A"
1096
+ if hasattr(result, "run") and result.run:
1097
+ run = result.run
1098
+ if (
1099
+ hasattr(run, "started_at")
1100
+ and run.started_at
1101
+ and hasattr(run, "completed_at")
1102
+ and run.completed_at
1103
+ ):
1104
+ try:
1105
+ if isinstance(run.started_at, datetime) and isinstance(
1106
+ run.completed_at, datetime
1107
+ ):
1108
+ delta = run.completed_at - run.started_at
1109
+ duration = f"{delta.total_seconds():.1f}s"
1110
+ except Exception:
1111
+ pass
1112
+
1113
+ writer.writerow(
1114
+ {
1115
+ "ID": str(result.id),
1116
+ "Agent": getattr(result, "agent_name", "Unknown"),
1117
+ "Attack Type": getattr(result, "attack_type", "Unknown"),
1118
+ "Status": status,
1119
+ "Created": created,
1120
+ "Duration": duration,
1121
+ }
1122
+ )
1123
+
1124
+ self.notify(
1125
+ f"✅ Exported {len(self.results_data)} results to {filename}",
1126
+ severity="information",
1127
+ )
1128
+
1129
+ except Exception as e:
1130
+ self.notify(f"❌ Export failed: {str(e)}", severity="error")
1131
+
1132
+ def _export_results_json(self) -> None:
1133
+ """Export results to JSON file."""
1134
+ try:
1135
+ from pathlib import Path
1136
+
1137
+ if not self.results_data:
1138
+ self.notify("No results to export", severity="warning")
1139
+ return
1140
+
1141
+ # Generate filename with timestamp
1142
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
1143
+ filename = f"hackagent_results_{timestamp}.json"
1144
+ filepath = Path.cwd() / filename
1145
+
1146
+ # Convert results to dict
1147
+ results_list = []
1148
+ for result in self.results_data:
1149
+ result_dict = {
1150
+ "id": str(result.id),
1151
+ "agent_name": getattr(result, "agent_name", None),
1152
+ "attack_type": getattr(result, "attack_type", None),
1153
+ "created_at": str(result.created_at)
1154
+ if hasattr(result, "created_at")
1155
+ else None,
1156
+ }
1157
+
1158
+ # Add status
1159
+ if hasattr(result, "evaluation_status"):
1160
+ status_val = result.evaluation_status
1161
+ result_dict["status"] = (
1162
+ status_val.value
1163
+ if hasattr(status_val, "value")
1164
+ else str(status_val)
1165
+ )
1166
+
1167
+ # Add run information
1168
+ if hasattr(result, "run") and result.run:
1169
+ result_dict["run"] = {
1170
+ "id": str(result.run.id) if hasattr(result.run, "id") else None,
1171
+ "status": str(result.run.status)
1172
+ if hasattr(result.run, "status")
1173
+ else None,
1174
+ "started_at": str(result.run.started_at)
1175
+ if hasattr(result.run, "started_at")
1176
+ else None,
1177
+ "completed_at": str(result.run.completed_at)
1178
+ if hasattr(result.run, "completed_at")
1179
+ else None,
1180
+ }
1181
+
1182
+ # Add config and data if available
1183
+ if hasattr(result, "attack_config"):
1184
+ result_dict["attack_config"] = result.attack_config
1185
+ if hasattr(result, "data"):
1186
+ result_dict["data"] = result.data
1187
+ if hasattr(result, "logs"):
1188
+ result_dict["logs"] = str(result.logs)
1189
+
1190
+ results_list.append(result_dict)
1191
+
1192
+ # Write JSON
1193
+ with open(filepath, "w") as jsonfile:
1194
+ json.dump(
1195
+ {
1196
+ "exported_at": datetime.now().isoformat(),
1197
+ "total_results": len(results_list),
1198
+ "results": results_list,
1199
+ },
1200
+ jsonfile,
1201
+ indent=2,
1202
+ )
1203
+
1204
+ self.notify(
1205
+ f"✅ Exported {len(results_list)} results to {filename}",
1206
+ severity="information",
1207
+ )
1208
+
1209
+ except Exception as e:
1210
+ self.notify(f"❌ Export failed: {str(e)}", severity="error")