gobby 0.2.6__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/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/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/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/sessions.py +1 -1
- gobby/cli/utils.py +9 -2
- 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/tasks.py +4 -28
- gobby/hooks/__init__.py +0 -13
- gobby/hooks/event_handlers.py +45 -2
- gobby/hooks/hook_manager.py +2 -2
- gobby/hooks/plugins.py +1 -1
- gobby/hooks/webhooks.py +1 -1
- gobby/llm/resolver.py +3 -2
- gobby/mcp_proxy/importer.py +62 -4
- gobby/mcp_proxy/instructions.py +2 -0
- gobby/mcp_proxy/registries.py +1 -4
- gobby/mcp_proxy/services/recommendation.py +43 -11
- gobby/mcp_proxy/tools/agents.py +31 -731
- 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 +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 +14 -29
- gobby/mcp_proxy/tools/spawn_agent.py +417 -0
- gobby/mcp_proxy/tools/tasks/_lifecycle.py +52 -18
- gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +1 -1
- gobby/mcp_proxy/tools/worktrees.py +0 -343
- 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/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 +1 -1
- gobby/servers/routes/mcp/tools.py +48 -1506
- gobby/sessions/lifecycle.py +1 -1
- gobby/sessions/processor.py +10 -0
- gobby/sessions/transcripts/base.py +1 -0
- gobby/sessions/transcripts/claude.py +15 -5
- gobby/skills/parser.py +30 -2
- gobby/storage/migrations.py +159 -372
- gobby/storage/sessions.py +43 -7
- gobby/storage/skills.py +37 -4
- 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/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/enforcement/task_policy.py +542 -0
- gobby/workflows/git_utils.py +106 -0
- gobby/workflows/llm_actions.py +30 -0
- gobby/workflows/mcp_actions.py +20 -1
- gobby/workflows/memory_actions.py +80 -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 +94 -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.6.dist-info → gobby-0.2.7.dist-info}/METADATA +6 -1
- {gobby-0.2.6.dist-info → gobby-0.2.7.dist-info}/RECORD +111 -111
- {gobby-0.2.6.dist-info → gobby-0.2.7.dist-info}/WHEEL +1 -1
- gobby/adapters/codex.py +0 -1332
- 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/workflows/task_enforcement_actions.py +0 -1343
- {gobby-0.2.6.dist-info → gobby-0.2.7.dist-info}/entry_points.txt +0 -0
- {gobby-0.2.6.dist-info → gobby-0.2.7.dist-info}/licenses/LICENSE.md +0 -0
- {gobby-0.2.6.dist-info → gobby-0.2.7.dist-info}/top_level.txt +0 -0
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
|
+
)
|
|
@@ -344,3 +344,83 @@ async def memory_extract(
|
|
|
344
344
|
except Exception as e:
|
|
345
345
|
logger.error(f"memory_extract: Failed: {e}", exc_info=True)
|
|
346
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)
|
|
@@ -161,3 +161,58 @@ def clear_stop_signal(
|
|
|
161
161
|
|
|
162
162
|
cleared = stop_registry.clear(session_id)
|
|
163
163
|
return {"success": True, "cleared": cleared}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# --- ActionHandler factory functions ---
|
|
167
|
+
# These create ActionHandler-compatible wrappers that close over the stop_registry.
|
|
168
|
+
# The ActionExecutor calls these factories in _register_defaults() to create handlers
|
|
169
|
+
# that have access to the executor's stop_registry instance.
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def make_handle_check_stop_signal(
|
|
173
|
+
stop_registry: "StopRegistry | None",
|
|
174
|
+
) -> Any:
|
|
175
|
+
"""Factory that creates a check_stop_signal handler with access to stop_registry."""
|
|
176
|
+
|
|
177
|
+
async def handler(context: "Any", **kwargs: Any) -> dict[str, Any] | None:
|
|
178
|
+
"""ActionHandler for check_stop_signal."""
|
|
179
|
+
return check_stop_signal(
|
|
180
|
+
stop_registry=stop_registry,
|
|
181
|
+
session_id=context.session_id,
|
|
182
|
+
state=context.state,
|
|
183
|
+
acknowledge=kwargs.get("acknowledge", False),
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
return handler
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def make_handle_request_stop(
|
|
190
|
+
stop_registry: "StopRegistry | None",
|
|
191
|
+
) -> Any:
|
|
192
|
+
"""Factory that creates a request_stop handler with access to stop_registry."""
|
|
193
|
+
|
|
194
|
+
async def handler(context: "Any", **kwargs: Any) -> dict[str, Any] | None:
|
|
195
|
+
"""ActionHandler for request_stop."""
|
|
196
|
+
return request_stop(
|
|
197
|
+
stop_registry=stop_registry,
|
|
198
|
+
session_id=kwargs.get("session_id", context.session_id),
|
|
199
|
+
source=kwargs.get("source", "workflow"),
|
|
200
|
+
reason=kwargs.get("reason"),
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
return handler
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def make_handle_clear_stop_signal(
|
|
207
|
+
stop_registry: "StopRegistry | None",
|
|
208
|
+
) -> Any:
|
|
209
|
+
"""Factory that creates a clear_stop_signal handler with access to stop_registry."""
|
|
210
|
+
|
|
211
|
+
async def handler(context: "Any", **kwargs: Any) -> dict[str, Any] | None:
|
|
212
|
+
"""ActionHandler for clear_stop_signal."""
|
|
213
|
+
return clear_stop_signal(
|
|
214
|
+
stop_registry=stop_registry,
|
|
215
|
+
session_id=kwargs.get("session_id", context.session_id),
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
return handler
|
|
@@ -9,10 +9,13 @@ from __future__ import annotations
|
|
|
9
9
|
import json
|
|
10
10
|
import logging
|
|
11
11
|
from pathlib import Path
|
|
12
|
-
from typing import Any, Literal
|
|
12
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
13
13
|
|
|
14
14
|
from gobby.workflows.git_utils import get_file_changes, get_git_status
|
|
15
15
|
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from gobby.workflows.actions import ActionContext
|
|
18
|
+
|
|
16
19
|
logger = logging.getLogger(__name__)
|
|
17
20
|
|
|
18
21
|
|
|
@@ -359,3 +362,93 @@ async def generate_handoff(
|
|
|
359
362
|
return {"error": "Failed to generate summary"}
|
|
360
363
|
|
|
361
364
|
return {"handoff_created": True, "summary_length": summary_result.get("summary_length", 0)}
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
# --- ActionHandler-compatible wrappers ---
|
|
368
|
+
# These match the ActionHandler protocol: (context: ActionContext, **kwargs) -> dict | None
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
async def handle_synthesize_title(context: ActionContext, **kwargs: Any) -> dict[str, Any] | None:
|
|
372
|
+
"""ActionHandler wrapper for synthesize_title."""
|
|
373
|
+
# Extract prompt from event data (UserPromptSubmit hook)
|
|
374
|
+
prompt = None
|
|
375
|
+
if context.event_data:
|
|
376
|
+
prompt = context.event_data.get("prompt")
|
|
377
|
+
|
|
378
|
+
return await synthesize_title(
|
|
379
|
+
session_manager=context.session_manager,
|
|
380
|
+
session_id=context.session_id,
|
|
381
|
+
llm_service=context.llm_service,
|
|
382
|
+
transcript_processor=context.transcript_processor,
|
|
383
|
+
template_engine=context.template_engine,
|
|
384
|
+
template=kwargs.get("template"),
|
|
385
|
+
prompt=prompt,
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
async def handle_generate_summary(context: ActionContext, **kwargs: Any) -> dict[str, Any] | None:
|
|
390
|
+
"""ActionHandler wrapper for generate_summary."""
|
|
391
|
+
return await generate_summary(
|
|
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=kwargs.get("template"),
|
|
397
|
+
mode=kwargs.get("mode", "clear"),
|
|
398
|
+
previous_summary=kwargs.get("previous_summary"),
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
async def handle_generate_handoff(context: ActionContext, **kwargs: Any) -> dict[str, Any] | None:
|
|
403
|
+
"""ActionHandler wrapper for generate_handoff.
|
|
404
|
+
|
|
405
|
+
Handles mode detection from event_data and previous summary fetching for compact mode.
|
|
406
|
+
Also supports loading templates from prompts collection via 'prompt' parameter.
|
|
407
|
+
"""
|
|
408
|
+
# Detect mode from kwargs or event data
|
|
409
|
+
mode = kwargs.get("mode", "clear")
|
|
410
|
+
|
|
411
|
+
# Check if this is a compact event based on event_data
|
|
412
|
+
COMPACT_EVENT_TYPES = {"pre_compact", "compact"}
|
|
413
|
+
if context.event_data:
|
|
414
|
+
raw_event_type = context.event_data.get("event_type") or ""
|
|
415
|
+
normalized_event_type = str(raw_event_type).strip().lower()
|
|
416
|
+
if normalized_event_type in COMPACT_EVENT_TYPES:
|
|
417
|
+
mode = "compact"
|
|
418
|
+
|
|
419
|
+
# For compact mode, fetch previous summary for cumulative compression
|
|
420
|
+
previous_summary = None
|
|
421
|
+
if mode == "compact":
|
|
422
|
+
current_session = context.session_manager.get(context.session_id)
|
|
423
|
+
if current_session:
|
|
424
|
+
previous_summary = getattr(current_session, "summary_markdown", None)
|
|
425
|
+
if previous_summary:
|
|
426
|
+
logger.debug(
|
|
427
|
+
f"Compact mode: using previous summary ({len(previous_summary)} chars) "
|
|
428
|
+
f"for cumulative compression"
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
# Load template from prompts collection if 'prompt' parameter provided
|
|
432
|
+
template = kwargs.get("template")
|
|
433
|
+
prompt_path = kwargs.get("prompt")
|
|
434
|
+
if prompt_path and not template:
|
|
435
|
+
try:
|
|
436
|
+
from gobby.prompts.loader import PromptLoader
|
|
437
|
+
|
|
438
|
+
loader = PromptLoader()
|
|
439
|
+
prompt_template = loader.load(prompt_path)
|
|
440
|
+
template = prompt_template.content
|
|
441
|
+
logger.debug(f"Loaded prompt template from: {prompt_path}")
|
|
442
|
+
except Exception as e:
|
|
443
|
+
logger.warning(f"Failed to load prompt from {prompt_path}: {e}")
|
|
444
|
+
# Fall back to inline template or default
|
|
445
|
+
|
|
446
|
+
return await generate_handoff(
|
|
447
|
+
session_manager=context.session_manager,
|
|
448
|
+
session_id=context.session_id,
|
|
449
|
+
llm_service=context.llm_service,
|
|
450
|
+
transcript_processor=context.transcript_processor,
|
|
451
|
+
template=template,
|
|
452
|
+
previous_summary=previous_summary,
|
|
453
|
+
mode=mode,
|
|
454
|
+
)
|