echo-agent 0.1.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 (219) hide show
  1. echo_agent/__init__.py +5 -0
  2. echo_agent/__main__.py +538 -0
  3. echo_agent/_bundled/skills/development/plan/SKILL.md +54 -0
  4. echo_agent/_bundled/skills/development/skill-creator/SKILL.md +270 -0
  5. echo_agent/_bundled/skills/development/skill-creator/scripts/init_skill.py +226 -0
  6. echo_agent/_bundled/skills/development/skill-creator/scripts/package_skill.py +146 -0
  7. echo_agent/_bundled/skills/development/skill-creator/scripts/quick_validate.py +222 -0
  8. echo_agent/_bundled/skills/productivity/summarize/SKILL.md +67 -0
  9. echo_agent/_bundled/skills/productivity/weather/SKILL.md +49 -0
  10. echo_agent/_bundled/skills/research/arxiv/SKILL.md +232 -0
  11. echo_agent/_bundled/skills/research/arxiv/scripts/search_arxiv.py +115 -0
  12. echo_agent/a2a/__init__.py +5 -0
  13. echo_agent/a2a/client.py +66 -0
  14. echo_agent/a2a/models.py +98 -0
  15. echo_agent/a2a/protocol.py +85 -0
  16. echo_agent/a2a/server.py +71 -0
  17. echo_agent/agent/__init__.py +0 -0
  18. echo_agent/agent/approval_gate.py +326 -0
  19. echo_agent/agent/compression/__init__.py +14 -0
  20. echo_agent/agent/compression/assembler.py +45 -0
  21. echo_agent/agent/compression/boundary.py +141 -0
  22. echo_agent/agent/compression/compressor.py +181 -0
  23. echo_agent/agent/compression/engine.py +88 -0
  24. echo_agent/agent/compression/pruner.py +150 -0
  25. echo_agent/agent/compression/summarizer.py +181 -0
  26. echo_agent/agent/compression/types.py +41 -0
  27. echo_agent/agent/compression/validator.py +96 -0
  28. echo_agent/agent/consolidation.py +96 -0
  29. echo_agent/agent/context.py +403 -0
  30. echo_agent/agent/executors/__init__.py +0 -0
  31. echo_agent/agent/executors/base.py +211 -0
  32. echo_agent/agent/executors/factory.py +34 -0
  33. echo_agent/agent/executors/remote.py +193 -0
  34. echo_agent/agent/loop.py +891 -0
  35. echo_agent/agent/multi_agent/__init__.py +15 -0
  36. echo_agent/agent/multi_agent/audit.py +19 -0
  37. echo_agent/agent/multi_agent/error_messages.py +35 -0
  38. echo_agent/agent/multi_agent/error_types.py +36 -0
  39. echo_agent/agent/multi_agent/models.py +37 -0
  40. echo_agent/agent/multi_agent/registry.py +41 -0
  41. echo_agent/agent/multi_agent/runtime.py +201 -0
  42. echo_agent/agent/pipeline/__init__.py +14 -0
  43. echo_agent/agent/pipeline/context_stage.py +219 -0
  44. echo_agent/agent/pipeline/inference_stage.py +433 -0
  45. echo_agent/agent/pipeline/response_stage.py +146 -0
  46. echo_agent/agent/pipeline/types.py +40 -0
  47. echo_agent/agent/planning/__init__.py +4 -0
  48. echo_agent/agent/planning/models.py +83 -0
  49. echo_agent/agent/planning/planner.py +57 -0
  50. echo_agent/agent/planning/reflection.py +54 -0
  51. echo_agent/agent/planning/strategies.py +183 -0
  52. echo_agent/agent/tools/__init__.py +167 -0
  53. echo_agent/agent/tools/base.py +149 -0
  54. echo_agent/agent/tools/circuit_breaker.py +82 -0
  55. echo_agent/agent/tools/clarify.py +42 -0
  56. echo_agent/agent/tools/code_exec.py +147 -0
  57. echo_agent/agent/tools/cronjob.py +93 -0
  58. echo_agent/agent/tools/delegate.py +393 -0
  59. echo_agent/agent/tools/filesystem.py +180 -0
  60. echo_agent/agent/tools/image_gen.py +65 -0
  61. echo_agent/agent/tools/knowledge.py +81 -0
  62. echo_agent/agent/tools/memory.py +198 -0
  63. echo_agent/agent/tools/message.py +39 -0
  64. echo_agent/agent/tools/notify.py +35 -0
  65. echo_agent/agent/tools/patch.py +178 -0
  66. echo_agent/agent/tools/process.py +139 -0
  67. echo_agent/agent/tools/registry.py +185 -0
  68. echo_agent/agent/tools/search.py +99 -0
  69. echo_agent/agent/tools/session_search.py +76 -0
  70. echo_agent/agent/tools/shell.py +164 -0
  71. echo_agent/agent/tools/skill_install.py +255 -0
  72. echo_agent/agent/tools/skills.py +177 -0
  73. echo_agent/agent/tools/task.py +104 -0
  74. echo_agent/agent/tools/todo.py +148 -0
  75. echo_agent/agent/tools/tts.py +77 -0
  76. echo_agent/agent/tools/vision.py +71 -0
  77. echo_agent/agent/tools/web.py +208 -0
  78. echo_agent/agent/tools/workflow.py +89 -0
  79. echo_agent/bus/__init__.py +11 -0
  80. echo_agent/bus/events.py +193 -0
  81. echo_agent/bus/queue.py +158 -0
  82. echo_agent/bus/rate_limiter.py +51 -0
  83. echo_agent/channels/__init__.py +0 -0
  84. echo_agent/channels/base.py +185 -0
  85. echo_agent/channels/cli.py +149 -0
  86. echo_agent/channels/cron.py +44 -0
  87. echo_agent/channels/dingtalk.py +195 -0
  88. echo_agent/channels/discord.py +359 -0
  89. echo_agent/channels/email.py +168 -0
  90. echo_agent/channels/feishu.py +240 -0
  91. echo_agent/channels/manager.py +417 -0
  92. echo_agent/channels/matrix.py +281 -0
  93. echo_agent/channels/qqbot.py +638 -0
  94. echo_agent/channels/qqbot_media.py +482 -0
  95. echo_agent/channels/slack.py +297 -0
  96. echo_agent/channels/telegram.py +275 -0
  97. echo_agent/channels/webhook.py +106 -0
  98. echo_agent/channels/wecom.py +152 -0
  99. echo_agent/channels/weixin.py +603 -0
  100. echo_agent/channels/whatsapp.py +138 -0
  101. echo_agent/cli/__init__.py +0 -0
  102. echo_agent/cli/colors.py +42 -0
  103. echo_agent/cli/evolution_cmd.py +299 -0
  104. echo_agent/cli/i18n/__init__.py +123 -0
  105. echo_agent/cli/i18n/en.py +275 -0
  106. echo_agent/cli/i18n/zh.py +275 -0
  107. echo_agent/cli/plugins_cmd.py +205 -0
  108. echo_agent/cli/prompt.py +102 -0
  109. echo_agent/cli/service.py +156 -0
  110. echo_agent/cli/setup.py +1111 -0
  111. echo_agent/cli/status.py +93 -0
  112. echo_agent/config/__init__.py +8 -0
  113. echo_agent/config/default.yaml +199 -0
  114. echo_agent/config/loader.py +125 -0
  115. echo_agent/config/schema.py +652 -0
  116. echo_agent/evaluation/__init__.py +4 -0
  117. echo_agent/evaluation/dataset.py +66 -0
  118. echo_agent/evaluation/metrics.py +70 -0
  119. echo_agent/evaluation/reporter.py +42 -0
  120. echo_agent/evaluation/runner.py +143 -0
  121. echo_agent/evolution/__init__.py +38 -0
  122. echo_agent/evolution/engine.py +335 -0
  123. echo_agent/evolution/evolver.py +397 -0
  124. echo_agent/evolution/gate.py +413 -0
  125. echo_agent/evolution/recorder.py +288 -0
  126. echo_agent/evolution/scheduler.py +133 -0
  127. echo_agent/evolution/store.py +331 -0
  128. echo_agent/evolution/tools.py +110 -0
  129. echo_agent/evolution/types.py +270 -0
  130. echo_agent/gateway/__init__.py +7 -0
  131. echo_agent/gateway/auth.py +178 -0
  132. echo_agent/gateway/editor.py +121 -0
  133. echo_agent/gateway/health.py +51 -0
  134. echo_agent/gateway/hooks.py +86 -0
  135. echo_agent/gateway/media.py +137 -0
  136. echo_agent/gateway/rate_limiter.py +72 -0
  137. echo_agent/gateway/router.py +86 -0
  138. echo_agent/gateway/server.py +570 -0
  139. echo_agent/gateway/session_context.py +57 -0
  140. echo_agent/gateway/session_policy.py +47 -0
  141. echo_agent/gateway/static/index.html +432 -0
  142. echo_agent/knowledge/__init__.py +5 -0
  143. echo_agent/knowledge/index.py +308 -0
  144. echo_agent/mcp/__init__.py +3 -0
  145. echo_agent/mcp/client.py +158 -0
  146. echo_agent/mcp/manager.py +161 -0
  147. echo_agent/mcp/oauth.py +208 -0
  148. echo_agent/mcp/security.py +79 -0
  149. echo_agent/mcp/tool_adapter.py +73 -0
  150. echo_agent/mcp/transport.py +353 -0
  151. echo_agent/memory/__init__.py +0 -0
  152. echo_agent/memory/consolidator.py +273 -0
  153. echo_agent/memory/contradiction.py +287 -0
  154. echo_agent/memory/forgetting.py +114 -0
  155. echo_agent/memory/retrieval.py +184 -0
  156. echo_agent/memory/reviewer.py +192 -0
  157. echo_agent/memory/store.py +706 -0
  158. echo_agent/memory/tiers.py +243 -0
  159. echo_agent/memory/types.py +168 -0
  160. echo_agent/memory/vectors.py +148 -0
  161. echo_agent/models/__init__.py +0 -0
  162. echo_agent/models/credential_pool.py +86 -0
  163. echo_agent/models/inference.py +98 -0
  164. echo_agent/models/provider.py +208 -0
  165. echo_agent/models/providers/__init__.py +209 -0
  166. echo_agent/models/providers/anthropic_provider.py +164 -0
  167. echo_agent/models/providers/bedrock_provider.py +261 -0
  168. echo_agent/models/providers/format_utils.py +198 -0
  169. echo_agent/models/providers/gemini_provider.py +159 -0
  170. echo_agent/models/providers/openai_provider.py +253 -0
  171. echo_agent/models/providers/openrouter_provider.py +38 -0
  172. echo_agent/models/rate_limiter.py +75 -0
  173. echo_agent/models/router.py +325 -0
  174. echo_agent/models/tokenizer.py +111 -0
  175. echo_agent/observability/__init__.py +0 -0
  176. echo_agent/observability/monitor.py +209 -0
  177. echo_agent/observability/spans.py +75 -0
  178. echo_agent/observability/telemetry.py +86 -0
  179. echo_agent/permissions/__init__.py +0 -0
  180. echo_agent/permissions/allowlist.py +97 -0
  181. echo_agent/permissions/manager.py +460 -0
  182. echo_agent/plugins/__init__.py +30 -0
  183. echo_agent/plugins/context.py +145 -0
  184. echo_agent/plugins/errors.py +23 -0
  185. echo_agent/plugins/hooks.py +126 -0
  186. echo_agent/plugins/loader.py +251 -0
  187. echo_agent/plugins/manager.py +216 -0
  188. echo_agent/plugins/manifest.py +70 -0
  189. echo_agent/runtime_paths.py +25 -0
  190. echo_agent/scheduler/__init__.py +0 -0
  191. echo_agent/scheduler/delivery.py +63 -0
  192. echo_agent/scheduler/service.py +398 -0
  193. echo_agent/security/__init__.py +11 -0
  194. echo_agent/security/capabilities.py +54 -0
  195. echo_agent/security/guards.py +265 -0
  196. echo_agent/security/path_policy.py +212 -0
  197. echo_agent/security/risk_classifier.py +75 -0
  198. echo_agent/security/smart_approval.py +60 -0
  199. echo_agent/security/tool_policy.py +159 -0
  200. echo_agent/session/__init__.py +0 -0
  201. echo_agent/session/manager.py +404 -0
  202. echo_agent/skills/__init__.py +0 -0
  203. echo_agent/skills/manager.py +279 -0
  204. echo_agent/skills/reviewer.py +163 -0
  205. echo_agent/skills/store.py +358 -0
  206. echo_agent/storage/__init__.py +0 -0
  207. echo_agent/storage/backend.py +111 -0
  208. echo_agent/storage/sqlite.py +523 -0
  209. echo_agent/tasks/__init__.py +20 -0
  210. echo_agent/tasks/manager.py +108 -0
  211. echo_agent/tasks/models.py +180 -0
  212. echo_agent/tasks/workflow.py +182 -0
  213. echo_agent/utils/__init__.py +0 -0
  214. echo_agent/utils/async_io.py +80 -0
  215. echo_agent/utils/text.py +91 -0
  216. echo_agent-0.1.0.dist-info/METADATA +286 -0
  217. echo_agent-0.1.0.dist-info/RECORD +219 -0
  218. echo_agent-0.1.0.dist-info/WHEEL +4 -0
  219. echo_agent-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,133 @@
1
+ """EvolutionScheduler — chooses *when* to invoke ``EvolutionEngine.run_evolution``.
2
+
3
+ Three trigger modes (mutually exclusive):
4
+
5
+ - manual : never fires automatically; CLI / agent tools / Gateway only.
6
+ - threshold : fires whenever the unconsumed-trajectory count crosses N.
7
+ - scheduled : fires on a cron expression (using croniter).
8
+
9
+ The scheduler runs a single asyncio polling task; it does not push work into
10
+ the global :class:`echo_agent.scheduler.service.Scheduler` because evolution
11
+ runs are session-internal and should not survive a restart unfinished.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ from datetime import datetime
18
+ from typing import Awaitable, Callable
19
+
20
+ from loguru import logger
21
+
22
+ from echo_agent.evolution.types import EvolutionRun
23
+
24
+
25
+ RunEvolutionFn = Callable[..., Awaitable[EvolutionRun]]
26
+
27
+
28
+ class EvolutionScheduler:
29
+ def __init__(
30
+ self,
31
+ *,
32
+ run_fn: RunEvolutionFn,
33
+ unconsumed_count_fn: Callable[[], Awaitable[int]],
34
+ trigger_mode: str = "manual",
35
+ threshold: int = 50,
36
+ cron_expression: str = "0 4 * * *",
37
+ poll_interval_seconds: float = 30.0,
38
+ ):
39
+ self._run_fn = run_fn
40
+ self._count_fn = unconsumed_count_fn
41
+ self._mode = trigger_mode
42
+ self._threshold = max(1, int(threshold))
43
+ self._cron_expression = cron_expression
44
+ self._poll_interval = max(5.0, float(poll_interval_seconds))
45
+ self._task: asyncio.Task | None = None
46
+ self._running = False
47
+ self._next_cron_ts: float | None = None
48
+
49
+ @property
50
+ def mode(self) -> str:
51
+ return self._mode
52
+
53
+ async def start(self) -> None:
54
+ if self._running or self._mode == "manual":
55
+ return
56
+ self._running = True
57
+ if self._mode == "scheduled":
58
+ self._next_cron_ts = self._compute_next_cron_ts(datetime.now())
59
+ self._task = asyncio.create_task(self._loop(), name="evolution-scheduler")
60
+ logger.info(
61
+ "EvolutionScheduler started (mode={}, threshold={}, cron='{}')",
62
+ self._mode,
63
+ self._threshold,
64
+ self._cron_expression,
65
+ )
66
+
67
+ async def stop(self) -> None:
68
+ self._running = False
69
+ if self._task is not None:
70
+ self._task.cancel()
71
+ try:
72
+ await self._task
73
+ except asyncio.CancelledError:
74
+ pass
75
+ except Exception as e:
76
+ logger.debug("EvolutionScheduler stop raised: {}", e)
77
+ self._task = None
78
+
79
+ async def _loop(self) -> None:
80
+ try:
81
+ while self._running:
82
+ try:
83
+ await self._tick()
84
+ except asyncio.CancelledError:
85
+ raise
86
+ except Exception as e:
87
+ logger.warning("EvolutionScheduler tick failed: {}", e)
88
+ await asyncio.sleep(self._poll_interval)
89
+ except asyncio.CancelledError:
90
+ return
91
+
92
+ async def _tick(self) -> None:
93
+ if self._mode == "threshold":
94
+ await self._tick_threshold()
95
+ elif self._mode == "scheduled":
96
+ await self._tick_cron()
97
+
98
+ async def _tick_threshold(self) -> None:
99
+ try:
100
+ count = await self._count_fn()
101
+ except Exception as e:
102
+ logger.debug("threshold count failed: {}", e)
103
+ return
104
+ if count >= self._threshold:
105
+ logger.info("EvolutionScheduler threshold reached ({} >= {})", count, self._threshold)
106
+ await self._safe_run("threshold")
107
+
108
+ async def _tick_cron(self) -> None:
109
+ if self._next_cron_ts is None:
110
+ self._next_cron_ts = self._compute_next_cron_ts(datetime.now())
111
+ return
112
+ now_ts = datetime.now().timestamp()
113
+ if now_ts >= self._next_cron_ts:
114
+ await self._safe_run("scheduled")
115
+ self._next_cron_ts = self._compute_next_cron_ts(datetime.now())
116
+
117
+ async def _safe_run(self, trigger: str) -> None:
118
+ try:
119
+ await self._run_fn(trigger=trigger)
120
+ except Exception as e:
121
+ logger.warning("Scheduled evolution run failed ({}): {}", trigger, e)
122
+
123
+ def _compute_next_cron_ts(self, base: datetime) -> float | None:
124
+ if not self._cron_expression:
125
+ return None
126
+ try:
127
+ from croniter import croniter
128
+
129
+ cron = croniter(self._cron_expression, base)
130
+ return cron.get_next(datetime).timestamp()
131
+ except Exception as e:
132
+ logger.warning("Invalid cron expression '{}': {}", self._cron_expression, e)
133
+ return None
@@ -0,0 +1,331 @@
1
+ """TrajectoryStore — SQLite-backed persistence for the evolution subsystem.
2
+
3
+ Adds three tables to the shared SQLite backend (created via execute_sql at
4
+ init time, so it does not fight the global migration list in storage.sqlite):
5
+
6
+ - evolution_trajectories
7
+ - evolution_candidates
8
+ - evolution_runs
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ from datetime import datetime, timedelta
15
+ from typing import Any
16
+
17
+ from loguru import logger
18
+
19
+ from echo_agent.evolution.types import (
20
+ CandidateStatus,
21
+ EvolutionRun,
22
+ Outcome,
23
+ SkillCandidate,
24
+ Trajectory,
25
+ )
26
+ from echo_agent.storage.backend import StorageBackend
27
+
28
+
29
+ _SCHEMA_STATEMENTS: list[str] = [
30
+ """CREATE TABLE IF NOT EXISTS evolution_trajectories (
31
+ id TEXT PRIMARY KEY,
32
+ session_id TEXT NOT NULL DEFAULT '',
33
+ chat_id TEXT NOT NULL DEFAULT '',
34
+ channel TEXT NOT NULL DEFAULT '',
35
+ task_type TEXT NOT NULL DEFAULT '',
36
+ outcome TEXT NOT NULL DEFAULT 'success',
37
+ score REAL,
38
+ iterations INTEGER NOT NULL DEFAULT 0,
39
+ duration_ms REAL NOT NULL DEFAULT 0,
40
+ data TEXT NOT NULL,
41
+ created_at TEXT NOT NULL,
42
+ consumed_run_id TEXT NOT NULL DEFAULT ''
43
+ )""",
44
+ "CREATE INDEX IF NOT EXISTS idx_evolution_traj_outcome "
45
+ "ON evolution_trajectories(outcome, created_at)",
46
+ "CREATE INDEX IF NOT EXISTS idx_evolution_traj_task_type "
47
+ "ON evolution_trajectories(task_type, outcome)",
48
+ "CREATE INDEX IF NOT EXISTS idx_evolution_traj_consumed "
49
+ "ON evolution_trajectories(consumed_run_id)",
50
+ """CREATE TABLE IF NOT EXISTS evolution_candidates (
51
+ id TEXT PRIMARY KEY,
52
+ run_id TEXT NOT NULL DEFAULT '',
53
+ operation TEXT NOT NULL DEFAULT 'create',
54
+ skill_name TEXT NOT NULL DEFAULT '',
55
+ status TEXT NOT NULL DEFAULT 'pending',
56
+ data TEXT NOT NULL,
57
+ created_at TEXT NOT NULL,
58
+ updated_at TEXT NOT NULL
59
+ )""",
60
+ "CREATE INDEX IF NOT EXISTS idx_evolution_cand_status "
61
+ "ON evolution_candidates(status)",
62
+ "CREATE INDEX IF NOT EXISTS idx_evolution_cand_run "
63
+ "ON evolution_candidates(run_id)",
64
+ "CREATE INDEX IF NOT EXISTS idx_evolution_cand_skill "
65
+ "ON evolution_candidates(skill_name)",
66
+ """CREATE TABLE IF NOT EXISTS evolution_runs (
67
+ id TEXT PRIMARY KEY,
68
+ triggered_by TEXT NOT NULL DEFAULT 'manual',
69
+ data TEXT NOT NULL,
70
+ started_at TEXT NOT NULL,
71
+ finished_at TEXT NOT NULL DEFAULT ''
72
+ )""",
73
+ "CREATE INDEX IF NOT EXISTS idx_evolution_runs_started "
74
+ "ON evolution_runs(started_at)",
75
+ ]
76
+
77
+
78
+ class TrajectoryStore:
79
+ """Persistence layer for trajectories, candidates, and evolution runs.
80
+
81
+ Designed to be safe to call concurrently from background tasks: every
82
+ method opens a single execute/fetch round-trip on the shared backend.
83
+ """
84
+
85
+ def __init__(self, storage: StorageBackend):
86
+ self._storage = storage
87
+ self._initialized = False
88
+
89
+ async def init_schema(self) -> None:
90
+ if self._initialized:
91
+ return
92
+ for stmt in _SCHEMA_STATEMENTS:
93
+ try:
94
+ await self._storage.execute_sql(stmt)
95
+ except Exception as e:
96
+ logger.error("Failed to apply evolution schema statement: {}", e)
97
+ raise
98
+ self._initialized = True
99
+
100
+ # ── Trajectories ────────────────────────────────────────────────────────
101
+
102
+ async def append_trajectory(self, trajectory: Trajectory) -> None:
103
+ payload = json.dumps(trajectory.to_dict(), ensure_ascii=False)
104
+ await self._storage.execute_sql(
105
+ "INSERT OR REPLACE INTO evolution_trajectories "
106
+ "(id, session_id, chat_id, channel, task_type, outcome, score, "
107
+ " iterations, duration_ms, data, created_at, consumed_run_id) "
108
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, "
109
+ " COALESCE((SELECT consumed_run_id FROM evolution_trajectories WHERE id = ?), ''))",
110
+ (
111
+ trajectory.id,
112
+ trajectory.session_id,
113
+ trajectory.chat_id,
114
+ trajectory.channel,
115
+ trajectory.task_type,
116
+ trajectory.outcome,
117
+ trajectory.reflection_score,
118
+ trajectory.iterations,
119
+ trajectory.duration_ms,
120
+ payload,
121
+ trajectory.created_at,
122
+ trajectory.id,
123
+ ),
124
+ )
125
+
126
+ async def get_trajectory(self, trajectory_id: str) -> Trajectory | None:
127
+ rows = await self._storage.fetch_sql(
128
+ "SELECT data FROM evolution_trajectories WHERE id = ?",
129
+ (trajectory_id,),
130
+ )
131
+ if not rows:
132
+ return None
133
+ return Trajectory.from_dict(json.loads(rows[0]["data"]))
134
+
135
+ async def list_trajectories(
136
+ self,
137
+ *,
138
+ limit: int = 200,
139
+ outcome: Outcome | None = None,
140
+ task_type: str | None = None,
141
+ only_unconsumed: bool = False,
142
+ since: str | None = None,
143
+ ) -> list[Trajectory]:
144
+ clauses: list[str] = []
145
+ params: list[Any] = []
146
+ if outcome:
147
+ clauses.append("outcome = ?")
148
+ params.append(outcome)
149
+ if task_type:
150
+ clauses.append("task_type = ?")
151
+ params.append(task_type)
152
+ if only_unconsumed:
153
+ clauses.append("consumed_run_id = ''")
154
+ if since:
155
+ clauses.append("created_at >= ?")
156
+ params.append(since)
157
+ where = ""
158
+ if clauses:
159
+ where = " WHERE " + " AND ".join(clauses)
160
+ params.append(int(limit))
161
+ rows = await self._storage.fetch_sql(
162
+ f"SELECT data FROM evolution_trajectories{where} "
163
+ f"ORDER BY created_at DESC LIMIT ?",
164
+ tuple(params),
165
+ )
166
+ out: list[Trajectory] = []
167
+ for r in rows:
168
+ try:
169
+ out.append(Trajectory.from_dict(json.loads(r["data"])))
170
+ except Exception as e:
171
+ logger.debug("Skipping malformed trajectory row: {}", e)
172
+ return out
173
+
174
+ async def count_unconsumed(self) -> int:
175
+ rows = await self._storage.fetch_sql(
176
+ "SELECT COUNT(*) AS n FROM evolution_trajectories WHERE consumed_run_id = ''",
177
+ )
178
+ if not rows:
179
+ return 0
180
+ try:
181
+ return int(rows[0].get("n", 0) or 0)
182
+ except (TypeError, ValueError):
183
+ return 0
184
+
185
+ async def mark_consumed(self, trajectory_ids: list[str], run_id: str) -> None:
186
+ if not trajectory_ids:
187
+ return
188
+ placeholders = ",".join(["?"] * len(trajectory_ids))
189
+ await self._storage.execute_sql(
190
+ f"UPDATE evolution_trajectories SET consumed_run_id = ? "
191
+ f"WHERE id IN ({placeholders})",
192
+ tuple([run_id, *trajectory_ids]),
193
+ )
194
+
195
+ async def purge_older_than(self, retention_days: int) -> int:
196
+ if retention_days <= 0:
197
+ return 0
198
+ cutoff = (datetime.now() - timedelta(days=retention_days)).isoformat()
199
+ before = await self._storage.fetch_sql(
200
+ "SELECT COUNT(*) AS n FROM evolution_trajectories WHERE created_at < ?",
201
+ (cutoff,),
202
+ )
203
+ try:
204
+ count = int(before[0].get("n", 0) or 0) if before else 0
205
+ except (TypeError, ValueError):
206
+ count = 0
207
+ await self._storage.execute_sql(
208
+ "DELETE FROM evolution_trajectories WHERE created_at < ?",
209
+ (cutoff,),
210
+ )
211
+ return count
212
+
213
+ # ── Candidates ──────────────────────────────────────────────────────────
214
+
215
+ async def save_candidate(self, candidate: SkillCandidate) -> None:
216
+ now = datetime.now().isoformat()
217
+ payload = json.dumps(candidate.to_dict(), ensure_ascii=False)
218
+ await self._storage.execute_sql(
219
+ "INSERT OR REPLACE INTO evolution_candidates "
220
+ "(id, run_id, operation, skill_name, status, data, created_at, updated_at) "
221
+ "VALUES (?, ?, ?, ?, ?, ?, "
222
+ " COALESCE((SELECT created_at FROM evolution_candidates WHERE id = ?), ?), "
223
+ " ?)",
224
+ (
225
+ candidate.id,
226
+ candidate.run_id,
227
+ candidate.operation,
228
+ candidate.skill_name,
229
+ candidate.status,
230
+ payload,
231
+ candidate.id,
232
+ candidate.created_at or now,
233
+ now,
234
+ ),
235
+ )
236
+
237
+ async def get_candidate(self, candidate_id: str) -> SkillCandidate | None:
238
+ rows = await self._storage.fetch_sql(
239
+ "SELECT data FROM evolution_candidates WHERE id = ?",
240
+ (candidate_id,),
241
+ )
242
+ if not rows:
243
+ return None
244
+ return SkillCandidate.from_dict(json.loads(rows[0]["data"]))
245
+
246
+ async def list_candidates(
247
+ self,
248
+ *,
249
+ status: CandidateStatus | None = None,
250
+ run_id: str | None = None,
251
+ skill_name: str | None = None,
252
+ limit: int = 100,
253
+ ) -> list[SkillCandidate]:
254
+ clauses: list[str] = []
255
+ params: list[Any] = []
256
+ if status:
257
+ clauses.append("status = ?")
258
+ params.append(status)
259
+ if run_id:
260
+ clauses.append("run_id = ?")
261
+ params.append(run_id)
262
+ if skill_name:
263
+ clauses.append("skill_name = ?")
264
+ params.append(skill_name)
265
+ where = ""
266
+ if clauses:
267
+ where = " WHERE " + " AND ".join(clauses)
268
+ params.append(int(limit))
269
+ rows = await self._storage.fetch_sql(
270
+ f"SELECT data FROM evolution_candidates{where} "
271
+ f"ORDER BY updated_at DESC LIMIT ?",
272
+ tuple(params),
273
+ )
274
+ out: list[SkillCandidate] = []
275
+ for r in rows:
276
+ try:
277
+ out.append(SkillCandidate.from_dict(json.loads(r["data"])))
278
+ except Exception as e:
279
+ logger.debug("Skipping malformed candidate row: {}", e)
280
+ return out
281
+
282
+ async def update_candidate(self, candidate: SkillCandidate) -> None:
283
+ await self.save_candidate(candidate)
284
+
285
+ async def latest_promoted_for_skill(self, skill_name: str) -> SkillCandidate | None:
286
+ rows = await self._storage.fetch_sql(
287
+ "SELECT data FROM evolution_candidates "
288
+ "WHERE skill_name = ? AND status = 'promoted' "
289
+ "ORDER BY updated_at DESC LIMIT 1",
290
+ (skill_name,),
291
+ )
292
+ if not rows:
293
+ return None
294
+ return SkillCandidate.from_dict(json.loads(rows[0]["data"]))
295
+
296
+ # ── Runs ────────────────────────────────────────────────────────────────
297
+
298
+ async def save_run(self, run: EvolutionRun) -> None:
299
+ payload = json.dumps(run.to_dict(), ensure_ascii=False)
300
+ await self._storage.execute_sql(
301
+ "INSERT OR REPLACE INTO evolution_runs "
302
+ "(id, triggered_by, data, started_at, finished_at) "
303
+ "VALUES (?, ?, ?, ?, ?)",
304
+ (run.id, run.triggered_by, payload, run.started_at, run.finished_at),
305
+ )
306
+
307
+ async def get_run(self, run_id: str) -> EvolutionRun | None:
308
+ rows = await self._storage.fetch_sql(
309
+ "SELECT data FROM evolution_runs WHERE id = ?",
310
+ (run_id,),
311
+ )
312
+ if not rows:
313
+ return None
314
+ return EvolutionRun.from_dict(json.loads(rows[0]["data"]))
315
+
316
+ async def list_runs(self, *, limit: int = 25) -> list[EvolutionRun]:
317
+ rows = await self._storage.fetch_sql(
318
+ "SELECT data FROM evolution_runs ORDER BY started_at DESC LIMIT ?",
319
+ (int(limit),),
320
+ )
321
+ out: list[EvolutionRun] = []
322
+ for r in rows:
323
+ try:
324
+ out.append(EvolutionRun.from_dict(json.loads(r["data"])))
325
+ except Exception as e:
326
+ logger.debug("Skipping malformed run row: {}", e)
327
+ return out
328
+
329
+ async def latest_run(self) -> EvolutionRun | None:
330
+ runs = await self.list_runs(limit=1)
331
+ return runs[0] if runs else None
@@ -0,0 +1,110 @@
1
+ """Agent-facing tools for the self-evolution subsystem.
2
+
3
+ These are intentionally small wrappers over :class:`EvolutionEngine` — they
4
+ let the agent itself inspect or trigger evolution, but every state change
5
+ goes through the same :class:`PromotionGate` as the CLI.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from typing import Any
12
+
13
+ from echo_agent.agent.tools.base import Tool, ToolExecutionContext, ToolResult
14
+ from echo_agent.evolution.engine import EvolutionEngine
15
+
16
+
17
+ class EvolutionStatusTool(Tool):
18
+ name = "evolution_status"
19
+ description = (
20
+ "Show the status of the self-evolving skill harness — most recent run, "
21
+ "pending and promoted candidate counts, cooldowns, and unconsumed "
22
+ "trajectory count."
23
+ )
24
+ parameters: dict[str, Any] = {"type": "object", "properties": {}, "required": []}
25
+ risk_level = "read_only"
26
+
27
+ def __init__(self, engine: EvolutionEngine):
28
+ self._engine = engine
29
+
30
+ def execution_mode(self, params: dict[str, Any]) -> str:
31
+ return "read_only"
32
+
33
+ async def execute(self, params: dict[str, Any], ctx: ToolExecutionContext | None = None) -> ToolResult:
34
+ try:
35
+ summary = await self._engine.status_summary()
36
+ return ToolResult(success=True, output=json.dumps(summary, ensure_ascii=False, indent=2))
37
+ except Exception as e:
38
+ return ToolResult(success=False, error=f"evolution_status failed: {e}")
39
+
40
+
41
+ class EvolutionRunTool(Tool):
42
+ name = "evolution_run"
43
+ description = (
44
+ "Trigger a self-evolution pass right now. Generates skill candidates from "
45
+ "recent trajectories and runs an A/B evaluation against the baseline "
46
+ "dataset before promoting any change. High-risk: requires approval."
47
+ )
48
+ parameters: dict[str, Any] = {"type": "object", "properties": {}, "required": []}
49
+ risk_level = "dangerous"
50
+
51
+ def __init__(self, engine: EvolutionEngine):
52
+ self._engine = engine
53
+
54
+ def execution_mode(self, params: dict[str, Any]) -> str:
55
+ return "side_effect"
56
+
57
+ async def execute(self, params: dict[str, Any], ctx: ToolExecutionContext | None = None) -> ToolResult:
58
+ try:
59
+ run = await self._engine.run_evolution(trigger="manual")
60
+ return ToolResult(
61
+ success=True,
62
+ output=json.dumps(run.to_dict(), ensure_ascii=False, indent=2),
63
+ )
64
+ except Exception as e:
65
+ return ToolResult(success=False, error=f"evolution_run failed: {e}")
66
+
67
+
68
+ class EvolutionRollbackTool(Tool):
69
+ name = "evolution_rollback"
70
+ description = (
71
+ "Roll back the most recent promoted change for a given skill. "
72
+ "Use when a promoted skill turns out to be harmful. High-risk: "
73
+ "requires approval."
74
+ )
75
+ parameters: dict[str, Any] = {
76
+ "type": "object",
77
+ "properties": {
78
+ "skill_name": {
79
+ "type": "string",
80
+ "description": "Name of the skill to roll back.",
81
+ },
82
+ },
83
+ "required": ["skill_name"],
84
+ }
85
+ risk_level = "dangerous"
86
+
87
+ def __init__(self, engine: EvolutionEngine):
88
+ self._engine = engine
89
+
90
+ def execution_mode(self, params: dict[str, Any]) -> str:
91
+ return "side_effect"
92
+
93
+ async def execute(self, params: dict[str, Any], ctx: ToolExecutionContext | None = None) -> ToolResult:
94
+ skill_name = (params.get("skill_name") or "").strip()
95
+ if not skill_name:
96
+ return ToolResult(success=False, error="skill_name is required")
97
+ try:
98
+ ok, message = await self._engine.rollback_skill(skill_name)
99
+ return ToolResult(success=ok, output=message if ok else "", error="" if ok else message)
100
+ except Exception as e:
101
+ return ToolResult(success=False, error=f"evolution_rollback failed: {e}")
102
+
103
+
104
+ def build_evolution_tools(engine: EvolutionEngine) -> list[Tool]:
105
+ """Construct the set of evolution tools to register on the ToolRegistry."""
106
+ return [
107
+ EvolutionStatusTool(engine),
108
+ EvolutionRunTool(engine),
109
+ EvolutionRollbackTool(engine),
110
+ ]