vox-code 2.0.0__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.
Files changed (88) hide show
  1. vox_code-2.0.0.dist-info/METADATA +258 -0
  2. vox_code-2.0.0.dist-info/RECORD +88 -0
  3. vox_code-2.0.0.dist-info/WHEEL +4 -0
  4. vox_code-2.0.0.dist-info/entry_points.txt +3 -0
  5. voxcli/__init__.py +3 -0
  6. voxcli/__main__.py +5 -0
  7. voxcli/agent/__init__.py +12 -0
  8. voxcli/agent/agent.py +449 -0
  9. voxcli/agent/agent_budget.py +133 -0
  10. voxcli/agent/agent_orchestrator.py +414 -0
  11. voxcli/agent/plan_execute_agent.py +514 -0
  12. voxcli/agent/roles.py +80 -0
  13. voxcli/agent/sub_agent.py +351 -0
  14. voxcli/catalog.py +477 -0
  15. voxcli/chat.py +91 -0
  16. voxcli/cli/__init__.py +4 -0
  17. voxcli/cli/main.py +452 -0
  18. voxcli/cli/parser.py +71 -0
  19. voxcli/config.py +518 -0
  20. voxcli/gui/__main__.py +3 -0
  21. voxcli/gui/main.py +22 -0
  22. voxcli/gui/pet/__init__.py +5 -0
  23. voxcli/gui/pet/base.py +62 -0
  24. voxcli/gui/pet/coordinator.py +888 -0
  25. voxcli/gui/pet/data.py +430 -0
  26. voxcli/gui/pet/widgets.py +683 -0
  27. voxcli/gui/pet/windows.py +2298 -0
  28. voxcli/gui/pet/workers.py +54 -0
  29. voxcli/gui/pet_app.py +7 -0
  30. voxcli/hitl/__init__.py +11 -0
  31. voxcli/hitl/handler.py +11 -0
  32. voxcli/hitl/policy.py +32 -0
  33. voxcli/hitl/request.py +13 -0
  34. voxcli/hitl/result.py +11 -0
  35. voxcli/hitl/terminal_handler.py +64 -0
  36. voxcli/hitl/tool_registry.py +64 -0
  37. voxcli/llm/base.py +93 -0
  38. voxcli/llm/factory.py +178 -0
  39. voxcli/llm/ollama_client.py +137 -0
  40. voxcli/llm/openai_compatible.py +249 -0
  41. voxcli/memory/base.py +16 -0
  42. voxcli/memory/budget.py +53 -0
  43. voxcli/memory/compressor.py +198 -0
  44. voxcli/memory/entry.py +36 -0
  45. voxcli/memory/long_term.py +126 -0
  46. voxcli/memory/manager.py +101 -0
  47. voxcli/memory/retriever.py +72 -0
  48. voxcli/memory/short_term.py +84 -0
  49. voxcli/memory/tokenizer.py +21 -0
  50. voxcli/plan/__init__.py +5 -0
  51. voxcli/plan/execution_plan.py +225 -0
  52. voxcli/plan/planner.py +198 -0
  53. voxcli/plan/task.py +123 -0
  54. voxcli/policy/audit_log.py +111 -0
  55. voxcli/policy/command_guard.py +34 -0
  56. voxcli/policy/exception.py +5 -0
  57. voxcli/policy/path_guard.py +32 -0
  58. voxcli/prompting/__init__.py +7 -0
  59. voxcli/prompting/presenter.py +154 -0
  60. voxcli/rag/__init__.py +16 -0
  61. voxcli/rag/analyzer.py +89 -0
  62. voxcli/rag/chunk.py +17 -0
  63. voxcli/rag/chunker.py +137 -0
  64. voxcli/rag/embedding.py +75 -0
  65. voxcli/rag/formatter.py +40 -0
  66. voxcli/rag/index.py +96 -0
  67. voxcli/rag/relation.py +14 -0
  68. voxcli/rag/retriever.py +58 -0
  69. voxcli/rag/store.py +155 -0
  70. voxcli/rag/tokenizer.py +26 -0
  71. voxcli/runtime/__init__.py +6 -0
  72. voxcli/runtime/session_controller.py +386 -0
  73. voxcli/tool/__init__.py +3 -0
  74. voxcli/tool/tool_registry.py +433 -0
  75. voxcli/util/animation.py +219 -0
  76. voxcli/util/ansi.py +82 -0
  77. voxcli/util/markdown.py +98 -0
  78. voxcli/web/__init__.py +17 -0
  79. voxcli/web/base.py +20 -0
  80. voxcli/web/extractor.py +77 -0
  81. voxcli/web/factory.py +38 -0
  82. voxcli/web/fetch_result.py +27 -0
  83. voxcli/web/fetcher.py +42 -0
  84. voxcli/web/network_policy.py +49 -0
  85. voxcli/web/result.py +23 -0
  86. voxcli/web/searxng.py +55 -0
  87. voxcli/web/serpapi.py +53 -0
  88. voxcli/web/zhipu.py +55 -0
@@ -0,0 +1,101 @@
1
+ """Memory 管理器 - Memory 系统的门面类"""
2
+
3
+ import uuid
4
+ from typing import List, Optional
5
+
6
+ from ..llm.base import LlmClient
7
+ from .entry import MemoryEntry, MemoryType, estimate_tokens
8
+ from .short_term import ConversationMemory
9
+ from .long_term import LongTermMemory
10
+ from .retriever import MemoryRetriever
11
+ from .budget import TokenBudget
12
+ from .compressor import ContextCompressor
13
+
14
+
15
+ _MAX_TOOL_RESULT_CHARS = 500
16
+
17
+
18
+ class MemoryManager:
19
+ def __init__(self, llm_client: LlmClient,
20
+ short_term_budget: int = 32768,
21
+ context_window: int = 200000,
22
+ long_term: Optional[LongTermMemory] = None):
23
+ self._short_term = ConversationMemory(short_term_budget)
24
+ self._long_term = long_term or LongTermMemory()
25
+ self._compressor = ContextCompressor(llm_client)
26
+ self._retriever = MemoryRetriever(self._short_term, self._long_term)
27
+ self._budget = TokenBudget(context_window)
28
+ self._llm = llm_client
29
+
30
+ def set_llm_client(self, llm_client: LlmClient):
31
+ self._llm = llm_client
32
+ self._compressor.set_llm_client(llm_client)
33
+
34
+ def add_user_message(self, content: str):
35
+ entry = MemoryEntry(
36
+ id=f"user-{uuid.uuid4().hex[:8]}",
37
+ content=content,
38
+ type=MemoryType.CONVERSATION,
39
+ metadata={"source": "user"},
40
+ )
41
+ self._short_term.store(entry)
42
+ self._compress_if_needed()
43
+
44
+ def add_assistant_message(self, content: str):
45
+ entry = MemoryEntry(
46
+ id=f"assistant-{uuid.uuid4().hex[:8]}",
47
+ content=content,
48
+ type=MemoryType.CONVERSATION,
49
+ metadata={"source": "assistant"},
50
+ )
51
+ self._short_term.store(entry)
52
+ self._compress_if_needed()
53
+
54
+ def add_tool_result(self, tool_name: str, result: str):
55
+ truncated = result[:_MAX_TOOL_RESULT_CHARS] + "...(已截断)" if len(result) > _MAX_TOOL_RESULT_CHARS else result
56
+ content = f"[{tool_name}] {truncated}"
57
+ entry = MemoryEntry(
58
+ id=f"tool-{uuid.uuid4().hex[:8]}",
59
+ content=content,
60
+ type=MemoryType.TOOL_RESULT,
61
+ metadata={"source": "tool", "toolName": tool_name},
62
+ )
63
+ self._short_term.store(entry)
64
+ self._compress_if_needed()
65
+
66
+ def store_fact(self, fact: str):
67
+ entry = MemoryEntry(
68
+ id=f"fact-{uuid.uuid4().hex[:8]}",
69
+ content=fact,
70
+ type=MemoryType.FACT,
71
+ metadata={"source": "fact"},
72
+ )
73
+ self._long_term.store(entry)
74
+
75
+ def retrieve_relevant(self, query: str, limit: int) -> List[MemoryEntry]:
76
+ return self._retriever.retrieve(query, limit)
77
+
78
+ def build_context_for_query(self, query: str, max_tokens: int) -> str:
79
+ return self._retriever.build_context_for_query(query, max_tokens)
80
+
81
+ def record_token_usage(self, input_tokens: int, output_tokens: int):
82
+ self._budget.record_usage(input_tokens, output_tokens)
83
+
84
+ def clear_short_term(self):
85
+ self._short_term.clear()
86
+
87
+ def clear_long_term(self):
88
+ self._long_term.clear()
89
+
90
+ def status_summary(self) -> str:
91
+ return (f"{self._short_term.status_summary()}\n"
92
+ f"{self._long_term.status_summary()}\n"
93
+ f"{self._budget.usage_report}")
94
+
95
+ def _compress_if_needed(self):
96
+ if not self._budget.needs_compression(self._short_term):
97
+ return
98
+ print("📦 短期记忆接近预算上限,触发压缩...")
99
+ summary = self._compressor.compress(self._short_term)
100
+ if summary:
101
+ print(f" 压缩完成,摘要: {summary[:100]}...")
@@ -0,0 +1,72 @@
1
+ """记忆检索器"""
2
+
3
+ import time
4
+ from typing import List
5
+
6
+ from .entry import MemoryEntry, MemoryType
7
+ from .long_term import LongTermMemory
8
+ from .short_term import ConversationMemory
9
+ from .tokenizer import tokenize
10
+
11
+
12
+ class MemoryRetriever:
13
+ def __init__(self, short_term: ConversationMemory, long_term: LongTermMemory):
14
+ self._short_term = short_term
15
+ self._long_term = long_term
16
+
17
+ def retrieve(self, query: str, limit: int) -> List[MemoryEntry]:
18
+ scored: list[tuple[MemoryEntry, float]] = []
19
+ for entry in self._short_term.get_all():
20
+ score = self._compute_relevance(entry, query)
21
+ if score > 0:
22
+ scored.append((entry, score))
23
+ for entry in self._long_term.get_all():
24
+ score = self._compute_relevance(entry, query) * 1.2
25
+ if score > 0:
26
+ scored.append((entry, score))
27
+ scored.sort(key=lambda x: x[1], reverse=True)
28
+ return [e for e, _ in scored[:limit]]
29
+
30
+ def retrieve_long_term(self, query: str, limit: int) -> List[MemoryEntry]:
31
+ scored: list[tuple[MemoryEntry, float]] = []
32
+ for entry in self._long_term.get_all():
33
+ score = self._compute_relevance(entry, query) * 1.2
34
+ if score > 0:
35
+ scored.append((entry, score))
36
+ scored.sort(key=lambda x: x[1], reverse=True)
37
+ return [e for e, _ in scored[:limit]]
38
+
39
+ def build_context_for_query(self, query: str, max_tokens: int) -> str:
40
+ relevant = self.retrieve_long_term(query, 10)
41
+ if not relevant:
42
+ return ""
43
+ parts = ["## 相关长期记忆\n"]
44
+ used = 0
45
+ for entry in relevant:
46
+ if used + entry.token_count > max_tokens:
47
+ break
48
+ parts.append(f"- [{entry.type.value}] {entry.content}\n")
49
+ used += entry.token_count
50
+ parts.append("\n")
51
+ return "".join(parts)
52
+
53
+ @staticmethod
54
+ def _compute_relevance(entry: MemoryEntry, query: str) -> float:
55
+ content_lower = entry.content.lower()
56
+ query_lower = query.lower()
57
+
58
+ if query_lower in content_lower:
59
+ return 1.0
60
+
61
+ query_words = tokenize(query_lower)
62
+ if not query_words:
63
+ return 0.0
64
+
65
+ matched = sum(1 for w in query_words if w in content_lower)
66
+ if matched == 0:
67
+ return 0.0
68
+
69
+ keyword_score = matched / len(query_words)
70
+ age_hours = (time.time() - entry.timestamp) / 3600
71
+ time_decay = max(0.5, 1.0 - age_hours / 24.0)
72
+ return keyword_score * time_decay
@@ -0,0 +1,84 @@
1
+ """短期记忆 - 管理当前对话上下文"""
2
+
3
+ from collections import OrderedDict
4
+ from typing import List, Optional
5
+
6
+ from .base import Memory
7
+ from .entry import MemoryEntry
8
+ from .tokenizer import matches, tokenize
9
+
10
+
11
+ class ConversationMemory(Memory):
12
+ def __init__(self, max_tokens: int = 32768):
13
+ self._entries: OrderedDict[str, MemoryEntry] = OrderedDict()
14
+ self._max_tokens = max_tokens
15
+ self._current_tokens = 0
16
+ self._compressed_summaries: List[MemoryEntry] = []
17
+
18
+ def store(self, entry: MemoryEntry):
19
+ self._entries[entry.id] = entry
20
+ self._current_tokens += entry.token_count
21
+ while self._current_tokens > self._max_tokens and len(self._entries) > 1:
22
+ self._evict_oldest()
23
+
24
+ def retrieve(self, id: str) -> Optional[MemoryEntry]:
25
+ return self._entries.get(id)
26
+
27
+ def search(self, query: str, limit: int) -> List[MemoryEntry]:
28
+ query_tokens = tokenize(query)
29
+ results = []
30
+ for entry in self._entries.values():
31
+ if matches(entry.content, query_tokens):
32
+ results.append(entry)
33
+ if len(results) >= limit:
34
+ break
35
+ return results
36
+
37
+ def get_all(self) -> List[MemoryEntry]:
38
+ return list(self._entries.values())
39
+
40
+ def delete(self, id: str) -> bool:
41
+ entry = self._entries.pop(id, None)
42
+ if entry:
43
+ self._current_tokens -= entry.token_count
44
+ return True
45
+ return False
46
+
47
+ def clear(self):
48
+ self._entries.clear()
49
+ self._current_tokens = 0
50
+ self._compressed_summaries.clear()
51
+
52
+ def token_count(self) -> int:
53
+ return self._current_tokens
54
+
55
+ def size(self) -> int:
56
+ return len(self._entries)
57
+
58
+ @property
59
+ def max_tokens(self) -> int:
60
+ return self._max_tokens
61
+
62
+ def get_compressed_summaries(self) -> List[MemoryEntry]:
63
+ return list(self._compressed_summaries)
64
+
65
+ def inject_summary(self, summary: MemoryEntry):
66
+ self._compressed_summaries.clear()
67
+ self._entries[summary.id] = summary
68
+ self._current_tokens += summary.token_count
69
+
70
+ @property
71
+ def usage_ratio(self) -> float:
72
+ return self._current_tokens / self._max_tokens if self._max_tokens > 0 else 0.0
73
+
74
+ def status_summary(self) -> str:
75
+ return (f"短期记忆: {self.size()}条 / {self._current_tokens} tokens "
76
+ f"(预算: {self._max_tokens}, 使用率: {self.usage_ratio * 100:.0f}%, "
77
+ f"已压缩: {len(self._compressed_summaries)}条)")
78
+
79
+ def _evict_oldest(self):
80
+ if not self._entries:
81
+ return
82
+ _id, entry = self._entries.popitem(last=False)
83
+ self._current_tokens -= entry.token_count
84
+ self._compressed_summaries.append(entry)
@@ -0,0 +1,21 @@
1
+ """记忆查询关键词分词"""
2
+
3
+ import re
4
+ from typing import Set
5
+
6
+
7
+ def tokenize(text: str) -> Set[str]:
8
+ if not text:
9
+ return set()
10
+ text = text.lower()
11
+ # 按非字母数字字符分割
12
+ tokens = set(re.findall(r'[a-zA-Z0-9一-鿿]+', text))
13
+ # 去掉单字词(无意义)
14
+ return {t for t in tokens if len(t) > 1 if not t.isascii() or len(t) > 2}
15
+
16
+
17
+ def matches(content: str, query_tokens: Set[str]) -> bool:
18
+ if not query_tokens:
19
+ return True
20
+ content_lower = content.lower()
21
+ return any(t in content_lower for t in query_tokens)
@@ -0,0 +1,5 @@
1
+ from .task import Task, TaskType, TaskStatus
2
+ from .execution_plan import ExecutionPlan, PlanStatus
3
+ from .planner import Planner
4
+
5
+ __all__ = ["Task", "TaskType", "TaskStatus", "ExecutionPlan", "PlanStatus", "Planner"]
@@ -0,0 +1,225 @@
1
+ """执行计划 - 包含一组有依赖关系的任务"""
2
+
3
+ import time
4
+ from enum import Enum
5
+ from typing import List, Optional, Dict, Set
6
+ from collections import OrderedDict
7
+
8
+ from .task import Task, TaskStatus
9
+
10
+
11
+ class PlanStatus(Enum):
12
+ CREATED = "CREATED"
13
+ RUNNING = "RUNNING"
14
+ COMPLETED = "COMPLETED"
15
+ FAILED = "FAILED"
16
+ CANCELLED = "CANCELLED"
17
+
18
+
19
+ class ExecutionPlan:
20
+ def __init__(self, id: str, goal: str):
21
+ self._id = id
22
+ self._goal = goal
23
+ self._tasks: Dict[str, Task] = OrderedDict()
24
+ self._execution_order: List[str] = []
25
+ self._status = PlanStatus.CREATED
26
+ self._summary: Optional[str] = None
27
+ self._start_time: float = 0.0
28
+ self._end_time: float = 0.0
29
+
30
+ @property
31
+ def id(self) -> str:
32
+ return self._id
33
+
34
+ @property
35
+ def goal(self) -> str:
36
+ return self._goal
37
+
38
+ @property
39
+ def status(self) -> PlanStatus:
40
+ return self._status
41
+
42
+ @property
43
+ def summary(self) -> Optional[str]:
44
+ return self._summary
45
+
46
+ @summary.setter
47
+ def summary(self, value: str):
48
+ self._summary = value
49
+
50
+ @property
51
+ def start_time(self) -> float:
52
+ return self._start_time
53
+
54
+ @property
55
+ def end_time(self) -> float:
56
+ return self._end_time
57
+
58
+ def add_task(self, task: Task):
59
+ self._tasks[task.id] = task
60
+ for dep_id in task.dependencies:
61
+ dep = self._tasks.get(dep_id)
62
+ if dep is not None:
63
+ dep.add_dependent(task.id)
64
+
65
+ def get_task(self, id: str) -> Optional[Task]:
66
+ return self._tasks.get(id)
67
+
68
+ def get_all_tasks(self) -> List[Task]:
69
+ return list(self._tasks.values())
70
+
71
+ def get_root_tasks(self) -> List[Task]:
72
+ return [t for t in self._tasks.values() if not t.dependencies]
73
+
74
+ def get_executable_tasks(self) -> List[Task]:
75
+ return [t for t in self._tasks.values() if t.is_executable(self._tasks)]
76
+
77
+ def compute_execution_order(self) -> bool:
78
+ self._execution_order.clear()
79
+ visited: Set[str] = set()
80
+ visiting: Set[str] = set()
81
+
82
+ def topo_sort(task: Task) -> bool:
83
+ if task.id in visiting:
84
+ return False
85
+ if task.id in visited:
86
+ return True
87
+ visiting.add(task.id)
88
+ for dep_id in task.dependencies:
89
+ dep = self._tasks.get(dep_id)
90
+ if dep is not None:
91
+ if not topo_sort(dep):
92
+ return False
93
+ visiting.remove(task.id)
94
+ visited.add(task.id)
95
+ self._execution_order.append(task.id)
96
+ return True
97
+
98
+ for task in self._tasks.values():
99
+ if task.id not in visited:
100
+ if not topo_sort(task):
101
+ return False
102
+ return True
103
+
104
+ def get_execution_order(self) -> List[str]:
105
+ if not self._execution_order:
106
+ self.compute_execution_order()
107
+ return list(self._execution_order)
108
+
109
+ def get_progress(self) -> float:
110
+ if not self._tasks:
111
+ return 1.0
112
+ completed = sum(1 for t in self._tasks.values()
113
+ if t.status == TaskStatus.COMPLETED)
114
+ return completed / len(self._tasks)
115
+
116
+ def is_all_completed(self) -> bool:
117
+ return all(t.status == TaskStatus.COMPLETED
118
+ for t in self._tasks.values())
119
+
120
+ def has_failed(self) -> bool:
121
+ return any(t.status == TaskStatus.FAILED
122
+ for t in self._tasks.values())
123
+
124
+ def mark_started(self):
125
+ self._status = PlanStatus.RUNNING
126
+ self._start_time = time.time()
127
+
128
+ def mark_completed(self):
129
+ self._status = PlanStatus.COMPLETED
130
+ self._end_time = time.time()
131
+
132
+ def mark_failed(self):
133
+ self._status = PlanStatus.FAILED
134
+ self._end_time = time.time()
135
+
136
+ def mark_cancelled(self):
137
+ self._status = PlanStatus.CANCELLED
138
+ self._end_time = time.time()
139
+
140
+ @property
141
+ def duration(self) -> float:
142
+ if self._start_time == 0:
143
+ return 0.0
144
+ if self._end_time == 0:
145
+ return time.time() - self._start_time
146
+ return self._end_time - self._start_time
147
+
148
+ def visualize(self) -> str:
149
+ lines = []
150
+ lines.append("╔══════════════════════════════════════════════════════════╗")
151
+ goal_display = self._goal[:46] + "..." if len(self._goal) > 46 else self._goal
152
+ lines.append(f"║ 执行计划: {goal_display:<46}║")
153
+ lines.append("╠══════════════════════════════════════════════════════════╣")
154
+
155
+ order = self.get_execution_order()
156
+ for i, task_id in enumerate(order):
157
+ task = self._tasks[task_id]
158
+ icon = self._status_icon(task.status)
159
+ deps = ",".join(task.dependencies) if task.dependencies else "无"
160
+ lines.append(f"║ {i+1}. {icon} {task._id:<20} [{task.type.value:<10}] 依赖: {deps:<15}║")
161
+ desc = task.description[:47] + "..." if len(task.description) > 50 else task.description
162
+ lines.append(f"║ {desc:<53}║")
163
+
164
+ lines.append("╚══════════════════════════════════════════════════════════╝")
165
+ lines.append(f" 进度: {self.get_progress()*100:.0f}% | 状态: {self._status.value}")
166
+ return "\n".join(lines)
167
+
168
+ def summarize(self) -> str:
169
+ batches = self.get_execution_batches()
170
+ ready = self.get_executable_tasks()
171
+ lines = ["📋 计划摘要",
172
+ f" - 目标: {self._compact_goal(48)}",
173
+ f" - 任务数: {len(self._tasks)} | 并行批次: {len(batches)} | "
174
+ f"当前可执行: {len(ready)} | 状态: {self._status.value}"]
175
+ if batches:
176
+ lines.append(f" - 首批执行: {self._format_task_list(batches[0], 5)}")
177
+ if len(batches) > 1:
178
+ lines.append(f" - 最终收敛: {self._format_task_list(batches[-1], 5)}")
179
+ return "\n".join(lines)
180
+
181
+ def get_execution_batches(self) -> List[List[Task]]:
182
+ if not self._tasks:
183
+ return []
184
+ remaining = OrderedDict(self._tasks)
185
+ completed: Set[str] = set()
186
+ batches = []
187
+ while remaining:
188
+ batch = [t for t in remaining.values()
189
+ if completed.issuperset(t.dependencies)]
190
+ if not batch:
191
+ break
192
+ batches.append(batch)
193
+ for t in batch:
194
+ remaining.pop(t.id)
195
+ completed.add(t.id)
196
+ return batches
197
+
198
+ def _compact_goal(self, max_length: int) -> str:
199
+ goal = self._goal.replace("\r\n", " ").replace("\r", " ").replace("\n", " ").strip()
200
+ import re
201
+ goal = re.sub(r" {2,}", " ", goal)
202
+ if len(goal) <= max_length:
203
+ return goal
204
+ return goal[:max_length - 3] + "..."
205
+
206
+ def _format_task_list(self, batch: List[Task], limit: int) -> str:
207
+ if not batch:
208
+ return "无"
209
+ ids = [t.id for t in batch]
210
+ if len(ids) <= limit:
211
+ return ", ".join(ids)
212
+ return ", ".join(ids[:limit]) + f" 等 {len(ids)} 个任务"
213
+
214
+ @staticmethod
215
+ def _status_icon(status: TaskStatus) -> str:
216
+ return {
217
+ TaskStatus.PENDING: "⏳",
218
+ TaskStatus.RUNNING: "▶️",
219
+ TaskStatus.COMPLETED: "✅",
220
+ TaskStatus.FAILED: "❌",
221
+ TaskStatus.SKIPPED: "⏭️",
222
+ }.get(status, "⏳")
223
+
224
+ def __repr__(self) -> str:
225
+ return f"ExecutionPlan[{self._id}: {self._goal}] ({len(self._tasks)} tasks, {self._status.value})"