gobby 0.2.5__py3-none-any.whl → 0.2.7__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.
- gobby/__init__.py +1 -1
- gobby/adapters/__init__.py +2 -1
- gobby/adapters/claude_code.py +13 -4
- gobby/adapters/codex_impl/__init__.py +28 -0
- gobby/adapters/codex_impl/adapter.py +722 -0
- gobby/adapters/codex_impl/client.py +679 -0
- gobby/adapters/codex_impl/protocol.py +20 -0
- gobby/adapters/codex_impl/types.py +68 -0
- gobby/agents/definitions.py +11 -1
- gobby/agents/isolation.py +395 -0
- gobby/agents/runner.py +8 -0
- gobby/agents/sandbox.py +261 -0
- gobby/agents/spawn.py +42 -287
- gobby/agents/spawn_executor.py +385 -0
- gobby/agents/spawners/__init__.py +24 -0
- gobby/agents/spawners/command_builder.py +189 -0
- gobby/agents/spawners/embedded.py +21 -2
- gobby/agents/spawners/headless.py +21 -2
- gobby/agents/spawners/prompt_manager.py +125 -0
- gobby/cli/__init__.py +6 -0
- gobby/cli/clones.py +419 -0
- gobby/cli/conductor.py +266 -0
- gobby/cli/install.py +4 -4
- gobby/cli/installers/antigravity.py +3 -9
- gobby/cli/installers/claude.py +15 -9
- gobby/cli/installers/codex.py +2 -8
- gobby/cli/installers/gemini.py +8 -8
- gobby/cli/installers/shared.py +175 -13
- gobby/cli/sessions.py +1 -1
- gobby/cli/skills.py +858 -0
- gobby/cli/tasks/ai.py +0 -440
- gobby/cli/tasks/crud.py +44 -6
- gobby/cli/tasks/main.py +0 -4
- gobby/cli/tui.py +2 -2
- gobby/cli/utils.py +12 -5
- gobby/clones/__init__.py +13 -0
- gobby/clones/git.py +547 -0
- gobby/conductor/__init__.py +16 -0
- gobby/conductor/alerts.py +135 -0
- gobby/conductor/loop.py +164 -0
- gobby/conductor/monitors/__init__.py +11 -0
- gobby/conductor/monitors/agents.py +116 -0
- gobby/conductor/monitors/tasks.py +155 -0
- gobby/conductor/pricing.py +234 -0
- gobby/conductor/token_tracker.py +160 -0
- gobby/config/__init__.py +12 -97
- gobby/config/app.py +69 -91
- gobby/config/extensions.py +2 -2
- gobby/config/features.py +7 -130
- gobby/config/search.py +110 -0
- gobby/config/servers.py +1 -1
- gobby/config/skills.py +43 -0
- gobby/config/tasks.py +9 -41
- gobby/hooks/__init__.py +0 -13
- gobby/hooks/event_handlers.py +188 -2
- gobby/hooks/hook_manager.py +50 -4
- gobby/hooks/plugins.py +1 -1
- gobby/hooks/skill_manager.py +130 -0
- gobby/hooks/webhooks.py +1 -1
- gobby/install/claude/hooks/hook_dispatcher.py +4 -4
- gobby/install/codex/hooks/hook_dispatcher.py +1 -1
- gobby/install/gemini/hooks/hook_dispatcher.py +87 -12
- gobby/llm/claude.py +22 -34
- gobby/llm/claude_executor.py +46 -256
- gobby/llm/codex_executor.py +59 -291
- gobby/llm/executor.py +21 -0
- gobby/llm/gemini.py +134 -110
- gobby/llm/litellm_executor.py +143 -6
- gobby/llm/resolver.py +98 -35
- gobby/mcp_proxy/importer.py +62 -4
- gobby/mcp_proxy/instructions.py +56 -0
- gobby/mcp_proxy/models.py +15 -0
- gobby/mcp_proxy/registries.py +68 -8
- gobby/mcp_proxy/server.py +33 -3
- gobby/mcp_proxy/services/recommendation.py +43 -11
- gobby/mcp_proxy/services/tool_proxy.py +81 -1
- gobby/mcp_proxy/stdio.py +2 -1
- gobby/mcp_proxy/tools/__init__.py +0 -2
- gobby/mcp_proxy/tools/agent_messaging.py +317 -0
- gobby/mcp_proxy/tools/agents.py +31 -731
- gobby/mcp_proxy/tools/clones.py +518 -0
- gobby/mcp_proxy/tools/memory.py +3 -26
- gobby/mcp_proxy/tools/metrics.py +65 -1
- gobby/mcp_proxy/tools/orchestration/__init__.py +3 -0
- gobby/mcp_proxy/tools/orchestration/cleanup.py +151 -0
- gobby/mcp_proxy/tools/orchestration/wait.py +467 -0
- gobby/mcp_proxy/tools/sessions/__init__.py +14 -0
- gobby/mcp_proxy/tools/sessions/_commits.py +232 -0
- gobby/mcp_proxy/tools/sessions/_crud.py +253 -0
- gobby/mcp_proxy/tools/sessions/_factory.py +63 -0
- gobby/mcp_proxy/tools/sessions/_handoff.py +499 -0
- gobby/mcp_proxy/tools/sessions/_messages.py +138 -0
- gobby/mcp_proxy/tools/skills/__init__.py +616 -0
- gobby/mcp_proxy/tools/spawn_agent.py +417 -0
- gobby/mcp_proxy/tools/task_orchestration.py +7 -0
- gobby/mcp_proxy/tools/task_readiness.py +14 -0
- gobby/mcp_proxy/tools/task_sync.py +1 -1
- gobby/mcp_proxy/tools/tasks/_context.py +0 -20
- gobby/mcp_proxy/tools/tasks/_crud.py +91 -4
- gobby/mcp_proxy/tools/tasks/_expansion.py +348 -0
- gobby/mcp_proxy/tools/tasks/_factory.py +6 -16
- gobby/mcp_proxy/tools/tasks/_lifecycle.py +110 -45
- gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +18 -29
- gobby/mcp_proxy/tools/workflows.py +1 -1
- gobby/mcp_proxy/tools/worktrees.py +0 -338
- gobby/memory/backends/__init__.py +6 -1
- gobby/memory/backends/mem0.py +6 -1
- gobby/memory/extractor.py +477 -0
- gobby/memory/ingestion/__init__.py +5 -0
- gobby/memory/ingestion/multimodal.py +221 -0
- gobby/memory/manager.py +73 -285
- gobby/memory/search/__init__.py +10 -0
- gobby/memory/search/coordinator.py +248 -0
- gobby/memory/services/__init__.py +5 -0
- gobby/memory/services/crossref.py +142 -0
- gobby/prompts/loader.py +5 -2
- gobby/runner.py +37 -16
- gobby/search/__init__.py +48 -6
- gobby/search/backends/__init__.py +159 -0
- gobby/search/backends/embedding.py +225 -0
- gobby/search/embeddings.py +238 -0
- gobby/search/models.py +148 -0
- gobby/search/unified.py +496 -0
- gobby/servers/http.py +24 -12
- gobby/servers/routes/admin.py +294 -0
- gobby/servers/routes/mcp/endpoints/__init__.py +61 -0
- gobby/servers/routes/mcp/endpoints/discovery.py +405 -0
- gobby/servers/routes/mcp/endpoints/execution.py +568 -0
- gobby/servers/routes/mcp/endpoints/registry.py +378 -0
- gobby/servers/routes/mcp/endpoints/server.py +304 -0
- gobby/servers/routes/mcp/hooks.py +1 -1
- gobby/servers/routes/mcp/tools.py +48 -1317
- gobby/servers/websocket.py +2 -2
- gobby/sessions/analyzer.py +2 -0
- gobby/sessions/lifecycle.py +1 -1
- gobby/sessions/processor.py +10 -0
- gobby/sessions/transcripts/base.py +2 -0
- gobby/sessions/transcripts/claude.py +79 -10
- gobby/skills/__init__.py +91 -0
- gobby/skills/loader.py +685 -0
- gobby/skills/manager.py +384 -0
- gobby/skills/parser.py +286 -0
- gobby/skills/search.py +463 -0
- gobby/skills/sync.py +119 -0
- gobby/skills/updater.py +385 -0
- gobby/skills/validator.py +368 -0
- gobby/storage/clones.py +378 -0
- gobby/storage/database.py +1 -1
- gobby/storage/memories.py +43 -13
- gobby/storage/migrations.py +162 -201
- gobby/storage/sessions.py +116 -7
- gobby/storage/skills.py +782 -0
- gobby/storage/tasks/_crud.py +4 -4
- gobby/storage/tasks/_lifecycle.py +57 -7
- gobby/storage/tasks/_manager.py +14 -5
- gobby/storage/tasks/_models.py +8 -3
- gobby/sync/memories.py +40 -5
- gobby/sync/tasks.py +83 -6
- gobby/tasks/__init__.py +1 -2
- gobby/tasks/external_validator.py +1 -1
- gobby/tasks/validation.py +46 -35
- gobby/tools/summarizer.py +91 -10
- gobby/tui/api_client.py +4 -7
- gobby/tui/app.py +5 -3
- gobby/tui/screens/orchestrator.py +1 -2
- gobby/tui/screens/tasks.py +2 -4
- gobby/tui/ws_client.py +1 -1
- gobby/utils/daemon_client.py +2 -2
- gobby/utils/project_context.py +2 -3
- gobby/utils/status.py +13 -0
- gobby/workflows/actions.py +221 -1135
- gobby/workflows/artifact_actions.py +31 -0
- gobby/workflows/autonomous_actions.py +11 -0
- gobby/workflows/context_actions.py +93 -1
- gobby/workflows/detection_helpers.py +115 -31
- gobby/workflows/enforcement/__init__.py +47 -0
- gobby/workflows/enforcement/blocking.py +269 -0
- gobby/workflows/enforcement/commit_policy.py +283 -0
- gobby/workflows/enforcement/handlers.py +269 -0
- gobby/workflows/{task_enforcement_actions.py → enforcement/task_policy.py} +29 -388
- gobby/workflows/engine.py +13 -2
- gobby/workflows/git_utils.py +106 -0
- gobby/workflows/lifecycle_evaluator.py +29 -1
- gobby/workflows/llm_actions.py +30 -0
- gobby/workflows/loader.py +19 -6
- gobby/workflows/mcp_actions.py +20 -1
- gobby/workflows/memory_actions.py +154 -0
- gobby/workflows/safe_evaluator.py +183 -0
- gobby/workflows/session_actions.py +44 -0
- gobby/workflows/state_actions.py +60 -1
- gobby/workflows/stop_signal_actions.py +55 -0
- gobby/workflows/summary_actions.py +111 -1
- gobby/workflows/task_sync_actions.py +347 -0
- gobby/workflows/todo_actions.py +34 -1
- gobby/workflows/webhook_actions.py +185 -0
- {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/METADATA +87 -21
- {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/RECORD +201 -172
- {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/WHEEL +1 -1
- gobby/adapters/codex.py +0 -1292
- gobby/install/claude/commands/gobby/bug.md +0 -51
- gobby/install/claude/commands/gobby/chore.md +0 -51
- gobby/install/claude/commands/gobby/epic.md +0 -52
- gobby/install/claude/commands/gobby/eval.md +0 -235
- gobby/install/claude/commands/gobby/feat.md +0 -49
- gobby/install/claude/commands/gobby/nit.md +0 -52
- gobby/install/claude/commands/gobby/ref.md +0 -52
- gobby/install/codex/prompts/forget.md +0 -7
- gobby/install/codex/prompts/memories.md +0 -7
- gobby/install/codex/prompts/recall.md +0 -7
- gobby/install/codex/prompts/remember.md +0 -13
- gobby/llm/gemini_executor.py +0 -339
- gobby/mcp_proxy/tools/session_messages.py +0 -1056
- gobby/mcp_proxy/tools/task_expansion.py +0 -591
- gobby/prompts/defaults/expansion/system.md +0 -119
- gobby/prompts/defaults/expansion/user.md +0 -48
- gobby/prompts/defaults/external_validation/agent.md +0 -72
- gobby/prompts/defaults/external_validation/external.md +0 -63
- gobby/prompts/defaults/external_validation/spawn.md +0 -83
- gobby/prompts/defaults/external_validation/system.md +0 -6
- gobby/prompts/defaults/features/import_mcp.md +0 -22
- gobby/prompts/defaults/features/import_mcp_github.md +0 -17
- gobby/prompts/defaults/features/import_mcp_search.md +0 -16
- gobby/prompts/defaults/features/recommend_tools.md +0 -32
- gobby/prompts/defaults/features/recommend_tools_hybrid.md +0 -35
- gobby/prompts/defaults/features/recommend_tools_llm.md +0 -30
- gobby/prompts/defaults/features/server_description.md +0 -20
- gobby/prompts/defaults/features/server_description_system.md +0 -6
- gobby/prompts/defaults/features/task_description.md +0 -31
- gobby/prompts/defaults/features/task_description_system.md +0 -6
- gobby/prompts/defaults/features/tool_summary.md +0 -17
- gobby/prompts/defaults/features/tool_summary_system.md +0 -6
- gobby/prompts/defaults/research/step.md +0 -58
- gobby/prompts/defaults/validation/criteria.md +0 -47
- gobby/prompts/defaults/validation/validate.md +0 -38
- gobby/storage/migrations_legacy.py +0 -1359
- gobby/tasks/context.py +0 -747
- gobby/tasks/criteria.py +0 -342
- gobby/tasks/expansion.py +0 -626
- gobby/tasks/prompts/expand.py +0 -327
- gobby/tasks/research.py +0 -421
- gobby/tasks/tdd.py +0 -352
- {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/entry_points.txt +0 -0
- {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/licenses/LICENSE.md +0 -0
- {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/top_level.txt +0 -0
|
@@ -439,6 +439,7 @@ async def evaluate_all_lifecycle_workflows(
|
|
|
439
439
|
detect_plan_mode_fn: Any,
|
|
440
440
|
check_premature_stop_fn: Any,
|
|
441
441
|
context_data: dict[str, Any] | None = None,
|
|
442
|
+
detect_plan_mode_from_context_fn: Any | None = None,
|
|
442
443
|
) -> HookResponse:
|
|
443
444
|
"""
|
|
444
445
|
Discover and evaluate all lifecycle workflows for the given event.
|
|
@@ -453,9 +454,10 @@ async def evaluate_all_lifecycle_workflows(
|
|
|
453
454
|
action_executor: Action executor for running actions
|
|
454
455
|
evaluator: Condition evaluator
|
|
455
456
|
detect_task_claim_fn: Function to detect task claims
|
|
456
|
-
detect_plan_mode_fn: Function to detect plan mode
|
|
457
|
+
detect_plan_mode_fn: Function to detect plan mode (from tool calls)
|
|
457
458
|
check_premature_stop_fn: Async function to check premature stop
|
|
458
459
|
context_data: Optional context data passed between actions
|
|
460
|
+
detect_plan_mode_from_context_fn: Function to detect plan mode from system reminders
|
|
459
461
|
|
|
460
462
|
Returns:
|
|
461
463
|
Merged HookResponse with combined context and first non-allow decision.
|
|
@@ -594,6 +596,21 @@ async def evaluate_all_lifecycle_workflows(
|
|
|
594
596
|
detect_plan_mode_fn(event, state)
|
|
595
597
|
state_manager.save_state(state)
|
|
596
598
|
|
|
599
|
+
# Detect plan mode from system reminders for BEFORE_AGENT events
|
|
600
|
+
# This catches plan mode when user enters via UI (not via EnterPlanMode tool)
|
|
601
|
+
if event.event_type == HookEventType.BEFORE_AGENT and detect_plan_mode_from_context_fn:
|
|
602
|
+
session_id = event.metadata.get("_platform_session_id")
|
|
603
|
+
if session_id:
|
|
604
|
+
state = state_manager.get_state(session_id)
|
|
605
|
+
if state is None:
|
|
606
|
+
state = WorkflowState(
|
|
607
|
+
session_id=session_id,
|
|
608
|
+
workflow_name="__lifecycle__",
|
|
609
|
+
step="",
|
|
610
|
+
)
|
|
611
|
+
detect_plan_mode_from_context_fn(event, state)
|
|
612
|
+
state_manager.save_state(state)
|
|
613
|
+
|
|
597
614
|
# Check for premature stop in active step workflows on STOP events
|
|
598
615
|
if event.event_type == HookEventType.STOP:
|
|
599
616
|
premature_response = await check_premature_stop_fn(event, context_data)
|
|
@@ -610,4 +627,15 @@ async def evaluate_all_lifecycle_workflows(
|
|
|
610
627
|
reason=final_reason,
|
|
611
628
|
context="\n\n".join(all_context) if all_context else None,
|
|
612
629
|
system_message=final_system_message,
|
|
630
|
+
metadata={
|
|
631
|
+
"discovered_workflows": [
|
|
632
|
+
{
|
|
633
|
+
"name": w.name,
|
|
634
|
+
"priority": w.priority,
|
|
635
|
+
"is_project": w.is_project,
|
|
636
|
+
"path": str(w.path),
|
|
637
|
+
}
|
|
638
|
+
for w in workflows
|
|
639
|
+
]
|
|
640
|
+
},
|
|
613
641
|
)
|
gobby/workflows/llm_actions.py
CHANGED
|
@@ -68,3 +68,33 @@ async def call_llm(
|
|
|
68
68
|
except Exception as e:
|
|
69
69
|
logger.error(f"call_llm: Failed: {e}")
|
|
70
70
|
return {"error": str(e)}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# --- ActionHandler-compatible wrappers ---
|
|
74
|
+
# These match the ActionHandler protocol: (context: ActionContext, **kwargs) -> dict | None
|
|
75
|
+
|
|
76
|
+
if __name__ != "__main__":
|
|
77
|
+
from typing import TYPE_CHECKING
|
|
78
|
+
|
|
79
|
+
if TYPE_CHECKING:
|
|
80
|
+
from gobby.workflows.actions import ActionContext
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
async def handle_call_llm(context: "ActionContext", **kwargs: Any) -> dict[str, Any] | None:
|
|
84
|
+
"""ActionHandler wrapper for call_llm."""
|
|
85
|
+
if context.session_manager is None:
|
|
86
|
+
return {"error": "Session manager not available"}
|
|
87
|
+
|
|
88
|
+
session = context.session_manager.get(context.session_id)
|
|
89
|
+
if session is None:
|
|
90
|
+
return {"error": f"Session not found: {context.session_id}"}
|
|
91
|
+
|
|
92
|
+
return await call_llm(
|
|
93
|
+
llm_service=context.llm_service,
|
|
94
|
+
template_engine=context.template_engine,
|
|
95
|
+
state=context.state,
|
|
96
|
+
session=session,
|
|
97
|
+
prompt=kwargs.get("prompt"),
|
|
98
|
+
output_as=kwargs.get("output_as"),
|
|
99
|
+
**{k: v for k, v in kwargs.items() if k not in ("prompt", "output_as")},
|
|
100
|
+
)
|
gobby/workflows/loader.py
CHANGED
|
@@ -193,6 +193,7 @@ class WorkflowLoader:
|
|
|
193
193
|
return self._discovery_cache[cache_key]
|
|
194
194
|
|
|
195
195
|
discovered: dict[str, DiscoveredWorkflow] = {} # name -> workflow (for shadowing)
|
|
196
|
+
failed: dict[str, str] = {} # name -> error message for failed workflows
|
|
196
197
|
|
|
197
198
|
# 1. Scan global lifecycle directory first (will be shadowed by project)
|
|
198
199
|
for global_dir in self.global_dirs:
|
|
@@ -201,7 +202,14 @@ class WorkflowLoader:
|
|
|
201
202
|
# 2. Scan project lifecycle directory (shadows global)
|
|
202
203
|
if project_path:
|
|
203
204
|
project_dir = Path(project_path) / ".gobby" / "workflows" / "lifecycle"
|
|
204
|
-
self._scan_directory(project_dir, is_project=True, discovered=discovered)
|
|
205
|
+
self._scan_directory(project_dir, is_project=True, discovered=discovered, failed=failed)
|
|
206
|
+
|
|
207
|
+
# Log errors when project workflow fails but global exists (failed shadowing)
|
|
208
|
+
for name, error in failed.items():
|
|
209
|
+
if name in discovered and not discovered[name].is_project:
|
|
210
|
+
logger.error(
|
|
211
|
+
f"Project workflow '{name}' failed to load, using global instead: {error}"
|
|
212
|
+
)
|
|
205
213
|
|
|
206
214
|
# 3. Filter to lifecycle workflows only
|
|
207
215
|
lifecycle_workflows = [w for w in discovered.values() if w.definition.type == "lifecycle"]
|
|
@@ -225,6 +233,7 @@ class WorkflowLoader:
|
|
|
225
233
|
directory: Path,
|
|
226
234
|
is_project: bool,
|
|
227
235
|
discovered: dict[str, DiscoveredWorkflow],
|
|
236
|
+
failed: dict[str, str] | None = None,
|
|
228
237
|
) -> None:
|
|
229
238
|
"""
|
|
230
239
|
Scan a directory for workflow YAML files and add to discovered dict.
|
|
@@ -233,6 +242,7 @@ class WorkflowLoader:
|
|
|
233
242
|
directory: Directory to scan
|
|
234
243
|
is_project: Whether this is a project directory (for shadowing)
|
|
235
244
|
discovered: Dict to update (name -> DiscoveredWorkflow)
|
|
245
|
+
failed: Optional dict to track failed workflows (name -> error message)
|
|
236
246
|
"""
|
|
237
247
|
if not directory.exists():
|
|
238
248
|
return
|
|
@@ -258,6 +268,8 @@ class WorkflowLoader:
|
|
|
258
268
|
data = self._merge_workflows(parent.model_dump(), data)
|
|
259
269
|
except ValueError as e:
|
|
260
270
|
logger.warning(f"Skipping workflow {name}: {e}")
|
|
271
|
+
if failed is not None:
|
|
272
|
+
failed[name] = str(e)
|
|
261
273
|
continue
|
|
262
274
|
|
|
263
275
|
definition = WorkflowDefinition(**data)
|
|
@@ -267,6 +279,10 @@ class WorkflowLoader:
|
|
|
267
279
|
if definition.settings and "priority" in definition.settings:
|
|
268
280
|
priority = definition.settings["priority"]
|
|
269
281
|
|
|
282
|
+
# Log successful shadowing when project workflow overrides global
|
|
283
|
+
if name in discovered and is_project and not discovered[name].is_project:
|
|
284
|
+
logger.info(f"Project workflow '{name}' shadows global workflow")
|
|
285
|
+
|
|
270
286
|
# Project workflows shadow global (overwrite in dict)
|
|
271
287
|
# Global is scanned first, so project overwrites
|
|
272
288
|
discovered[name] = DiscoveredWorkflow(
|
|
@@ -279,6 +295,8 @@ class WorkflowLoader:
|
|
|
279
295
|
|
|
280
296
|
except Exception as e:
|
|
281
297
|
logger.warning(f"Failed to load workflow from {yaml_path}: {e}")
|
|
298
|
+
if failed is not None:
|
|
299
|
+
failed[name] = str(e)
|
|
282
300
|
|
|
283
301
|
def clear_cache(self) -> None:
|
|
284
302
|
"""
|
|
@@ -288,11 +306,6 @@ class WorkflowLoader:
|
|
|
288
306
|
self._cache.clear()
|
|
289
307
|
self._discovery_cache.clear()
|
|
290
308
|
|
|
291
|
-
def clear_discovery_cache(self) -> None:
|
|
292
|
-
"""Clear the discovery cache. Call when workflows may have changed."""
|
|
293
|
-
# Deprecated: use clear_cache instead to clear everything
|
|
294
|
-
self.clear_cache()
|
|
295
|
-
|
|
296
309
|
def validate_workflow_for_agent(
|
|
297
310
|
self,
|
|
298
311
|
workflow_name: str,
|
gobby/workflows/mcp_actions.py
CHANGED
|
@@ -5,7 +5,10 @@ These functions handle MCP tool calls from workflows.
|
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
import logging
|
|
8
|
-
from typing import Any
|
|
8
|
+
from typing import TYPE_CHECKING, Any
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from gobby.workflows.actions import ActionContext
|
|
9
12
|
|
|
10
13
|
logger = logging.getLogger(__name__)
|
|
11
14
|
|
|
@@ -58,3 +61,19 @@ async def call_mcp_tool(
|
|
|
58
61
|
except Exception as e:
|
|
59
62
|
logger.error(f"call_mcp_tool: Failed: {e}")
|
|
60
63
|
return {"error": str(e)}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# --- ActionHandler-compatible wrappers ---
|
|
67
|
+
# These match the ActionHandler protocol: (context: ActionContext, **kwargs) -> dict | None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
async def handle_call_mcp_tool(context: "ActionContext", **kwargs: Any) -> dict[str, Any] | None:
|
|
71
|
+
"""ActionHandler wrapper for call_mcp_tool."""
|
|
72
|
+
return await call_mcp_tool(
|
|
73
|
+
mcp_manager=context.mcp_manager,
|
|
74
|
+
state=context.state,
|
|
75
|
+
server_name=kwargs.get("server_name"),
|
|
76
|
+
tool_name=kwargs.get("tool_name"),
|
|
77
|
+
arguments=kwargs.get("arguments"),
|
|
78
|
+
output_as=kwargs.get("as"),
|
|
79
|
+
)
|
|
@@ -270,3 +270,157 @@ def reset_memory_injection_tracking(state: Any | None = None) -> dict[str, Any]:
|
|
|
270
270
|
logger.info(f"reset_memory_injection_tracking: Cleared {cleared_count} injected memory IDs")
|
|
271
271
|
|
|
272
272
|
return {"success": True, "cleared": cleared_count}
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
async def memory_extract(
|
|
276
|
+
session_manager: Any,
|
|
277
|
+
session_id: str,
|
|
278
|
+
llm_service: Any,
|
|
279
|
+
memory_manager: Any,
|
|
280
|
+
transcript_processor: Any | None = None,
|
|
281
|
+
min_importance: float = 0.7,
|
|
282
|
+
max_memories: int = 5,
|
|
283
|
+
dry_run: bool = False,
|
|
284
|
+
) -> dict[str, Any] | None:
|
|
285
|
+
"""Extract memories from a session transcript.
|
|
286
|
+
|
|
287
|
+
Uses LLM analysis to identify high-value, reusable knowledge from
|
|
288
|
+
session transcripts and stores them as memories.
|
|
289
|
+
|
|
290
|
+
Args:
|
|
291
|
+
session_manager: The session manager instance
|
|
292
|
+
session_id: Current session ID
|
|
293
|
+
llm_service: LLM service for analysis
|
|
294
|
+
memory_manager: Memory manager for storage
|
|
295
|
+
transcript_processor: Optional transcript processor
|
|
296
|
+
min_importance: Minimum importance threshold (0.0-1.0)
|
|
297
|
+
max_memories: Maximum memories to extract
|
|
298
|
+
dry_run: If True, don't store memories
|
|
299
|
+
|
|
300
|
+
Returns:
|
|
301
|
+
Dict with extracted_count and memory details, or error
|
|
302
|
+
"""
|
|
303
|
+
if not memory_manager:
|
|
304
|
+
return {"error": "Memory Manager not available"}
|
|
305
|
+
|
|
306
|
+
if not memory_manager.config.enabled:
|
|
307
|
+
logger.debug("memory_extract: Memory system disabled")
|
|
308
|
+
return None
|
|
309
|
+
|
|
310
|
+
if not llm_service:
|
|
311
|
+
return {"error": "LLM service not available"}
|
|
312
|
+
|
|
313
|
+
try:
|
|
314
|
+
from gobby.memory.extractor import SessionMemoryExtractor
|
|
315
|
+
|
|
316
|
+
extractor = SessionMemoryExtractor(
|
|
317
|
+
memory_manager=memory_manager,
|
|
318
|
+
session_manager=session_manager,
|
|
319
|
+
llm_service=llm_service,
|
|
320
|
+
transcript_processor=transcript_processor,
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
candidates = await extractor.extract(
|
|
324
|
+
session_id=session_id,
|
|
325
|
+
min_importance=min_importance,
|
|
326
|
+
max_memories=max_memories,
|
|
327
|
+
dry_run=dry_run,
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
if not candidates:
|
|
331
|
+
logger.debug(f"memory_extract: No memories extracted from session {session_id}")
|
|
332
|
+
return {"extracted_count": 0, "memories": []}
|
|
333
|
+
|
|
334
|
+
logger.info(
|
|
335
|
+
f"memory_extract: Extracted {len(candidates)} memories from session {session_id}"
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
return {
|
|
339
|
+
"extracted_count": len(candidates),
|
|
340
|
+
"memories": [c.to_dict() for c in candidates],
|
|
341
|
+
"dry_run": dry_run,
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
except Exception as e:
|
|
345
|
+
logger.error(f"memory_extract: Failed: {e}", exc_info=True)
|
|
346
|
+
return {"error": str(e)}
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
# --- ActionHandler-compatible wrappers ---
|
|
350
|
+
# These match the ActionHandler protocol: (context: ActionContext, **kwargs) -> dict | None
|
|
351
|
+
|
|
352
|
+
if __name__ != "__main__":
|
|
353
|
+
from typing import TYPE_CHECKING
|
|
354
|
+
|
|
355
|
+
if TYPE_CHECKING:
|
|
356
|
+
from gobby.workflows.actions import ActionContext
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
async def handle_memory_sync_import(
|
|
360
|
+
context: "ActionContext", **kwargs: Any
|
|
361
|
+
) -> dict[str, Any] | None:
|
|
362
|
+
"""ActionHandler wrapper for memory_sync_import."""
|
|
363
|
+
return await memory_sync_import(context.memory_sync_manager)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
async def handle_memory_sync_export(
|
|
367
|
+
context: "ActionContext", **kwargs: Any
|
|
368
|
+
) -> dict[str, Any] | None:
|
|
369
|
+
"""ActionHandler wrapper for memory_sync_export."""
|
|
370
|
+
return await memory_sync_export(context.memory_sync_manager)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
async def handle_memory_save(context: "ActionContext", **kwargs: Any) -> dict[str, Any] | None:
|
|
374
|
+
"""ActionHandler wrapper for memory_save."""
|
|
375
|
+
return await memory_save(
|
|
376
|
+
memory_manager=context.memory_manager,
|
|
377
|
+
session_manager=context.session_manager,
|
|
378
|
+
session_id=context.session_id,
|
|
379
|
+
content=kwargs.get("content"),
|
|
380
|
+
memory_type=kwargs.get("memory_type", "fact"),
|
|
381
|
+
importance=kwargs.get("importance", 0.5),
|
|
382
|
+
tags=kwargs.get("tags"),
|
|
383
|
+
project_id=kwargs.get("project_id"),
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
async def handle_memory_recall_relevant(
|
|
388
|
+
context: "ActionContext", **kwargs: Any
|
|
389
|
+
) -> dict[str, Any] | None:
|
|
390
|
+
"""ActionHandler wrapper for memory_recall_relevant."""
|
|
391
|
+
prompt_text = None
|
|
392
|
+
if context.event_data:
|
|
393
|
+
# Check both "prompt" (from hook event) and "prompt_text" (legacy/alternative)
|
|
394
|
+
prompt_text = context.event_data.get("prompt") or context.event_data.get("prompt_text")
|
|
395
|
+
|
|
396
|
+
return await memory_recall_relevant(
|
|
397
|
+
memory_manager=context.memory_manager,
|
|
398
|
+
session_manager=context.session_manager,
|
|
399
|
+
session_id=context.session_id,
|
|
400
|
+
prompt_text=prompt_text,
|
|
401
|
+
project_id=kwargs.get("project_id"),
|
|
402
|
+
limit=kwargs.get("limit", 5),
|
|
403
|
+
min_importance=kwargs.get("min_importance", 0.3),
|
|
404
|
+
state=context.state,
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
async def handle_reset_memory_injection_tracking(
|
|
409
|
+
context: "ActionContext", **kwargs: Any
|
|
410
|
+
) -> dict[str, Any] | None:
|
|
411
|
+
"""ActionHandler wrapper for reset_memory_injection_tracking."""
|
|
412
|
+
return reset_memory_injection_tracking(state=context.state)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
async def handle_memory_extract(context: "ActionContext", **kwargs: Any) -> dict[str, Any] | None:
|
|
416
|
+
"""ActionHandler wrapper for memory_extract."""
|
|
417
|
+
return await memory_extract(
|
|
418
|
+
session_manager=context.session_manager,
|
|
419
|
+
session_id=context.session_id,
|
|
420
|
+
llm_service=context.llm_service,
|
|
421
|
+
memory_manager=context.memory_manager,
|
|
422
|
+
transcript_processor=context.transcript_processor,
|
|
423
|
+
min_importance=kwargs.get("min_importance", 0.7),
|
|
424
|
+
max_memories=kwargs.get("max_memories", 5),
|
|
425
|
+
dry_run=kwargs.get("dry_run", False),
|
|
426
|
+
)
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Safe expression evaluation utilities.
|
|
2
|
+
|
|
3
|
+
Provides AST-based expression evaluation without using eval(),
|
|
4
|
+
and lazy boolean evaluation for deferred computation.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import ast
|
|
10
|
+
import operator
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
__all__ = ["LazyBool", "SafeExpressionEvaluator"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class LazyBool:
|
|
18
|
+
"""Lazy boolean that defers computation until first access.
|
|
19
|
+
|
|
20
|
+
Used to avoid expensive operations (git status, DB queries) when
|
|
21
|
+
evaluating block_tools conditions that don't reference certain values.
|
|
22
|
+
|
|
23
|
+
The computation is triggered when the value is used in a boolean context
|
|
24
|
+
(e.g., `if lazy_val:` or `not lazy_val`), which happens during eval().
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
__slots__ = ("_thunk", "_computed", "_value")
|
|
28
|
+
|
|
29
|
+
def __init__(self, thunk: Callable[[], bool]) -> None:
|
|
30
|
+
self._thunk = thunk
|
|
31
|
+
self._computed = False
|
|
32
|
+
self._value = False
|
|
33
|
+
|
|
34
|
+
def __bool__(self) -> bool:
|
|
35
|
+
if not self._computed:
|
|
36
|
+
self._value = self._thunk()
|
|
37
|
+
self._computed = True
|
|
38
|
+
return self._value
|
|
39
|
+
|
|
40
|
+
def __repr__(self) -> str:
|
|
41
|
+
if self._computed:
|
|
42
|
+
return f"LazyBool({self._value})"
|
|
43
|
+
return "LazyBool(<not computed>)"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class SafeExpressionEvaluator(ast.NodeVisitor):
|
|
47
|
+
"""Safe expression evaluator using AST.
|
|
48
|
+
|
|
49
|
+
Evaluates simple Python expressions without using eval().
|
|
50
|
+
Supports boolean operations, comparisons, attribute access, subscripts,
|
|
51
|
+
and a limited set of allowed function calls.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
# Comparison operators mapping
|
|
55
|
+
CMP_OPS: dict[type[ast.cmpop], Callable[[Any, Any], bool]] = {
|
|
56
|
+
ast.Eq: operator.eq,
|
|
57
|
+
ast.NotEq: operator.ne,
|
|
58
|
+
ast.Lt: operator.lt,
|
|
59
|
+
ast.LtE: operator.le,
|
|
60
|
+
ast.Gt: operator.gt,
|
|
61
|
+
ast.GtE: operator.ge,
|
|
62
|
+
ast.Is: operator.is_,
|
|
63
|
+
ast.IsNot: operator.is_not,
|
|
64
|
+
ast.In: lambda a, b: a in b,
|
|
65
|
+
ast.NotIn: lambda a, b: a not in b,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
def __init__(
|
|
69
|
+
self, context: dict[str, Any], allowed_funcs: dict[str, Callable[..., Any]]
|
|
70
|
+
) -> None:
|
|
71
|
+
self.context = context
|
|
72
|
+
self.allowed_funcs = allowed_funcs
|
|
73
|
+
|
|
74
|
+
def evaluate(self, expr: str) -> bool:
|
|
75
|
+
"""Evaluate expression and return boolean result."""
|
|
76
|
+
try:
|
|
77
|
+
tree = ast.parse(expr, mode="eval")
|
|
78
|
+
return bool(self.visit(tree.body))
|
|
79
|
+
except Exception as e:
|
|
80
|
+
raise ValueError(f"Invalid expression: {e}") from e
|
|
81
|
+
|
|
82
|
+
def visit_BoolOp(self, node: ast.BoolOp) -> bool:
|
|
83
|
+
"""Handle 'and' / 'or' operations."""
|
|
84
|
+
if isinstance(node.op, ast.And):
|
|
85
|
+
return all(self.visit(v) for v in node.values)
|
|
86
|
+
elif isinstance(node.op, ast.Or):
|
|
87
|
+
return any(self.visit(v) for v in node.values)
|
|
88
|
+
raise ValueError(f"Unsupported boolean operator: {type(node.op).__name__}")
|
|
89
|
+
|
|
90
|
+
def visit_Compare(self, node: ast.Compare) -> bool:
|
|
91
|
+
"""Handle comparison operations (==, !=, <, >, in, not in, etc.)."""
|
|
92
|
+
left = self.visit(node.left)
|
|
93
|
+
for op, comparator in zip(node.ops, node.comparators, strict=False):
|
|
94
|
+
right = self.visit(comparator)
|
|
95
|
+
op_func = self.CMP_OPS.get(type(op))
|
|
96
|
+
if op_func is None:
|
|
97
|
+
raise ValueError(f"Unsupported comparison: {type(op).__name__}")
|
|
98
|
+
if not op_func(left, right):
|
|
99
|
+
return False
|
|
100
|
+
left = right
|
|
101
|
+
return True
|
|
102
|
+
|
|
103
|
+
def visit_UnaryOp(self, node: ast.UnaryOp) -> Any:
|
|
104
|
+
"""Handle unary operations (not, -, +)."""
|
|
105
|
+
operand = self.visit(node.operand)
|
|
106
|
+
if isinstance(node.op, ast.Not):
|
|
107
|
+
return not operand
|
|
108
|
+
elif isinstance(node.op, ast.USub):
|
|
109
|
+
return -operand
|
|
110
|
+
elif isinstance(node.op, ast.UAdd):
|
|
111
|
+
return +operand
|
|
112
|
+
raise ValueError(f"Unsupported unary operator: {type(node.op).__name__}")
|
|
113
|
+
|
|
114
|
+
def visit_Name(self, node: ast.Name) -> Any:
|
|
115
|
+
"""Handle variable names."""
|
|
116
|
+
name = node.id
|
|
117
|
+
# Built-in constants
|
|
118
|
+
if name == "True":
|
|
119
|
+
return True
|
|
120
|
+
if name == "False":
|
|
121
|
+
return False
|
|
122
|
+
if name == "None":
|
|
123
|
+
return None
|
|
124
|
+
# Context variables
|
|
125
|
+
if name in self.context:
|
|
126
|
+
return self.context[name]
|
|
127
|
+
raise ValueError(f"Unknown variable: {name}")
|
|
128
|
+
|
|
129
|
+
def visit_Constant(self, node: ast.Constant) -> Any:
|
|
130
|
+
"""Handle literal values (strings, numbers, booleans, None)."""
|
|
131
|
+
return node.value
|
|
132
|
+
|
|
133
|
+
def visit_Call(self, node: ast.Call) -> Any:
|
|
134
|
+
"""Handle function calls (only allowed functions)."""
|
|
135
|
+
# Get function name
|
|
136
|
+
if isinstance(node.func, ast.Name):
|
|
137
|
+
func_name = node.func.id
|
|
138
|
+
elif isinstance(node.func, ast.Attribute):
|
|
139
|
+
# Handle method calls like tool_input.get('key')
|
|
140
|
+
obj = self.visit(node.func.value)
|
|
141
|
+
method_name = node.func.attr
|
|
142
|
+
if method_name == "get" and isinstance(obj, dict):
|
|
143
|
+
args = [self.visit(arg) for arg in node.args]
|
|
144
|
+
return obj.get(*args)
|
|
145
|
+
raise ValueError(f"Unsupported method call: {method_name}")
|
|
146
|
+
else:
|
|
147
|
+
raise ValueError(f"Unsupported call type: {type(node.func).__name__}")
|
|
148
|
+
|
|
149
|
+
# Check if function is allowed
|
|
150
|
+
if func_name not in self.allowed_funcs:
|
|
151
|
+
raise ValueError(f"Function not allowed: {func_name}")
|
|
152
|
+
|
|
153
|
+
# Evaluate arguments
|
|
154
|
+
args = [self.visit(arg) for arg in node.args]
|
|
155
|
+
kwargs = {kw.arg: self.visit(kw.value) for kw in node.keywords if kw.arg}
|
|
156
|
+
|
|
157
|
+
return self.allowed_funcs[func_name](*args, **kwargs)
|
|
158
|
+
|
|
159
|
+
def visit_Attribute(self, node: ast.Attribute) -> Any:
|
|
160
|
+
"""Handle attribute access (e.g., obj.attr)."""
|
|
161
|
+
obj = self.visit(node.value)
|
|
162
|
+
attr = node.attr
|
|
163
|
+
if isinstance(obj, dict):
|
|
164
|
+
# Allow dict-style attribute access for convenience
|
|
165
|
+
if attr in obj:
|
|
166
|
+
return obj[attr]
|
|
167
|
+
raise ValueError(f"Key not found: {attr}")
|
|
168
|
+
if hasattr(obj, attr):
|
|
169
|
+
return getattr(obj, attr)
|
|
170
|
+
raise ValueError(f"Attribute not found: {attr}")
|
|
171
|
+
|
|
172
|
+
def visit_Subscript(self, node: ast.Subscript) -> Any:
|
|
173
|
+
"""Handle subscript access (e.g., obj['key'] or obj[0])."""
|
|
174
|
+
obj = self.visit(node.value)
|
|
175
|
+
key = self.visit(node.slice)
|
|
176
|
+
try:
|
|
177
|
+
return obj[key]
|
|
178
|
+
except (KeyError, IndexError, TypeError) as e:
|
|
179
|
+
raise ValueError(f"Subscript access failed: {e}") from e
|
|
180
|
+
|
|
181
|
+
def generic_visit(self, node: ast.AST) -> Any:
|
|
182
|
+
"""Reject any unsupported AST nodes."""
|
|
183
|
+
raise ValueError(f"Unsupported expression type: {type(node).__name__}")
|
|
@@ -137,3 +137,47 @@ def switch_mode(mode: str | None = None) -> dict[str, Any]:
|
|
|
137
137
|
)
|
|
138
138
|
|
|
139
139
|
return {"inject_context": message, "mode_switch": mode}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# --- ActionHandler-compatible wrappers ---
|
|
143
|
+
# These match the ActionHandler protocol: (context: ActionContext, **kwargs) -> dict | None
|
|
144
|
+
|
|
145
|
+
if __name__ != "__main__":
|
|
146
|
+
from typing import TYPE_CHECKING
|
|
147
|
+
|
|
148
|
+
if TYPE_CHECKING:
|
|
149
|
+
from gobby.workflows.actions import ActionContext
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
async def handle_start_new_session(
|
|
153
|
+
context: "ActionContext", **kwargs: Any
|
|
154
|
+
) -> dict[str, Any] | None:
|
|
155
|
+
"""ActionHandler wrapper for start_new_session."""
|
|
156
|
+
import asyncio
|
|
157
|
+
|
|
158
|
+
return await asyncio.to_thread(
|
|
159
|
+
start_new_session,
|
|
160
|
+
session_manager=context.session_manager,
|
|
161
|
+
session_id=context.session_id,
|
|
162
|
+
command=kwargs.get("command"),
|
|
163
|
+
args=kwargs.get("args"),
|
|
164
|
+
prompt=kwargs.get("prompt"),
|
|
165
|
+
cwd=kwargs.get("cwd"),
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
async def handle_mark_session_status(
|
|
170
|
+
context: "ActionContext", **kwargs: Any
|
|
171
|
+
) -> dict[str, Any] | None:
|
|
172
|
+
"""ActionHandler wrapper for mark_session_status."""
|
|
173
|
+
return mark_session_status(
|
|
174
|
+
session_manager=context.session_manager,
|
|
175
|
+
session_id=context.session_id,
|
|
176
|
+
status=kwargs.get("status"),
|
|
177
|
+
target=kwargs.get("target", "current_session"),
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
async def handle_switch_mode(context: "ActionContext", **kwargs: Any) -> dict[str, Any] | None:
|
|
182
|
+
"""ActionHandler wrapper for switch_mode."""
|
|
183
|
+
return switch_mode(kwargs.get("mode"))
|
gobby/workflows/state_actions.py
CHANGED
|
@@ -4,8 +4,12 @@ Extracted from actions.py as part of strangler fig decomposition.
|
|
|
4
4
|
These functions handle workflow state persistence and variable management.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
+
import asyncio
|
|
7
8
|
import logging
|
|
8
|
-
from typing import Any
|
|
9
|
+
from typing import TYPE_CHECKING, Any
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from gobby.workflows.actions import ActionContext
|
|
9
13
|
|
|
10
14
|
logger = logging.getLogger(__name__)
|
|
11
15
|
|
|
@@ -121,3 +125,58 @@ def mark_loop_complete(state: Any) -> dict[str, Any]:
|
|
|
121
125
|
state.variables = {}
|
|
122
126
|
state.variables["stop_reason"] = "completed"
|
|
123
127
|
return {"loop_marked_complete": True}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# --- ActionHandler-compatible wrappers ---
|
|
131
|
+
# These match the ActionHandler protocol: (context: ActionContext, **kwargs) -> dict | None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
async def handle_load_workflow_state(
|
|
135
|
+
context: "ActionContext", **kwargs: Any
|
|
136
|
+
) -> dict[str, Any] | None:
|
|
137
|
+
"""ActionHandler wrapper for load_workflow_state."""
|
|
138
|
+
return await asyncio.to_thread(
|
|
139
|
+
load_workflow_state, context.db, context.session_id, context.state
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
async def handle_save_workflow_state(
|
|
144
|
+
context: "ActionContext", **kwargs: Any
|
|
145
|
+
) -> dict[str, Any] | None:
|
|
146
|
+
"""ActionHandler wrapper for save_workflow_state."""
|
|
147
|
+
return await asyncio.to_thread(save_workflow_state, context.db, context.state)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
async def handle_set_variable(context: "ActionContext", **kwargs: Any) -> dict[str, Any] | None:
|
|
151
|
+
"""ActionHandler wrapper for set_variable.
|
|
152
|
+
|
|
153
|
+
Values containing Jinja2 templates ({{ ... }}) are rendered before setting.
|
|
154
|
+
"""
|
|
155
|
+
value = kwargs.get("value")
|
|
156
|
+
|
|
157
|
+
# Render template if value contains Jinja2 syntax
|
|
158
|
+
if isinstance(value, str) and "{{" in value:
|
|
159
|
+
template_context = {
|
|
160
|
+
"variables": context.state.variables or {},
|
|
161
|
+
"state": context.state,
|
|
162
|
+
}
|
|
163
|
+
if context.template_engine:
|
|
164
|
+
value = context.template_engine.render(value, template_context)
|
|
165
|
+
else:
|
|
166
|
+
logger.warning("handle_set_variable: template_engine is None, skipping template render")
|
|
167
|
+
|
|
168
|
+
return set_variable(context.state, kwargs.get("name"), value)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
async def handle_increment_variable(
|
|
172
|
+
context: "ActionContext", **kwargs: Any
|
|
173
|
+
) -> dict[str, Any] | None:
|
|
174
|
+
"""ActionHandler wrapper for increment_variable."""
|
|
175
|
+
return increment_variable(context.state, kwargs.get("name"), kwargs.get("amount", 1))
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
async def handle_mark_loop_complete(
|
|
179
|
+
context: "ActionContext", **kwargs: Any
|
|
180
|
+
) -> dict[str, Any] | None:
|
|
181
|
+
"""ActionHandler wrapper for mark_loop_complete."""
|
|
182
|
+
return mark_loop_complete(context.state)
|