steerdev 0.4.27__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.
- steerdev-0.4.27.dist-info/METADATA +224 -0
- steerdev-0.4.27.dist-info/RECORD +57 -0
- steerdev-0.4.27.dist-info/WHEEL +4 -0
- steerdev-0.4.27.dist-info/entry_points.txt +2 -0
- steerdev_agent/__init__.py +10 -0
- steerdev_agent/api/__init__.py +32 -0
- steerdev_agent/api/activity.py +278 -0
- steerdev_agent/api/agents.py +145 -0
- steerdev_agent/api/client.py +158 -0
- steerdev_agent/api/commands.py +399 -0
- steerdev_agent/api/configs.py +238 -0
- steerdev_agent/api/context.py +306 -0
- steerdev_agent/api/events.py +294 -0
- steerdev_agent/api/hooks.py +178 -0
- steerdev_agent/api/implementation_plan.py +408 -0
- steerdev_agent/api/messages.py +231 -0
- steerdev_agent/api/prd.py +281 -0
- steerdev_agent/api/runs.py +526 -0
- steerdev_agent/api/sessions.py +403 -0
- steerdev_agent/api/specs.py +321 -0
- steerdev_agent/api/tasks.py +659 -0
- steerdev_agent/api/workflow_runs.py +351 -0
- steerdev_agent/api/workflows.py +191 -0
- steerdev_agent/cli.py +2254 -0
- steerdev_agent/config/__init__.py +19 -0
- steerdev_agent/config/models.py +236 -0
- steerdev_agent/config/platform.py +272 -0
- steerdev_agent/config/settings.py +62 -0
- steerdev_agent/daemon.py +675 -0
- steerdev_agent/executor/__init__.py +64 -0
- steerdev_agent/executor/base.py +121 -0
- steerdev_agent/executor/claude.py +328 -0
- steerdev_agent/executor/stream.py +163 -0
- steerdev_agent/git/__init__.py +1 -0
- steerdev_agent/handlers/__init__.py +5 -0
- steerdev_agent/handlers/prd.py +533 -0
- steerdev_agent/integration.py +334 -0
- steerdev_agent/prompt/__init__.py +10 -0
- steerdev_agent/prompt/builder.py +263 -0
- steerdev_agent/prompt/templates.py +422 -0
- steerdev_agent/py.typed +0 -0
- steerdev_agent/runner.py +829 -0
- steerdev_agent/setup/__init__.py +5 -0
- steerdev_agent/setup/claude_setup.py +560 -0
- steerdev_agent/setup/templates/claude_md_section.md +140 -0
- steerdev_agent/setup/templates/settings.json +69 -0
- steerdev_agent/setup/templates/skills/activity/SKILL.md +160 -0
- steerdev_agent/setup/templates/skills/context/SKILL.md +122 -0
- steerdev_agent/setup/templates/skills/git-workflow/SKILL.md +218 -0
- steerdev_agent/setup/templates/skills/progress-logging/SKILL.md +211 -0
- steerdev_agent/setup/templates/skills/specs-management/SKILL.md +161 -0
- steerdev_agent/setup/templates/skills/task-management/SKILL.md +343 -0
- steerdev_agent/setup/templates/steerdev.yaml +51 -0
- steerdev_agent/version.py +149 -0
- steerdev_agent/workflow/__init__.py +10 -0
- steerdev_agent/workflow/executor.py +494 -0
- steerdev_agent/workflow/memory.py +185 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# SteerDev Agent Configuration
|
|
2
|
+
|
|
3
|
+
agent:
|
|
4
|
+
# Model to use (e.g., claude-sonnet-4-20250514)
|
|
5
|
+
model: null
|
|
6
|
+
|
|
7
|
+
# Maximum number of agent turns per task
|
|
8
|
+
max_turns: null
|
|
9
|
+
|
|
10
|
+
# Maximum execution time in seconds (default: 1 hour)
|
|
11
|
+
timeout_seconds: 3600
|
|
12
|
+
|
|
13
|
+
# Workflow ID to use for multi-phase task execution
|
|
14
|
+
# If not set, tasks run as single-phase execution
|
|
15
|
+
workflow_id: null
|
|
16
|
+
|
|
17
|
+
api:
|
|
18
|
+
# API endpoint for steerdev.com
|
|
19
|
+
api_endpoint: "https://steerdev.com/api/v1"
|
|
20
|
+
|
|
21
|
+
# Environment variable name for API key
|
|
22
|
+
api_key_env: STEERDEV_API_KEY
|
|
23
|
+
|
|
24
|
+
# Environment variable name for project ID
|
|
25
|
+
project_id_env: STEERDEV_PROJECT_ID
|
|
26
|
+
|
|
27
|
+
events:
|
|
28
|
+
# Number of events to batch before sending
|
|
29
|
+
batch_size: 10
|
|
30
|
+
|
|
31
|
+
# Maximum seconds between flushes
|
|
32
|
+
flush_interval_seconds: 5.0
|
|
33
|
+
|
|
34
|
+
executor:
|
|
35
|
+
# Executor type: claude (future: codex, aider)
|
|
36
|
+
type: claude
|
|
37
|
+
|
|
38
|
+
# Permission mode for the executor
|
|
39
|
+
permission_mode: dangerously-skip-permissions
|
|
40
|
+
|
|
41
|
+
# Tools to allow (empty = all allowed)
|
|
42
|
+
allowed_tools: []
|
|
43
|
+
|
|
44
|
+
# Tools to disallow
|
|
45
|
+
disallowed_tools: []
|
|
46
|
+
|
|
47
|
+
# Path to MCP config file
|
|
48
|
+
mcp_config: null
|
|
49
|
+
|
|
50
|
+
worktrees:
|
|
51
|
+
enabled: false
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Version management utilities for SteerDev Agent.
|
|
2
|
+
|
|
3
|
+
Provides functions to get and manage the project version from pyproject.toml.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import subprocess
|
|
7
|
+
import tomllib
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_project_root() -> Path:
|
|
12
|
+
"""Get the project root directory (where pyproject.toml lives)."""
|
|
13
|
+
return Path(__file__).parent.parent.parent
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_version() -> str:
|
|
17
|
+
"""Get the current project version from pyproject.toml.
|
|
18
|
+
|
|
19
|
+
First tries to use `uv version --short`, then falls back to reading
|
|
20
|
+
pyproject.toml directly.
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
The current version string (e.g., "0.1.0")
|
|
24
|
+
"""
|
|
25
|
+
try:
|
|
26
|
+
result = subprocess.run(
|
|
27
|
+
["uv", "version", "--short"],
|
|
28
|
+
capture_output=True,
|
|
29
|
+
text=True,
|
|
30
|
+
check=True,
|
|
31
|
+
cwd=get_project_root(),
|
|
32
|
+
)
|
|
33
|
+
return result.stdout.strip()
|
|
34
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
35
|
+
# Fallback: read directly from pyproject.toml
|
|
36
|
+
pyproject_path = get_project_root() / "pyproject.toml"
|
|
37
|
+
if pyproject_path.exists():
|
|
38
|
+
with open(pyproject_path, "rb") as f:
|
|
39
|
+
data = tomllib.load(f)
|
|
40
|
+
return data.get("project", {}).get("version", "unknown")
|
|
41
|
+
return "unknown"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def bump_version(
|
|
45
|
+
bump_type: str,
|
|
46
|
+
dry_run: bool = False,
|
|
47
|
+
) -> tuple[str, str]:
|
|
48
|
+
"""Bump the project version.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
bump_type: Type of bump (major, minor, patch, stable, alpha, beta, rc, post, dev)
|
|
52
|
+
dry_run: If True, don't actually change the version
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
Tuple of (old_version, new_version)
|
|
56
|
+
|
|
57
|
+
Raises:
|
|
58
|
+
ValueError: If bump_type is invalid
|
|
59
|
+
RuntimeError: If version bump fails
|
|
60
|
+
"""
|
|
61
|
+
valid_bumps = [
|
|
62
|
+
"major",
|
|
63
|
+
"minor",
|
|
64
|
+
"patch",
|
|
65
|
+
"stable",
|
|
66
|
+
"alpha",
|
|
67
|
+
"beta",
|
|
68
|
+
"rc",
|
|
69
|
+
"post",
|
|
70
|
+
"dev",
|
|
71
|
+
]
|
|
72
|
+
if bump_type not in valid_bumps:
|
|
73
|
+
raise ValueError(
|
|
74
|
+
f"Invalid bump type '{bump_type}'. Valid options: {', '.join(valid_bumps)}"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
old_version = get_version()
|
|
78
|
+
|
|
79
|
+
cmd = ["uv", "version", "--bump", bump_type]
|
|
80
|
+
if dry_run:
|
|
81
|
+
cmd.append("--dry-run")
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
subprocess.run(
|
|
85
|
+
cmd,
|
|
86
|
+
capture_output=True,
|
|
87
|
+
text=True,
|
|
88
|
+
check=True,
|
|
89
|
+
cwd=get_project_root(),
|
|
90
|
+
)
|
|
91
|
+
except subprocess.CalledProcessError as e:
|
|
92
|
+
raise RuntimeError(f"Failed to bump version: {e.stderr}") from e
|
|
93
|
+
|
|
94
|
+
new_version = get_version() if not dry_run else _predict_new_version(old_version, bump_type)
|
|
95
|
+
return old_version, new_version
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def set_version(version: str, dry_run: bool = False) -> str:
|
|
99
|
+
"""Set the project version to a specific value.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
version: The version string to set (e.g., "1.2.3")
|
|
103
|
+
dry_run: If True, don't actually change the version
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
The new version string
|
|
107
|
+
|
|
108
|
+
Raises:
|
|
109
|
+
RuntimeError: If setting version fails
|
|
110
|
+
"""
|
|
111
|
+
cmd = ["uv", "version", version]
|
|
112
|
+
if dry_run:
|
|
113
|
+
cmd.append("--dry-run")
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
subprocess.run(
|
|
117
|
+
cmd,
|
|
118
|
+
capture_output=True,
|
|
119
|
+
text=True,
|
|
120
|
+
check=True,
|
|
121
|
+
cwd=get_project_root(),
|
|
122
|
+
)
|
|
123
|
+
except subprocess.CalledProcessError as e:
|
|
124
|
+
raise RuntimeError(f"Failed to set version: {e.stderr}") from e
|
|
125
|
+
|
|
126
|
+
return version if not dry_run else get_version()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _predict_new_version(current: str, bump_type: str) -> str:
|
|
130
|
+
"""Predict the new version after a bump (for dry-run display)."""
|
|
131
|
+
parts = current.split(".")
|
|
132
|
+
if len(parts) < 3:
|
|
133
|
+
return f"{current} -> ?"
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
major, minor, patch_str = parts[0], parts[1], parts[2]
|
|
137
|
+
# Handle versions like "0.1.0a1" or "0.1.0-alpha"
|
|
138
|
+
patch = int("".join(c for c in patch_str if c.isdigit()))
|
|
139
|
+
|
|
140
|
+
if bump_type == "major":
|
|
141
|
+
return f"{int(major) + 1}.0.0"
|
|
142
|
+
elif bump_type == "minor":
|
|
143
|
+
return f"{major}.{int(minor) + 1}.0"
|
|
144
|
+
elif bump_type == "patch":
|
|
145
|
+
return f"{major}.{minor}.{patch + 1}"
|
|
146
|
+
else:
|
|
147
|
+
return f"{current} -> (uv will calculate)"
|
|
148
|
+
except (ValueError, IndexError):
|
|
149
|
+
return f"{current} -> ?"
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Workflow orchestration module for multi-phase task execution."""
|
|
2
|
+
|
|
3
|
+
from steerdev_agent.workflow.executor import WorkflowExecutor
|
|
4
|
+
from steerdev_agent.workflow.memory import PhaseMemory, WorkflowMemoryManager
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"PhaseMemory",
|
|
8
|
+
"WorkflowExecutor",
|
|
9
|
+
"WorkflowMemoryManager",
|
|
10
|
+
]
|
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
"""Workflow executor for multi-phase task execution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from string import Template
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from loguru import logger
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
|
|
12
|
+
from steerdev_agent.api.events import EventsClient
|
|
13
|
+
from steerdev_agent.api.sessions import SessionCreateRequest, SessionsClient
|
|
14
|
+
from steerdev_agent.api.workflow_runs import WorkflowRunResponse, WorkflowRunsClient
|
|
15
|
+
from steerdev_agent.api.workflows import WorkflowPhaseResponse, WorkflowsClient
|
|
16
|
+
from steerdev_agent.config.models import ExecutorConfig
|
|
17
|
+
from steerdev_agent.executor import ExecutorFactory
|
|
18
|
+
from steerdev_agent.executor.base import EventType, StreamEvent
|
|
19
|
+
from steerdev_agent.workflow.memory import PhaseMemory, WorkflowMemoryManager
|
|
20
|
+
|
|
21
|
+
console = Console()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class WorkflowExecutorError(Exception):
|
|
25
|
+
"""Error during workflow execution."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class WorkflowExecutor:
|
|
29
|
+
"""Executes workflow phases sequentially.
|
|
30
|
+
|
|
31
|
+
Handles the complete lifecycle of multi-phase workflow execution:
|
|
32
|
+
1. Fetch workflow definition from API
|
|
33
|
+
2. Start workflow run via API
|
|
34
|
+
3. Execute each phase in order
|
|
35
|
+
4. Build prompts with accumulated context
|
|
36
|
+
5. Track phase completion and handle failures
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
working_directory: str | Path,
|
|
42
|
+
api_key: str | None = None,
|
|
43
|
+
executor_config: ExecutorConfig | None = None,
|
|
44
|
+
model: str | None = None,
|
|
45
|
+
max_turns: int | None = None,
|
|
46
|
+
dry_run: bool = False,
|
|
47
|
+
project_id: str | None = None,
|
|
48
|
+
agent_type: str = "claude",
|
|
49
|
+
) -> None:
|
|
50
|
+
"""Initialize the workflow executor.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
working_directory: Directory to run agents in.
|
|
54
|
+
api_key: API key for steerdev.com.
|
|
55
|
+
executor_config: Configuration for the agent executor.
|
|
56
|
+
model: Model override for agents.
|
|
57
|
+
max_turns: Maximum turns per phase.
|
|
58
|
+
dry_run: If True, print commands without executing.
|
|
59
|
+
project_id: Project ID for session tracking.
|
|
60
|
+
agent_type: Agent type for session tracking.
|
|
61
|
+
"""
|
|
62
|
+
self.working_directory = Path(working_directory)
|
|
63
|
+
self._api_key = api_key
|
|
64
|
+
self._executor_config = executor_config or ExecutorConfig()
|
|
65
|
+
self.model = model
|
|
66
|
+
self.max_turns = max_turns
|
|
67
|
+
self.dry_run = dry_run
|
|
68
|
+
self.project_id = project_id
|
|
69
|
+
self.agent_type = agent_type
|
|
70
|
+
|
|
71
|
+
self._memory_manager = WorkflowMemoryManager(working_directory)
|
|
72
|
+
self._workflows_client: WorkflowsClient | None = None
|
|
73
|
+
self._runs_client: WorkflowRunsClient | None = None
|
|
74
|
+
self._sessions_client: SessionsClient | None = None
|
|
75
|
+
self._events_sent = 0
|
|
76
|
+
|
|
77
|
+
async def _get_workflows_client(self) -> WorkflowsClient:
|
|
78
|
+
"""Get or create workflows client."""
|
|
79
|
+
if self._workflows_client is None:
|
|
80
|
+
self._workflows_client = WorkflowsClient(api_key=self._api_key)
|
|
81
|
+
return self._workflows_client
|
|
82
|
+
|
|
83
|
+
async def _get_runs_client(self) -> WorkflowRunsClient:
|
|
84
|
+
"""Get or create workflow runs client."""
|
|
85
|
+
if self._runs_client is None:
|
|
86
|
+
self._runs_client = WorkflowRunsClient(api_key=self._api_key)
|
|
87
|
+
return self._runs_client
|
|
88
|
+
|
|
89
|
+
async def _get_sessions_client(self) -> SessionsClient:
|
|
90
|
+
"""Get or create sessions client."""
|
|
91
|
+
if self._sessions_client is None:
|
|
92
|
+
self._sessions_client = SessionsClient(api_key=self._api_key)
|
|
93
|
+
return self._sessions_client
|
|
94
|
+
|
|
95
|
+
async def close(self) -> None:
|
|
96
|
+
"""Close all clients."""
|
|
97
|
+
if self._workflows_client:
|
|
98
|
+
await self._workflows_client.close()
|
|
99
|
+
self._workflows_client = None
|
|
100
|
+
if self._runs_client:
|
|
101
|
+
await self._runs_client.close()
|
|
102
|
+
self._runs_client = None
|
|
103
|
+
if self._sessions_client:
|
|
104
|
+
await self._sessions_client.close()
|
|
105
|
+
self._sessions_client = None
|
|
106
|
+
|
|
107
|
+
def _build_phase_prompt(
|
|
108
|
+
self,
|
|
109
|
+
phase: WorkflowPhaseResponse,
|
|
110
|
+
task_context: dict[str, Any],
|
|
111
|
+
accumulated_context: dict[str, Any],
|
|
112
|
+
) -> str:
|
|
113
|
+
"""Build the prompt for a phase.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
phase: Phase definition with prompt template.
|
|
117
|
+
task_context: Context from the task being executed.
|
|
118
|
+
accumulated_context: Merged context from previous phases.
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
Rendered prompt string.
|
|
122
|
+
"""
|
|
123
|
+
# Combine all available context for template substitution
|
|
124
|
+
template_vars = {
|
|
125
|
+
**task_context,
|
|
126
|
+
**accumulated_context,
|
|
127
|
+
"phase_name": phase.name,
|
|
128
|
+
"phase_type": phase.phase_type,
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
# Use phase prompt template if available
|
|
132
|
+
if phase.prompt_template:
|
|
133
|
+
try:
|
|
134
|
+
template = Template(phase.prompt_template)
|
|
135
|
+
return template.safe_substitute(template_vars)
|
|
136
|
+
except (KeyError, ValueError) as e:
|
|
137
|
+
logger.warning(f"Template substitution failed: {e}, using raw template")
|
|
138
|
+
return phase.prompt_template
|
|
139
|
+
|
|
140
|
+
# Default prompt if no template
|
|
141
|
+
return f"""Execute the '{phase.name}' phase for this task.
|
|
142
|
+
|
|
143
|
+
Task: {task_context.get("task_title", "Unknown")}
|
|
144
|
+
|
|
145
|
+
Task Description:
|
|
146
|
+
{task_context.get("task_prompt", "No description provided")}
|
|
147
|
+
|
|
148
|
+
Previous Phase Context:
|
|
149
|
+
{accumulated_context if accumulated_context else "No previous context"}
|
|
150
|
+
|
|
151
|
+
Instructions:
|
|
152
|
+
Complete this phase according to its type: {phase.phase_type}
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
async def _stream_event_to_api(self, events_client: EventsClient, event: StreamEvent) -> None:
|
|
156
|
+
"""Stream an event to the API.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
events_client: The events client to use.
|
|
160
|
+
event: The event to stream.
|
|
161
|
+
"""
|
|
162
|
+
await events_client.add_event(
|
|
163
|
+
event_type=event.event_type.value,
|
|
164
|
+
data=event.data,
|
|
165
|
+
raw_json=event.raw_json,
|
|
166
|
+
timestamp=event.timestamp,
|
|
167
|
+
)
|
|
168
|
+
self._events_sent += 1
|
|
169
|
+
|
|
170
|
+
async def execute_phase(
|
|
171
|
+
self,
|
|
172
|
+
workflow_run: WorkflowRunResponse,
|
|
173
|
+
phase: WorkflowPhaseResponse,
|
|
174
|
+
task_context: dict[str, Any],
|
|
175
|
+
run_id: str,
|
|
176
|
+
) -> dict[str, Any]:
|
|
177
|
+
"""Execute a single phase.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
workflow_run: The workflow run being executed.
|
|
181
|
+
phase: Phase to execute.
|
|
182
|
+
task_context: Task context data.
|
|
183
|
+
run_id: Agent run ID for tracking.
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
Phase execution result with output_context.
|
|
187
|
+
"""
|
|
188
|
+
console.print(f"\n[bold cyan]Phase: {phase.name}[/bold cyan]")
|
|
189
|
+
console.print(f"[dim]Type: {phase.phase_type}[/dim]")
|
|
190
|
+
|
|
191
|
+
# Build accumulated context from previous phases
|
|
192
|
+
accumulated_context = self._memory_manager.build_accumulated_context(
|
|
193
|
+
workflow_run.workflow_id, workflow_run.id
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
# Build phase prompt
|
|
197
|
+
prompt = self._build_phase_prompt(phase, task_context, accumulated_context)
|
|
198
|
+
|
|
199
|
+
if self.dry_run:
|
|
200
|
+
console.print(f"[dim]Would execute with prompt:[/dim]\n{prompt[:200]}...")
|
|
201
|
+
return {"success": True, "output_context": {}}
|
|
202
|
+
|
|
203
|
+
# Create session for this phase (for event tracking)
|
|
204
|
+
session_id: str | None = None
|
|
205
|
+
events_client: EventsClient | None = None
|
|
206
|
+
|
|
207
|
+
if self.project_id:
|
|
208
|
+
sessions_client = await self._get_sessions_client()
|
|
209
|
+
task_id = task_context.get("task_id")
|
|
210
|
+
|
|
211
|
+
request = SessionCreateRequest(
|
|
212
|
+
project_id=self.project_id,
|
|
213
|
+
task_id=task_id,
|
|
214
|
+
agent_type=self.agent_type,
|
|
215
|
+
prompt=prompt,
|
|
216
|
+
working_directory=str(self.working_directory),
|
|
217
|
+
metadata={
|
|
218
|
+
"workflow_id": workflow_run.workflow_id,
|
|
219
|
+
"workflow_run_id": workflow_run.id,
|
|
220
|
+
"phase_id": phase.id,
|
|
221
|
+
"phase_name": phase.name,
|
|
222
|
+
"phase_type": phase.phase_type,
|
|
223
|
+
},
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
session = await sessions_client.create_session(request)
|
|
227
|
+
if session:
|
|
228
|
+
session_id = session.id
|
|
229
|
+
logger.info(f"Phase session created: {session_id}")
|
|
230
|
+
|
|
231
|
+
# Initialize events client
|
|
232
|
+
events_client = EventsClient(
|
|
233
|
+
session_id=session_id,
|
|
234
|
+
api_key=self._api_key,
|
|
235
|
+
)
|
|
236
|
+
await events_client.start()
|
|
237
|
+
|
|
238
|
+
# Mark session as running
|
|
239
|
+
await sessions_client.mark_running(session_id)
|
|
240
|
+
else:
|
|
241
|
+
logger.warning("Failed to create phase session, events will not be streamed")
|
|
242
|
+
|
|
243
|
+
# Create and run executor
|
|
244
|
+
executor = ExecutorFactory.create(
|
|
245
|
+
config=self._executor_config,
|
|
246
|
+
working_directory=str(self.working_directory),
|
|
247
|
+
model=self.model,
|
|
248
|
+
max_turns=self.max_turns,
|
|
249
|
+
dry_run=self.dry_run,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
try:
|
|
253
|
+
await executor.start(prompt)
|
|
254
|
+
console.print("[dim]Agent started, streaming output...[/dim]")
|
|
255
|
+
|
|
256
|
+
# Stream and collect events
|
|
257
|
+
result_summary = ""
|
|
258
|
+
async for event in executor.stream_events():
|
|
259
|
+
# Stream event to API if we have an events client
|
|
260
|
+
if events_client:
|
|
261
|
+
await self._stream_event_to_api(events_client, event)
|
|
262
|
+
|
|
263
|
+
if event.event_type == EventType.ASSISTANT:
|
|
264
|
+
message = event.data.get("message", {})
|
|
265
|
+
# Handle both dict and string message formats
|
|
266
|
+
if isinstance(message, dict):
|
|
267
|
+
content = message.get("content", "")
|
|
268
|
+
else:
|
|
269
|
+
content = str(message) if message else ""
|
|
270
|
+
if isinstance(content, str) and content:
|
|
271
|
+
# Capture last assistant message as summary
|
|
272
|
+
result_summary = content[:500]
|
|
273
|
+
preview = content[:100] + "..." if len(content) > 100 else content
|
|
274
|
+
console.print(f"[cyan]Assistant:[/cyan] {preview}")
|
|
275
|
+
|
|
276
|
+
if event.event_type == EventType.RESULT:
|
|
277
|
+
result_data = event.data.get("result", {})
|
|
278
|
+
# Handle both dict and string result formats
|
|
279
|
+
if isinstance(result_data, dict):
|
|
280
|
+
result_summary = result_data.get("summary", result_summary)
|
|
281
|
+
elif isinstance(result_data, str) and result_data:
|
|
282
|
+
result_summary = result_data[:500]
|
|
283
|
+
|
|
284
|
+
# Wait for completion
|
|
285
|
+
exit_code = await executor.wait()
|
|
286
|
+
|
|
287
|
+
# Get agent session ID for resume capability
|
|
288
|
+
agent_session_id = executor.session_id
|
|
289
|
+
|
|
290
|
+
if exit_code != 0:
|
|
291
|
+
stderr = await executor.get_stderr()
|
|
292
|
+
error_msg = stderr.strip() if stderr else f"Process exited with code {exit_code}"
|
|
293
|
+
|
|
294
|
+
# Mark session as failed
|
|
295
|
+
if session_id and self.project_id:
|
|
296
|
+
sessions_client = await self._get_sessions_client()
|
|
297
|
+
await sessions_client.mark_failed(
|
|
298
|
+
session_id,
|
|
299
|
+
metadata={"error": error_msg, "exit_code": exit_code},
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
return {
|
|
303
|
+
"success": False,
|
|
304
|
+
"error": error_msg,
|
|
305
|
+
"exit_code": exit_code,
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
# Mark session as completed
|
|
309
|
+
if session_id and self.project_id:
|
|
310
|
+
sessions_client = await self._get_sessions_client()
|
|
311
|
+
await sessions_client.mark_completed(
|
|
312
|
+
session_id,
|
|
313
|
+
agent_session_id=agent_session_id,
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
# Build output context from phase execution
|
|
317
|
+
output_context = {
|
|
318
|
+
f"{phase.phase_type}_completed": True,
|
|
319
|
+
f"{phase.phase_type}_summary": result_summary,
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
# Save phase memory
|
|
323
|
+
memory = PhaseMemory(
|
|
324
|
+
phase_id=phase.id,
|
|
325
|
+
phase_name=phase.name,
|
|
326
|
+
phase_type=phase.phase_type,
|
|
327
|
+
input_context=accumulated_context,
|
|
328
|
+
output_context=output_context,
|
|
329
|
+
result_summary=result_summary,
|
|
330
|
+
)
|
|
331
|
+
self._memory_manager.save_phase_memory(
|
|
332
|
+
workflow_run.workflow_id, workflow_run.id, memory
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
return {
|
|
336
|
+
"success": True,
|
|
337
|
+
"output_context": output_context,
|
|
338
|
+
"result_summary": result_summary,
|
|
339
|
+
"session_id": session_id,
|
|
340
|
+
"events_sent": self._events_sent,
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
except Exception as e:
|
|
344
|
+
logger.error(f"Phase execution failed: {e}")
|
|
345
|
+
|
|
346
|
+
# Mark session as failed
|
|
347
|
+
if session_id and self.project_id:
|
|
348
|
+
sessions_client = await self._get_sessions_client()
|
|
349
|
+
await sessions_client.mark_failed(
|
|
350
|
+
session_id,
|
|
351
|
+
metadata={"error": str(e)},
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
return {"success": False, "error": str(e)}
|
|
355
|
+
|
|
356
|
+
finally:
|
|
357
|
+
# Close events client (flushes remaining events)
|
|
358
|
+
if events_client:
|
|
359
|
+
await events_client.close()
|
|
360
|
+
logger.info(
|
|
361
|
+
f"Events flushed for phase {phase.name}, total sent: {self._events_sent}"
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
if executor.is_running:
|
|
365
|
+
await executor.stop()
|
|
366
|
+
|
|
367
|
+
async def execute_workflow(
|
|
368
|
+
self,
|
|
369
|
+
workflow_id: str,
|
|
370
|
+
task_context: dict[str, Any],
|
|
371
|
+
run_id: str,
|
|
372
|
+
) -> dict[str, Any]:
|
|
373
|
+
"""Execute a complete workflow.
|
|
374
|
+
|
|
375
|
+
Args:
|
|
376
|
+
workflow_id: Workflow ID to execute.
|
|
377
|
+
task_context: Context from the task being executed.
|
|
378
|
+
run_id: Agent run ID for tracking.
|
|
379
|
+
|
|
380
|
+
Returns:
|
|
381
|
+
Workflow execution result.
|
|
382
|
+
"""
|
|
383
|
+
console.print("\n[bold blue]Starting Workflow[/bold blue]")
|
|
384
|
+
console.print(f"[dim]Workflow ID: {workflow_id}[/dim]")
|
|
385
|
+
|
|
386
|
+
try:
|
|
387
|
+
# Fetch workflow definition
|
|
388
|
+
workflows_client = await self._get_workflows_client()
|
|
389
|
+
workflow = await workflows_client.get_workflow(workflow_id)
|
|
390
|
+
|
|
391
|
+
if not workflow:
|
|
392
|
+
raise WorkflowExecutorError(f"Workflow not found: {workflow_id}")
|
|
393
|
+
|
|
394
|
+
console.print(f"[bold]{workflow.name}[/bold]")
|
|
395
|
+
console.print(f"[dim]Phases: {len(workflow.phases)}[/dim]")
|
|
396
|
+
|
|
397
|
+
# Sort phases by order
|
|
398
|
+
phases = sorted(workflow.phases, key=lambda p: p.phase_order)
|
|
399
|
+
|
|
400
|
+
if not phases:
|
|
401
|
+
raise WorkflowExecutorError("Workflow has no phases defined")
|
|
402
|
+
|
|
403
|
+
# Start workflow run via API (skip in dry run)
|
|
404
|
+
runs_client = await self._get_runs_client()
|
|
405
|
+
workflow_run: WorkflowRunResponse | None = None
|
|
406
|
+
|
|
407
|
+
if not self.dry_run:
|
|
408
|
+
workflow_run = await runs_client.start_workflow(
|
|
409
|
+
workflow_id=workflow_id,
|
|
410
|
+
run_id=run_id,
|
|
411
|
+
initial_context=task_context,
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
if not workflow_run:
|
|
415
|
+
raise WorkflowExecutorError("Failed to start workflow run")
|
|
416
|
+
|
|
417
|
+
console.print(f"[dim]Workflow Run ID: {workflow_run.id}[/dim]")
|
|
418
|
+
else:
|
|
419
|
+
# Create a mock workflow run for dry run
|
|
420
|
+
from datetime import UTC, datetime
|
|
421
|
+
|
|
422
|
+
workflow_run = WorkflowRunResponse(
|
|
423
|
+
id="dry-run-workflow",
|
|
424
|
+
workflow_id=workflow_id,
|
|
425
|
+
workflow_name=workflow.name,
|
|
426
|
+
project_id="dry-run-project",
|
|
427
|
+
status="running",
|
|
428
|
+
total_phases=len(phases),
|
|
429
|
+
created_at=datetime.now(UTC).isoformat(),
|
|
430
|
+
updated_at=datetime.now(UTC).isoformat(),
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
# Execute each phase
|
|
434
|
+
phases_completed = 0
|
|
435
|
+
phases_failed = 0
|
|
436
|
+
|
|
437
|
+
for phase in phases:
|
|
438
|
+
result = await self.execute_phase(
|
|
439
|
+
workflow_run=workflow_run,
|
|
440
|
+
phase=phase,
|
|
441
|
+
task_context=task_context,
|
|
442
|
+
run_id=run_id,
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
if result.get("success"):
|
|
446
|
+
phases_completed += 1
|
|
447
|
+
console.print(f"[green]Phase '{phase.name}' completed[/green]")
|
|
448
|
+
|
|
449
|
+
# Advance phase via API (skip in dry run)
|
|
450
|
+
if not self.dry_run:
|
|
451
|
+
await runs_client.advance_phase(
|
|
452
|
+
workflow_run_id=workflow_run.id,
|
|
453
|
+
output_context=result.get("output_context"),
|
|
454
|
+
result_summary=result.get("result_summary"),
|
|
455
|
+
)
|
|
456
|
+
else:
|
|
457
|
+
phases_failed += 1
|
|
458
|
+
error_msg = result.get("error", "Unknown error")
|
|
459
|
+
console.print(f"[red]Phase '{phase.name}' failed: {error_msg}[/red]")
|
|
460
|
+
|
|
461
|
+
# Report failure via API (skip in dry run)
|
|
462
|
+
if not self.dry_run:
|
|
463
|
+
await runs_client.fail_phase(
|
|
464
|
+
workflow_run_id=workflow_run.id,
|
|
465
|
+
error_message=error_msg,
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
# Check if phase is required
|
|
469
|
+
if phase.is_required:
|
|
470
|
+
console.print("[red]Required phase failed, stopping workflow[/red]")
|
|
471
|
+
break
|
|
472
|
+
|
|
473
|
+
# Only cleanup memory on complete success to preserve failed runs for debugging
|
|
474
|
+
workflow_success = phases_failed == 0
|
|
475
|
+
|
|
476
|
+
if workflow_success:
|
|
477
|
+
self._memory_manager.cleanup_run(workflow_run.workflow_id, workflow_run.id)
|
|
478
|
+
else:
|
|
479
|
+
run_path = self._memory_manager._get_run_path(
|
|
480
|
+
workflow_run.workflow_id, workflow_run.id
|
|
481
|
+
)
|
|
482
|
+
logger.info(f"Preserving workflow memory for debugging: {run_path}")
|
|
483
|
+
|
|
484
|
+
return {
|
|
485
|
+
"success": workflow_success,
|
|
486
|
+
"workflow_id": workflow_id,
|
|
487
|
+
"workflow_run_id": workflow_run.id,
|
|
488
|
+
"phases_completed": phases_completed,
|
|
489
|
+
"phases_failed": phases_failed,
|
|
490
|
+
"total_phases": len(phases),
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
finally:
|
|
494
|
+
await self.close()
|