kweaver-dolphin 0.1.0__py3-none-any.whl → 0.2.1__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.
- dolphin/cli/runner/runner.py +20 -0
- dolphin/cli/ui/console.py +35 -17
- dolphin/cli/utils/helpers.py +4 -4
- dolphin/core/agent/base_agent.py +70 -7
- dolphin/core/code_block/basic_code_block.py +162 -26
- dolphin/core/code_block/explore_block.py +438 -35
- dolphin/core/code_block/explore_block_v2.py +105 -16
- dolphin/core/code_block/explore_strategy.py +3 -1
- dolphin/core/code_block/judge_block.py +41 -8
- dolphin/core/code_block/skill_call_deduplicator.py +32 -10
- dolphin/core/code_block/tool_block.py +69 -23
- dolphin/core/common/constants.py +25 -1
- dolphin/core/config/global_config.py +35 -0
- dolphin/core/context/context.py +175 -9
- dolphin/core/context/cow_context.py +392 -0
- dolphin/core/executor/dolphin_executor.py +9 -0
- dolphin/core/flags/definitions.py +2 -2
- dolphin/core/llm/llm.py +2 -3
- dolphin/core/llm/llm_client.py +1 -0
- dolphin/core/runtime/runtime_instance.py +31 -0
- dolphin/core/skill/context_retention.py +3 -3
- dolphin/core/task_registry.py +404 -0
- dolphin/core/utils/cache_kv.py +70 -8
- dolphin/core/utils/tools.py +2 -0
- dolphin/lib/__init__.py +0 -2
- dolphin/lib/skillkits/__init__.py +2 -2
- dolphin/lib/skillkits/plan_skillkit.py +756 -0
- dolphin/lib/skillkits/system_skillkit.py +103 -30
- dolphin/sdk/skill/global_skills.py +43 -3
- dolphin/sdk/skill/traditional_toolkit.py +4 -0
- {kweaver_dolphin-0.1.0.dist-info → kweaver_dolphin-0.2.1.dist-info}/METADATA +1 -1
- {kweaver_dolphin-0.1.0.dist-info → kweaver_dolphin-0.2.1.dist-info}/RECORD +36 -34
- {kweaver_dolphin-0.1.0.dist-info → kweaver_dolphin-0.2.1.dist-info}/WHEEL +1 -1
- kweaver_dolphin-0.2.1.dist-info/entry_points.txt +15 -0
- dolphin/lib/skillkits/plan_act_skillkit.py +0 -452
- kweaver_dolphin-0.1.0.dist-info/entry_points.txt +0 -27
- {kweaver_dolphin-0.1.0.dist-info → kweaver_dolphin-0.2.1.dist-info}/licenses/LICENSE.txt +0 -0
- {kweaver_dolphin-0.1.0.dist-info → kweaver_dolphin-0.2.1.dist-info}/top_level.txt +0 -0
dolphin/core/llm/llm.py
CHANGED
|
@@ -249,7 +249,6 @@ class LLMModelFactory(LLM):
|
|
|
249
249
|
finish_reason = None
|
|
250
250
|
# Use ToolCallsParser to handle tool calls parsing
|
|
251
251
|
tool_parser = ToolCallsParser()
|
|
252
|
-
|
|
253
252
|
timeout = aiohttp.ClientTimeout(
|
|
254
253
|
total=1800, # Disable overall timeout (use with caution)
|
|
255
254
|
sock_connect=30, # Keep connection timeout
|
|
@@ -320,7 +319,7 @@ class LLMModelFactory(LLM):
|
|
|
320
319
|
|
|
321
320
|
accu_content += delta_content
|
|
322
321
|
reasoning_content += delta_reasoning
|
|
323
|
-
|
|
322
|
+
|
|
324
323
|
# Capture finish_reason
|
|
325
324
|
chunk_finish_reason = line_json["choices"][0].get("finish_reason")
|
|
326
325
|
if chunk_finish_reason:
|
|
@@ -461,7 +460,7 @@ class LLMOpenai(LLM):
|
|
|
461
460
|
and delta.reasoning_content is not None
|
|
462
461
|
):
|
|
463
462
|
accu_reasoning += delta.reasoning_content
|
|
464
|
-
|
|
463
|
+
|
|
465
464
|
# Capture finish_reason
|
|
466
465
|
chunk_finish_reason = chunk.choices[0].finish_reason
|
|
467
466
|
if chunk_finish_reason:
|
dolphin/core/llm/llm_client.py
CHANGED
|
@@ -182,6 +182,7 @@ class LLMClient:
|
|
|
182
182
|
sock_connect=30, # Keep connection timeout
|
|
183
183
|
sock_read=300, # Single read timeout (for slow streaming data)
|
|
184
184
|
)
|
|
185
|
+
print(f"------------------------llm={payload}")
|
|
185
186
|
async with aiohttp.ClientSession(timeout=timeout) as session:
|
|
186
187
|
async with session.post(
|
|
187
188
|
model_config.api,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
from enum import Enum
|
|
2
|
+
import logging
|
|
2
3
|
import time
|
|
3
4
|
from typing import List, Optional, TYPE_CHECKING
|
|
4
5
|
import uuid
|
|
@@ -6,6 +7,8 @@ import uuid
|
|
|
6
7
|
from dolphin.core.common.enums import Messages, SkillInfo, Status, TypeStage
|
|
7
8
|
from dolphin.core.common.constants import estimate_tokens_from_chars
|
|
8
9
|
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
9
12
|
if TYPE_CHECKING:
|
|
10
13
|
from dolphin.core.agent.base_agent import BaseAgent
|
|
11
14
|
from dolphin.core.code_block.basic_code_block import BasicCodeBlock
|
|
@@ -292,6 +295,7 @@ class ProgressInstance(RuntimeInstance):
|
|
|
292
295
|
self.context = context
|
|
293
296
|
self.stages: List[StageInstance] = []
|
|
294
297
|
self.flags = flags
|
|
298
|
+
self._next_stage_id: Optional[str] = None # ✅ NEW: for interrupt resume
|
|
295
299
|
|
|
296
300
|
def add_stage(
|
|
297
301
|
self,
|
|
@@ -306,6 +310,7 @@ class ProgressInstance(RuntimeInstance):
|
|
|
306
310
|
input_content: str = "",
|
|
307
311
|
input_messages: Optional[Messages] = None,
|
|
308
312
|
interrupted: bool = False,
|
|
313
|
+
stage_id: Optional[str] = None, # ✅ NEW: support custom stage_id for resume
|
|
309
314
|
):
|
|
310
315
|
pop_last_stage = False
|
|
311
316
|
if len(self.stages) > 0 and self.stages[-1].llm_empty_answer():
|
|
@@ -325,6 +330,15 @@ class ProgressInstance(RuntimeInstance):
|
|
|
325
330
|
interrupted=interrupted,
|
|
326
331
|
flags=self.flags,
|
|
327
332
|
)
|
|
333
|
+
|
|
334
|
+
# ✅ NEW: Override ID if custom stage_id is provided (for interrupt resume)
|
|
335
|
+
# Priority: explicit stage_id parameter > _next_stage_id temporary variable
|
|
336
|
+
if stage_id is not None:
|
|
337
|
+
stage_instance.id = stage_id
|
|
338
|
+
elif self._next_stage_id is not None:
|
|
339
|
+
stage_instance.id = self._next_stage_id
|
|
340
|
+
self._next_stage_id = None # Clear after use (one-time only)
|
|
341
|
+
|
|
328
342
|
self.add_stage_instance(stage_instance, pop_last_stage)
|
|
329
343
|
|
|
330
344
|
def add_stage_instance(
|
|
@@ -333,6 +347,7 @@ class ProgressInstance(RuntimeInstance):
|
|
|
333
347
|
stage_instance.set_parent(self)
|
|
334
348
|
if pop_last_stage:
|
|
335
349
|
self.stages.pop()
|
|
350
|
+
|
|
336
351
|
self.stages.append(stage_instance)
|
|
337
352
|
|
|
338
353
|
# Register stage instance to runtime_graph if available
|
|
@@ -376,6 +391,22 @@ class ProgressInstance(RuntimeInstance):
|
|
|
376
391
|
# Check if we need to create a new stage (when stage type changes)
|
|
377
392
|
last_stage = self.stages[-1]
|
|
378
393
|
|
|
394
|
+
# *** FIX: If _next_stage_id is set and doesn't match last stage, create new stage ***
|
|
395
|
+
# This handles resume cases where we need to create a stage with a specific ID
|
|
396
|
+
if self._next_stage_id is not None and self._next_stage_id != last_stage.id:
|
|
397
|
+
logger.debug(f"_next_stage_id ({self._next_stage_id}) != last_stage.id ({last_stage.id}), creating new stage for resume")
|
|
398
|
+
self.add_stage(
|
|
399
|
+
stage=stage if stage is not None else last_stage.stage,
|
|
400
|
+
answer=answer,
|
|
401
|
+
think=think,
|
|
402
|
+
raw_output=raw_output,
|
|
403
|
+
status=status,
|
|
404
|
+
skill_info=skill_info,
|
|
405
|
+
block_answer=block_answer,
|
|
406
|
+
input_messages=input_messages,
|
|
407
|
+
)
|
|
408
|
+
return
|
|
409
|
+
|
|
379
410
|
# Create new stage if stage type is changing (and it's not None)
|
|
380
411
|
if stage is not None and stage != last_stage.stage:
|
|
381
412
|
self.add_stage(
|
|
@@ -54,7 +54,7 @@ class SummaryContextStrategy(ContextRetentionStrategy):
|
|
|
54
54
|
# Provide reference_id so LLM can fetch full content if needed
|
|
55
55
|
ref_hint = ""
|
|
56
56
|
if reference_id:
|
|
57
|
-
ref_hint = f"\n[For full content, call
|
|
57
|
+
ref_hint = f"\n[For full content, call _get_cached_result_detail('{reference_id}', scope='skill')]"
|
|
58
58
|
|
|
59
59
|
omitted = len(result) - head_chars - tail_chars
|
|
60
60
|
# Ensure we don't have negative omission if rounding puts us over
|
|
@@ -114,8 +114,8 @@ class ReferenceContextStrategy(ContextRetentionStrategy):
|
|
|
114
114
|
hint = config.reference_hint or "Full result stored"
|
|
115
115
|
return (f"[{hint}]\n"
|
|
116
116
|
f"Original length: {len(result)} chars\n"
|
|
117
|
-
f"Get full content:
|
|
118
|
-
f"Get range:
|
|
117
|
+
f"Get full content: _get_cached_result_detail('{reference_id}', scope='skill')\n"
|
|
118
|
+
f"Get range: _get_cached_result_detail('{reference_id}', scope='skill', offset=0, limit=2000)")
|
|
119
119
|
|
|
120
120
|
|
|
121
121
|
# Strategy mapping
|
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
"""Task Registry for Plan Mode.
|
|
2
|
+
|
|
3
|
+
This module provides task state management for unified plan architecture.
|
|
4
|
+
|
|
5
|
+
Logging conventions:
|
|
6
|
+
- DEBUG: Task registration, status updates, cancellations
|
|
7
|
+
- INFO: Registry lifecycle events (reset, mode changes)
|
|
8
|
+
- WARNING: Invalid operations (unknown task, missing registry)
|
|
9
|
+
- ERROR: Critical failures in task management
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import time
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from enum import Enum
|
|
16
|
+
from typing import Any, Dict, List, Optional
|
|
17
|
+
|
|
18
|
+
from dolphin.core.logging.logger import get_logger
|
|
19
|
+
|
|
20
|
+
logger = get_logger("task_registry")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class OutputEventType(str, Enum):
|
|
24
|
+
"""Output event types for UI/SDK consumers.
|
|
25
|
+
|
|
26
|
+
These events are emitted via Context.write_output() and can be consumed
|
|
27
|
+
by UI components or SDK integrations for real-time updates.
|
|
28
|
+
"""
|
|
29
|
+
TASK_STARTED = "task_started"
|
|
30
|
+
TASK_COMPLETED = "task_completed"
|
|
31
|
+
TASK_FAILED = "task_failed"
|
|
32
|
+
TASK_CANCELLED = "task_cancelled"
|
|
33
|
+
TASK_PROGRESS = "task_progress"
|
|
34
|
+
PLAN_CREATED = "plan_created"
|
|
35
|
+
PLAN_UPDATED = "plan_updated"
|
|
36
|
+
PLAN_FINISHED = "plan_finished"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class TaskStatus(str, Enum):
|
|
40
|
+
"""Task status values."""
|
|
41
|
+
PENDING = "pending"
|
|
42
|
+
RUNNING = "running"
|
|
43
|
+
COMPLETED = "completed"
|
|
44
|
+
FAILED = "failed"
|
|
45
|
+
CANCELLED = "cancelled"
|
|
46
|
+
SKIPPED = "skipped"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class PlanExecMode(str, Enum):
|
|
50
|
+
"""Execution mode for plan orchestration."""
|
|
51
|
+
PARALLEL = "parallel"
|
|
52
|
+
SEQUENTIAL = "sequential"
|
|
53
|
+
|
|
54
|
+
@staticmethod
|
|
55
|
+
def from_str(mode: str) -> "PlanExecMode":
|
|
56
|
+
"""Convert a string (seq/para/sequential/parallel) to PlanExecMode."""
|
|
57
|
+
if not mode:
|
|
58
|
+
return PlanExecMode.PARALLEL
|
|
59
|
+
|
|
60
|
+
mode = mode.lower().strip()
|
|
61
|
+
if mode in ("seq", "sequential"):
|
|
62
|
+
return PlanExecMode.SEQUENTIAL
|
|
63
|
+
if mode in ("para", "parallel"):
|
|
64
|
+
return PlanExecMode.PARALLEL
|
|
65
|
+
|
|
66
|
+
raise ValueError(f"Invalid execution mode: {mode}. Must be 'seq' or 'para'.")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class Task:
|
|
71
|
+
"""Task metadata and state."""
|
|
72
|
+
id: str
|
|
73
|
+
name: str
|
|
74
|
+
prompt: str
|
|
75
|
+
|
|
76
|
+
# Runtime fields
|
|
77
|
+
status: TaskStatus = TaskStatus.PENDING
|
|
78
|
+
answer: Optional[str] = None
|
|
79
|
+
think: Optional[str] = None
|
|
80
|
+
block_answer: Optional[str] = None
|
|
81
|
+
error: Optional[str] = None
|
|
82
|
+
started_at: Optional[float] = None
|
|
83
|
+
duration: Optional[float] = None
|
|
84
|
+
attempt: int = 0
|
|
85
|
+
|
|
86
|
+
# Reserved for Phase 2
|
|
87
|
+
depends_on: List[str] = field(default_factory=list)
|
|
88
|
+
|
|
89
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
90
|
+
"""Convert Task to dictionary."""
|
|
91
|
+
return {
|
|
92
|
+
"id": self.id,
|
|
93
|
+
"name": self.name,
|
|
94
|
+
"prompt": self.prompt,
|
|
95
|
+
"status": self.status.value,
|
|
96
|
+
"answer": self.answer,
|
|
97
|
+
"think": self.think,
|
|
98
|
+
"block_answer": self.block_answer,
|
|
99
|
+
"error": self.error,
|
|
100
|
+
"started_at": self.started_at,
|
|
101
|
+
"duration": self.duration,
|
|
102
|
+
"attempt": self.attempt,
|
|
103
|
+
"depends_on": self.depends_on,
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
@classmethod
|
|
107
|
+
def from_dict(cls, data: Dict[str, Any]) -> "Task":
|
|
108
|
+
"""Create Task from dictionary."""
|
|
109
|
+
return cls(
|
|
110
|
+
id=data["id"],
|
|
111
|
+
name=data["name"],
|
|
112
|
+
prompt=data["prompt"],
|
|
113
|
+
status=TaskStatus(data["status"]),
|
|
114
|
+
answer=data.get("answer"),
|
|
115
|
+
think=data.get("think"),
|
|
116
|
+
block_answer=data.get("block_answer"),
|
|
117
|
+
error=data.get("error"),
|
|
118
|
+
started_at=data.get("started_at"),
|
|
119
|
+
duration=data.get("duration"),
|
|
120
|
+
attempt=data.get("attempt", 0),
|
|
121
|
+
depends_on=data.get("depends_on", []),
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class TaskRegistry:
|
|
126
|
+
"""Persistent task state registry.
|
|
127
|
+
|
|
128
|
+
Notes:
|
|
129
|
+
- Stores only serializable task state.
|
|
130
|
+
- Runtime handles (asyncio.Task) are kept outside for correctness and recoverability.
|
|
131
|
+
- Thread-safe via asyncio.Lock.
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
def __init__(self):
|
|
135
|
+
self.tasks: Dict[str, Task] = {}
|
|
136
|
+
self._lock = asyncio.Lock()
|
|
137
|
+
|
|
138
|
+
# Config fields (set by PlanSkillkit._plan_tasks)
|
|
139
|
+
self.exec_mode: PlanExecMode = PlanExecMode.PARALLEL
|
|
140
|
+
self.max_concurrency: int = 5
|
|
141
|
+
|
|
142
|
+
# Runtime handles (not persisted)
|
|
143
|
+
self.running_asyncio_tasks: Dict[str, asyncio.Task] = {}
|
|
144
|
+
|
|
145
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
146
|
+
"""Convert TaskRegistry to dictionary."""
|
|
147
|
+
return {
|
|
148
|
+
"tasks": {task_id: task.to_dict() for task_id, task in self.tasks.items()},
|
|
149
|
+
"exec_mode": self.exec_mode.value,
|
|
150
|
+
"max_concurrency": self.max_concurrency,
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
@classmethod
|
|
154
|
+
def from_dict(cls, data: Dict[str, Any]) -> "TaskRegistry":
|
|
155
|
+
"""Create TaskRegistry from dictionary."""
|
|
156
|
+
registry = cls()
|
|
157
|
+
registry.tasks = {
|
|
158
|
+
task_id: Task.from_dict(task_data)
|
|
159
|
+
for task_id, task_data in data.get("tasks", {}).items()
|
|
160
|
+
}
|
|
161
|
+
registry.exec_mode = PlanExecMode(data.get("exec_mode", PlanExecMode.PARALLEL.value))
|
|
162
|
+
registry.max_concurrency = data.get("max_concurrency", 5)
|
|
163
|
+
return registry
|
|
164
|
+
|
|
165
|
+
async def add_task(self, task: Task):
|
|
166
|
+
"""Register a new task."""
|
|
167
|
+
async with self._lock:
|
|
168
|
+
self.tasks[task.id] = task
|
|
169
|
+
logger.debug(f"Task registered: {task.id} ({task.name})")
|
|
170
|
+
|
|
171
|
+
async def get_task(self, task_id: str) -> Optional[Task]:
|
|
172
|
+
"""Retrieve a task by ID (thread-safe)."""
|
|
173
|
+
async with self._lock:
|
|
174
|
+
return self.tasks.get(task_id)
|
|
175
|
+
|
|
176
|
+
async def get_all_tasks(self) -> List[Task]:
|
|
177
|
+
"""Return all tasks (thread-safe)."""
|
|
178
|
+
async with self._lock:
|
|
179
|
+
return list(self.tasks.values())
|
|
180
|
+
|
|
181
|
+
async def get_pending_tasks(self) -> List[Task]:
|
|
182
|
+
"""Return tasks that are pending (thread-safe)."""
|
|
183
|
+
async with self._lock:
|
|
184
|
+
return [t for t in self.tasks.values() if t.status == TaskStatus.PENDING]
|
|
185
|
+
|
|
186
|
+
async def get_ready_tasks(self) -> List[Task]:
|
|
187
|
+
"""Return tasks that are ready to be started (thread-safe).
|
|
188
|
+
|
|
189
|
+
Phase 1 (no dependency scheduling):
|
|
190
|
+
- All PENDING tasks are considered ready.
|
|
191
|
+
|
|
192
|
+
Phase 2 (reserved):
|
|
193
|
+
- Check depends_on and only return tasks whose dependencies are completed.
|
|
194
|
+
"""
|
|
195
|
+
async with self._lock:
|
|
196
|
+
return [task for task in self.tasks.values() if task.status == TaskStatus.PENDING]
|
|
197
|
+
|
|
198
|
+
async def get_running_tasks(self) -> List[Task]:
|
|
199
|
+
"""Return tasks that are running (thread-safe)."""
|
|
200
|
+
async with self._lock:
|
|
201
|
+
return [t for t in self.tasks.values() if t.status == TaskStatus.RUNNING]
|
|
202
|
+
|
|
203
|
+
async def get_completed_tasks(self) -> List[Task]:
|
|
204
|
+
"""Return tasks that are completed (thread-safe)."""
|
|
205
|
+
async with self._lock:
|
|
206
|
+
return [t for t in self.tasks.values() if t.status == TaskStatus.COMPLETED]
|
|
207
|
+
|
|
208
|
+
async def get_failed_tasks(self) -> List[Task]:
|
|
209
|
+
"""Return tasks that have failed (thread-safe)."""
|
|
210
|
+
async with self._lock:
|
|
211
|
+
return [t for t in self.tasks.values() if t.status == TaskStatus.FAILED]
|
|
212
|
+
|
|
213
|
+
async def has_tasks(self) -> bool:
|
|
214
|
+
"""Return whether any tasks are registered (thread-safe)."""
|
|
215
|
+
async with self._lock:
|
|
216
|
+
return bool(self.tasks)
|
|
217
|
+
|
|
218
|
+
async def reset(self):
|
|
219
|
+
"""Reset task state (used for replan).
|
|
220
|
+
|
|
221
|
+
Clears:
|
|
222
|
+
- All tasks
|
|
223
|
+
- Running asyncio task handles (cancels them first)
|
|
224
|
+
|
|
225
|
+
Preserves:
|
|
226
|
+
- exec_mode (will be overwritten by next _plan_tasks call)
|
|
227
|
+
- max_concurrency (will be overwritten by next _plan_tasks call)
|
|
228
|
+
|
|
229
|
+
Note:
|
|
230
|
+
This is an async method to ensure proper locking for concurrent safety.
|
|
231
|
+
"""
|
|
232
|
+
async with self._lock:
|
|
233
|
+
# Cancel all running asyncio tasks to prevent background leaks during replan
|
|
234
|
+
for task_id, asyncio_task in self.running_asyncio_tasks.items():
|
|
235
|
+
if not asyncio_task.done():
|
|
236
|
+
asyncio_task.cancel()
|
|
237
|
+
logger.debug(f"Cancelled orphaned task {task_id} during registry reset")
|
|
238
|
+
|
|
239
|
+
self.tasks.clear()
|
|
240
|
+
self.running_asyncio_tasks.clear()
|
|
241
|
+
logger.info("TaskRegistry reset (replan)")
|
|
242
|
+
|
|
243
|
+
async def is_all_done(self) -> bool:
|
|
244
|
+
"""Return whether all tasks have reached a terminal state (thread-safe)."""
|
|
245
|
+
async with self._lock:
|
|
246
|
+
terminal = {TaskStatus.COMPLETED, TaskStatus.FAILED, TaskStatus.CANCELLED, TaskStatus.SKIPPED}
|
|
247
|
+
return all(task.status in terminal for task in self.tasks.values())
|
|
248
|
+
|
|
249
|
+
async def update_status(
|
|
250
|
+
self,
|
|
251
|
+
task_id: str,
|
|
252
|
+
status: TaskStatus,
|
|
253
|
+
**kwargs
|
|
254
|
+
):
|
|
255
|
+
"""Update task status and related fields.
|
|
256
|
+
|
|
257
|
+
Args:
|
|
258
|
+
task_id: Task identifier
|
|
259
|
+
status: New status
|
|
260
|
+
**kwargs: Additional fields to update (answer, think, block_answer, error, started_at, etc.)
|
|
261
|
+
|
|
262
|
+
Note:
|
|
263
|
+
Terminal states (COMPLETED, FAILED, CANCELLED, SKIPPED) cannot be transitioned.
|
|
264
|
+
This prevents race conditions during task cancellation or completion.
|
|
265
|
+
"""
|
|
266
|
+
async with self._lock:
|
|
267
|
+
task = self.tasks.get(task_id)
|
|
268
|
+
if not task:
|
|
269
|
+
logger.warning(f"Cannot update status for unknown task: {task_id}")
|
|
270
|
+
return
|
|
271
|
+
|
|
272
|
+
# Validate state transitions: terminal states cannot be changed
|
|
273
|
+
# Note: FAILED is excluded from terminal states to allow retries
|
|
274
|
+
terminal_states = {TaskStatus.COMPLETED, TaskStatus.CANCELLED, TaskStatus.SKIPPED}
|
|
275
|
+
if task.status in terminal_states:
|
|
276
|
+
logger.warning(
|
|
277
|
+
f"Cannot transition task {task_id} from terminal state {task.status.value} to {status.value}"
|
|
278
|
+
)
|
|
279
|
+
return
|
|
280
|
+
|
|
281
|
+
task.status = status
|
|
282
|
+
|
|
283
|
+
# Update additional fields
|
|
284
|
+
for key, value in kwargs.items():
|
|
285
|
+
if hasattr(task, key):
|
|
286
|
+
setattr(task, key, value)
|
|
287
|
+
|
|
288
|
+
# Compute duration for terminal states
|
|
289
|
+
if status in (TaskStatus.COMPLETED, TaskStatus.FAILED, TaskStatus.CANCELLED):
|
|
290
|
+
if task.started_at and not task.duration:
|
|
291
|
+
task.duration = time.time() - task.started_at
|
|
292
|
+
|
|
293
|
+
logger.debug(f"Task {task_id} status updated: {status.value}")
|
|
294
|
+
|
|
295
|
+
async def get_status_counts(self) -> Dict[str, int]:
|
|
296
|
+
"""Return count per status (thread-safe)."""
|
|
297
|
+
async with self._lock:
|
|
298
|
+
counts = {status.value: 0 for status in TaskStatus}
|
|
299
|
+
for task in self.tasks.values():
|
|
300
|
+
counts[task.status.value] += 1
|
|
301
|
+
return counts
|
|
302
|
+
|
|
303
|
+
async def get_progress_signature(self) -> tuple:
|
|
304
|
+
"""Compute a signature representing the current task progress state (thread-safe).
|
|
305
|
+
|
|
306
|
+
Returns:
|
|
307
|
+
A tuple of (task_id, status) pairs sorted by task_id.
|
|
308
|
+
This signature changes whenever any task changes status.
|
|
309
|
+
|
|
310
|
+
Usage:
|
|
311
|
+
Used by ExploreBlock to detect whether tasks have made progress.
|
|
312
|
+
If the signature is the same across multiple rounds, it indicates
|
|
313
|
+
the plan is stalled (no status changes).
|
|
314
|
+
|
|
315
|
+
Example:
|
|
316
|
+
>>> registry.get_progress_signature()
|
|
317
|
+
(('task_1', 'running'), ('task_2', 'pending'))
|
|
318
|
+
"""
|
|
319
|
+
async with self._lock:
|
|
320
|
+
tasks = list(self.tasks.values())
|
|
321
|
+
return tuple(
|
|
322
|
+
(t.id, getattr(t.status, "value", str(t.status)))
|
|
323
|
+
for t in sorted(tasks, key=lambda x: x.id)
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
async def get_all_status(self) -> str:
|
|
327
|
+
"""Return a formatted status summary (for _check_progress, thread-safe).
|
|
328
|
+
|
|
329
|
+
Returns:
|
|
330
|
+
A multi-line string with task status, including error details for failed tasks.
|
|
331
|
+
"""
|
|
332
|
+
async with self._lock:
|
|
333
|
+
lines = []
|
|
334
|
+
now = time.time()
|
|
335
|
+
for task in self.tasks.values():
|
|
336
|
+
if task.status == TaskStatus.RUNNING and task.started_at:
|
|
337
|
+
duration_str = f"{now - task.started_at:.1f}s+"
|
|
338
|
+
else:
|
|
339
|
+
duration_str = f"{task.duration:.1f}s" if task.duration else "N/A"
|
|
340
|
+
|
|
341
|
+
icon_map = {
|
|
342
|
+
TaskStatus.PENDING: "⏳",
|
|
343
|
+
TaskStatus.RUNNING: "🔄",
|
|
344
|
+
TaskStatus.COMPLETED: "✅",
|
|
345
|
+
TaskStatus.FAILED: "❌",
|
|
346
|
+
TaskStatus.CANCELLED: "🚫",
|
|
347
|
+
TaskStatus.SKIPPED: "⏭️",
|
|
348
|
+
}
|
|
349
|
+
icon = icon_map.get(task.status, "?")
|
|
350
|
+
status_label = task.status.value
|
|
351
|
+
|
|
352
|
+
# Item 3 Optimization: Check if task is in the process of being cancelled
|
|
353
|
+
if task.status == TaskStatus.RUNNING and task.id in self.running_asyncio_tasks:
|
|
354
|
+
asyncio_task = self.running_asyncio_tasks[task.id]
|
|
355
|
+
# Check if cancelling (Python 3.11+) or if it's already done but status not updated
|
|
356
|
+
is_cancelling = False
|
|
357
|
+
if hasattr(asyncio_task, "cancelling"):
|
|
358
|
+
is_cancelling = asyncio_task.cancelling() > 0
|
|
359
|
+
|
|
360
|
+
if is_cancelling:
|
|
361
|
+
icon = "⏳🚫"
|
|
362
|
+
status_label = "cancelling..."
|
|
363
|
+
|
|
364
|
+
base_line = f"{icon} {task.id}: {task.name} [{status_label}] ({duration_str})"
|
|
365
|
+
|
|
366
|
+
# For failed tasks, include error details to enable self-correction
|
|
367
|
+
if task.status == TaskStatus.FAILED and task.error:
|
|
368
|
+
error_preview = task.error[:150] # Limit error length
|
|
369
|
+
if len(task.error) > 150:
|
|
370
|
+
error_preview += "..."
|
|
371
|
+
base_line += f"\n Error: {error_preview}"
|
|
372
|
+
|
|
373
|
+
lines.append(base_line)
|
|
374
|
+
|
|
375
|
+
return "\n".join(lines)
|
|
376
|
+
|
|
377
|
+
async def cancel_all_running(self) -> int:
|
|
378
|
+
"""Cancel all running asyncio tasks and update their status.
|
|
379
|
+
|
|
380
|
+
Returns:
|
|
381
|
+
Number of tasks cancelled
|
|
382
|
+
|
|
383
|
+
Note:
|
|
384
|
+
This method both cancels the asyncio.Task objects and updates
|
|
385
|
+
the Task status to CANCELLED to keep state synchronized.
|
|
386
|
+
"""
|
|
387
|
+
async with self._lock:
|
|
388
|
+
cancelled = 0
|
|
389
|
+
for task_id, asyncio_task in list(self.running_asyncio_tasks.items()):
|
|
390
|
+
if not asyncio_task.done():
|
|
391
|
+
asyncio_task.cancel()
|
|
392
|
+
cancelled += 1
|
|
393
|
+
# Update task status to CANCELLED
|
|
394
|
+
# This ensures Registry state matches actual execution state
|
|
395
|
+
task = self.tasks.get(task_id)
|
|
396
|
+
if task:
|
|
397
|
+
task.status = TaskStatus.CANCELLED
|
|
398
|
+
# Compute duration if task was started
|
|
399
|
+
if task.started_at and not task.duration:
|
|
400
|
+
task.duration = time.time() - task.started_at
|
|
401
|
+
logger.debug(f"Cancelled running task: {task_id}")
|
|
402
|
+
|
|
403
|
+
self.running_asyncio_tasks.clear()
|
|
404
|
+
return cancelled
|
dolphin/core/utils/cache_kv.py
CHANGED
|
@@ -1,15 +1,79 @@
|
|
|
1
|
-
import fcntl
|
|
2
1
|
import json
|
|
3
2
|
import os
|
|
3
|
+
import sys
|
|
4
4
|
import threading
|
|
5
5
|
import time
|
|
6
6
|
from typing import List, Dict, Any, Optional
|
|
7
7
|
import uuid
|
|
8
8
|
from dolphin.core.logging.logger import get_logger
|
|
9
9
|
|
|
10
|
+
# Cross-platform file locking support
|
|
11
|
+
_HAS_FCNTL = False
|
|
12
|
+
_HAS_MSVCRT = False
|
|
13
|
+
|
|
14
|
+
if sys.platform == 'win32':
|
|
15
|
+
# Windows: use msvcrt for file locking
|
|
16
|
+
try:
|
|
17
|
+
import msvcrt
|
|
18
|
+
_HAS_MSVCRT = True
|
|
19
|
+
except ImportError:
|
|
20
|
+
pass
|
|
21
|
+
else:
|
|
22
|
+
# Unix/Linux: use fcntl for file locking
|
|
23
|
+
try:
|
|
24
|
+
import fcntl
|
|
25
|
+
_HAS_FCNTL = True
|
|
26
|
+
except ImportError:
|
|
27
|
+
pass
|
|
28
|
+
|
|
10
29
|
logger = get_logger("utils.cache_kv")
|
|
11
30
|
|
|
12
31
|
|
|
32
|
+
def _lock_file(f, exclusive=False):
|
|
33
|
+
"""
|
|
34
|
+
Cross-platform file locking
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
f: File object
|
|
38
|
+
exclusive: If True, acquire exclusive lock; otherwise shared lock
|
|
39
|
+
"""
|
|
40
|
+
if _HAS_FCNTL:
|
|
41
|
+
# Unix/Linux: use fcntl
|
|
42
|
+
lock_type = fcntl.LOCK_EX if exclusive else fcntl.LOCK_SH
|
|
43
|
+
fcntl.flock(f.fileno(), lock_type)
|
|
44
|
+
elif _HAS_MSVCRT:
|
|
45
|
+
# Windows: use msvcrt
|
|
46
|
+
# msvcrt.locking doesn't support shared locks, so we use exclusive
|
|
47
|
+
# Note: msvcrt requires seeking to the start
|
|
48
|
+
f.seek(0)
|
|
49
|
+
try:
|
|
50
|
+
msvcrt.locking(f.fileno(), msvcrt.LK_LOCK, 1)
|
|
51
|
+
except OSError:
|
|
52
|
+
# File might be too short, which is ok for our use case
|
|
53
|
+
pass
|
|
54
|
+
# If neither is available, proceed without locking (not ideal but won't crash)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _unlock_file(f):
|
|
58
|
+
"""
|
|
59
|
+
Cross-platform file unlocking
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
f: File object
|
|
63
|
+
"""
|
|
64
|
+
if _HAS_FCNTL:
|
|
65
|
+
# Unix/Linux: use fcntl
|
|
66
|
+
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
|
67
|
+
elif _HAS_MSVCRT:
|
|
68
|
+
# Windows: use msvcrt
|
|
69
|
+
f.seek(0)
|
|
70
|
+
try:
|
|
71
|
+
msvcrt.locking(f.fileno(), msvcrt.LK_UNLCK, 1)
|
|
72
|
+
except OSError:
|
|
73
|
+
pass
|
|
74
|
+
# If neither is available, nothing to unlock
|
|
75
|
+
|
|
76
|
+
|
|
13
77
|
class CacheKV:
|
|
14
78
|
def __init__(
|
|
15
79
|
self, filePath: str, dumpInterval: int = 5, expireTimeByDay: float = 1
|
|
@@ -28,8 +92,7 @@ class CacheKV:
|
|
|
28
92
|
|
|
29
93
|
try:
|
|
30
94
|
with open(self.filePath, "r", encoding="utf-8") as f:
|
|
31
|
-
|
|
32
|
-
fcntl.flock(fd, fcntl.LOCK_SH) # Shared lock for reading
|
|
95
|
+
_lock_file(f, exclusive=False) # Shared lock for reading
|
|
33
96
|
try:
|
|
34
97
|
loaded_cache = json.load(f)
|
|
35
98
|
for key, value in loaded_cache.items():
|
|
@@ -46,7 +109,7 @@ class CacheKV:
|
|
|
46
109
|
# Compatible with old formats
|
|
47
110
|
self.cache[key] = {"value": value, "timestamp": time.time()}
|
|
48
111
|
finally:
|
|
49
|
-
|
|
112
|
+
_unlock_file(f)
|
|
50
113
|
except (json.JSONDecodeError, IOError) as e:
|
|
51
114
|
logger.error(
|
|
52
115
|
f"Error loading cache file {self.filePath}: {e} try to backup and reset cache"
|
|
@@ -77,14 +140,13 @@ class CacheKV:
|
|
|
77
140
|
temp_path = f"{self.filePath}.tmp.{os.getpid()}.{uuid.uuid4().hex}"
|
|
78
141
|
try:
|
|
79
142
|
with open(temp_path, "w", encoding="utf-8") as f:
|
|
80
|
-
|
|
81
|
-
fcntl.flock(fd, fcntl.LOCK_EX) # Exclusive lock for writing
|
|
143
|
+
_lock_file(f, exclusive=True) # Exclusive lock for writing
|
|
82
144
|
try:
|
|
83
145
|
json.dump(self.cache, f, ensure_ascii=False)
|
|
84
146
|
f.flush()
|
|
85
|
-
os.fsync(
|
|
147
|
+
os.fsync(f.fileno()) # Ensure data is written to disk
|
|
86
148
|
finally:
|
|
87
|
-
|
|
149
|
+
_unlock_file(f)
|
|
88
150
|
|
|
89
151
|
# Atomic rename
|
|
90
152
|
os.rename(temp_path, self.filePath)
|
dolphin/core/utils/tools.py
CHANGED
|
@@ -332,9 +332,11 @@ class ToolInterrupt(Exception):
|
|
|
332
332
|
message="The tool was interrupted.",
|
|
333
333
|
tool_name: str = None,
|
|
334
334
|
tool_args: List[Dict] = None,
|
|
335
|
+
tool_config: Dict = None,
|
|
335
336
|
*args,
|
|
336
337
|
**kwargs,
|
|
337
338
|
):
|
|
338
339
|
super().__init__(message, *args, **kwargs)
|
|
339
340
|
self.tool_name = tool_name if tool_name else ""
|
|
340
341
|
self.tool_args = tool_args if tool_args else []
|
|
342
|
+
self.tool_config = tool_config if tool_config else {}
|
dolphin/lib/__init__.py
CHANGED
|
@@ -24,7 +24,6 @@ if TYPE_CHECKING:
|
|
|
24
24
|
MemorySkillkit,
|
|
25
25
|
MCPSkillkit,
|
|
26
26
|
OntologySkillkit,
|
|
27
|
-
PlanActSkillkit,
|
|
28
27
|
CognitiveSkillkit,
|
|
29
28
|
VMSkillkit,
|
|
30
29
|
NoopSkillkit,
|
|
@@ -61,7 +60,6 @@ _module_lookup = {
|
|
|
61
60
|
"MemorySkillkit": "dolphin.lib.skillkits",
|
|
62
61
|
"MCPSkillkit": "dolphin.lib.skillkits",
|
|
63
62
|
"OntologySkillkit": "dolphin.lib.skillkits",
|
|
64
|
-
"PlanActSkillkit": "dolphin.lib.skillkits",
|
|
65
63
|
"CognitiveSkillkit": "dolphin.lib.skillkits",
|
|
66
64
|
"VMSkillkit": "dolphin.lib.skillkits",
|
|
67
65
|
"NoopSkillkit": "dolphin.lib.skillkits",
|