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
@@ -0,0 +1,568 @@
1
+ import json
2
+ import uuid
3
+ from datetime import UTC, datetime
4
+ from typing import Any
5
+
6
+ from strix.tools.registry import register_tool
7
+
8
+
9
+ VALID_PRIORITIES = ["low", "normal", "high", "critical"]
10
+ VALID_STATUSES = ["pending", "in_progress", "done"]
11
+
12
+ _todos_storage: dict[str, dict[str, dict[str, Any]]] = {}
13
+
14
+
15
+ def _get_agent_todos(agent_id: str) -> dict[str, dict[str, Any]]:
16
+ if agent_id not in _todos_storage:
17
+ _todos_storage[agent_id] = {}
18
+ return _todos_storage[agent_id]
19
+
20
+
21
+ def _normalize_priority(priority: str | None, default: str = "normal") -> str:
22
+ candidate = (priority or default or "normal").lower()
23
+ if candidate not in VALID_PRIORITIES:
24
+ raise ValueError(f"Invalid priority. Must be one of: {', '.join(VALID_PRIORITIES)}")
25
+ return candidate
26
+
27
+
28
+ def _sorted_todos(agent_id: str) -> list[dict[str, Any]]:
29
+ agent_todos = _get_agent_todos(agent_id)
30
+
31
+ todos_list: list[dict[str, Any]] = []
32
+ for todo_id, todo in agent_todos.items():
33
+ entry = todo.copy()
34
+ entry["todo_id"] = todo_id
35
+ todos_list.append(entry)
36
+
37
+ priority_order = {"critical": 0, "high": 1, "normal": 2, "low": 3}
38
+ status_order = {"done": 0, "in_progress": 1, "pending": 2}
39
+
40
+ todos_list.sort(
41
+ key=lambda x: (
42
+ status_order.get(x.get("status", "pending"), 99),
43
+ priority_order.get(x.get("priority", "normal"), 99),
44
+ x.get("created_at", ""),
45
+ )
46
+ )
47
+ return todos_list
48
+
49
+
50
+ def _normalize_todo_ids(raw_ids: Any) -> list[str]:
51
+ if raw_ids is None:
52
+ return []
53
+
54
+ if isinstance(raw_ids, str):
55
+ stripped = raw_ids.strip()
56
+ if not stripped:
57
+ return []
58
+ try:
59
+ data = json.loads(stripped)
60
+ except json.JSONDecodeError:
61
+ data = stripped.split(",") if "," in stripped else [stripped]
62
+ if isinstance(data, list):
63
+ return [str(item).strip() for item in data if str(item).strip()]
64
+ return [str(data).strip()]
65
+
66
+ if isinstance(raw_ids, list):
67
+ return [str(item).strip() for item in raw_ids if str(item).strip()]
68
+
69
+ return [str(raw_ids).strip()]
70
+
71
+
72
+ def _normalize_bulk_updates(raw_updates: Any) -> list[dict[str, Any]]:
73
+ if raw_updates is None:
74
+ return []
75
+
76
+ data = raw_updates
77
+ if isinstance(raw_updates, str):
78
+ stripped = raw_updates.strip()
79
+ if not stripped:
80
+ return []
81
+ try:
82
+ data = json.loads(stripped)
83
+ except json.JSONDecodeError as e:
84
+ raise ValueError("Updates must be valid JSON") from e
85
+
86
+ if isinstance(data, dict):
87
+ data = [data]
88
+
89
+ if not isinstance(data, list):
90
+ raise TypeError("Updates must be a list of update objects")
91
+
92
+ normalized: list[dict[str, Any]] = []
93
+ for item in data:
94
+ if not isinstance(item, dict):
95
+ raise TypeError("Each update must be an object with todo_id")
96
+
97
+ todo_id = item.get("todo_id") or item.get("id")
98
+ if not todo_id:
99
+ raise ValueError("Each update must include 'todo_id'")
100
+
101
+ normalized.append(
102
+ {
103
+ "todo_id": str(todo_id).strip(),
104
+ "title": item.get("title"),
105
+ "description": item.get("description"),
106
+ "priority": item.get("priority"),
107
+ "status": item.get("status"),
108
+ }
109
+ )
110
+
111
+ return normalized
112
+
113
+
114
+ def _normalize_bulk_todos(raw_todos: Any) -> list[dict[str, Any]]:
115
+ if raw_todos is None:
116
+ return []
117
+
118
+ data = raw_todos
119
+ if isinstance(raw_todos, str):
120
+ stripped = raw_todos.strip()
121
+ if not stripped:
122
+ return []
123
+ try:
124
+ data = json.loads(stripped)
125
+ except json.JSONDecodeError:
126
+ entries = [line.strip(" -*\t") for line in stripped.splitlines() if line.strip(" -*\t")]
127
+ return [{"title": entry} for entry in entries]
128
+
129
+ if isinstance(data, dict):
130
+ data = [data]
131
+
132
+ if not isinstance(data, list):
133
+ raise TypeError("Todos must be provided as a list, dict, or JSON string")
134
+
135
+ normalized: list[dict[str, Any]] = []
136
+ for item in data:
137
+ if isinstance(item, str):
138
+ title = item.strip()
139
+ if title:
140
+ normalized.append({"title": title})
141
+ continue
142
+
143
+ if not isinstance(item, dict):
144
+ raise TypeError("Each todo entry must be a string or object with a title")
145
+
146
+ title = item.get("title", "")
147
+ if not isinstance(title, str) or not title.strip():
148
+ raise ValueError("Each todo entry must include a non-empty 'title'")
149
+
150
+ normalized.append(
151
+ {
152
+ "title": title.strip(),
153
+ "description": (item.get("description") or "").strip() or None,
154
+ "priority": item.get("priority"),
155
+ }
156
+ )
157
+
158
+ return normalized
159
+
160
+
161
+ @register_tool(sandbox_execution=False)
162
+ def create_todo(
163
+ agent_state: Any,
164
+ title: str | None = None,
165
+ description: str | None = None,
166
+ priority: str = "normal",
167
+ todos: Any | None = None,
168
+ ) -> dict[str, Any]:
169
+ try:
170
+ agent_id = agent_state.agent_id
171
+ default_priority = _normalize_priority(priority)
172
+
173
+ tasks_to_create: list[dict[str, Any]] = []
174
+
175
+ if todos is not None:
176
+ tasks_to_create.extend(_normalize_bulk_todos(todos))
177
+
178
+ if title and title.strip():
179
+ tasks_to_create.append(
180
+ {
181
+ "title": title.strip(),
182
+ "description": description.strip() if description else None,
183
+ "priority": default_priority,
184
+ }
185
+ )
186
+
187
+ if not tasks_to_create:
188
+ return {
189
+ "success": False,
190
+ "error": "Provide a title or 'todos' list to create.",
191
+ "todo_id": None,
192
+ }
193
+
194
+ agent_todos = _get_agent_todos(agent_id)
195
+ created: list[dict[str, Any]] = []
196
+
197
+ for task in tasks_to_create:
198
+ task_priority = _normalize_priority(task.get("priority"), default_priority)
199
+ todo_id = str(uuid.uuid4())[:6]
200
+ timestamp = datetime.now(UTC).isoformat()
201
+
202
+ todo = {
203
+ "title": task["title"],
204
+ "description": task.get("description"),
205
+ "priority": task_priority,
206
+ "status": "pending",
207
+ "created_at": timestamp,
208
+ "updated_at": timestamp,
209
+ "completed_at": None,
210
+ }
211
+
212
+ agent_todos[todo_id] = todo
213
+ created.append(
214
+ {
215
+ "todo_id": todo_id,
216
+ "title": task["title"],
217
+ "priority": task_priority,
218
+ }
219
+ )
220
+
221
+ except (ValueError, TypeError) as e:
222
+ return {"success": False, "error": f"Failed to create todo: {e}", "todo_id": None}
223
+ else:
224
+ todos_list = _sorted_todos(agent_id)
225
+
226
+ response: dict[str, Any] = {
227
+ "success": True,
228
+ "created": created,
229
+ "count": len(created),
230
+ "todos": todos_list,
231
+ "total_count": len(todos_list),
232
+ }
233
+ return response
234
+
235
+
236
+ @register_tool(sandbox_execution=False)
237
+ def list_todos(
238
+ agent_state: Any,
239
+ status: str | None = None,
240
+ priority: str | None = None,
241
+ ) -> dict[str, Any]:
242
+ try:
243
+ agent_id = agent_state.agent_id
244
+ agent_todos = _get_agent_todos(agent_id)
245
+
246
+ status_filter = status.lower() if isinstance(status, str) else None
247
+ priority_filter = priority.lower() if isinstance(priority, str) else None
248
+
249
+ todos_list = []
250
+ for todo_id, todo in agent_todos.items():
251
+ if status_filter and todo.get("status") != status_filter:
252
+ continue
253
+
254
+ if priority_filter and todo.get("priority") != priority_filter:
255
+ continue
256
+
257
+ todo_with_id = todo.copy()
258
+ todo_with_id["todo_id"] = todo_id
259
+ todos_list.append(todo_with_id)
260
+
261
+ priority_order = {"critical": 0, "high": 1, "normal": 2, "low": 3}
262
+ status_order = {"done": 0, "in_progress": 1, "pending": 2}
263
+
264
+ todos_list.sort(
265
+ key=lambda x: (
266
+ status_order.get(x.get("status", "pending"), 99),
267
+ priority_order.get(x.get("priority", "normal"), 99),
268
+ x.get("created_at", ""),
269
+ )
270
+ )
271
+
272
+ summary_counts = {
273
+ "pending": 0,
274
+ "in_progress": 0,
275
+ "done": 0,
276
+ }
277
+ for todo in todos_list:
278
+ status_value = todo.get("status", "pending")
279
+ if status_value not in summary_counts:
280
+ summary_counts[status_value] = 0
281
+ summary_counts[status_value] += 1
282
+
283
+ return {
284
+ "success": True,
285
+ "todos": todos_list,
286
+ "total_count": len(todos_list),
287
+ "summary": summary_counts,
288
+ }
289
+
290
+ except (ValueError, TypeError) as e:
291
+ return {
292
+ "success": False,
293
+ "error": f"Failed to list todos: {e}",
294
+ "todos": [],
295
+ "total_count": 0,
296
+ "summary": {"pending": 0, "in_progress": 0, "done": 0},
297
+ }
298
+
299
+
300
+ def _apply_single_update(
301
+ agent_todos: dict[str, dict[str, Any]],
302
+ todo_id: str,
303
+ title: str | None = None,
304
+ description: str | None = None,
305
+ priority: str | None = None,
306
+ status: str | None = None,
307
+ ) -> dict[str, Any] | None:
308
+ if todo_id not in agent_todos:
309
+ return {"todo_id": todo_id, "error": f"Todo with ID '{todo_id}' not found"}
310
+
311
+ todo = agent_todos[todo_id]
312
+
313
+ if title is not None:
314
+ if not title.strip():
315
+ return {"todo_id": todo_id, "error": "Title cannot be empty"}
316
+ todo["title"] = title.strip()
317
+
318
+ if description is not None:
319
+ todo["description"] = description.strip() if description else None
320
+
321
+ if priority is not None:
322
+ try:
323
+ todo["priority"] = _normalize_priority(priority, str(todo.get("priority", "normal")))
324
+ except ValueError as exc:
325
+ return {"todo_id": todo_id, "error": str(exc)}
326
+
327
+ if status is not None:
328
+ status_candidate = status.lower()
329
+ if status_candidate not in VALID_STATUSES:
330
+ return {
331
+ "todo_id": todo_id,
332
+ "error": f"Invalid status. Must be one of: {', '.join(VALID_STATUSES)}",
333
+ }
334
+ todo["status"] = status_candidate
335
+ if status_candidate == "done":
336
+ todo["completed_at"] = datetime.now(UTC).isoformat()
337
+ else:
338
+ todo["completed_at"] = None
339
+
340
+ todo["updated_at"] = datetime.now(UTC).isoformat()
341
+ return None
342
+
343
+
344
+ @register_tool(sandbox_execution=False)
345
+ def update_todo(
346
+ agent_state: Any,
347
+ todo_id: str | None = None,
348
+ title: str | None = None,
349
+ description: str | None = None,
350
+ priority: str | None = None,
351
+ status: str | None = None,
352
+ updates: Any | None = None,
353
+ ) -> dict[str, Any]:
354
+ try:
355
+ agent_id = agent_state.agent_id
356
+ agent_todos = _get_agent_todos(agent_id)
357
+
358
+ updates_to_apply: list[dict[str, Any]] = []
359
+
360
+ if updates is not None:
361
+ updates_to_apply.extend(_normalize_bulk_updates(updates))
362
+
363
+ if todo_id is not None:
364
+ updates_to_apply.append(
365
+ {
366
+ "todo_id": todo_id,
367
+ "title": title,
368
+ "description": description,
369
+ "priority": priority,
370
+ "status": status,
371
+ }
372
+ )
373
+
374
+ if not updates_to_apply:
375
+ return {
376
+ "success": False,
377
+ "error": "Provide todo_id or 'updates' list to update.",
378
+ }
379
+
380
+ updated: list[str] = []
381
+ errors: list[dict[str, Any]] = []
382
+
383
+ for update in updates_to_apply:
384
+ error = _apply_single_update(
385
+ agent_todos,
386
+ update["todo_id"],
387
+ update.get("title"),
388
+ update.get("description"),
389
+ update.get("priority"),
390
+ update.get("status"),
391
+ )
392
+ if error:
393
+ errors.append(error)
394
+ else:
395
+ updated.append(update["todo_id"])
396
+
397
+ todos_list = _sorted_todos(agent_id)
398
+
399
+ response: dict[str, Any] = {
400
+ "success": len(errors) == 0,
401
+ "updated": updated,
402
+ "updated_count": len(updated),
403
+ "todos": todos_list,
404
+ "total_count": len(todos_list),
405
+ }
406
+
407
+ if errors:
408
+ response["errors"] = errors
409
+
410
+ except (ValueError, TypeError) as e:
411
+ return {"success": False, "error": str(e)}
412
+ else:
413
+ return response
414
+
415
+
416
+ @register_tool(sandbox_execution=False)
417
+ def mark_todo_done(
418
+ agent_state: Any,
419
+ todo_id: str | None = None,
420
+ todo_ids: Any | None = None,
421
+ ) -> dict[str, Any]:
422
+ try:
423
+ agent_id = agent_state.agent_id
424
+ agent_todos = _get_agent_todos(agent_id)
425
+
426
+ ids_to_mark: list[str] = []
427
+ if todo_ids is not None:
428
+ ids_to_mark.extend(_normalize_todo_ids(todo_ids))
429
+ if todo_id is not None:
430
+ ids_to_mark.append(todo_id)
431
+
432
+ if not ids_to_mark:
433
+ return {"success": False, "error": "Provide todo_id or todo_ids to mark as done."}
434
+
435
+ marked: list[str] = []
436
+ errors: list[dict[str, Any]] = []
437
+ timestamp = datetime.now(UTC).isoformat()
438
+
439
+ for tid in ids_to_mark:
440
+ if tid not in agent_todos:
441
+ errors.append({"todo_id": tid, "error": f"Todo with ID '{tid}' not found"})
442
+ continue
443
+
444
+ todo = agent_todos[tid]
445
+ todo["status"] = "done"
446
+ todo["completed_at"] = timestamp
447
+ todo["updated_at"] = timestamp
448
+ marked.append(tid)
449
+
450
+ todos_list = _sorted_todos(agent_id)
451
+
452
+ response: dict[str, Any] = {
453
+ "success": len(errors) == 0,
454
+ "marked_done": marked,
455
+ "marked_count": len(marked),
456
+ "todos": todos_list,
457
+ "total_count": len(todos_list),
458
+ }
459
+
460
+ if errors:
461
+ response["errors"] = errors
462
+
463
+ except (ValueError, TypeError) as e:
464
+ return {"success": False, "error": str(e)}
465
+ else:
466
+ return response
467
+
468
+
469
+ @register_tool(sandbox_execution=False)
470
+ def mark_todo_pending(
471
+ agent_state: Any,
472
+ todo_id: str | None = None,
473
+ todo_ids: Any | None = None,
474
+ ) -> dict[str, Any]:
475
+ try:
476
+ agent_id = agent_state.agent_id
477
+ agent_todos = _get_agent_todos(agent_id)
478
+
479
+ ids_to_mark: list[str] = []
480
+ if todo_ids is not None:
481
+ ids_to_mark.extend(_normalize_todo_ids(todo_ids))
482
+ if todo_id is not None:
483
+ ids_to_mark.append(todo_id)
484
+
485
+ if not ids_to_mark:
486
+ return {"success": False, "error": "Provide todo_id or todo_ids to mark as pending."}
487
+
488
+ marked: list[str] = []
489
+ errors: list[dict[str, Any]] = []
490
+ timestamp = datetime.now(UTC).isoformat()
491
+
492
+ for tid in ids_to_mark:
493
+ if tid not in agent_todos:
494
+ errors.append({"todo_id": tid, "error": f"Todo with ID '{tid}' not found"})
495
+ continue
496
+
497
+ todo = agent_todos[tid]
498
+ todo["status"] = "pending"
499
+ todo["completed_at"] = None
500
+ todo["updated_at"] = timestamp
501
+ marked.append(tid)
502
+
503
+ todos_list = _sorted_todos(agent_id)
504
+
505
+ response: dict[str, Any] = {
506
+ "success": len(errors) == 0,
507
+ "marked_pending": marked,
508
+ "marked_count": len(marked),
509
+ "todos": todos_list,
510
+ "total_count": len(todos_list),
511
+ }
512
+
513
+ if errors:
514
+ response["errors"] = errors
515
+
516
+ except (ValueError, TypeError) as e:
517
+ return {"success": False, "error": str(e)}
518
+ else:
519
+ return response
520
+
521
+
522
+ @register_tool(sandbox_execution=False)
523
+ def delete_todo(
524
+ agent_state: Any,
525
+ todo_id: str | None = None,
526
+ todo_ids: Any | None = None,
527
+ ) -> dict[str, Any]:
528
+ try:
529
+ agent_id = agent_state.agent_id
530
+ agent_todos = _get_agent_todos(agent_id)
531
+
532
+ ids_to_delete: list[str] = []
533
+ if todo_ids is not None:
534
+ ids_to_delete.extend(_normalize_todo_ids(todo_ids))
535
+ if todo_id is not None:
536
+ ids_to_delete.append(todo_id)
537
+
538
+ if not ids_to_delete:
539
+ return {"success": False, "error": "Provide todo_id or todo_ids to delete."}
540
+
541
+ deleted: list[str] = []
542
+ errors: list[dict[str, Any]] = []
543
+
544
+ for tid in ids_to_delete:
545
+ if tid not in agent_todos:
546
+ errors.append({"todo_id": tid, "error": f"Todo with ID '{tid}' not found"})
547
+ continue
548
+
549
+ del agent_todos[tid]
550
+ deleted.append(tid)
551
+
552
+ todos_list = _sorted_todos(agent_id)
553
+
554
+ response: dict[str, Any] = {
555
+ "success": len(errors) == 0,
556
+ "deleted": deleted,
557
+ "deleted_count": len(deleted),
558
+ "todos": todos_list,
559
+ "total_count": len(todos_list),
560
+ }
561
+
562
+ if errors:
563
+ response["errors"] = errors
564
+
565
+ except (ValueError, TypeError) as e:
566
+ return {"success": False, "error": str(e)}
567
+ else:
568
+ return response