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.
- gobby/__init__.py +1 -1
- gobby/adapters/__init__.py +2 -1
- gobby/adapters/claude_code.py +96 -35
- 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/adapters/gemini.py +140 -38
- gobby/agents/definitions.py +11 -1
- gobby/agents/isolation.py +525 -0
- gobby/agents/registry.py +11 -0
- gobby/agents/sandbox.py +261 -0
- gobby/agents/session.py +1 -0
- gobby/agents/spawn.py +42 -287
- gobby/agents/spawn_executor.py +415 -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/macos.py +26 -1
- gobby/agents/spawners/prompt_manager.py +125 -0
- gobby/cli/__init__.py +0 -2
- gobby/cli/install.py +4 -4
- gobby/cli/installers/claude.py +6 -0
- gobby/cli/installers/gemini.py +6 -0
- gobby/cli/installers/shared.py +103 -4
- gobby/cli/memory.py +185 -0
- gobby/cli/sessions.py +1 -1
- gobby/cli/utils.py +9 -2
- gobby/clones/git.py +177 -0
- gobby/config/__init__.py +12 -97
- gobby/config/app.py +10 -94
- gobby/config/extensions.py +2 -2
- gobby/config/features.py +7 -130
- gobby/config/skills.py +31 -0
- gobby/config/tasks.py +4 -28
- gobby/hooks/__init__.py +0 -13
- gobby/hooks/event_handlers.py +150 -8
- gobby/hooks/hook_manager.py +21 -3
- gobby/hooks/plugins.py +1 -1
- gobby/hooks/webhooks.py +1 -1
- gobby/install/gemini/hooks/hook_dispatcher.py +74 -15
- gobby/llm/resolver.py +3 -2
- gobby/mcp_proxy/importer.py +62 -4
- gobby/mcp_proxy/instructions.py +4 -2
- gobby/mcp_proxy/registries.py +22 -8
- gobby/mcp_proxy/services/recommendation.py +43 -11
- gobby/mcp_proxy/tools/agent_messaging.py +93 -44
- gobby/mcp_proxy/tools/agents.py +76 -740
- gobby/mcp_proxy/tools/artifacts.py +43 -9
- gobby/mcp_proxy/tools/clones.py +0 -385
- gobby/mcp_proxy/tools/memory.py +2 -2
- gobby/mcp_proxy/tools/sessions/__init__.py +14 -0
- gobby/mcp_proxy/tools/sessions/_commits.py +239 -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 +503 -0
- gobby/mcp_proxy/tools/sessions/_messages.py +166 -0
- gobby/mcp_proxy/tools/skills/__init__.py +14 -29
- gobby/mcp_proxy/tools/spawn_agent.py +455 -0
- gobby/mcp_proxy/tools/tasks/_context.py +18 -0
- gobby/mcp_proxy/tools/tasks/_crud.py +13 -6
- gobby/mcp_proxy/tools/tasks/_lifecycle.py +79 -30
- gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +1 -1
- gobby/mcp_proxy/tools/tasks/_session.py +22 -7
- gobby/mcp_proxy/tools/workflows.py +84 -34
- gobby/mcp_proxy/tools/worktrees.py +32 -350
- gobby/memory/extractor.py +15 -1
- gobby/memory/ingestion/__init__.py +5 -0
- gobby/memory/ingestion/multimodal.py +221 -0
- gobby/memory/manager.py +62 -283
- 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 +13 -0
- gobby/servers/http.py +1 -4
- gobby/servers/routes/admin.py +14 -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 +51 -4
- gobby/servers/routes/mcp/tools.py +48 -1506
- gobby/servers/websocket.py +57 -1
- gobby/sessions/analyzer.py +2 -2
- gobby/sessions/lifecycle.py +1 -1
- gobby/sessions/manager.py +9 -0
- gobby/sessions/processor.py +10 -0
- gobby/sessions/transcripts/base.py +1 -0
- gobby/sessions/transcripts/claude.py +15 -5
- gobby/sessions/transcripts/gemini.py +100 -34
- gobby/skills/parser.py +30 -2
- gobby/storage/database.py +9 -2
- gobby/storage/memories.py +32 -21
- gobby/storage/migrations.py +174 -368
- gobby/storage/sessions.py +45 -7
- gobby/storage/skills.py +80 -7
- gobby/storage/tasks/_lifecycle.py +18 -3
- gobby/sync/memories.py +1 -1
- gobby/tasks/external_validator.py +1 -1
- gobby/tasks/validation.py +22 -20
- gobby/tools/summarizer.py +91 -10
- gobby/utils/project_context.py +2 -3
- gobby/utils/status.py +13 -0
- gobby/workflows/actions.py +221 -1217
- gobby/workflows/artifact_actions.py +31 -0
- gobby/workflows/autonomous_actions.py +11 -0
- gobby/workflows/context_actions.py +50 -1
- gobby/workflows/detection_helpers.py +38 -24
- gobby/workflows/enforcement/__init__.py +47 -0
- gobby/workflows/enforcement/blocking.py +281 -0
- gobby/workflows/enforcement/commit_policy.py +283 -0
- gobby/workflows/enforcement/handlers.py +269 -0
- gobby/workflows/enforcement/task_policy.py +542 -0
- gobby/workflows/engine.py +93 -0
- gobby/workflows/evaluator.py +110 -0
- gobby/workflows/git_utils.py +106 -0
- gobby/workflows/hooks.py +41 -0
- gobby/workflows/llm_actions.py +30 -0
- gobby/workflows/mcp_actions.py +20 -1
- gobby/workflows/memory_actions.py +91 -0
- gobby/workflows/safe_evaluator.py +191 -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 +217 -51
- 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.6.dist-info → gobby-0.2.8.dist-info}/METADATA +6 -1
- {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/RECORD +139 -163
- {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/WHEEL +1 -1
- gobby/adapters/codex.py +0 -1332
- gobby/cli/tui.py +0 -34
- 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/mcp_proxy/tools/session_messages.py +0 -1055
- 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/handoff/compact.md +0 -63
- gobby/prompts/defaults/handoff/session_end.md +0 -57
- gobby/prompts/defaults/memory/extract.md +0 -61
- 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/tui/__init__.py +0 -5
- gobby/tui/api_client.py +0 -278
- gobby/tui/app.py +0 -329
- gobby/tui/screens/__init__.py +0 -25
- gobby/tui/screens/agents.py +0 -333
- gobby/tui/screens/chat.py +0 -450
- gobby/tui/screens/dashboard.py +0 -377
- gobby/tui/screens/memory.py +0 -305
- gobby/tui/screens/metrics.py +0 -231
- gobby/tui/screens/orchestrator.py +0 -903
- gobby/tui/screens/sessions.py +0 -412
- gobby/tui/screens/tasks.py +0 -440
- gobby/tui/screens/workflows.py +0 -289
- gobby/tui/screens/worktrees.py +0 -174
- gobby/tui/widgets/__init__.py +0 -21
- gobby/tui/widgets/chat.py +0 -210
- gobby/tui/widgets/conductor.py +0 -104
- gobby/tui/widgets/menu.py +0 -132
- gobby/tui/widgets/message_panel.py +0 -160
- gobby/tui/widgets/review_gate.py +0 -224
- gobby/tui/widgets/task_tree.py +0 -99
- gobby/tui/widgets/token_budget.py +0 -166
- gobby/tui/ws_client.py +0 -258
- gobby/workflows/task_enforcement_actions.py +0 -1343
- {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/entry_points.txt +0 -0
- {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/licenses/LICENSE.md +0 -0
- {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/top_level.txt +0 -0
gobby/workflows/evaluator.py
CHANGED
|
@@ -348,6 +348,116 @@ class ConditionEvaluator:
|
|
|
348
348
|
|
|
349
349
|
allowed_globals["mcp_called"] = _mcp_called
|
|
350
350
|
|
|
351
|
+
def _mcp_result_is_null(server: str, tool: str) -> bool:
|
|
352
|
+
"""Check if MCP tool result is null/missing.
|
|
353
|
+
|
|
354
|
+
Used in workflow conditions like:
|
|
355
|
+
when: "mcp_result_is_null('gobby-tasks', 'suggest_next_task')"
|
|
356
|
+
|
|
357
|
+
Args:
|
|
358
|
+
server: MCP server name
|
|
359
|
+
tool: Tool name
|
|
360
|
+
|
|
361
|
+
Returns:
|
|
362
|
+
True if the result is null/missing, False if result exists.
|
|
363
|
+
"""
|
|
364
|
+
variables = context.get("variables", {})
|
|
365
|
+
if isinstance(variables, dict):
|
|
366
|
+
mcp_results = variables.get("mcp_results", {})
|
|
367
|
+
else:
|
|
368
|
+
mcp_results = getattr(variables, "mcp_results", {})
|
|
369
|
+
|
|
370
|
+
if not isinstance(mcp_results, dict):
|
|
371
|
+
return True # No results means null
|
|
372
|
+
|
|
373
|
+
server_results = mcp_results.get(server, {})
|
|
374
|
+
if not isinstance(server_results, dict):
|
|
375
|
+
return True
|
|
376
|
+
|
|
377
|
+
result = server_results.get(tool)
|
|
378
|
+
return result is None
|
|
379
|
+
|
|
380
|
+
allowed_globals["mcp_result_is_null"] = _mcp_result_is_null
|
|
381
|
+
|
|
382
|
+
def _mcp_failed(server: str, tool: str) -> bool:
|
|
383
|
+
"""Check if MCP tool call failed.
|
|
384
|
+
|
|
385
|
+
Used in workflow conditions like:
|
|
386
|
+
when: "mcp_failed('gobby-agents', 'spawn_agent')"
|
|
387
|
+
|
|
388
|
+
Args:
|
|
389
|
+
server: MCP server name
|
|
390
|
+
tool: Tool name
|
|
391
|
+
|
|
392
|
+
Returns:
|
|
393
|
+
True if the result exists and indicates failure.
|
|
394
|
+
"""
|
|
395
|
+
variables = context.get("variables", {})
|
|
396
|
+
if isinstance(variables, dict):
|
|
397
|
+
mcp_results = variables.get("mcp_results", {})
|
|
398
|
+
else:
|
|
399
|
+
mcp_results = getattr(variables, "mcp_results", {})
|
|
400
|
+
|
|
401
|
+
if not isinstance(mcp_results, dict):
|
|
402
|
+
return False # No results means we can't determine failure
|
|
403
|
+
|
|
404
|
+
server_results = mcp_results.get(server, {})
|
|
405
|
+
if not isinstance(server_results, dict):
|
|
406
|
+
return False
|
|
407
|
+
|
|
408
|
+
result = server_results.get(tool)
|
|
409
|
+
if result is None:
|
|
410
|
+
return False
|
|
411
|
+
|
|
412
|
+
# Check for failure indicators
|
|
413
|
+
if isinstance(result, dict):
|
|
414
|
+
if result.get("success") is False:
|
|
415
|
+
return True
|
|
416
|
+
if result.get("error"):
|
|
417
|
+
return True
|
|
418
|
+
if result.get("status") == "failed":
|
|
419
|
+
return True
|
|
420
|
+
return False
|
|
421
|
+
|
|
422
|
+
allowed_globals["mcp_failed"] = _mcp_failed
|
|
423
|
+
|
|
424
|
+
def _mcp_result_has(server: str, tool: str, field: str, value: Any) -> bool:
|
|
425
|
+
"""Check if MCP tool result has a specific field value.
|
|
426
|
+
|
|
427
|
+
Used in workflow conditions like:
|
|
428
|
+
when: "mcp_result_has('gobby-tasks', 'wait_for_task', 'timed_out', True)"
|
|
429
|
+
|
|
430
|
+
Args:
|
|
431
|
+
server: MCP server name
|
|
432
|
+
tool: Tool name
|
|
433
|
+
field: Field name to check
|
|
434
|
+
value: Expected value (supports bool, str, int, float)
|
|
435
|
+
|
|
436
|
+
Returns:
|
|
437
|
+
True if the field equals the expected value.
|
|
438
|
+
"""
|
|
439
|
+
variables = context.get("variables", {})
|
|
440
|
+
if isinstance(variables, dict):
|
|
441
|
+
mcp_results = variables.get("mcp_results", {})
|
|
442
|
+
else:
|
|
443
|
+
mcp_results = getattr(variables, "mcp_results", {})
|
|
444
|
+
|
|
445
|
+
if not isinstance(mcp_results, dict):
|
|
446
|
+
return False
|
|
447
|
+
|
|
448
|
+
server_results = mcp_results.get(server, {})
|
|
449
|
+
if not isinstance(server_results, dict):
|
|
450
|
+
return False
|
|
451
|
+
|
|
452
|
+
result = server_results.get(tool)
|
|
453
|
+
if not isinstance(result, dict):
|
|
454
|
+
return False
|
|
455
|
+
|
|
456
|
+
actual_value = result.get(field)
|
|
457
|
+
return bool(actual_value == value)
|
|
458
|
+
|
|
459
|
+
allowed_globals["mcp_result_has"] = _mcp_result_has
|
|
460
|
+
|
|
351
461
|
# eval used with restricted allowed_globals for workflow conditions
|
|
352
462
|
# nosec B307: eval is intentional here for DSL evaluation with
|
|
353
463
|
# restricted globals (__builtins__={}) and controlled workflow conditions
|
gobby/workflows/git_utils.py
CHANGED
|
@@ -4,8 +4,15 @@ Extracted from actions.py as part of strangler fig decomposition.
|
|
|
4
4
|
These are pure utility functions with no ActionContext dependency.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
7
9
|
import logging
|
|
8
10
|
import subprocess # nosec B404 - subprocess needed for git commands
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from gobby.storage.session_tasks import SessionTaskManager
|
|
15
|
+
from gobby.storage.sessions import LocalSessionManager
|
|
9
16
|
|
|
10
17
|
logger = logging.getLogger(__name__)
|
|
11
18
|
|
|
@@ -94,3 +101,102 @@ def get_file_changes() -> str:
|
|
|
94
101
|
|
|
95
102
|
except Exception:
|
|
96
103
|
return "Unable to determine file changes"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def get_dirty_files(project_path: str | None = None) -> set[str]:
|
|
107
|
+
"""
|
|
108
|
+
Get the set of dirty files from git status --porcelain.
|
|
109
|
+
|
|
110
|
+
Excludes .gobby/ files from the result.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
project_path: Path to the project directory
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
Set of dirty file paths (relative to repo root)
|
|
117
|
+
"""
|
|
118
|
+
if project_path is None:
|
|
119
|
+
logger.warning(
|
|
120
|
+
"get_dirty_files: project_path is None, git status will use daemon's cwd "
|
|
121
|
+
"which may not be the project directory"
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
result = subprocess.run( # nosec B603 B607 - hardcoded git command
|
|
126
|
+
["git", "status", "--porcelain"],
|
|
127
|
+
cwd=project_path,
|
|
128
|
+
capture_output=True,
|
|
129
|
+
text=True,
|
|
130
|
+
timeout=10,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
if result.returncode != 0:
|
|
134
|
+
logger.warning(f"get_dirty_files: git status failed: {result.stderr}")
|
|
135
|
+
return set()
|
|
136
|
+
|
|
137
|
+
dirty_files = set()
|
|
138
|
+
# Split by newline first, don't strip() the whole string as it removes
|
|
139
|
+
# the leading space from git status format (e.g., " M file.py")
|
|
140
|
+
for line in result.stdout.split("\n"):
|
|
141
|
+
line = line.rstrip() # Remove trailing whitespace only
|
|
142
|
+
if not line:
|
|
143
|
+
continue
|
|
144
|
+
# Format is "XY filename" or "XY filename -> newname" for renames
|
|
145
|
+
# Skip the status prefix (first 3 chars: 2 status chars + space)
|
|
146
|
+
filepath = line[3:].split(" -> ")[0] # Handle renames
|
|
147
|
+
# Exclude .gobby/ files
|
|
148
|
+
if not filepath.startswith(".gobby/"):
|
|
149
|
+
dirty_files.add(filepath)
|
|
150
|
+
|
|
151
|
+
return dirty_files
|
|
152
|
+
|
|
153
|
+
except subprocess.TimeoutExpired:
|
|
154
|
+
logger.warning("get_dirty_files: git status timed out")
|
|
155
|
+
return set()
|
|
156
|
+
except FileNotFoundError:
|
|
157
|
+
logger.warning("get_dirty_files: git not found")
|
|
158
|
+
return set()
|
|
159
|
+
except Exception as e:
|
|
160
|
+
logger.error(f"get_dirty_files: Error running git status: {e}")
|
|
161
|
+
return set()
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def get_task_session_liveness(
|
|
165
|
+
task_id: str,
|
|
166
|
+
session_task_manager: SessionTaskManager | None,
|
|
167
|
+
session_manager: LocalSessionManager | None,
|
|
168
|
+
exclude_session_id: str | None = None,
|
|
169
|
+
) -> bool:
|
|
170
|
+
"""
|
|
171
|
+
Check if a task is currently being worked on by an active session.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
task_id: The task ID to check
|
|
175
|
+
session_task_manager: Manager to look up session-task links
|
|
176
|
+
session_manager: Manager to check session status
|
|
177
|
+
exclude_session_id: ID of session to exclude from check (e.g. current one)
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
True if an active session (status='active') is linked to this task.
|
|
181
|
+
"""
|
|
182
|
+
if not session_task_manager or not session_manager:
|
|
183
|
+
return False
|
|
184
|
+
|
|
185
|
+
try:
|
|
186
|
+
# Get all sessions linked to this task
|
|
187
|
+
linked_sessions = session_task_manager.get_task_sessions(task_id)
|
|
188
|
+
|
|
189
|
+
for link in linked_sessions:
|
|
190
|
+
session_id = link.get("session_id")
|
|
191
|
+
if not session_id or session_id == exclude_session_id:
|
|
192
|
+
continue
|
|
193
|
+
|
|
194
|
+
# Check if session is truly active
|
|
195
|
+
session = session_manager.get(session_id)
|
|
196
|
+
if session and session.status == "active":
|
|
197
|
+
return True
|
|
198
|
+
|
|
199
|
+
return False
|
|
200
|
+
except Exception as e:
|
|
201
|
+
logger.warning(f"get_task_session_liveness: Error checking liveness for {task_id}: {e}")
|
|
202
|
+
return False
|
gobby/workflows/hooks.py
CHANGED
|
@@ -167,3 +167,44 @@ class WorkflowHookHandler:
|
|
|
167
167
|
except Exception as e:
|
|
168
168
|
logger.error(f"Error handling lifecycle workflow: {e}", exc_info=True)
|
|
169
169
|
return HookResponse(decision="allow")
|
|
170
|
+
|
|
171
|
+
def activate_workflow(
|
|
172
|
+
self,
|
|
173
|
+
workflow_name: str,
|
|
174
|
+
session_id: str,
|
|
175
|
+
project_path: str | None = None,
|
|
176
|
+
variables: dict[str, Any] | None = None,
|
|
177
|
+
) -> dict[str, Any]:
|
|
178
|
+
"""
|
|
179
|
+
Activate a step-based workflow for a session.
|
|
180
|
+
|
|
181
|
+
This is used during session startup for terminal-mode agents that have
|
|
182
|
+
a workflow_name set. It's a synchronous wrapper around the engine's
|
|
183
|
+
activate_workflow method.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
workflow_name: Name of the workflow to activate
|
|
187
|
+
session_id: Session ID to activate for
|
|
188
|
+
project_path: Optional project path for workflow discovery
|
|
189
|
+
variables: Optional initial variables to merge with workflow defaults
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
Dict with success status and workflow info
|
|
193
|
+
"""
|
|
194
|
+
if not self._enabled:
|
|
195
|
+
return {"success": False, "error": "Workflow engine is disabled"}
|
|
196
|
+
|
|
197
|
+
from pathlib import Path
|
|
198
|
+
|
|
199
|
+
path = Path(project_path) if project_path else None
|
|
200
|
+
|
|
201
|
+
try:
|
|
202
|
+
return self.engine.activate_workflow(
|
|
203
|
+
workflow_name=workflow_name,
|
|
204
|
+
session_id=session_id,
|
|
205
|
+
project_path=path,
|
|
206
|
+
variables=variables,
|
|
207
|
+
)
|
|
208
|
+
except Exception as e:
|
|
209
|
+
logger.error(f"Error activating workflow: {e}", exc_info=True)
|
|
210
|
+
return {"success": False, "error": str(e)}
|
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/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
|
+
)
|
|
@@ -205,6 +205,17 @@ async def memory_recall_relevant(
|
|
|
205
205
|
# Filter out memories that have already been injected in this session
|
|
206
206
|
new_memories = [m for m in memories if m.id not in injected_ids]
|
|
207
207
|
|
|
208
|
+
# Deduplicate by content to avoid showing same content with different IDs
|
|
209
|
+
# (can happen when same content was stored with different project_ids)
|
|
210
|
+
seen_content: set[str] = set()
|
|
211
|
+
unique_memories = []
|
|
212
|
+
for m in new_memories:
|
|
213
|
+
normalized = m.content.strip()
|
|
214
|
+
if normalized not in seen_content:
|
|
215
|
+
seen_content.add(normalized)
|
|
216
|
+
unique_memories.append(m)
|
|
217
|
+
new_memories = unique_memories
|
|
218
|
+
|
|
208
219
|
if not new_memories:
|
|
209
220
|
logger.debug(
|
|
210
221
|
f"memory_recall_relevant: All {len(memories)} memories already injected, skipping"
|
|
@@ -344,3 +355,83 @@ async def memory_extract(
|
|
|
344
355
|
except Exception as e:
|
|
345
356
|
logger.error(f"memory_extract: Failed: {e}", exc_info=True)
|
|
346
357
|
return {"error": str(e)}
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
# --- ActionHandler-compatible wrappers ---
|
|
361
|
+
# These match the ActionHandler protocol: (context: ActionContext, **kwargs) -> dict | None
|
|
362
|
+
|
|
363
|
+
if __name__ != "__main__":
|
|
364
|
+
from typing import TYPE_CHECKING
|
|
365
|
+
|
|
366
|
+
if TYPE_CHECKING:
|
|
367
|
+
from gobby.workflows.actions import ActionContext
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
async def handle_memory_sync_import(
|
|
371
|
+
context: "ActionContext", **kwargs: Any
|
|
372
|
+
) -> dict[str, Any] | None:
|
|
373
|
+
"""ActionHandler wrapper for memory_sync_import."""
|
|
374
|
+
return await memory_sync_import(context.memory_sync_manager)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
async def handle_memory_sync_export(
|
|
378
|
+
context: "ActionContext", **kwargs: Any
|
|
379
|
+
) -> dict[str, Any] | None:
|
|
380
|
+
"""ActionHandler wrapper for memory_sync_export."""
|
|
381
|
+
return await memory_sync_export(context.memory_sync_manager)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
async def handle_memory_save(context: "ActionContext", **kwargs: Any) -> dict[str, Any] | None:
|
|
385
|
+
"""ActionHandler wrapper for memory_save."""
|
|
386
|
+
return await memory_save(
|
|
387
|
+
memory_manager=context.memory_manager,
|
|
388
|
+
session_manager=context.session_manager,
|
|
389
|
+
session_id=context.session_id,
|
|
390
|
+
content=kwargs.get("content"),
|
|
391
|
+
memory_type=kwargs.get("memory_type", "fact"),
|
|
392
|
+
importance=kwargs.get("importance", 0.5),
|
|
393
|
+
tags=kwargs.get("tags"),
|
|
394
|
+
project_id=kwargs.get("project_id"),
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
async def handle_memory_recall_relevant(
|
|
399
|
+
context: "ActionContext", **kwargs: Any
|
|
400
|
+
) -> dict[str, Any] | None:
|
|
401
|
+
"""ActionHandler wrapper for memory_recall_relevant."""
|
|
402
|
+
prompt_text = None
|
|
403
|
+
if context.event_data:
|
|
404
|
+
# Check both "prompt" (from hook event) and "prompt_text" (legacy/alternative)
|
|
405
|
+
prompt_text = context.event_data.get("prompt") or context.event_data.get("prompt_text")
|
|
406
|
+
|
|
407
|
+
return await memory_recall_relevant(
|
|
408
|
+
memory_manager=context.memory_manager,
|
|
409
|
+
session_manager=context.session_manager,
|
|
410
|
+
session_id=context.session_id,
|
|
411
|
+
prompt_text=prompt_text,
|
|
412
|
+
project_id=kwargs.get("project_id"),
|
|
413
|
+
limit=kwargs.get("limit", 5),
|
|
414
|
+
min_importance=kwargs.get("min_importance", 0.3),
|
|
415
|
+
state=context.state,
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
async def handle_reset_memory_injection_tracking(
|
|
420
|
+
context: "ActionContext", **kwargs: Any
|
|
421
|
+
) -> dict[str, Any] | None:
|
|
422
|
+
"""ActionHandler wrapper for reset_memory_injection_tracking."""
|
|
423
|
+
return reset_memory_injection_tracking(state=context.state)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
async def handle_memory_extract(context: "ActionContext", **kwargs: Any) -> dict[str, Any] | None:
|
|
427
|
+
"""ActionHandler wrapper for memory_extract."""
|
|
428
|
+
return await memory_extract(
|
|
429
|
+
session_manager=context.session_manager,
|
|
430
|
+
session_id=context.session_id,
|
|
431
|
+
llm_service=context.llm_service,
|
|
432
|
+
memory_manager=context.memory_manager,
|
|
433
|
+
transcript_processor=context.transcript_processor,
|
|
434
|
+
min_importance=kwargs.get("min_importance", 0.7),
|
|
435
|
+
max_memories=kwargs.get("max_memories", 5),
|
|
436
|
+
dry_run=kwargs.get("dry_run", False),
|
|
437
|
+
)
|
|
@@ -0,0 +1,191 @@
|
|
|
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 visit_List(self, node: ast.List) -> list[Any]:
|
|
182
|
+
"""Handle list literals (e.g., ['a', 'b', 'c'])."""
|
|
183
|
+
return [self.visit(elt) for elt in node.elts]
|
|
184
|
+
|
|
185
|
+
def visit_Tuple(self, node: ast.Tuple) -> tuple[Any, ...]:
|
|
186
|
+
"""Handle tuple literals (e.g., ('a', 'b', 'c'))."""
|
|
187
|
+
return tuple(self.visit(elt) for elt in node.elts)
|
|
188
|
+
|
|
189
|
+
def generic_visit(self, node: ast.AST) -> Any:
|
|
190
|
+
"""Reject any unsupported AST nodes."""
|
|
191
|
+
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"))
|