gobby 0.2.6__py3-none-any.whl → 0.2.8__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 (198) hide show
  1. gobby/__init__.py +1 -1
  2. gobby/adapters/__init__.py +2 -1
  3. gobby/adapters/claude_code.py +96 -35
  4. gobby/adapters/codex_impl/__init__.py +28 -0
  5. gobby/adapters/codex_impl/adapter.py +722 -0
  6. gobby/adapters/codex_impl/client.py +679 -0
  7. gobby/adapters/codex_impl/protocol.py +20 -0
  8. gobby/adapters/codex_impl/types.py +68 -0
  9. gobby/adapters/gemini.py +140 -38
  10. gobby/agents/definitions.py +11 -1
  11. gobby/agents/isolation.py +525 -0
  12. gobby/agents/registry.py +11 -0
  13. gobby/agents/sandbox.py +261 -0
  14. gobby/agents/session.py +1 -0
  15. gobby/agents/spawn.py +42 -287
  16. gobby/agents/spawn_executor.py +415 -0
  17. gobby/agents/spawners/__init__.py +24 -0
  18. gobby/agents/spawners/command_builder.py +189 -0
  19. gobby/agents/spawners/embedded.py +21 -2
  20. gobby/agents/spawners/headless.py +21 -2
  21. gobby/agents/spawners/macos.py +26 -1
  22. gobby/agents/spawners/prompt_manager.py +125 -0
  23. gobby/cli/__init__.py +0 -2
  24. gobby/cli/install.py +4 -4
  25. gobby/cli/installers/claude.py +6 -0
  26. gobby/cli/installers/gemini.py +6 -0
  27. gobby/cli/installers/shared.py +103 -4
  28. gobby/cli/memory.py +185 -0
  29. gobby/cli/sessions.py +1 -1
  30. gobby/cli/utils.py +9 -2
  31. gobby/clones/git.py +177 -0
  32. gobby/config/__init__.py +12 -97
  33. gobby/config/app.py +10 -94
  34. gobby/config/extensions.py +2 -2
  35. gobby/config/features.py +7 -130
  36. gobby/config/skills.py +31 -0
  37. gobby/config/tasks.py +4 -28
  38. gobby/hooks/__init__.py +0 -13
  39. gobby/hooks/event_handlers.py +150 -8
  40. gobby/hooks/hook_manager.py +21 -3
  41. gobby/hooks/plugins.py +1 -1
  42. gobby/hooks/webhooks.py +1 -1
  43. gobby/install/gemini/hooks/hook_dispatcher.py +74 -15
  44. gobby/llm/resolver.py +3 -2
  45. gobby/mcp_proxy/importer.py +62 -4
  46. gobby/mcp_proxy/instructions.py +4 -2
  47. gobby/mcp_proxy/registries.py +22 -8
  48. gobby/mcp_proxy/services/recommendation.py +43 -11
  49. gobby/mcp_proxy/tools/agent_messaging.py +93 -44
  50. gobby/mcp_proxy/tools/agents.py +76 -740
  51. gobby/mcp_proxy/tools/artifacts.py +43 -9
  52. gobby/mcp_proxy/tools/clones.py +0 -385
  53. gobby/mcp_proxy/tools/memory.py +2 -2
  54. gobby/mcp_proxy/tools/sessions/__init__.py +14 -0
  55. gobby/mcp_proxy/tools/sessions/_commits.py +239 -0
  56. gobby/mcp_proxy/tools/sessions/_crud.py +253 -0
  57. gobby/mcp_proxy/tools/sessions/_factory.py +63 -0
  58. gobby/mcp_proxy/tools/sessions/_handoff.py +503 -0
  59. gobby/mcp_proxy/tools/sessions/_messages.py +166 -0
  60. gobby/mcp_proxy/tools/skills/__init__.py +14 -29
  61. gobby/mcp_proxy/tools/spawn_agent.py +455 -0
  62. gobby/mcp_proxy/tools/tasks/_context.py +18 -0
  63. gobby/mcp_proxy/tools/tasks/_crud.py +13 -6
  64. gobby/mcp_proxy/tools/tasks/_lifecycle.py +79 -30
  65. gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +1 -1
  66. gobby/mcp_proxy/tools/tasks/_session.py +22 -7
  67. gobby/mcp_proxy/tools/workflows.py +84 -34
  68. gobby/mcp_proxy/tools/worktrees.py +32 -350
  69. gobby/memory/extractor.py +15 -1
  70. gobby/memory/ingestion/__init__.py +5 -0
  71. gobby/memory/ingestion/multimodal.py +221 -0
  72. gobby/memory/manager.py +62 -283
  73. gobby/memory/search/__init__.py +10 -0
  74. gobby/memory/search/coordinator.py +248 -0
  75. gobby/memory/services/__init__.py +5 -0
  76. gobby/memory/services/crossref.py +142 -0
  77. gobby/prompts/loader.py +5 -2
  78. gobby/runner.py +13 -0
  79. gobby/servers/http.py +1 -4
  80. gobby/servers/routes/admin.py +14 -0
  81. gobby/servers/routes/mcp/endpoints/__init__.py +61 -0
  82. gobby/servers/routes/mcp/endpoints/discovery.py +405 -0
  83. gobby/servers/routes/mcp/endpoints/execution.py +568 -0
  84. gobby/servers/routes/mcp/endpoints/registry.py +378 -0
  85. gobby/servers/routes/mcp/endpoints/server.py +304 -0
  86. gobby/servers/routes/mcp/hooks.py +51 -4
  87. gobby/servers/routes/mcp/tools.py +48 -1506
  88. gobby/servers/websocket.py +57 -1
  89. gobby/sessions/analyzer.py +2 -2
  90. gobby/sessions/lifecycle.py +1 -1
  91. gobby/sessions/manager.py +9 -0
  92. gobby/sessions/processor.py +10 -0
  93. gobby/sessions/transcripts/base.py +1 -0
  94. gobby/sessions/transcripts/claude.py +15 -5
  95. gobby/sessions/transcripts/gemini.py +100 -34
  96. gobby/skills/parser.py +30 -2
  97. gobby/storage/database.py +9 -2
  98. gobby/storage/memories.py +32 -21
  99. gobby/storage/migrations.py +174 -368
  100. gobby/storage/sessions.py +45 -7
  101. gobby/storage/skills.py +80 -7
  102. gobby/storage/tasks/_lifecycle.py +18 -3
  103. gobby/sync/memories.py +1 -1
  104. gobby/tasks/external_validator.py +1 -1
  105. gobby/tasks/validation.py +22 -20
  106. gobby/tools/summarizer.py +91 -10
  107. gobby/utils/project_context.py +2 -3
  108. gobby/utils/status.py +13 -0
  109. gobby/workflows/actions.py +221 -1217
  110. gobby/workflows/artifact_actions.py +31 -0
  111. gobby/workflows/autonomous_actions.py +11 -0
  112. gobby/workflows/context_actions.py +50 -1
  113. gobby/workflows/detection_helpers.py +38 -24
  114. gobby/workflows/enforcement/__init__.py +47 -0
  115. gobby/workflows/enforcement/blocking.py +281 -0
  116. gobby/workflows/enforcement/commit_policy.py +283 -0
  117. gobby/workflows/enforcement/handlers.py +269 -0
  118. gobby/workflows/enforcement/task_policy.py +542 -0
  119. gobby/workflows/engine.py +93 -0
  120. gobby/workflows/evaluator.py +110 -0
  121. gobby/workflows/git_utils.py +106 -0
  122. gobby/workflows/hooks.py +41 -0
  123. gobby/workflows/llm_actions.py +30 -0
  124. gobby/workflows/mcp_actions.py +20 -1
  125. gobby/workflows/memory_actions.py +91 -0
  126. gobby/workflows/safe_evaluator.py +191 -0
  127. gobby/workflows/session_actions.py +44 -0
  128. gobby/workflows/state_actions.py +60 -1
  129. gobby/workflows/stop_signal_actions.py +55 -0
  130. gobby/workflows/summary_actions.py +217 -51
  131. gobby/workflows/task_sync_actions.py +347 -0
  132. gobby/workflows/todo_actions.py +34 -1
  133. gobby/workflows/webhook_actions.py +185 -0
  134. {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/METADATA +6 -1
  135. {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/RECORD +139 -163
  136. {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/WHEEL +1 -1
  137. gobby/adapters/codex.py +0 -1332
  138. gobby/cli/tui.py +0 -34
  139. gobby/install/claude/commands/gobby/bug.md +0 -51
  140. gobby/install/claude/commands/gobby/chore.md +0 -51
  141. gobby/install/claude/commands/gobby/epic.md +0 -52
  142. gobby/install/claude/commands/gobby/eval.md +0 -235
  143. gobby/install/claude/commands/gobby/feat.md +0 -49
  144. gobby/install/claude/commands/gobby/nit.md +0 -52
  145. gobby/install/claude/commands/gobby/ref.md +0 -52
  146. gobby/mcp_proxy/tools/session_messages.py +0 -1055
  147. gobby/prompts/defaults/expansion/system.md +0 -119
  148. gobby/prompts/defaults/expansion/user.md +0 -48
  149. gobby/prompts/defaults/external_validation/agent.md +0 -72
  150. gobby/prompts/defaults/external_validation/external.md +0 -63
  151. gobby/prompts/defaults/external_validation/spawn.md +0 -83
  152. gobby/prompts/defaults/external_validation/system.md +0 -6
  153. gobby/prompts/defaults/features/import_mcp.md +0 -22
  154. gobby/prompts/defaults/features/import_mcp_github.md +0 -17
  155. gobby/prompts/defaults/features/import_mcp_search.md +0 -16
  156. gobby/prompts/defaults/features/recommend_tools.md +0 -32
  157. gobby/prompts/defaults/features/recommend_tools_hybrid.md +0 -35
  158. gobby/prompts/defaults/features/recommend_tools_llm.md +0 -30
  159. gobby/prompts/defaults/features/server_description.md +0 -20
  160. gobby/prompts/defaults/features/server_description_system.md +0 -6
  161. gobby/prompts/defaults/features/task_description.md +0 -31
  162. gobby/prompts/defaults/features/task_description_system.md +0 -6
  163. gobby/prompts/defaults/features/tool_summary.md +0 -17
  164. gobby/prompts/defaults/features/tool_summary_system.md +0 -6
  165. gobby/prompts/defaults/handoff/compact.md +0 -63
  166. gobby/prompts/defaults/handoff/session_end.md +0 -57
  167. gobby/prompts/defaults/memory/extract.md +0 -61
  168. gobby/prompts/defaults/research/step.md +0 -58
  169. gobby/prompts/defaults/validation/criteria.md +0 -47
  170. gobby/prompts/defaults/validation/validate.md +0 -38
  171. gobby/storage/migrations_legacy.py +0 -1359
  172. gobby/tui/__init__.py +0 -5
  173. gobby/tui/api_client.py +0 -278
  174. gobby/tui/app.py +0 -329
  175. gobby/tui/screens/__init__.py +0 -25
  176. gobby/tui/screens/agents.py +0 -333
  177. gobby/tui/screens/chat.py +0 -450
  178. gobby/tui/screens/dashboard.py +0 -377
  179. gobby/tui/screens/memory.py +0 -305
  180. gobby/tui/screens/metrics.py +0 -231
  181. gobby/tui/screens/orchestrator.py +0 -903
  182. gobby/tui/screens/sessions.py +0 -412
  183. gobby/tui/screens/tasks.py +0 -440
  184. gobby/tui/screens/workflows.py +0 -289
  185. gobby/tui/screens/worktrees.py +0 -174
  186. gobby/tui/widgets/__init__.py +0 -21
  187. gobby/tui/widgets/chat.py +0 -210
  188. gobby/tui/widgets/conductor.py +0 -104
  189. gobby/tui/widgets/menu.py +0 -132
  190. gobby/tui/widgets/message_panel.py +0 -160
  191. gobby/tui/widgets/review_gate.py +0 -224
  192. gobby/tui/widgets/task_tree.py +0 -99
  193. gobby/tui/widgets/token_budget.py +0 -166
  194. gobby/tui/ws_client.py +0 -258
  195. gobby/workflows/task_enforcement_actions.py +0 -1343
  196. {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/entry_points.txt +0 -0
  197. {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/licenses/LICENSE.md +0 -0
  198. {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/top_level.txt +0 -0
@@ -6,8 +6,10 @@ from typing import Any, Protocol
6
6
 
7
7
  from gobby.storage.database import DatabaseProtocol
8
8
  from gobby.storage.sessions import LocalSessionManager
9
- from gobby.storage.tasks import LocalTaskManager # noqa: F401
10
- from gobby.workflows.artifact_actions import capture_artifact, read_artifact
9
+ from gobby.workflows.artifact_actions import (
10
+ handle_capture_artifact,
11
+ handle_read_artifact,
12
+ )
11
13
  from gobby.workflows.autonomous_actions import (
12
14
  detect_stuck,
13
15
  detect_task_loop,
@@ -18,34 +20,41 @@ from gobby.workflows.autonomous_actions import (
18
20
  stop_progress_tracking,
19
21
  )
20
22
  from gobby.workflows.context_actions import (
21
- extract_handoff_context,
22
- format_handoff_as_markdown,
23
- inject_context,
24
- inject_message,
23
+ handle_extract_handoff_context,
24
+ handle_inject_context,
25
+ handle_inject_message,
25
26
  )
26
27
  from gobby.workflows.definitions import WorkflowState
27
- from gobby.workflows.git_utils import get_file_changes, get_git_status, get_recent_git_commits
28
- from gobby.workflows.llm_actions import call_llm
29
- from gobby.workflows.mcp_actions import call_mcp_tool
28
+ from gobby.workflows.enforcement import (
29
+ handle_block_tools,
30
+ handle_capture_baseline_dirty_files,
31
+ handle_require_active_task,
32
+ handle_require_commit_before_stop,
33
+ handle_require_task_complete,
34
+ handle_require_task_review_or_close_before_stop,
35
+ handle_validate_session_task_scope,
36
+ )
37
+ from gobby.workflows.llm_actions import handle_call_llm
38
+ from gobby.workflows.mcp_actions import handle_call_mcp_tool
30
39
  from gobby.workflows.memory_actions import (
31
- memory_extract,
32
- memory_recall_relevant,
33
- memory_save,
34
- memory_sync_export,
35
- memory_sync_import,
36
- reset_memory_injection_tracking,
40
+ handle_memory_extract,
41
+ handle_memory_recall_relevant,
42
+ handle_memory_save,
43
+ handle_memory_sync_export,
44
+ handle_memory_sync_import,
45
+ handle_reset_memory_injection_tracking,
37
46
  )
38
47
  from gobby.workflows.session_actions import (
39
- mark_session_status,
40
- start_new_session,
41
- switch_mode,
48
+ handle_mark_session_status,
49
+ handle_start_new_session,
50
+ handle_switch_mode,
42
51
  )
43
52
  from gobby.workflows.state_actions import (
44
- increment_variable,
45
- load_workflow_state,
46
- mark_loop_complete,
47
- save_workflow_state,
48
- set_variable,
53
+ handle_increment_variable,
54
+ handle_load_workflow_state,
55
+ handle_mark_loop_complete,
56
+ handle_save_workflow_state,
57
+ handle_set_variable,
49
58
  )
50
59
  from gobby.workflows.stop_signal_actions import (
51
60
  check_stop_signal,
@@ -53,24 +62,23 @@ from gobby.workflows.stop_signal_actions import (
53
62
  request_stop,
54
63
  )
55
64
  from gobby.workflows.summary_actions import (
56
- format_turns_for_llm,
57
- generate_handoff,
58
- generate_summary,
59
- synthesize_title,
65
+ handle_generate_handoff,
66
+ handle_generate_summary,
67
+ handle_synthesize_title,
60
68
  )
61
- from gobby.workflows.task_enforcement_actions import (
62
- block_tools,
63
- capture_baseline_dirty_files,
64
- require_active_task,
65
- require_commit_before_stop,
66
- require_task_complete,
67
- require_task_review_or_close_before_stop,
68
- validate_session_task_scope,
69
+ from gobby.workflows.task_sync_actions import (
70
+ handle_get_workflow_tasks,
71
+ handle_persist_tasks,
72
+ handle_task_sync_export,
73
+ handle_task_sync_import,
74
+ handle_update_workflow_task,
69
75
  )
70
76
  from gobby.workflows.templates import TemplateEngine
71
- from gobby.workflows.todo_actions import mark_todo_complete, write_todos
72
- from gobby.workflows.webhook import WebhookAction
73
- from gobby.workflows.webhook_executor import WebhookExecutor
77
+ from gobby.workflows.todo_actions import (
78
+ handle_mark_todo_complete,
79
+ handle_write_todos,
80
+ )
81
+ from gobby.workflows.webhook_actions import handle_webhook
74
82
 
75
83
  logger = logging.getLogger(__name__)
76
84
 
@@ -148,17 +156,7 @@ class ActionExecutor:
148
156
  self._handlers[name] = handler
149
157
 
150
158
  def register_plugin_actions(self, plugin_registry: Any) -> None:
151
- """
152
- Register actions from loaded plugins.
153
-
154
- Actions are registered with the naming convention:
155
- plugin:<plugin-name>:<action-name>
156
-
157
- Plugin actions with schemas will have their inputs validated before execution.
158
-
159
- Args:
160
- plugin_registry: PluginRegistry instance containing loaded plugins.
161
- """
159
+ """Register actions from loaded plugins."""
162
160
  if plugin_registry is None:
163
161
  return
164
162
 
@@ -166,1227 +164,233 @@ class ActionExecutor:
166
164
  for action_name, plugin_action in plugin._actions.items():
167
165
  full_name = f"plugin:{plugin_name}:{action_name}"
168
166
 
169
- # Create wrapper that validates schema before calling handler
170
167
  if plugin_action.schema:
171
168
  wrapper = self._create_validating_wrapper(plugin_action)
172
169
  self._handlers[full_name] = wrapper
173
170
  else:
174
- # No schema, use handler directly
175
171
  self._handlers[full_name] = plugin_action.handler
176
172
 
177
173
  logger.debug(f"Registered plugin action: {full_name}")
178
174
 
179
175
  def _create_validating_wrapper(self, plugin_action: Any) -> ActionHandler:
180
- """Create a wrapper handler that validates input against schema.
181
-
182
- Args:
183
- plugin_action: PluginAction with schema and handler.
184
-
185
- Returns:
186
- Wrapper handler that validates before calling the real handler.
187
- """
176
+ """Create a wrapper handler that validates input against schema."""
188
177
 
189
178
  async def validating_handler(
190
179
  context: ActionContext, **kwargs: Any
191
180
  ) -> dict[str, Any] | None:
192
- # Validate input against schema
193
181
  is_valid, error = plugin_action.validate_input(kwargs)
194
182
  if not is_valid:
195
183
  logger.warning(f"Plugin action '{plugin_action.name}' validation failed: {error}")
196
184
  return {"error": f"Schema validation failed: {error}"}
197
185
 
198
- # Call the actual handler
199
186
  result = await plugin_action.handler(context, **kwargs)
200
187
  return dict(result) if isinstance(result, dict) else None
201
188
 
202
189
  return validating_handler
203
190
 
204
191
  def _register_defaults(self) -> None:
205
- """Register built-in actions."""
206
- self.register("inject_context", self._handle_inject_context)
207
- self.register("inject_message", self._handle_inject_message)
208
- self.register("capture_artifact", self._handle_capture_artifact)
209
- self.register("generate_handoff", self._handle_generate_handoff)
210
- self.register("generate_summary", self._handle_generate_summary)
211
- self.register("mark_session_status", self._handle_mark_session_status)
212
- self.register("switch_mode", self._handle_switch_mode)
213
- self.register("read_artifact", self._handle_read_artifact)
214
- self.register("load_workflow_state", self._handle_load_workflow_state)
215
- self.register("save_workflow_state", self._handle_save_workflow_state)
216
- self.register("set_variable", self._handle_set_variable)
217
- self.register("increment_variable", self._handle_increment_variable)
218
- self.register("call_llm", self._handle_call_llm)
219
- self.register("synthesize_title", self._handle_synthesize_title)
220
- self.register("write_todos", self._handle_write_todos)
221
- self.register("mark_todo_complete", self._handle_mark_todo_complete)
222
- self.register("persist_tasks", self._handle_persist_tasks)
223
- self.register("get_workflow_tasks", self._handle_get_workflow_tasks)
224
- self.register("update_workflow_task", self._handle_update_workflow_task)
225
- self.register("call_mcp_tool", self._handle_call_mcp_tool)
226
- # Memory actions - underscore pattern (memory_*)
227
- self.register("memory_save", self._handle_save_memory)
228
- self.register("memory_recall_relevant", self._handle_memory_recall_relevant)
229
- self.register("memory_sync_import", self._handle_memory_sync_import)
230
- self.register("memory_sync_export", self._handle_memory_sync_export)
231
- self.register("memory_extract", self._handle_memory_extract)
232
- self.register(
233
- "reset_memory_injection_tracking", self._handle_reset_memory_injection_tracking
234
- )
235
- # Task sync actions
236
- self.register("task_sync_import", self._handle_task_sync_import)
237
- self.register("task_sync_export", self._handle_task_sync_export)
238
- self.register("extract_handoff_context", self._handle_extract_handoff_context)
239
- self.register("start_new_session", self._handle_start_new_session)
240
- self.register("mark_loop_complete", self._handle_mark_loop_complete)
241
- # Task enforcement
242
- self.register("block_tools", self._handle_block_tools)
243
- self.register("require_active_task", self._handle_require_active_task)
244
- self.register("require_commit_before_stop", self._handle_require_commit_before_stop)
245
- self.register(
246
- "require_task_review_or_close_before_stop",
247
- self._handle_require_task_review_or_close_before_stop,
248
- )
249
- self.register("require_task_complete", self._handle_require_task_complete)
250
- self.register("validate_session_task_scope", self._handle_validate_session_task_scope)
251
- self.register("capture_baseline_dirty_files", self._handle_capture_baseline_dirty_files)
252
- # Webhook
253
- self.register("webhook", self._handle_webhook)
254
- # Stop signal actions
255
- self.register("check_stop_signal", self._handle_check_stop_signal)
256
- self.register("request_stop", self._handle_request_stop)
257
- self.register("clear_stop_signal", self._handle_clear_stop_signal)
258
- # Autonomous execution actions
259
- self.register("start_progress_tracking", self._handle_start_progress_tracking)
260
- self.register("stop_progress_tracking", self._handle_stop_progress_tracking)
261
- self.register("record_progress", self._handle_record_progress)
262
- self.register("detect_task_loop", self._handle_detect_task_loop)
263
- self.register("detect_stuck", self._handle_detect_stuck)
264
- self.register("record_task_selection", self._handle_record_task_selection)
265
- self.register("get_progress_summary", self._handle_get_progress_summary)
266
-
267
- async def execute(
268
- self, action_type: str, context: ActionContext, **kwargs: Any
269
- ) -> dict[str, Any] | None:
270
- """Execute an action."""
271
- handler = self._handlers.get(action_type)
272
- if not handler:
273
- logger.warning(f"Unknown action type: {action_type}")
274
- return None
275
-
276
- try:
277
- return await handler(context, **kwargs)
278
- except Exception as e:
279
- logger.error(f"Error executing action {action_type}: {e}", exc_info=True)
280
- return {"error": str(e)}
281
-
282
- # --- Action Implementations ---
283
-
284
- async def _handle_inject_context(
285
- self, context: ActionContext, **kwargs: Any
286
- ) -> dict[str, Any] | None:
287
- """Inject context from a source."""
288
- return inject_context(
289
- session_manager=context.session_manager,
290
- session_id=context.session_id,
291
- state=context.state,
292
- template_engine=context.template_engine,
293
- source=kwargs.get("source"),
294
- template=kwargs.get("template"),
295
- require=kwargs.get("require", False),
296
- )
297
-
298
- async def _handle_inject_message(
299
- self, context: ActionContext, **kwargs: Any
300
- ) -> dict[str, Any] | None:
301
- """Inject a message to the user/assistant, rendering it as a template."""
302
- return inject_message(
303
- session_manager=context.session_manager,
304
- session_id=context.session_id,
305
- state=context.state,
306
- template_engine=context.template_engine,
307
- content=kwargs.get("content"),
308
- **{k: v for k, v in kwargs.items() if k != "content"},
309
- )
310
-
311
- async def _handle_capture_artifact(
312
- self, context: ActionContext, **kwargs: Any
313
- ) -> dict[str, Any] | None:
314
- """Capture an artifact (file) and store its path in state."""
315
- return capture_artifact(
316
- state=context.state,
317
- pattern=kwargs.get("pattern"),
318
- save_as=kwargs.get("as"),
319
- )
320
-
321
- async def _handle_read_artifact(
322
- self, context: ActionContext, **kwargs: Any
323
- ) -> dict[str, Any] | None:
324
- """Read an artifact's content into a workflow variable."""
325
- return read_artifact(
326
- state=context.state,
327
- pattern=kwargs.get("pattern"),
328
- variable_name=kwargs.get("as"),
329
- )
330
-
331
- async def _handle_load_workflow_state(
332
- self, context: ActionContext, **kwargs: Any
333
- ) -> dict[str, Any] | None:
334
- """Load workflow state from DB."""
335
- return load_workflow_state(context.db, context.session_id, context.state)
336
-
337
- async def _handle_save_workflow_state(
338
- self, context: ActionContext, **kwargs: Any
339
- ) -> dict[str, Any] | None:
340
- """Save workflow state to DB."""
341
- return save_workflow_state(context.db, context.state)
342
-
343
- async def _handle_set_variable(
344
- self, context: ActionContext, **kwargs: Any
345
- ) -> dict[str, Any] | None:
346
- """Set a workflow variable.
347
-
348
- Values containing Jinja2 templates ({{ ... }}) are rendered before setting.
349
- """
350
- value = kwargs.get("value")
351
-
352
- # Render template if value contains Jinja2 syntax
353
- if isinstance(value, str) and "{{" in value:
354
- template_context = {
355
- "variables": context.state.variables or {},
356
- "state": context.state,
357
- }
358
- value = context.template_engine.render(value, template_context)
359
-
360
- return set_variable(context.state, kwargs.get("name"), value)
361
-
362
- async def _handle_increment_variable(
363
- self, context: ActionContext, **kwargs: Any
364
- ) -> dict[str, Any] | None:
365
- """Increment a numeric workflow variable."""
366
- return increment_variable(context.state, kwargs.get("name"), kwargs.get("amount", 1))
367
-
368
- async def _handle_call_llm(
369
- self, context: ActionContext, **kwargs: Any
370
- ) -> dict[str, Any] | None:
371
- """Call LLM with a prompt template and store result in variable."""
372
- return await call_llm(
373
- llm_service=context.llm_service,
374
- template_engine=context.template_engine,
375
- state=context.state,
376
- session=context.session_manager.get(context.session_id),
377
- prompt=kwargs.get("prompt"),
378
- output_as=kwargs.get("output_as"),
379
- **{k: v for k, v in kwargs.items() if k not in ("prompt", "output_as")},
380
- )
381
-
382
- async def _handle_synthesize_title(
383
- self, context: ActionContext, **kwargs: Any
384
- ) -> dict[str, Any] | None:
385
- """Synthesize and set a session title."""
386
- # Extract prompt from event data (UserPromptSubmit hook)
387
- prompt = None
388
- if context.event_data:
389
- prompt = context.event_data.get("prompt")
390
-
391
- return await synthesize_title(
392
- session_manager=context.session_manager,
393
- session_id=context.session_id,
394
- llm_service=context.llm_service,
395
- transcript_processor=context.transcript_processor,
396
- template_engine=context.template_engine,
397
- template=kwargs.get("template"),
398
- prompt=prompt,
399
- )
400
-
401
- async def _handle_write_todos(
402
- self, context: ActionContext, **kwargs: Any
403
- ) -> dict[str, Any] | None:
404
- """Write todos to a file (default TODO.md)."""
405
- return write_todos(
406
- todos=kwargs.get("todos", []),
407
- filename=kwargs.get("filename", "TODO.md"),
408
- mode=kwargs.get("mode", "w"),
409
- )
410
-
411
- async def _handle_mark_todo_complete(
412
- self, context: ActionContext, **kwargs: Any
413
- ) -> dict[str, Any] | None:
414
- """Mark a todo as complete in TODO.md."""
415
- return mark_todo_complete(
416
- todo_text=kwargs.get("todo_text", ""),
417
- filename=kwargs.get("filename", "TODO.md"),
418
- )
419
-
420
- async def _handle_memory_sync_import(
421
- self, context: ActionContext, **kwargs: Any
422
- ) -> dict[str, Any] | None:
423
- """Import memories from filesystem."""
424
- return await memory_sync_import(context.memory_sync_manager)
425
-
426
- async def _handle_memory_sync_export(
427
- self, context: ActionContext, **kwargs: Any
428
- ) -> dict[str, Any] | None:
429
- """Export memories to filesystem."""
430
- return await memory_sync_export(context.memory_sync_manager)
431
-
432
- async def _handle_task_sync_import(
433
- self, context: ActionContext, **kwargs: Any
434
- ) -> dict[str, Any] | None:
435
- """Import tasks from JSONL file.
436
-
437
- Reads .gobby/tasks.jsonl and imports tasks into SQLite using
438
- Last-Write-Wins conflict resolution based on updated_at.
439
- """
440
- if not context.task_sync_manager:
441
- logger.debug("task_sync_import: No task_sync_manager available")
442
- return {"error": "Task Sync Manager not available"}
443
-
444
- try:
445
- # Get project_id from session for project-scoped sync
446
- project_id = None
447
- session = context.session_manager.get(context.session_id)
448
- if session:
449
- project_id = session.project_id
450
-
451
- context.task_sync_manager.import_from_jsonl(project_id=project_id)
452
- logger.info("Task sync import completed")
453
- return {"imported": True}
454
- except Exception as e:
455
- logger.error(f"task_sync_import failed: {e}", exc_info=True)
456
- return {"error": str(e)}
457
-
458
- async def _handle_task_sync_export(
459
- self, context: ActionContext, **kwargs: Any
460
- ) -> dict[str, Any] | None:
461
- """Export tasks to JSONL file.
462
-
463
- Writes tasks and dependencies to .gobby/tasks.jsonl for Git persistence.
464
- Uses content hashing to skip writes if nothing changed.
465
- """
466
- if not context.task_sync_manager:
467
- logger.debug("task_sync_export: No task_sync_manager available")
468
- return {"error": "Task Sync Manager not available"}
469
-
470
- try:
471
- # Get project_id from session for project-scoped sync
472
- project_id = None
473
- session = context.session_manager.get(context.session_id)
474
- if session:
475
- project_id = session.project_id
476
-
477
- context.task_sync_manager.export_to_jsonl(project_id=project_id)
478
- logger.info("Task sync export completed")
479
- return {"exported": True}
480
- except Exception as e:
481
- logger.error(f"task_sync_export failed: {e}", exc_info=True)
482
- return {"error": str(e)}
483
-
484
- async def _handle_persist_tasks(
485
- self, context: ActionContext, **kwargs: Any
486
- ) -> dict[str, Any] | None:
487
- """Persist a list of task dicts to Gobby task system.
488
-
489
- Enhanced to support workflow integration with ID mapping.
490
-
491
- Args (via kwargs):
492
- tasks: List of task dicts (or source variable name)
493
- source: Variable name containing task list (alternative to tasks)
494
- workflow_name: Associate tasks with this workflow
495
- parent_task_id: Optional parent task for all created tasks
496
-
497
- Returns:
498
- Dict with tasks_persisted count, ids list, and id_mapping dict
499
- """
500
- # Get tasks from either 'tasks' kwarg or 'source' variable
501
- tasks = kwargs.get("tasks", [])
502
- source = kwargs.get("source")
503
-
504
- if source and context.state.variables:
505
- source_data = context.state.variables.get(source)
506
- if source_data:
507
- # Handle nested structure like task_list.tasks
508
- if isinstance(source_data, dict) and "tasks" in source_data:
509
- tasks = source_data["tasks"]
510
- elif isinstance(source_data, list):
511
- tasks = source_data
512
-
513
- if not tasks:
514
- return {"tasks_persisted": 0, "ids": [], "id_mapping": {}}
515
-
516
- try:
517
- from gobby.workflows.task_actions import persist_decomposed_tasks
518
-
519
- current_session = context.session_manager.get(context.session_id)
520
- project_id = current_session.project_id if current_session else "default"
521
-
522
- # Get workflow name from kwargs or state
523
- workflow_name = kwargs.get("workflow_name")
524
- if not workflow_name and context.state.workflow_name:
525
- workflow_name = context.state.workflow_name
526
-
527
- parent_task_id = kwargs.get("parent_task_id")
528
-
529
- id_mapping = persist_decomposed_tasks(
530
- db=context.db,
531
- project_id=project_id,
532
- tasks_data=tasks,
533
- workflow_name=workflow_name or "unnamed",
534
- parent_task_id=parent_task_id,
535
- created_in_session_id=context.session_id,
192
+ """Register built-in actions using external handlers."""
193
+ # --- Context/injection actions ---
194
+ self.register("inject_context", handle_inject_context)
195
+ self.register("inject_message", handle_inject_message)
196
+ self.register("extract_handoff_context", handle_extract_handoff_context)
197
+
198
+ # --- Artifact actions ---
199
+ self.register("capture_artifact", handle_capture_artifact)
200
+ self.register("read_artifact", handle_read_artifact)
201
+
202
+ # --- State actions ---
203
+ self.register("load_workflow_state", handle_load_workflow_state)
204
+ self.register("save_workflow_state", handle_save_workflow_state)
205
+ self.register("set_variable", handle_set_variable)
206
+ self.register("increment_variable", handle_increment_variable)
207
+ self.register("mark_loop_complete", handle_mark_loop_complete)
208
+
209
+ # --- Session actions ---
210
+ self.register("start_new_session", handle_start_new_session)
211
+ self.register("mark_session_status", handle_mark_session_status)
212
+ self.register("switch_mode", handle_switch_mode)
213
+
214
+ # --- Todo actions ---
215
+ self.register("write_todos", handle_write_todos)
216
+ self.register("mark_todo_complete", handle_mark_todo_complete)
217
+
218
+ # --- LLM actions ---
219
+ self.register("call_llm", handle_call_llm)
220
+
221
+ # --- MCP actions ---
222
+ self.register("call_mcp_tool", handle_call_mcp_tool)
223
+
224
+ # --- Summary actions ---
225
+ self.register("synthesize_title", handle_synthesize_title)
226
+ self.register("generate_summary", handle_generate_summary)
227
+ self.register("generate_handoff", handle_generate_handoff)
228
+
229
+ # --- Memory actions ---
230
+ self.register("memory_save", handle_memory_save)
231
+ self.register("memory_recall_relevant", handle_memory_recall_relevant)
232
+ self.register("memory_sync_import", handle_memory_sync_import)
233
+ self.register("memory_sync_export", handle_memory_sync_export)
234
+ self.register("memory_extract", handle_memory_extract)
235
+ self.register("reset_memory_injection_tracking", handle_reset_memory_injection_tracking)
236
+
237
+ # --- Task sync actions ---
238
+ self.register("task_sync_import", handle_task_sync_import)
239
+ self.register("task_sync_export", handle_task_sync_export)
240
+ self.register("persist_tasks", handle_persist_tasks)
241
+ self.register("get_workflow_tasks", handle_get_workflow_tasks)
242
+ self.register("update_workflow_task", handle_update_workflow_task)
243
+
244
+ # --- Task enforcement actions (closures for task_manager access) ---
245
+ self._register_task_enforcement_actions()
246
+
247
+ # --- Webhook (closure for config access) ---
248
+ self._register_webhook_action()
249
+
250
+ # --- Stop signal actions (closures for stop_registry access) ---
251
+ self._register_stop_signal_actions()
252
+
253
+ # --- Autonomous execution actions (closures for progress_tracker/stuck_detector) ---
254
+ self._register_autonomous_actions()
255
+
256
+ def _register_task_enforcement_actions(self) -> None:
257
+ """Register task enforcement actions with task_manager closure."""
258
+ tm = self.task_manager
259
+ te = self.template_engine
260
+
261
+ async def block_tools(context: ActionContext, **kw: Any) -> dict[str, Any] | None:
262
+ return await handle_block_tools(context, task_manager=tm, **kw)
263
+
264
+ async def require_active(context: ActionContext, **kw: Any) -> dict[str, Any] | None:
265
+ return await handle_require_active_task(context, task_manager=tm, **kw)
266
+
267
+ async def require_complete(context: ActionContext, **kw: Any) -> dict[str, Any] | None:
268
+ return await handle_require_task_complete(
269
+ context, task_manager=tm, template_engine=te, **kw
536
270
  )
537
271
 
538
- # Store ID mapping in workflow state for reference
539
- if not context.state.variables:
540
- context.state.variables = {}
541
- context.state.variables["task_id_mapping"] = id_mapping
542
-
543
- return {
544
- "tasks_persisted": len(id_mapping),
545
- "ids": list(id_mapping.values()),
546
- "id_mapping": id_mapping,
547
- }
548
- except Exception as e:
549
- logger.error(f"persist_tasks: Failed: {e}")
550
- return {"error": str(e)}
551
-
552
- async def _handle_get_workflow_tasks(
553
- self, context: ActionContext, **kwargs: Any
554
- ) -> dict[str, Any] | None:
555
- """Get tasks associated with the current workflow.
556
-
557
- Args (via kwargs):
558
- workflow_name: Override workflow name (defaults to current)
559
- include_closed: Include closed tasks (default: False)
560
- as: Variable name to store result in
561
-
562
- Returns:
563
- Dict with tasks list and count
564
- """
565
- from gobby.workflows.task_actions import get_workflow_tasks
272
+ async def require_commit(context: ActionContext, **kw: Any) -> dict[str, Any] | None:
273
+ return await handle_require_commit_before_stop(context, task_manager=tm, **kw)
566
274
 
567
- workflow_name = kwargs.get("workflow_name")
568
- if not workflow_name and context.state.workflow_name:
569
- workflow_name = context.state.workflow_name
570
-
571
- if not workflow_name:
572
- return {"error": "No workflow name specified"}
573
-
574
- current_session = context.session_manager.get(context.session_id)
575
- project_id = current_session.project_id if current_session else None
576
-
577
- include_closed = kwargs.get("include_closed", False)
578
-
579
- tasks = get_workflow_tasks(
580
- db=context.db,
581
- workflow_name=workflow_name,
582
- project_id=project_id,
583
- include_closed=include_closed,
584
- )
585
-
586
- # Convert to dicts for YAML/JSON serialization
587
- tasks_data = [t.to_dict() for t in tasks]
588
-
589
- # Store in variable if requested
590
- output_as = kwargs.get("as")
591
- if output_as:
592
- if not context.state.variables:
593
- context.state.variables = {}
594
- context.state.variables[output_as] = tasks_data
595
-
596
- # Also update task_list in state for workflow engine use
597
- context.state.task_list = [
598
- {"id": t.id, "title": t.title, "status": t.status} for t in tasks
599
- ]
275
+ async def require_review(context: ActionContext, **kw: Any) -> dict[str, Any] | None:
276
+ return await handle_require_task_review_or_close_before_stop(
277
+ context, task_manager=tm, **kw
278
+ )
600
279
 
601
- return {"tasks": tasks_data, "count": len(tasks)}
280
+ async def validate_scope(context: ActionContext, **kw: Any) -> dict[str, Any] | None:
281
+ return await handle_validate_session_task_scope(context, task_manager=tm, **kw)
602
282
 
603
- async def _handle_update_workflow_task(
604
- self, context: ActionContext, **kwargs: Any
605
- ) -> dict[str, Any] | None:
606
- """Update a task from workflow context.
607
-
608
- Args (via kwargs):
609
- task_id: ID of task to update (required)
610
- status: New status
611
- verification: Verification result
612
- validation_status: Validation status
613
-
614
- Returns:
615
- Dict with updated task data
616
- """
617
- from gobby.workflows.task_actions import update_task_from_workflow
618
-
619
- task_id = kwargs.get("task_id")
620
- if not task_id:
621
- # Try to get from current_task_index in state
622
- if context.state.task_list and context.state.current_task_index is not None:
623
- idx = context.state.current_task_index
624
- if 0 <= idx < len(context.state.task_list):
625
- task_id = context.state.task_list[idx].get("id")
626
-
627
- if not task_id:
628
- return {"error": "No task_id specified"}
629
-
630
- task = update_task_from_workflow(
631
- db=context.db,
632
- task_id=task_id,
633
- status=kwargs.get("status"),
634
- verification=kwargs.get("verification"),
635
- validation_status=kwargs.get("validation_status"),
636
- validation_feedback=kwargs.get("validation_feedback"),
637
- )
638
-
639
- if task:
640
- return {"updated": True, "task": task.to_dict()}
641
- return {"updated": False, "error": "Task not found"}
642
-
643
- async def _handle_call_mcp_tool(
644
- self,
645
- context: ActionContext,
646
- **kwargs: Any,
647
- ) -> dict[str, Any] | None:
648
- """Call an MCP tool on a connected server."""
649
- return await call_mcp_tool(
650
- mcp_manager=context.mcp_manager,
651
- state=context.state,
652
- server_name=kwargs.get("server_name"),
653
- tool_name=kwargs.get("tool_name"),
654
- arguments=kwargs.get("arguments"),
655
- output_as=kwargs.get("as"),
656
- )
657
-
658
- async def _handle_generate_handoff(
659
- self, context: ActionContext, **kwargs: Any
660
- ) -> dict[str, Any] | None:
661
- """Generate a handoff record (summary + mark status).
662
-
663
- For compact mode, fetches the current session's existing summary_markdown
664
- as previous_summary for cumulative compression.
665
-
666
- Supports loading prompts from the prompts collection via the 'prompt' parameter.
667
- """
668
- # Detect mode from kwargs or event data
669
- mode = kwargs.get("mode", "clear")
670
-
671
- # Check if this is a compact event based on event_data
672
- # Use precise matching against known compact event types to avoid false positives
673
- COMPACT_EVENT_TYPES = {"pre_compact", "compact"}
674
- if context.event_data:
675
- raw_event_type = context.event_data.get("event_type") or ""
676
- normalized_event_type = str(raw_event_type).strip().lower()
677
- if normalized_event_type in COMPACT_EVENT_TYPES:
678
- mode = "compact"
679
-
680
- # For compact mode, fetch previous summary for cumulative compression
681
- previous_summary = None
682
- if mode == "compact":
683
- current_session = context.session_manager.get(context.session_id)
684
- if current_session:
685
- previous_summary = getattr(current_session, "summary_markdown", None)
686
- if previous_summary:
687
- logger.debug(
688
- f"Compact mode: using previous summary ({len(previous_summary)} chars) "
689
- f"for cumulative compression"
690
- )
691
-
692
- # Load template from prompts collection if 'prompt' parameter provided
693
- template = kwargs.get("template")
694
- prompt_path = kwargs.get("prompt")
695
- if prompt_path and not template:
696
- try:
697
- from gobby.prompts.loader import PromptLoader
698
-
699
- loader = PromptLoader()
700
- prompt_template = loader.load(prompt_path)
701
- template = prompt_template.content
702
- logger.debug(f"Loaded prompt template from: {prompt_path}")
703
- except Exception as e:
704
- logger.warning(f"Failed to load prompt from {prompt_path}: {e}")
705
- # Fall back to inline template or default
706
-
707
- return await generate_handoff(
708
- session_manager=context.session_manager,
709
- session_id=context.session_id,
710
- llm_service=context.llm_service,
711
- transcript_processor=context.transcript_processor,
712
- template=template,
713
- previous_summary=previous_summary,
714
- mode=mode,
715
- )
716
-
717
- async def _handle_generate_summary(
718
- self, context: ActionContext, **kwargs: Any
719
- ) -> dict[str, Any] | None:
720
- """Generate a session summary using LLM."""
721
- return await generate_summary(
722
- session_manager=context.session_manager,
723
- session_id=context.session_id,
724
- llm_service=context.llm_service,
725
- transcript_processor=context.transcript_processor,
726
- template=kwargs.get("template"),
727
- )
728
-
729
- async def _handle_start_new_session(
730
- self, context: ActionContext, **kwargs: Any
731
- ) -> dict[str, Any] | None:
732
- """Start a new CLI session (chaining)."""
733
- return start_new_session(
734
- session_manager=context.session_manager,
735
- session_id=context.session_id,
736
- command=kwargs.get("command"),
737
- args=kwargs.get("args"),
738
- prompt=kwargs.get("prompt"),
739
- cwd=kwargs.get("cwd"),
740
- )
741
-
742
- async def _handle_mark_loop_complete(
743
- self, context: ActionContext, **kwargs: Any
744
- ) -> dict[str, Any] | None:
745
- """Mark the autonomous loop as complete."""
746
- return mark_loop_complete(context.state)
747
-
748
- async def _handle_extract_handoff_context(
749
- self, context: ActionContext, **kwargs: Any
750
- ) -> dict[str, Any] | None:
751
- """Extract handoff context from transcript and save to session.compact_markdown."""
752
- return extract_handoff_context(
753
- session_manager=context.session_manager,
754
- session_id=context.session_id,
755
- config=context.config,
756
- db=self.db,
757
- )
758
-
759
- def _format_handoff_as_markdown(self, ctx: Any, prompt_template: str | None = None) -> str:
760
- """Format HandoffContext as markdown for injection."""
761
- return format_handoff_as_markdown(ctx, prompt_template)
762
-
763
- async def _handle_save_memory(
764
- self, context: ActionContext, **kwargs: Any
765
- ) -> dict[str, Any] | None:
766
- """Save a memory directly from workflow context."""
767
- return await memory_save(
768
- memory_manager=context.memory_manager,
769
- session_manager=context.session_manager,
770
- session_id=context.session_id,
771
- content=kwargs.get("content"),
772
- memory_type=kwargs.get("memory_type", "fact"),
773
- importance=kwargs.get("importance", 0.5),
774
- tags=kwargs.get("tags"),
775
- project_id=kwargs.get("project_id"),
776
- )
777
-
778
- async def _handle_memory_recall_relevant(
779
- self, context: ActionContext, **kwargs: Any
780
- ) -> dict[str, Any] | None:
781
- """Recall memories relevant to the current user prompt."""
782
- prompt_text = None
783
- if context.event_data:
784
- # Check both "prompt" (from hook event) and "prompt_text" (legacy/alternative)
785
- prompt_text = context.event_data.get("prompt") or context.event_data.get("prompt_text")
786
-
787
- return await memory_recall_relevant(
788
- memory_manager=context.memory_manager,
789
- session_manager=context.session_manager,
790
- session_id=context.session_id,
791
- prompt_text=prompt_text,
792
- project_id=kwargs.get("project_id"),
793
- limit=kwargs.get("limit", 5),
794
- min_importance=kwargs.get("min_importance", 0.3),
795
- state=context.state,
796
- )
797
-
798
- async def _handle_reset_memory_injection_tracking(
799
- self, context: ActionContext, **kwargs: Any
800
- ) -> dict[str, Any] | None:
801
- """Reset memory injection tracking to allow re-injection after context loss."""
802
- return reset_memory_injection_tracking(state=context.state)
283
+ async def capture_baseline(context: ActionContext, **kw: Any) -> dict[str, Any] | None:
284
+ return await handle_capture_baseline_dirty_files(context, task_manager=tm, **kw)
803
285
 
804
- async def _handle_memory_extract(
805
- self, context: ActionContext, **kwargs: Any
806
- ) -> dict[str, Any] | None:
807
- """Extract memories from the current session.
808
-
809
- Args (via kwargs):
810
- min_importance: Minimum importance threshold (default: 0.7)
811
- max_memories: Maximum memories to extract (default: 5)
812
- dry_run: If True, don't store memories (default: False)
813
-
814
- Returns:
815
- Dict with extracted_count and optional memory details
816
- """
817
- return await memory_extract(
818
- session_manager=context.session_manager,
819
- session_id=context.session_id,
820
- llm_service=context.llm_service,
821
- memory_manager=context.memory_manager,
822
- transcript_processor=context.transcript_processor,
823
- min_importance=kwargs.get("min_importance", 0.7),
824
- max_memories=kwargs.get("max_memories", 5),
825
- dry_run=kwargs.get("dry_run", False),
826
- )
827
-
828
- async def _handle_mark_session_status(
829
- self, context: ActionContext, **kwargs: Any
830
- ) -> dict[str, Any] | None:
831
- """Mark a session status (current or parent)."""
832
- return mark_session_status(
833
- session_manager=context.session_manager,
834
- session_id=context.session_id,
835
- status=kwargs.get("status"),
836
- target=kwargs.get("target", "current_session"),
837
- )
838
-
839
- async def _handle_switch_mode(
840
- self, context: ActionContext, **kwargs: Any
841
- ) -> dict[str, Any] | None:
842
- """Signal the agent to switch modes (e.g., PLAN, ACT)."""
843
- return switch_mode(kwargs.get("mode"))
286
+ self.register("block_tools", block_tools)
287
+ self.register("require_active_task", require_active)
288
+ self.register("require_task_complete", require_complete)
289
+ self.register("require_commit_before_stop", require_commit)
290
+ self.register("require_task_review_or_close_before_stop", require_review)
291
+ self.register("validate_session_task_scope", validate_scope)
292
+ self.register("capture_baseline_dirty_files", capture_baseline)
844
293
 
845
- def _format_turns_for_llm(self, turns: list[dict[str, Any]]) -> str:
846
- """Format transcript turns for LLM analysis."""
847
- return format_turns_for_llm(turns)
294
+ def _register_webhook_action(self) -> None:
295
+ """Register webhook action with config closure."""
296
+ cfg = self.config
848
297
 
849
- def _get_git_status(self) -> str:
850
- """Get git status for current directory."""
851
- return get_git_status()
298
+ async def webhook(context: ActionContext, **kw: Any) -> dict[str, Any] | None:
299
+ return await handle_webhook(context, config=cfg, **kw)
852
300
 
853
- def _get_recent_git_commits(self, max_commits: int = 10) -> list[dict[str, str]]:
854
- """Get recent git commits with hash and message."""
855
- return get_recent_git_commits(max_commits)
301
+ self.register("webhook", webhook)
856
302
 
857
- def _get_file_changes(self) -> str:
858
- """Get detailed file changes from git."""
859
- return get_file_changes()
303
+ def _register_stop_signal_actions(self) -> None:
304
+ """Register stop signal actions accessing self at call time."""
305
+ executor = self
860
306
 
861
- async def _handle_capture_baseline_dirty_files(
862
- self, context: ActionContext, **kwargs: Any
863
- ) -> dict[str, Any] | None:
864
- """Capture baseline dirty files at session start."""
865
- # Get project path - prioritize session lookup over hook payload
866
- project_path = None
867
-
868
- # 1. Get from session's project (most reliable - session exists by now)
869
- if context.session_id and context.session_manager:
870
- session = context.session_manager.get(context.session_id)
871
- if session and session.project_id:
872
- from gobby.storage.projects import LocalProjectManager
873
-
874
- project_mgr = LocalProjectManager(context.db)
875
- project = project_mgr.get(session.project_id)
876
- if project and project.repo_path:
877
- project_path = project.repo_path
878
-
879
- # 2. Fallback to event_data.cwd (from hook payload)
880
- if not project_path and context.event_data:
881
- project_path = context.event_data.get("cwd")
882
-
883
- return await capture_baseline_dirty_files(
884
- workflow_state=context.state,
885
- project_path=project_path,
886
- )
887
-
888
- async def _handle_block_tools(
889
- self, context: ActionContext, **kwargs: Any
890
- ) -> dict[str, Any] | None:
891
- """Block tools based on configurable rules.
892
-
893
- This is the unified tool blocking action that replaces require_active_task
894
- for CC native task blocking while also supporting task-before-edit enforcement.
895
-
896
- For MCP tool blocking (mcp_tools rules), also passes:
897
- - project_path: for checking dirty files in git status
898
- - task_manager: for checking if claimed task has commits
899
- - source: CLI source for is_plan_file checks
900
- """
901
- # Get project_path for git dirty file checks
902
- project_path = kwargs.get("project_path")
903
- if not project_path and context.event_data:
904
- project_path = context.event_data.get("cwd")
905
-
906
- # Get source from session for is_plan_file checks
907
- source = None
908
- current_session = context.session_manager.get(context.session_id)
909
- if current_session:
910
- source = current_session.source
911
-
912
- return await block_tools(
913
- rules=kwargs.get("rules"),
914
- event_data=context.event_data,
915
- workflow_state=context.state,
916
- project_path=project_path,
917
- task_manager=self.task_manager,
918
- source=source,
919
- )
920
-
921
- async def _handle_require_active_task(
922
- self, context: ActionContext, **kwargs: Any
923
- ) -> dict[str, Any] | None:
924
- """Check for active task before allowing protected tools.
925
-
926
- DEPRECATED: Use block_tools action with rules instead.
927
- Kept for backward compatibility with existing workflows.
928
- """
929
- # Get project_id from session for project-scoped task filtering
930
- current_session = context.session_manager.get(context.session_id)
931
- project_id = current_session.project_id if current_session else None
932
-
933
- return await require_active_task(
934
- task_manager=self.task_manager,
935
- session_id=context.session_id,
936
- config=context.config,
937
- event_data=context.event_data,
938
- project_id=project_id,
939
- workflow_state=context.state,
940
- session_manager=context.session_manager,
941
- session_task_manager=context.session_task_manager,
942
- )
943
-
944
- async def _handle_require_commit_before_stop(
945
- self, context: ActionContext, **kwargs: Any
946
- ) -> dict[str, Any] | None:
947
- """Block stop if task has uncommitted changes."""
948
- # Get project path - prioritize session lookup over hook payload
949
- project_path = None
950
-
951
- # 1. Get from session's project (most reliable - session exists by now)
952
- if context.session_id and context.session_manager:
953
- session = context.session_manager.get(context.session_id)
954
- if session and session.project_id:
955
- from gobby.storage.projects import LocalProjectManager
956
-
957
- project_mgr = LocalProjectManager(context.db)
958
- project = project_mgr.get(session.project_id)
959
- if project and project.repo_path:
960
- project_path = project.repo_path
961
-
962
- # 2. Fallback to event_data.cwd (from hook payload)
963
- if not project_path and context.event_data:
964
- project_path = context.event_data.get("cwd")
965
-
966
- return await require_commit_before_stop(
967
- workflow_state=context.state,
968
- project_path=project_path,
969
- task_manager=self.task_manager,
970
- )
971
-
972
- async def _handle_require_task_review_or_close_before_stop(
973
- self, context: ActionContext, **kwargs: Any
974
- ) -> dict[str, Any] | None:
975
- """Block stop if task is still in_progress (regardless of dirty files)."""
976
- # Get project_id from session for task reference resolution
977
- project_id = None
978
- session = context.session_manager.get(context.session_id)
979
- if session:
980
- project_id = session.project_id
981
-
982
- return await require_task_review_or_close_before_stop(
983
- workflow_state=context.state,
984
- task_manager=self.task_manager,
985
- project_id=project_id,
986
- )
987
-
988
- async def _handle_require_task_complete(
989
- self, context: ActionContext, **kwargs: Any
990
- ) -> dict[str, Any] | None:
991
- """Check that a task (and its subtasks) are complete before allowing stop.
992
-
993
- Supports:
994
- - Single task ID: "#47"
995
- - List of task IDs: ["#47", "#48"]
996
- - Wildcard: "*" - work until no ready tasks remain
997
- """
998
- current_session = context.session_manager.get(context.session_id)
999
- project_id = current_session.project_id if current_session else None
1000
-
1001
- # Get task_id from kwargs - may be a template that needs resolving
1002
- task_spec = kwargs.get("task_id")
1003
-
1004
- # If it's a template reference like "{{ variables.session_task }}", resolve it
1005
- if task_spec and "{{" in str(task_spec):
1006
- task_spec = context.template_engine.render(
1007
- str(task_spec),
1008
- {"variables": context.state.variables or {}},
307
+ async def check_stop(context: ActionContext, **kw: Any) -> dict[str, Any] | None:
308
+ return check_stop_signal(
309
+ executor.stop_registry,
310
+ context.session_id,
311
+ context.state,
312
+ kw.get("acknowledge", False),
1009
313
  )
1010
314
 
1011
- # Handle different task_spec types:
1012
- # - None/empty: no enforcement
1013
- # - "*": wildcard - fetch ready tasks
1014
- # - list: multiple specific tasks
1015
- # - string: single task ID
1016
- task_ids: list[str] | None = None
1017
-
1018
- if not task_spec:
1019
- return None
1020
- elif task_spec == "*":
1021
- # Wildcard: get all ready tasks for this project
1022
- if self.task_manager:
1023
- ready_tasks = self.task_manager.list_ready_tasks(
1024
- project_id=project_id,
1025
- limit=100,
1026
- )
1027
- task_ids = [t.id for t in ready_tasks]
1028
- if not task_ids:
1029
- # No ready tasks - allow stop
1030
- logger.debug("require_task_complete: Wildcard mode, no ready tasks")
1031
- return None
1032
- elif isinstance(task_spec, list):
1033
- task_ids = task_spec
1034
- else:
1035
- task_ids = [str(task_spec)]
1036
-
1037
- return await require_task_complete(
1038
- task_manager=self.task_manager,
1039
- session_id=context.session_id,
1040
- task_ids=task_ids,
1041
- event_data=context.event_data,
1042
- project_id=project_id,
1043
- workflow_state=context.state,
1044
- )
1045
-
1046
- async def _handle_validate_session_task_scope(
1047
- self, context: ActionContext, **kwargs: Any
1048
- ) -> dict[str, Any] | None:
1049
- """Validate that claimed task is within session_task scope.
1050
-
1051
- When session_task is set in workflow state, this blocks claiming
1052
- tasks that are not descendants of session_task.
1053
- """
1054
- return await validate_session_task_scope(
1055
- task_manager=self.task_manager,
1056
- workflow_state=context.state,
1057
- event_data=context.event_data,
1058
- )
1059
-
1060
- async def _handle_webhook(self, context: ActionContext, **kwargs: Any) -> dict[str, Any] | None:
1061
- """Execute a webhook HTTP request.
1062
-
1063
- Args (via kwargs):
1064
- url: Target URL for the request (required unless webhook_id provided)
1065
- webhook_id: ID of a pre-configured webhook (alternative to url)
1066
- method: HTTP method (GET, POST, PUT, PATCH, DELETE), default: POST
1067
- headers: Request headers dict (supports ${secrets.VAR} interpolation)
1068
- payload: Request body as dict or string (supports template interpolation)
1069
- timeout: Request timeout in seconds (1-300), default: 30
1070
- retry: Retry configuration dict with:
1071
- - max_attempts: Max retry attempts (1-10), default: 3
1072
- - backoff_seconds: Initial backoff delay, default: 1
1073
- - retry_on_status: HTTP status codes to retry on
1074
- capture_response: Response capture config with:
1075
- - status_var: Variable name for status code
1076
- - body_var: Variable name for response body
1077
- - headers_var: Variable name for response headers
1078
- on_success: Step to transition to on success (2xx)
1079
- on_failure: Step to transition to on failure
1080
-
1081
- Returns:
1082
- Dict with success status, status_code, and captured response data.
1083
- """
1084
- try:
1085
- # Parse WebhookAction from kwargs to validate config
1086
- webhook_action = WebhookAction.from_dict(kwargs)
1087
- except ValueError as e:
1088
- logger.error(f"Invalid webhook action config: {e}")
1089
- return {"success": False, "error": str(e)}
1090
-
1091
- # Build context for variable interpolation
1092
- interpolation_context: dict[str, Any] = {}
1093
- if context.state.variables:
1094
- interpolation_context["state"] = {"variables": context.state.variables}
1095
- if context.state.artifacts:
1096
- interpolation_context["artifacts"] = context.state.artifacts
1097
-
1098
- # Get secrets from config if available
1099
- secrets: dict[str, str] = {}
1100
- if self.config:
1101
- secrets = getattr(self.config, "webhook_secrets", {})
1102
-
1103
- # Create executor with template engine for payload interpolation
1104
- executor = WebhookExecutor(
1105
- template_engine=context.template_engine,
1106
- secrets=secrets,
1107
- )
1108
-
1109
- # Execute the webhook
1110
- if webhook_action.url:
1111
- result = await executor.execute(
1112
- url=webhook_action.url,
1113
- method=webhook_action.method,
1114
- headers=webhook_action.headers,
1115
- payload=webhook_action.payload,
1116
- timeout=webhook_action.timeout,
1117
- retry_config=webhook_action.retry.to_dict() if webhook_action.retry else None,
1118
- context=interpolation_context,
1119
- )
1120
- elif webhook_action.webhook_id:
1121
- # webhook_id execution requires a registry which would be configured
1122
- # at the daemon level - for now we return an error if no registry
1123
- logger.warning("webhook_id execution not yet supported without registry")
1124
- return {"success": False, "error": "webhook_id requires configured webhook registry"}
1125
- else:
1126
- return {"success": False, "error": "Either url or webhook_id is required"}
1127
-
1128
- # Capture response into workflow variables if configured
1129
- if webhook_action.capture_response:
1130
- if not context.state.variables:
1131
- context.state.variables = {}
1132
-
1133
- capture = webhook_action.capture_response
1134
- if capture.status_var and result.status_code is not None:
1135
- context.state.variables[capture.status_var] = result.status_code
1136
- if capture.body_var and result.body is not None:
1137
- # Try to parse as JSON, fall back to raw string
1138
- json_body = result.json_body()
1139
- context.state.variables[capture.body_var] = json_body if json_body else result.body
1140
- if capture.headers_var and result.headers is not None:
1141
- context.state.variables[capture.headers_var] = result.headers
1142
-
1143
- # Log outcome
1144
- if result.success:
1145
- logger.info(
1146
- f"Webhook {webhook_action.method} {webhook_action.url} succeeded: {result.status_code}"
315
+ async def req_stop(context: ActionContext, **kw: Any) -> dict[str, Any] | None:
316
+ return request_stop(
317
+ executor.stop_registry,
318
+ context.session_id,
319
+ kw.get("source", "workflow"),
320
+ kw.get("reason"),
1147
321
  )
1148
- else:
1149
- logger.warning(
1150
- f"Webhook {webhook_action.method} {webhook_action.url} failed: "
1151
- f"{result.error or result.status_code}"
1152
- )
1153
-
1154
- return {
1155
- "success": result.success,
1156
- "status_code": result.status_code,
1157
- "error": result.error,
1158
- "body": result.body if result.success else None,
1159
- }
1160
322
 
1161
- # --- Stop Signal Actions ---
1162
-
1163
- async def _handle_check_stop_signal(
1164
- self, context: ActionContext, **kwargs: Any
1165
- ) -> dict[str, Any] | None:
1166
- """Check if a stop signal has been sent for this session.
1167
-
1168
- Args (via kwargs):
1169
- acknowledge: If True, acknowledge the signal (session will stop)
1170
-
1171
- Returns:
1172
- Dict with has_signal, signal details, and optional inject_context
1173
- """
1174
- return check_stop_signal(
1175
- stop_registry=self.stop_registry,
1176
- session_id=context.session_id,
1177
- state=context.state,
1178
- acknowledge=kwargs.get("acknowledge", False),
1179
- )
1180
-
1181
- async def _handle_request_stop(
1182
- self, context: ActionContext, **kwargs: Any
1183
- ) -> dict[str, Any] | None:
1184
- """Request a session to stop (used by stuck detection, etc.).
1185
-
1186
- Args (via kwargs):
1187
- session_id: The session to signal (defaults to current session)
1188
- source: Source of the request (default: "workflow")
1189
- reason: Optional reason for the stop request
1190
-
1191
- Returns:
1192
- Dict with success status and signal details
1193
- """
1194
- target_session = kwargs.get("session_id", context.session_id)
1195
- return request_stop(
1196
- stop_registry=self.stop_registry,
1197
- session_id=target_session,
1198
- source=kwargs.get("source", "workflow"),
1199
- reason=kwargs.get("reason"),
1200
- )
1201
-
1202
- async def _handle_clear_stop_signal(
1203
- self, context: ActionContext, **kwargs: Any
1204
- ) -> dict[str, Any] | None:
1205
- """Clear any stop signal for a session.
1206
-
1207
- Args (via kwargs):
1208
- session_id: The session to clear (defaults to current session)
1209
-
1210
- Returns:
1211
- Dict with success status
1212
- """
1213
- target_session = kwargs.get("session_id", context.session_id)
1214
- return clear_stop_signal(
1215
- stop_registry=self.stop_registry,
1216
- session_id=target_session,
1217
- )
1218
-
1219
- # --- Autonomous Execution Actions ---
1220
-
1221
- async def _broadcast_autonomous_event(self, event: str, session_id: str, **kwargs: Any) -> None:
1222
- """Helper to broadcast autonomous events via WebSocket.
323
+ async def clear_stop(context: ActionContext, **kw: Any) -> dict[str, Any] | None:
324
+ return clear_stop_signal(
325
+ executor.stop_registry, kw.get("session_id") or context.session_id
326
+ )
1223
327
 
1224
- Non-blocking fire-and-forget broadcast.
328
+ self.register("check_stop_signal", check_stop)
329
+ self.register("request_stop", req_stop)
330
+ self.register("clear_stop_signal", clear_stop)
1225
331
 
1226
- Args:
1227
- event: Event type (task_started, stuck_detected, etc.)
1228
- session_id: Session ID
1229
- **kwargs: Additional event data
1230
- """
1231
- import asyncio
332
+ def _register_autonomous_actions(self) -> None:
333
+ """Register autonomous actions accessing self at call time."""
334
+ executor = self
1232
335
 
1233
- if not self.websocket_server:
1234
- return
1235
-
1236
- try:
1237
- # Create non-blocking task for broadcast
1238
- task = asyncio.create_task(
1239
- self.websocket_server.broadcast_autonomous_event(
1240
- event=event,
1241
- session_id=session_id,
1242
- **kwargs,
1243
- )
336
+ async def start_tracking(context: ActionContext, **kw: Any) -> dict[str, Any] | None:
337
+ return start_progress_tracking(
338
+ executor.progress_tracker, context.session_id, context.state
1244
339
  )
1245
- # Add callback to log errors silently
1246
- task.add_done_callback(
1247
- lambda t: (
1248
- logger.debug(f"Broadcast {event} failed: {t.exception()}")
1249
- if t.exception()
1250
- else None
1251
- )
1252
- )
1253
- except Exception as e:
1254
- logger.debug(f"Failed to schedule broadcast for {event}: {e}")
1255
340
 
1256
- async def _handle_start_progress_tracking(
1257
- self, context: ActionContext, **kwargs: Any
1258
- ) -> dict[str, Any] | None:
1259
- """Start progress tracking for a session."""
1260
- result = start_progress_tracking(
1261
- progress_tracker=self.progress_tracker,
1262
- session_id=context.session_id,
1263
- state=context.state,
1264
- )
1265
-
1266
- # Broadcast loop_started event
1267
- if result and result.get("success"):
1268
- await self._broadcast_autonomous_event(
1269
- event="loop_started",
1270
- session_id=context.session_id,
341
+ async def stop_tracking(context: ActionContext, **kw: Any) -> dict[str, Any] | None:
342
+ return stop_progress_tracking(
343
+ executor.progress_tracker,
344
+ context.session_id,
345
+ context.state,
346
+ kw.get("keep_data", False),
1271
347
  )
1272
348
 
1273
- return result
1274
-
1275
- async def _handle_stop_progress_tracking(
1276
- self, context: ActionContext, **kwargs: Any
1277
- ) -> dict[str, Any] | None:
1278
- """Stop progress tracking for a session."""
1279
- result = stop_progress_tracking(
1280
- progress_tracker=self.progress_tracker,
1281
- session_id=context.session_id,
1282
- state=context.state,
1283
- keep_data=kwargs.get("keep_data", False),
1284
- )
1285
-
1286
- # Broadcast loop_stopped event
1287
- if result and result.get("success"):
1288
- await self._broadcast_autonomous_event(
1289
- event="loop_stopped",
1290
- session_id=context.session_id,
1291
- final_summary=result.get("final_summary"),
349
+ async def record_prog(context: ActionContext, **kw: Any) -> dict[str, Any] | None:
350
+ return record_progress(
351
+ executor.progress_tracker,
352
+ context.session_id,
353
+ kw.get("progress_type", "tool_call"),
354
+ kw.get("tool_name"),
355
+ kw.get("details"),
1292
356
  )
1293
357
 
1294
- return result
358
+ async def detect_loop(context: ActionContext, **kw: Any) -> dict[str, Any] | None:
359
+ return detect_task_loop(executor.stuck_detector, context.session_id, context.state)
1295
360
 
1296
- async def _handle_record_progress(
1297
- self, context: ActionContext, **kwargs: Any
1298
- ) -> dict[str, Any] | None:
1299
- """Record a progress event."""
1300
- result = record_progress(
1301
- progress_tracker=self.progress_tracker,
1302
- session_id=context.session_id,
1303
- progress_type=kwargs.get("progress_type", "tool_call"),
1304
- tool_name=kwargs.get("tool_name"),
1305
- details=kwargs.get("details"),
1306
- )
1307
-
1308
- # Broadcast progress_recorded event for high-value events
1309
- if result and result.get("success") and result.get("event", {}).get("is_high_value"):
1310
- await self._broadcast_autonomous_event(
1311
- event="progress_recorded",
1312
- session_id=context.session_id,
1313
- progress_type=result.get("event", {}).get("type"),
1314
- is_high_value=True,
1315
- )
361
+ async def detect_stk(context: ActionContext, **kw: Any) -> dict[str, Any] | None:
362
+ return detect_stuck(executor.stuck_detector, context.session_id, context.state)
1316
363
 
1317
- return result
1318
-
1319
- async def _handle_detect_task_loop(
1320
- self, context: ActionContext, **kwargs: Any
1321
- ) -> dict[str, Any] | None:
1322
- """Detect task selection loops."""
1323
- result = detect_task_loop(
1324
- stuck_detector=self.stuck_detector,
1325
- session_id=context.session_id,
1326
- state=context.state,
1327
- )
1328
-
1329
- # Broadcast stuck_detected if stuck
1330
- if result and result.get("is_stuck"):
1331
- await self._broadcast_autonomous_event(
1332
- event="stuck_detected",
1333
- session_id=context.session_id,
1334
- layer="task_loop",
1335
- reason=result.get("reason"),
1336
- details=result.get("details"),
364
+ async def record_sel(context: ActionContext, **kw: Any) -> dict[str, Any] | None:
365
+ return record_task_selection(
366
+ executor.stuck_detector,
367
+ context.session_id,
368
+ kw.get("task_id", ""),
369
+ kw.get("context"),
1337
370
  )
1338
371
 
1339
- return result
372
+ async def get_summary(context: ActionContext, **kw: Any) -> dict[str, Any] | None:
373
+ return get_progress_summary(executor.progress_tracker, context.session_id)
1340
374
 
1341
- async def _handle_detect_stuck(
1342
- self, context: ActionContext, **kwargs: Any
1343
- ) -> dict[str, Any] | None:
1344
- """Run full stuck detection (all layers)."""
1345
- result = detect_stuck(
1346
- stuck_detector=self.stuck_detector,
1347
- session_id=context.session_id,
1348
- state=context.state,
1349
- )
1350
-
1351
- # Broadcast stuck_detected if stuck
1352
- if result and result.get("is_stuck"):
1353
- await self._broadcast_autonomous_event(
1354
- event="stuck_detected",
1355
- session_id=context.session_id,
1356
- layer=result.get("layer"),
1357
- reason=result.get("reason"),
1358
- suggested_action=result.get("suggested_action"),
1359
- )
1360
-
1361
- return result
375
+ self.register("start_progress_tracking", start_tracking)
376
+ self.register("stop_progress_tracking", stop_tracking)
377
+ self.register("record_progress", record_prog)
378
+ self.register("detect_task_loop", detect_loop)
379
+ self.register("detect_stuck", detect_stk)
380
+ self.register("record_task_selection", record_sel)
381
+ self.register("get_progress_summary", get_summary)
1362
382
 
1363
- async def _handle_record_task_selection(
1364
- self, context: ActionContext, **kwargs: Any
383
+ async def execute(
384
+ self, action_type: str, context: ActionContext, **kwargs: Any
1365
385
  ) -> dict[str, Any] | None:
1366
- """Record a task selection for loop detection."""
1367
- task_id = kwargs.get("task_id", "")
1368
- result = record_task_selection(
1369
- stuck_detector=self.stuck_detector,
1370
- session_id=context.session_id,
1371
- task_id=task_id,
1372
- context=kwargs.get("context"),
1373
- )
1374
-
1375
- # Broadcast task_started event
1376
- if result and result.get("success"):
1377
- await self._broadcast_autonomous_event(
1378
- event="task_started",
1379
- session_id=context.session_id,
1380
- task_id=task_id,
1381
- )
1382
-
1383
- return result
386
+ """Execute an action."""
387
+ handler = self._handlers.get(action_type)
388
+ if not handler:
389
+ logger.warning(f"Unknown action type: {action_type}")
390
+ return None
1384
391
 
1385
- async def _handle_get_progress_summary(
1386
- self, context: ActionContext, **kwargs: Any
1387
- ) -> dict[str, Any] | None:
1388
- """Get a summary of progress for a session."""
1389
- return get_progress_summary(
1390
- progress_tracker=self.progress_tracker,
1391
- session_id=context.session_id,
1392
- )
392
+ try:
393
+ return await handler(context, **kwargs)
394
+ except Exception as e:
395
+ logger.error(f"Error executing action {action_type}: {e}", exc_info=True)
396
+ return {"error": str(e)}