copex 0.8.4__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.
- copex/__init__.py +69 -0
- copex/checkpoint.py +445 -0
- copex/cli.py +1106 -0
- copex/client.py +725 -0
- copex/config.py +311 -0
- copex/mcp.py +561 -0
- copex/metrics.py +383 -0
- copex/models.py +50 -0
- copex/persistence.py +324 -0
- copex/plan.py +358 -0
- copex/ralph.py +247 -0
- copex/tools.py +404 -0
- copex/ui.py +971 -0
- copex-0.8.4.dist-info/METADATA +511 -0
- copex-0.8.4.dist-info/RECORD +18 -0
- copex-0.8.4.dist-info/WHEEL +4 -0
- copex-0.8.4.dist-info/entry_points.txt +2 -0
- copex-0.8.4.dist-info/licenses/LICENSE +21 -0
copex/persistence.py
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Session Persistence - Save and restore conversation history.
|
|
3
|
+
|
|
4
|
+
Allows saving sessions to disk and resuming later, useful for:
|
|
5
|
+
- Long-running tasks that span multiple sessions
|
|
6
|
+
- Crash recovery
|
|
7
|
+
- Sharing context between different runs
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from dataclasses import asdict, dataclass, field
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
import logging
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
from copex.models import Model, ReasoningEffort
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class Message:
|
|
26
|
+
"""A message in the conversation history."""
|
|
27
|
+
|
|
28
|
+
role: str # "user", "assistant", "system"
|
|
29
|
+
content: str
|
|
30
|
+
timestamp: str = field(default_factory=lambda: datetime.now().isoformat())
|
|
31
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class SessionData:
|
|
36
|
+
"""Persistent session data."""
|
|
37
|
+
|
|
38
|
+
id: str
|
|
39
|
+
created_at: str
|
|
40
|
+
updated_at: str
|
|
41
|
+
model: str
|
|
42
|
+
reasoning_effort: str
|
|
43
|
+
messages: list[Message]
|
|
44
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
45
|
+
|
|
46
|
+
def to_dict(self) -> dict[str, Any]:
|
|
47
|
+
"""Convert to dictionary for serialization."""
|
|
48
|
+
return {
|
|
49
|
+
"id": self.id,
|
|
50
|
+
"created_at": self.created_at,
|
|
51
|
+
"updated_at": self.updated_at,
|
|
52
|
+
"model": self.model,
|
|
53
|
+
"reasoning_effort": self.reasoning_effort,
|
|
54
|
+
"messages": [asdict(m) for m in self.messages],
|
|
55
|
+
"metadata": self.metadata,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
@classmethod
|
|
59
|
+
def from_dict(cls, data: dict[str, Any]) -> "SessionData":
|
|
60
|
+
"""Create from dictionary."""
|
|
61
|
+
messages = [Message(**m) for m in data.get("messages", [])]
|
|
62
|
+
return cls(
|
|
63
|
+
id=data["id"],
|
|
64
|
+
created_at=data["created_at"],
|
|
65
|
+
updated_at=data["updated_at"],
|
|
66
|
+
model=data["model"],
|
|
67
|
+
reasoning_effort=data["reasoning_effort"],
|
|
68
|
+
messages=messages,
|
|
69
|
+
metadata=data.get("metadata", {}),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class SessionStore:
|
|
74
|
+
"""
|
|
75
|
+
Persistent storage for Copex sessions.
|
|
76
|
+
|
|
77
|
+
Usage:
|
|
78
|
+
store = SessionStore()
|
|
79
|
+
|
|
80
|
+
# Save a session
|
|
81
|
+
store.save(session_id, messages, model, reasoning)
|
|
82
|
+
|
|
83
|
+
# Load a session
|
|
84
|
+
data = store.load(session_id)
|
|
85
|
+
|
|
86
|
+
# List all sessions
|
|
87
|
+
sessions = store.list_sessions()
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
def __init__(self, base_dir: Path | str | None = None):
|
|
91
|
+
"""
|
|
92
|
+
Initialize session store.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
base_dir: Directory for session files. Defaults to ~/.copex/sessions
|
|
96
|
+
"""
|
|
97
|
+
if base_dir is None:
|
|
98
|
+
base_dir = Path.home() / ".copex" / "sessions"
|
|
99
|
+
self.base_dir = Path(base_dir)
|
|
100
|
+
self.base_dir.mkdir(parents=True, exist_ok=True)
|
|
101
|
+
|
|
102
|
+
def _session_path(self, session_id: str) -> Path:
|
|
103
|
+
"""Get path for a session file."""
|
|
104
|
+
# Sanitize session ID for filesystem
|
|
105
|
+
safe_id = "".join(c if c.isalnum() or c in "-_" else "_" for c in session_id)
|
|
106
|
+
return self.base_dir / f"{safe_id}.json"
|
|
107
|
+
|
|
108
|
+
def save(
|
|
109
|
+
self,
|
|
110
|
+
session_id: str,
|
|
111
|
+
messages: list[Message],
|
|
112
|
+
model: Model | str,
|
|
113
|
+
reasoning_effort: ReasoningEffort | str,
|
|
114
|
+
metadata: dict[str, Any] | None = None,
|
|
115
|
+
) -> Path:
|
|
116
|
+
"""
|
|
117
|
+
Save a session to disk.
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
Path to the saved session file
|
|
121
|
+
"""
|
|
122
|
+
path = self._session_path(session_id)
|
|
123
|
+
now = datetime.now().isoformat()
|
|
124
|
+
|
|
125
|
+
# Check if session exists
|
|
126
|
+
if path.exists():
|
|
127
|
+
existing = self.load(session_id)
|
|
128
|
+
created_at = existing.created_at if existing else now
|
|
129
|
+
else:
|
|
130
|
+
created_at = now
|
|
131
|
+
|
|
132
|
+
data = SessionData(
|
|
133
|
+
id=session_id,
|
|
134
|
+
created_at=created_at,
|
|
135
|
+
updated_at=now,
|
|
136
|
+
model=model.value if isinstance(model, Model) else model,
|
|
137
|
+
reasoning_effort=(
|
|
138
|
+
reasoning_effort.value
|
|
139
|
+
if isinstance(reasoning_effort, ReasoningEffort)
|
|
140
|
+
else reasoning_effort
|
|
141
|
+
),
|
|
142
|
+
messages=messages,
|
|
143
|
+
metadata=metadata or {},
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
147
|
+
json.dump(data.to_dict(), f, indent=2, ensure_ascii=False)
|
|
148
|
+
|
|
149
|
+
return path
|
|
150
|
+
|
|
151
|
+
def load(self, session_id: str) -> SessionData | None:
|
|
152
|
+
"""Load a session from disk."""
|
|
153
|
+
path = self._session_path(session_id)
|
|
154
|
+
if not path.exists():
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
158
|
+
data = json.load(f)
|
|
159
|
+
|
|
160
|
+
return SessionData.from_dict(data)
|
|
161
|
+
|
|
162
|
+
def delete(self, session_id: str) -> bool:
|
|
163
|
+
"""Delete a session."""
|
|
164
|
+
path = self._session_path(session_id)
|
|
165
|
+
if path.exists():
|
|
166
|
+
path.unlink()
|
|
167
|
+
return True
|
|
168
|
+
return False
|
|
169
|
+
|
|
170
|
+
def list_sessions(self) -> list[dict[str, Any]]:
|
|
171
|
+
"""
|
|
172
|
+
List all saved sessions.
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
List of session summaries (id, created_at, updated_at, message_count)
|
|
176
|
+
"""
|
|
177
|
+
sessions = []
|
|
178
|
+
for path in self.base_dir.glob("*.json"):
|
|
179
|
+
try:
|
|
180
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
181
|
+
data = json.load(f)
|
|
182
|
+
sessions.append({
|
|
183
|
+
"id": data["id"],
|
|
184
|
+
"created_at": data["created_at"],
|
|
185
|
+
"updated_at": data["updated_at"],
|
|
186
|
+
"model": data["model"],
|
|
187
|
+
"message_count": len(data.get("messages", [])),
|
|
188
|
+
})
|
|
189
|
+
except (json.JSONDecodeError, KeyError):
|
|
190
|
+
logger.warning("Skipping invalid session file: %s", path, exc_info=True)
|
|
191
|
+
continue
|
|
192
|
+
|
|
193
|
+
# Sort by updated_at descending
|
|
194
|
+
sessions.sort(key=lambda x: x["updated_at"], reverse=True)
|
|
195
|
+
return sessions
|
|
196
|
+
|
|
197
|
+
def export(self, session_id: str, format: str = "json") -> str:
|
|
198
|
+
"""
|
|
199
|
+
Export a session to a string.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
session_id: Session to export
|
|
203
|
+
format: Export format ("json" or "markdown")
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
Exported session as string
|
|
207
|
+
"""
|
|
208
|
+
data = self.load(session_id)
|
|
209
|
+
if not data:
|
|
210
|
+
raise ValueError(f"Session not found: {session_id}")
|
|
211
|
+
|
|
212
|
+
if format == "json":
|
|
213
|
+
return json.dumps(data.to_dict(), indent=2, ensure_ascii=False)
|
|
214
|
+
|
|
215
|
+
elif format == "markdown":
|
|
216
|
+
lines = [
|
|
217
|
+
f"# Session: {data.id}",
|
|
218
|
+
"",
|
|
219
|
+
f"- **Model**: {data.model}",
|
|
220
|
+
f"- **Reasoning**: {data.reasoning_effort}",
|
|
221
|
+
f"- **Created**: {data.created_at}",
|
|
222
|
+
f"- **Updated**: {data.updated_at}",
|
|
223
|
+
"",
|
|
224
|
+
"---",
|
|
225
|
+
"",
|
|
226
|
+
]
|
|
227
|
+
|
|
228
|
+
for msg in data.messages:
|
|
229
|
+
role_label = {"user": "👤 User", "assistant": "🤖 Assistant", "system": "⚙️ System"}.get(
|
|
230
|
+
msg.role, msg.role
|
|
231
|
+
)
|
|
232
|
+
lines.append(f"### {role_label}")
|
|
233
|
+
lines.append(f"*{msg.timestamp}*")
|
|
234
|
+
lines.append("")
|
|
235
|
+
lines.append(msg.content)
|
|
236
|
+
lines.append("")
|
|
237
|
+
lines.append("---")
|
|
238
|
+
lines.append("")
|
|
239
|
+
|
|
240
|
+
return "\n".join(lines)
|
|
241
|
+
|
|
242
|
+
else:
|
|
243
|
+
raise ValueError(f"Unknown format: {format}")
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
class PersistentSession:
|
|
247
|
+
"""
|
|
248
|
+
A session wrapper that auto-saves to disk.
|
|
249
|
+
|
|
250
|
+
Usage:
|
|
251
|
+
session = PersistentSession("my-project", store)
|
|
252
|
+
session.add_user_message("Hello")
|
|
253
|
+
session.add_assistant_message("Hi there!")
|
|
254
|
+
# Automatically saved after each message
|
|
255
|
+
"""
|
|
256
|
+
|
|
257
|
+
def __init__(
|
|
258
|
+
self,
|
|
259
|
+
session_id: str,
|
|
260
|
+
store: SessionStore,
|
|
261
|
+
model: Model | str = Model.CLAUDE_OPUS_4_5,
|
|
262
|
+
reasoning_effort: ReasoningEffort | str = ReasoningEffort.XHIGH,
|
|
263
|
+
auto_save: bool = True,
|
|
264
|
+
):
|
|
265
|
+
self.session_id = session_id
|
|
266
|
+
self.store = store
|
|
267
|
+
self.model = model
|
|
268
|
+
self.reasoning_effort = reasoning_effort
|
|
269
|
+
self.auto_save = auto_save
|
|
270
|
+
self.messages: list[Message] = []
|
|
271
|
+
self.metadata: dict[str, Any] = {}
|
|
272
|
+
|
|
273
|
+
# Load existing session if it exists
|
|
274
|
+
existing = store.load(session_id)
|
|
275
|
+
if existing:
|
|
276
|
+
self.messages = existing.messages
|
|
277
|
+
self.metadata = existing.metadata
|
|
278
|
+
self.model = existing.model
|
|
279
|
+
self.reasoning_effort = existing.reasoning_effort
|
|
280
|
+
|
|
281
|
+
def add_message(self, role: str, content: str, metadata: dict[str, Any] | None = None) -> None:
|
|
282
|
+
"""Add a message to the session."""
|
|
283
|
+
msg = Message(role=role, content=content, metadata=metadata or {})
|
|
284
|
+
self.messages.append(msg)
|
|
285
|
+
if self.auto_save:
|
|
286
|
+
self.save()
|
|
287
|
+
|
|
288
|
+
def add_user_message(self, content: str) -> None:
|
|
289
|
+
"""Add a user message."""
|
|
290
|
+
self.add_message("user", content)
|
|
291
|
+
|
|
292
|
+
def add_assistant_message(self, content: str, reasoning: str | None = None) -> None:
|
|
293
|
+
"""Add an assistant message."""
|
|
294
|
+
metadata = {"reasoning": reasoning} if reasoning else {}
|
|
295
|
+
self.add_message("assistant", content, metadata)
|
|
296
|
+
|
|
297
|
+
def save(self) -> Path:
|
|
298
|
+
"""Save the session to disk."""
|
|
299
|
+
return self.store.save(
|
|
300
|
+
self.session_id,
|
|
301
|
+
self.messages,
|
|
302
|
+
self.model,
|
|
303
|
+
self.reasoning_effort,
|
|
304
|
+
self.metadata,
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
def clear(self) -> None:
|
|
308
|
+
"""Clear all messages (keeps session file)."""
|
|
309
|
+
self.messages = []
|
|
310
|
+
if self.auto_save:
|
|
311
|
+
self.save()
|
|
312
|
+
|
|
313
|
+
def get_context(self, max_messages: int | None = None) -> list[dict[str, str]]:
|
|
314
|
+
"""
|
|
315
|
+
Get messages formatted for API context.
|
|
316
|
+
|
|
317
|
+
Args:
|
|
318
|
+
max_messages: Limit to last N messages (None = all)
|
|
319
|
+
|
|
320
|
+
Returns:
|
|
321
|
+
List of {"role": ..., "content": ...} dicts
|
|
322
|
+
"""
|
|
323
|
+
messages = self.messages[-max_messages:] if max_messages else self.messages
|
|
324
|
+
return [{"role": m.role, "content": m.content} for m in messages]
|
copex/plan.py
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Plan Mode - Step-by-step task planning and execution for Copex.
|
|
3
|
+
|
|
4
|
+
Provides structured planning capabilities:
|
|
5
|
+
- Generate step-by-step plans from task descriptions
|
|
6
|
+
- Execute plans step by step with progress tracking
|
|
7
|
+
- Interactive review before execution
|
|
8
|
+
- Resume execution from specific steps
|
|
9
|
+
- Save/load plans to files
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import re
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from datetime import datetime
|
|
18
|
+
from enum import Enum
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any, Callable
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class StepStatus(Enum):
|
|
24
|
+
"""Status of a plan step."""
|
|
25
|
+
|
|
26
|
+
PENDING = "pending"
|
|
27
|
+
RUNNING = "running"
|
|
28
|
+
COMPLETED = "completed"
|
|
29
|
+
FAILED = "failed"
|
|
30
|
+
SKIPPED = "skipped"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class PlanStep:
|
|
35
|
+
"""A single step in a plan."""
|
|
36
|
+
|
|
37
|
+
number: int
|
|
38
|
+
description: str
|
|
39
|
+
status: StepStatus = StepStatus.PENDING
|
|
40
|
+
result: str | None = None
|
|
41
|
+
error: str | None = None
|
|
42
|
+
started_at: datetime | None = None
|
|
43
|
+
completed_at: datetime | None = None
|
|
44
|
+
|
|
45
|
+
def to_dict(self) -> dict[str, Any]:
|
|
46
|
+
"""Convert step to dictionary for serialization."""
|
|
47
|
+
return {
|
|
48
|
+
"number": self.number,
|
|
49
|
+
"description": self.description,
|
|
50
|
+
"status": self.status.value,
|
|
51
|
+
"result": self.result,
|
|
52
|
+
"error": self.error,
|
|
53
|
+
"started_at": self.started_at.isoformat() if self.started_at else None,
|
|
54
|
+
"completed_at": self.completed_at.isoformat() if self.completed_at else None,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def from_dict(cls, data: dict[str, Any]) -> PlanStep:
|
|
59
|
+
"""Create step from dictionary."""
|
|
60
|
+
return cls(
|
|
61
|
+
number=data["number"],
|
|
62
|
+
description=data["description"],
|
|
63
|
+
status=StepStatus(data.get("status", "pending")),
|
|
64
|
+
result=data.get("result"),
|
|
65
|
+
error=data.get("error"),
|
|
66
|
+
started_at=datetime.fromisoformat(data["started_at"]) if data.get("started_at") else None,
|
|
67
|
+
completed_at=datetime.fromisoformat(data["completed_at"]) if data.get("completed_at") else None,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class Plan:
|
|
73
|
+
"""A complete execution plan."""
|
|
74
|
+
|
|
75
|
+
task: str
|
|
76
|
+
steps: list[PlanStep] = field(default_factory=list)
|
|
77
|
+
created_at: datetime = field(default_factory=datetime.now)
|
|
78
|
+
completed_at: datetime | None = None
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def is_complete(self) -> bool:
|
|
82
|
+
"""Check if all steps are completed."""
|
|
83
|
+
return all(
|
|
84
|
+
step.status in (StepStatus.COMPLETED, StepStatus.SKIPPED)
|
|
85
|
+
for step in self.steps
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def current_step(self) -> PlanStep | None:
|
|
90
|
+
"""Get the next pending step."""
|
|
91
|
+
for step in self.steps:
|
|
92
|
+
if step.status == StepStatus.PENDING:
|
|
93
|
+
return step
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def completed_count(self) -> int:
|
|
98
|
+
"""Count of completed steps."""
|
|
99
|
+
return sum(
|
|
100
|
+
1 for step in self.steps
|
|
101
|
+
if step.status in (StepStatus.COMPLETED, StepStatus.SKIPPED)
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def failed_count(self) -> int:
|
|
106
|
+
"""Count of failed steps."""
|
|
107
|
+
return sum(1 for step in self.steps if step.status == StepStatus.FAILED)
|
|
108
|
+
|
|
109
|
+
def to_dict(self) -> dict[str, Any]:
|
|
110
|
+
"""Convert plan to dictionary for serialization."""
|
|
111
|
+
return {
|
|
112
|
+
"task": self.task,
|
|
113
|
+
"steps": [step.to_dict() for step in self.steps],
|
|
114
|
+
"created_at": self.created_at.isoformat(),
|
|
115
|
+
"completed_at": self.completed_at.isoformat() if self.completed_at else None,
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
@classmethod
|
|
119
|
+
def from_dict(cls, data: dict[str, Any]) -> Plan:
|
|
120
|
+
"""Create plan from dictionary."""
|
|
121
|
+
return cls(
|
|
122
|
+
task=data["task"],
|
|
123
|
+
steps=[PlanStep.from_dict(s) for s in data.get("steps", [])],
|
|
124
|
+
created_at=datetime.fromisoformat(data["created_at"]) if data.get("created_at") else datetime.now(),
|
|
125
|
+
completed_at=datetime.fromisoformat(data["completed_at"]) if data.get("completed_at") else None,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
def to_json(self) -> str:
|
|
129
|
+
"""Serialize plan to JSON string."""
|
|
130
|
+
return json.dumps(self.to_dict(), indent=2)
|
|
131
|
+
|
|
132
|
+
@classmethod
|
|
133
|
+
def from_json(cls, json_str: str) -> Plan:
|
|
134
|
+
"""Create plan from JSON string."""
|
|
135
|
+
return cls.from_dict(json.loads(json_str))
|
|
136
|
+
|
|
137
|
+
def save(self, path: Path) -> None:
|
|
138
|
+
"""Save plan to a file."""
|
|
139
|
+
path.write_text(self.to_json())
|
|
140
|
+
|
|
141
|
+
@classmethod
|
|
142
|
+
def load(cls, path: Path) -> Plan:
|
|
143
|
+
"""Load plan from a file."""
|
|
144
|
+
return cls.from_json(path.read_text())
|
|
145
|
+
|
|
146
|
+
def to_markdown(self) -> str:
|
|
147
|
+
"""Format plan as markdown."""
|
|
148
|
+
lines = [f"# Plan: {self.task}", ""]
|
|
149
|
+
for step in self.steps:
|
|
150
|
+
status_icon = {
|
|
151
|
+
StepStatus.PENDING: "⬜",
|
|
152
|
+
StepStatus.RUNNING: "🔄",
|
|
153
|
+
StepStatus.COMPLETED: "✅",
|
|
154
|
+
StepStatus.FAILED: "❌",
|
|
155
|
+
StepStatus.SKIPPED: "⏭️",
|
|
156
|
+
}.get(step.status, "⬜")
|
|
157
|
+
lines.append(f"{status_icon} **Step {step.number}:** {step.description}")
|
|
158
|
+
if step.result:
|
|
159
|
+
lines.append(f" - Result: {step.result[:100]}...")
|
|
160
|
+
if step.error:
|
|
161
|
+
lines.append(f" - Error: {step.error}")
|
|
162
|
+
return "\n".join(lines)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
PLAN_GENERATION_PROMPT = """You are a planning assistant. Generate a step-by-step plan for the following task.
|
|
166
|
+
|
|
167
|
+
TASK: {task}
|
|
168
|
+
|
|
169
|
+
Generate a numbered list of concrete, actionable steps. Each step should be:
|
|
170
|
+
1. Specific and executable
|
|
171
|
+
2. Self-contained (can be done independently or builds on previous steps)
|
|
172
|
+
3. Verifiable (you can check if it's done)
|
|
173
|
+
|
|
174
|
+
Format your response EXACTLY as:
|
|
175
|
+
STEP 1: [description]
|
|
176
|
+
STEP 2: [description]
|
|
177
|
+
...
|
|
178
|
+
|
|
179
|
+
Include 3-10 steps. Be concise but thorough."""
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
STEP_EXECUTION_PROMPT = """You are executing step {step_number} of a plan.
|
|
183
|
+
|
|
184
|
+
OVERALL TASK: {task}
|
|
185
|
+
|
|
186
|
+
COMPLETED STEPS:
|
|
187
|
+
{completed_steps}
|
|
188
|
+
|
|
189
|
+
CURRENT STEP: {current_step}
|
|
190
|
+
|
|
191
|
+
Execute this step now. When done, summarize what you accomplished."""
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class PlanExecutor:
|
|
195
|
+
"""Executes plans step by step using a Copex client."""
|
|
196
|
+
|
|
197
|
+
def __init__(self, client: Any):
|
|
198
|
+
"""Initialize executor with a Copex client."""
|
|
199
|
+
self.client = client
|
|
200
|
+
self._cancelled = False
|
|
201
|
+
|
|
202
|
+
def cancel(self) -> None:
|
|
203
|
+
"""Cancel ongoing execution."""
|
|
204
|
+
self._cancelled = True
|
|
205
|
+
|
|
206
|
+
async def generate_plan(
|
|
207
|
+
self,
|
|
208
|
+
task: str,
|
|
209
|
+
*,
|
|
210
|
+
on_plan_generated: Callable[[Plan], None] | None = None,
|
|
211
|
+
) -> Plan:
|
|
212
|
+
"""Generate a plan for a task."""
|
|
213
|
+
prompt = PLAN_GENERATION_PROMPT.format(task=task)
|
|
214
|
+
response = await self.client.send(prompt)
|
|
215
|
+
|
|
216
|
+
steps = self._parse_steps(response.content)
|
|
217
|
+
plan = Plan(task=task, steps=steps)
|
|
218
|
+
|
|
219
|
+
if on_plan_generated:
|
|
220
|
+
on_plan_generated(plan)
|
|
221
|
+
|
|
222
|
+
return plan
|
|
223
|
+
|
|
224
|
+
def _parse_steps(self, content: str) -> list[PlanStep]:
|
|
225
|
+
"""Parse steps from AI response."""
|
|
226
|
+
steps = []
|
|
227
|
+
|
|
228
|
+
# Try line-by-line parsing first (most reliable)
|
|
229
|
+
lines = content.strip().split("\n")
|
|
230
|
+
for line in lines:
|
|
231
|
+
line = line.strip()
|
|
232
|
+
if not line:
|
|
233
|
+
continue
|
|
234
|
+
|
|
235
|
+
# Match "STEP N: description" or "N. description" or "N: description"
|
|
236
|
+
match = re.match(
|
|
237
|
+
r"^(?:STEP\s*)?(\d+)[.:\)]\s*(.+)$",
|
|
238
|
+
line,
|
|
239
|
+
re.IGNORECASE,
|
|
240
|
+
)
|
|
241
|
+
if match:
|
|
242
|
+
desc = match.group(2).strip()
|
|
243
|
+
if desc:
|
|
244
|
+
steps.append(PlanStep(number=len(steps) + 1, description=desc))
|
|
245
|
+
|
|
246
|
+
# Fallback: if line parsing failed, try multi-line pattern
|
|
247
|
+
if not steps:
|
|
248
|
+
pattern = r"(?:STEP\s*)?(\d+)[.:]\s*(.+?)(?=(?:\n\s*)?(?:STEP\s*)?\d+[.:]|\Z)"
|
|
249
|
+
matches = re.findall(pattern, content, re.IGNORECASE | re.DOTALL)
|
|
250
|
+
for i, (num, desc) in enumerate(matches, 1):
|
|
251
|
+
description = " ".join(desc.strip().split())
|
|
252
|
+
if description:
|
|
253
|
+
steps.append(PlanStep(number=i, description=description))
|
|
254
|
+
|
|
255
|
+
# Final fallback: split by lines and clean prefixes
|
|
256
|
+
if not steps:
|
|
257
|
+
for i, line in enumerate(lines, 1):
|
|
258
|
+
clean = re.sub(r"^[\d]+[.:)]\s*", "", line.strip())
|
|
259
|
+
clean = re.sub(r"^[-*]\s*", "", clean)
|
|
260
|
+
if clean:
|
|
261
|
+
steps.append(PlanStep(number=i, description=clean))
|
|
262
|
+
|
|
263
|
+
return steps
|
|
264
|
+
|
|
265
|
+
async def execute_plan(
|
|
266
|
+
self,
|
|
267
|
+
plan: Plan,
|
|
268
|
+
*,
|
|
269
|
+
from_step: int = 1,
|
|
270
|
+
on_step_start: Callable[[PlanStep], None] | None = None,
|
|
271
|
+
on_step_complete: Callable[[PlanStep], None] | None = None,
|
|
272
|
+
on_error: Callable[[PlanStep, Exception], bool] | None = None,
|
|
273
|
+
) -> Plan:
|
|
274
|
+
"""
|
|
275
|
+
Execute a plan step by step.
|
|
276
|
+
|
|
277
|
+
Args:
|
|
278
|
+
plan: The plan to execute
|
|
279
|
+
from_step: Start execution from this step number
|
|
280
|
+
on_step_start: Called when a step starts
|
|
281
|
+
on_step_complete: Called when a step completes
|
|
282
|
+
on_error: Called on error, return True to continue, False to stop
|
|
283
|
+
|
|
284
|
+
Returns:
|
|
285
|
+
The updated plan with execution results
|
|
286
|
+
"""
|
|
287
|
+
self._cancelled = False
|
|
288
|
+
|
|
289
|
+
for step in plan.steps:
|
|
290
|
+
if self._cancelled:
|
|
291
|
+
break
|
|
292
|
+
|
|
293
|
+
if step.number < from_step:
|
|
294
|
+
if step.status == StepStatus.PENDING:
|
|
295
|
+
step.status = StepStatus.SKIPPED
|
|
296
|
+
continue
|
|
297
|
+
|
|
298
|
+
if step.status in (StepStatus.COMPLETED, StepStatus.SKIPPED):
|
|
299
|
+
continue
|
|
300
|
+
|
|
301
|
+
step.status = StepStatus.RUNNING
|
|
302
|
+
step.started_at = datetime.now()
|
|
303
|
+
|
|
304
|
+
if on_step_start:
|
|
305
|
+
on_step_start(step)
|
|
306
|
+
|
|
307
|
+
try:
|
|
308
|
+
# Build context from completed steps
|
|
309
|
+
completed_steps = "\n".join(
|
|
310
|
+
f"Step {s.number}: {s.description} - {s.result or 'Done'}"
|
|
311
|
+
for s in plan.steps
|
|
312
|
+
if s.status == StepStatus.COMPLETED and s.number < step.number
|
|
313
|
+
) or "(none)"
|
|
314
|
+
|
|
315
|
+
prompt = STEP_EXECUTION_PROMPT.format(
|
|
316
|
+
step_number=step.number,
|
|
317
|
+
task=plan.task,
|
|
318
|
+
completed_steps=completed_steps,
|
|
319
|
+
current_step=step.description,
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
response = await self.client.send(prompt)
|
|
323
|
+
step.result = response.content
|
|
324
|
+
step.status = StepStatus.COMPLETED
|
|
325
|
+
step.completed_at = datetime.now()
|
|
326
|
+
|
|
327
|
+
if on_step_complete:
|
|
328
|
+
on_step_complete(step)
|
|
329
|
+
|
|
330
|
+
except Exception as e:
|
|
331
|
+
step.status = StepStatus.FAILED
|
|
332
|
+
step.error = str(e)
|
|
333
|
+
step.completed_at = datetime.now()
|
|
334
|
+
|
|
335
|
+
if on_error:
|
|
336
|
+
should_continue = on_error(step, e)
|
|
337
|
+
if not should_continue:
|
|
338
|
+
break
|
|
339
|
+
else:
|
|
340
|
+
break
|
|
341
|
+
|
|
342
|
+
if plan.is_complete:
|
|
343
|
+
plan.completed_at = datetime.now()
|
|
344
|
+
|
|
345
|
+
return plan
|
|
346
|
+
|
|
347
|
+
async def execute_step(
|
|
348
|
+
self,
|
|
349
|
+
plan: Plan,
|
|
350
|
+
step_number: int,
|
|
351
|
+
) -> PlanStep:
|
|
352
|
+
"""Execute a single step from a plan."""
|
|
353
|
+
step = next((s for s in plan.steps if s.number == step_number), None)
|
|
354
|
+
if not step:
|
|
355
|
+
raise ValueError(f"Step {step_number} not found in plan")
|
|
356
|
+
|
|
357
|
+
await self.execute_plan(plan, from_step=step_number)
|
|
358
|
+
return step
|