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,66 @@
1
+ """A2A client — discover and delegate to remote agents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import aiohttp
6
+
7
+ from echo_agent.a2a.models import AgentCard, A2ATask, A2AMessage
8
+
9
+
10
+ class A2AClient:
11
+ def __init__(self, timeout: float = 60):
12
+ self._timeout = timeout
13
+
14
+ async def discover(self, base_url: str) -> AgentCard:
15
+ url = f"{base_url.rstrip('/')}/.well-known/agent.json"
16
+ async with aiohttp.ClientSession() as session:
17
+ async with session.get(url, timeout=aiohttp.ClientTimeout(total=self._timeout)) as resp:
18
+ data = await resp.json()
19
+ return AgentCard(
20
+ name=data.get("name", ""),
21
+ description=data.get("description", ""),
22
+ url=base_url,
23
+ version=data.get("version", ""),
24
+ capabilities=[],
25
+ )
26
+
27
+ async def send_task(self, base_url: str, message: str, task_id: str = "") -> A2ATask:
28
+ url = f"{base_url.rstrip('/')}/a2a"
29
+ payload = {
30
+ "jsonrpc": "2.0",
31
+ "id": "1",
32
+ "method": "tasks/send",
33
+ "params": {
34
+ "id": task_id,
35
+ "message": A2AMessage.text("user", message).to_dict(),
36
+ },
37
+ }
38
+ async with aiohttp.ClientSession() as session:
39
+ async with session.post(url, json=payload, timeout=aiohttp.ClientTimeout(total=self._timeout)) as resp:
40
+ data = await resp.json()
41
+ result = data.get("result", {})
42
+ return A2ATask.from_dict(result)
43
+
44
+ async def get_task(self, base_url: str, task_id: str) -> A2ATask:
45
+ url = f"{base_url.rstrip('/')}/a2a"
46
+ payload = {
47
+ "jsonrpc": "2.0", "id": "1",
48
+ "method": "tasks/get",
49
+ "params": {"id": task_id},
50
+ }
51
+ async with aiohttp.ClientSession() as session:
52
+ async with session.post(url, json=payload, timeout=aiohttp.ClientTimeout(total=self._timeout)) as resp:
53
+ data = await resp.json()
54
+ return A2ATask.from_dict(data.get("result", {}))
55
+
56
+ async def cancel_task(self, base_url: str, task_id: str) -> A2ATask:
57
+ url = f"{base_url.rstrip('/')}/a2a"
58
+ payload = {
59
+ "jsonrpc": "2.0", "id": "1",
60
+ "method": "tasks/cancel",
61
+ "params": {"id": task_id},
62
+ }
63
+ async with aiohttp.ClientSession() as session:
64
+ async with session.post(url, json=payload, timeout=aiohttp.ClientTimeout(total=self._timeout)) as resp:
65
+ data = await resp.json()
66
+ return A2ATask.from_dict(data.get("result", {}))
@@ -0,0 +1,98 @@
1
+ """A2A protocol data models — Agent Card, Task, Message, Artifact."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from dataclasses import dataclass, field
7
+ from datetime import datetime
8
+ from enum import Enum
9
+ from typing import Any
10
+
11
+
12
+ class TaskState(str, Enum):
13
+ SUBMITTED = "submitted"
14
+ WORKING = "working"
15
+ INPUT_REQUIRED = "input-required"
16
+ COMPLETED = "completed"
17
+ FAILED = "failed"
18
+ CANCELED = "canceled"
19
+
20
+
21
+ @dataclass
22
+ class A2AMessage:
23
+ role: str # "user" or "agent"
24
+ parts: list[dict[str, Any]] = field(default_factory=list)
25
+
26
+ @classmethod
27
+ def text(cls, role: str, content: str) -> A2AMessage:
28
+ return cls(role=role, parts=[{"type": "text", "text": content}])
29
+
30
+ def to_dict(self) -> dict[str, Any]:
31
+ return {"role": self.role, "parts": self.parts}
32
+
33
+ @classmethod
34
+ def from_dict(cls, data: dict[str, Any]) -> A2AMessage:
35
+ return cls(role=data.get("role", "user"), parts=data.get("parts", []))
36
+
37
+ @property
38
+ def text_content(self) -> str:
39
+ return " ".join(p.get("text", "") for p in self.parts if p.get("type") == "text")
40
+
41
+
42
+ @dataclass
43
+ class Artifact:
44
+ name: str = ""
45
+ content_type: str = "text/plain"
46
+ data: str = ""
47
+
48
+ def to_dict(self) -> dict[str, Any]:
49
+ return {"name": self.name, "contentType": self.content_type, "data": self.data}
50
+
51
+
52
+ @dataclass
53
+ class A2ATask:
54
+ id: str = field(default_factory=lambda: uuid.uuid4().hex[:16])
55
+ state: TaskState = TaskState.SUBMITTED
56
+ messages: list[A2AMessage] = field(default_factory=list)
57
+ artifacts: list[Artifact] = field(default_factory=list)
58
+ created_at: str = field(default_factory=lambda: datetime.now().isoformat())
59
+ updated_at: str = field(default_factory=lambda: datetime.now().isoformat())
60
+ metadata: dict[str, Any] = field(default_factory=dict)
61
+
62
+ def to_dict(self) -> dict[str, Any]:
63
+ return {
64
+ "id": self.id, "state": self.state.value,
65
+ "messages": [m.to_dict() for m in self.messages],
66
+ "artifacts": [a.to_dict() for a in self.artifacts],
67
+ "metadata": self.metadata,
68
+ }
69
+
70
+ @classmethod
71
+ def from_dict(cls, data: dict[str, Any]) -> A2ATask:
72
+ return cls(
73
+ id=data.get("id", uuid.uuid4().hex[:16]),
74
+ state=TaskState(data.get("state", "submitted")),
75
+ messages=[A2AMessage.from_dict(m) for m in data.get("messages", [])],
76
+ artifacts=[],
77
+ metadata=data.get("metadata", {}),
78
+ )
79
+
80
+
81
+ @dataclass
82
+ class AgentCard:
83
+ name: str = "echo-agent"
84
+ description: str = "A modular AI agent framework"
85
+ url: str = ""
86
+ version: str = "0.1.0"
87
+ capabilities: list[str] = field(default_factory=lambda: ["chat", "tool_use"])
88
+ skills: list[str] = field(default_factory=list)
89
+ authentication: dict[str, Any] = field(default_factory=lambda: {"schemes": ["bearer"]})
90
+
91
+ def to_dict(self) -> dict[str, Any]:
92
+ return {
93
+ "name": self.name, "description": self.description,
94
+ "url": self.url, "version": self.version,
95
+ "capabilities": {"streaming": True, "pushNotifications": False},
96
+ "skills": [{"id": s, "name": s} for s in self.skills],
97
+ "authentication": self.authentication,
98
+ }
@@ -0,0 +1,85 @@
1
+ """A2A JSON-RPC protocol handler."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any, Callable, Awaitable
7
+
8
+ from loguru import logger
9
+
10
+ from echo_agent.a2a.models import A2ATask, A2AMessage, TaskState
11
+
12
+ # JSON-RPC 2.0 标准错误码
13
+ _JSONRPC_PARSE_ERROR = -32700
14
+ _JSONRPC_METHOD_NOT_FOUND = -32601
15
+ _JSONRPC_INTERNAL_ERROR = -32603
16
+
17
+
18
+ class A2AProtocol:
19
+ """Handles A2A JSON-RPC methods: tasks/send, tasks/get, tasks/cancel."""
20
+
21
+ def __init__(self, process_fn: Callable[[A2ATask], Awaitable[A2ATask]]):
22
+ self._process = process_fn
23
+ self._tasks: dict[str, A2ATask] = {}
24
+
25
+ async def handle(self, request: dict[str, Any]) -> dict[str, Any]:
26
+ """分发 JSON-RPC 请求到对应的处理方法。
27
+
28
+ 支持的方法: tasks/send(发送任务), tasks/get(查询任务), tasks/cancel(取消任务)。
29
+ """
30
+ method = request.get("method", "")
31
+ params = request.get("params", {})
32
+ req_id = request.get("id")
33
+
34
+ try:
35
+ if method == "tasks/send":
36
+ result = await self._handle_send(params)
37
+ elif method == "tasks/get":
38
+ result = self._handle_get(params)
39
+ elif method == "tasks/cancel":
40
+ result = self._handle_cancel(params)
41
+ else:
42
+ return self._error(req_id, _JSONRPC_METHOD_NOT_FOUND, f"Method not found: {method}")
43
+ return {"jsonrpc": "2.0", "id": req_id, "result": result}
44
+ except Exception as e:
45
+ logger.error("A2A protocol error: {}", e)
46
+ return self._error(req_id, _JSONRPC_INTERNAL_ERROR, str(e))
47
+
48
+ async def _handle_send(self, params: dict[str, Any]) -> dict[str, Any]:
49
+ """处理任务发送请求。如果任务 ID 已存在则追加消息,否则创建新任务。"""
50
+ task_id = params.get("id", "")
51
+ message = params.get("message", {})
52
+
53
+ if task_id and task_id in self._tasks:
54
+ task = self._tasks[task_id]
55
+ task.messages.append(A2AMessage.from_dict(message))
56
+ else:
57
+ task = A2ATask()
58
+ if task_id:
59
+ task.id = task_id
60
+ task.messages.append(A2AMessage.from_dict(message))
61
+ self._tasks[task.id] = task
62
+
63
+ task.state = TaskState.WORKING
64
+ task = await self._process(task)
65
+ self._tasks[task.id] = task
66
+ return task.to_dict()
67
+
68
+ def _handle_get(self, params: dict[str, Any]) -> dict[str, Any]:
69
+ task_id = params.get("id", "")
70
+ task = self._tasks.get(task_id)
71
+ if not task:
72
+ raise ValueError(f"Task not found: {task_id}")
73
+ return task.to_dict()
74
+
75
+ def _handle_cancel(self, params: dict[str, Any]) -> dict[str, Any]:
76
+ task_id = params.get("id", "")
77
+ task = self._tasks.get(task_id)
78
+ if not task:
79
+ raise ValueError(f"Task not found: {task_id}")
80
+ task.state = TaskState.CANCELED
81
+ return task.to_dict()
82
+
83
+ @staticmethod
84
+ def _error(req_id: Any, code: int, message: str) -> dict[str, Any]:
85
+ return {"jsonrpc": "2.0", "id": req_id, "error": {"code": code, "message": message}}
@@ -0,0 +1,71 @@
1
+ """A2A HTTP server — agent card, task handling, SSE streaming."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import TYPE_CHECKING
7
+
8
+ from aiohttp import web
9
+ from loguru import logger
10
+
11
+ from echo_agent.a2a.models import AgentCard, A2ATask, A2AMessage, TaskState
12
+ from echo_agent.a2a.protocol import A2AProtocol
13
+
14
+ if TYPE_CHECKING:
15
+ from echo_agent.agent.loop import AgentLoop
16
+
17
+
18
+ class A2AServer:
19
+ def __init__(self, agent_loop: AgentLoop, agent_card: AgentCard):
20
+ self._loop = agent_loop
21
+ self._card = agent_card
22
+ self._protocol = A2AProtocol(self._process_task)
23
+
24
+ def register_routes(self, app: web.Application) -> None:
25
+ app.router.add_get("/.well-known/agent.json", self._handle_agent_card)
26
+ app.router.add_post("/a2a", self._handle_rpc)
27
+ logger.info("A2A routes registered: /.well-known/agent.json, /a2a")
28
+
29
+ async def _handle_agent_card(self, request: web.Request) -> web.Response:
30
+ return web.json_response(self._card.to_dict())
31
+
32
+ async def _handle_rpc(self, request: web.Request) -> web.Response:
33
+ try:
34
+ body = await request.json()
35
+ except json.JSONDecodeError:
36
+ return web.json_response(
37
+ {"jsonrpc": "2.0", "id": None, "error": {"code": -32700, "message": "Parse error"}},
38
+ status=400,
39
+ )
40
+ result = await self._protocol.handle(body)
41
+ return web.json_response(result)
42
+
43
+ async def _process_task(self, task: A2ATask) -> A2ATask:
44
+ """Process an A2A task by routing through the agent loop."""
45
+ user_text = ""
46
+ for msg in task.messages:
47
+ if msg.role == "user":
48
+ user_text = msg.text_content
49
+ break
50
+
51
+ if not user_text:
52
+ task.state = TaskState.FAILED
53
+ task.messages.append(A2AMessage.text("agent", "No user message found"))
54
+ return task
55
+
56
+ try:
57
+ from echo_agent.bus.events import InboundEvent
58
+ event = InboundEvent.text_message(
59
+ channel="a2a", sender_id="a2a-client",
60
+ chat_id=f"a2a:{task.id}", text=user_text,
61
+ )
62
+ import uuid
63
+ result = await self._loop._process_event(event, trace_id=uuid.uuid4().hex[:12])
64
+ task.state = TaskState.COMPLETED
65
+ task.messages.append(A2AMessage.text("agent", result.response_text or ""))
66
+ except Exception as e:
67
+ logger.error("A2A task processing failed: {}", e)
68
+ task.state = TaskState.FAILED
69
+ task.messages.append(A2AMessage.text("agent", f"Error: {e}"))
70
+
71
+ return task
File without changes
@@ -0,0 +1,326 @@
1
+ """Approval and security gate for tool calls."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+ from loguru import logger
9
+
10
+ from echo_agent.agent.tools.base import ToolResult
11
+ from echo_agent.bus.events import InboundEvent, OutboundEvent
12
+ from echo_agent.bus.queue import MessageBus
13
+ from echo_agent.config.schema import Config
14
+ from echo_agent.models.inference import InferenceController
15
+ from echo_agent.permissions.allowlist import ApprovalAllowlist, ApprovalLevel, build_pattern_key
16
+ from echo_agent.permissions.manager import ApprovalManager, ApprovalStatus
17
+ from echo_agent.security.guards import GuardDecision, evaluate_tool_call
18
+ from echo_agent.security.risk_classifier import RiskLevel, classify_risk
19
+
20
+
21
+ @dataclass
22
+ class ApprovalCheck:
23
+ denial: ToolResult | None = None
24
+ approved_actions: frozenset[str] = frozenset()
25
+
26
+
27
+ class ApprovalGate:
28
+ """Combines risk classification, channel trust, smart approval, and manual approval."""
29
+
30
+ def __init__(
31
+ self,
32
+ *,
33
+ config: Config,
34
+ approval: ApprovalManager,
35
+ inference: InferenceController,
36
+ bus: MessageBus,
37
+ provider: Any = None,
38
+ allowlist: ApprovalAllowlist | None = None,
39
+ ):
40
+ self._config = config
41
+ self._approval = approval
42
+ self._inference = inference
43
+ self._bus = bus
44
+ self._provider = provider
45
+ self._allowlist = allowlist or ApprovalAllowlist()
46
+
47
+ async def check(
48
+ self,
49
+ tool_name: str,
50
+ arguments: dict[str, Any],
51
+ sender_id: str,
52
+ *,
53
+ channel: str = "",
54
+ event: InboundEvent | None = None,
55
+ running: bool = True,
56
+ ) -> ApprovalCheck:
57
+ approval_cfg = self._config.permissions.approval
58
+
59
+ # Step 1: Static guard (hard block dangerous patterns)
60
+ guard = evaluate_tool_call(self._config, tool_name, arguments)
61
+ if guard.denied:
62
+ return ApprovalCheck(ToolResult(
63
+ success=False,
64
+ error=f"Tool '{tool_name}' blocked by security policy: {guard.reason}",
65
+ metadata={"guard_pattern": guard.pattern_key},
66
+ ))
67
+
68
+ # Step 2: Elevated rights check
69
+ if self._requires_elevated(tool_name) and not self._elevated_allowed(channel, sender_id):
70
+ return ApprovalCheck(ToolResult(
71
+ success=False,
72
+ error=(
73
+ f"Tool '{tool_name}' requires elevated execution rights for the configured "
74
+ "executor/security policy."
75
+ ),
76
+ metadata={"requires_elevated": True},
77
+ ))
78
+
79
+ # Step 3: Risk classification
80
+ risk = classify_risk(tool_name, arguments)
81
+
82
+ # Approved-pass: any path that lets the call through must tell the tool
83
+ # which actions were approved. The tool's own guard (e.g. CodeExecTool)
84
+ # re-checks exec/code policy and blocks unless ctx.approved_actions
85
+ # contains the action — so returning an empty ApprovalCheck() here would
86
+ # silently fail EXEC tools even though the gate "approved" them.
87
+ def _approved() -> ApprovalCheck:
88
+ return ApprovalCheck(approved_actions=self._approved_actions(tool_name, guard))
89
+
90
+ # Step 4: READ_ONLY and WRITE always pass — they are protected by sandbox/path restrictions
91
+ if risk in (RiskLevel.READ_ONLY, RiskLevel.WRITE):
92
+ return _approved()
93
+
94
+ # Step 5: Explicit auto_approve list
95
+ if tool_name in approval_cfg.auto_approve:
96
+ return _approved()
97
+
98
+ # Step 6: CLI auto-approve (all levels)
99
+ if self._should_auto_approve_cli(channel):
100
+ return _approved()
101
+
102
+ # Step 7: Channel trust — EXEC level auto-approved on trusted channels
103
+ if risk == RiskLevel.EXEC and self._is_trusted_channel(channel):
104
+ return _approved()
105
+
106
+ # Step 8: Approval mode "off" — bypass everything except hard blocks
107
+ if approval_cfg.mode == "off":
108
+ return _approved()
109
+
110
+ # Step 9: Check if this tool actually requires approval
111
+ if not self._approval_required(tool_name, guard, risk):
112
+ return _approved()
113
+
114
+ # Step 10: Allowlist check (EXEC level)
115
+ session_key = event.session_key if event else ""
116
+ pattern_key = build_pattern_key(tool_name, arguments)
117
+ if risk == RiskLevel.EXEC and self._allowlist.is_approved(session_key, pattern_key):
118
+ return _approved()
119
+
120
+ # Step 11: Unattended mode check
121
+ if self._is_unattended(event, channel):
122
+ return self._resolve_unattended(tool_name, risk, session_key, pattern_key, guard)
123
+
124
+ # Step 12: Smart approval (EXEC level only)
125
+ if risk == RiskLevel.EXEC and approval_cfg.mode == "smart" and self._provider:
126
+ verdict = await self._run_smart_approval(tool_name, arguments, guard)
127
+ if verdict == "approve":
128
+ self._allowlist.approve(session_key, pattern_key, ApprovalLevel.SESSION)
129
+ return _approved()
130
+ if verdict == "deny":
131
+ return ApprovalCheck(ToolResult(
132
+ success=False,
133
+ error=f"Smart approval denied '{tool_name}': {guard.reason or 'assessed as dangerous'}",
134
+ ))
135
+
136
+ # Step 13: Manual approval flow
137
+ approved_actions = self._approved_actions(tool_name, guard)
138
+ return await self._manual_approval_flow(
139
+ tool_name, arguments, sender_id, channel, event, running,
140
+ guard, approved_actions, session_key, pattern_key,
141
+ )
142
+
143
+ # PLACEHOLDER_METHODS
144
+
145
+ async def _run_smart_approval(
146
+ self, tool_name: str, arguments: dict[str, Any], guard: GuardDecision,
147
+ ) -> str:
148
+ from echo_agent.security.smart_approval import smart_approve
149
+ command = str(arguments.get("command", "") or arguments.get("code", "") or arguments)
150
+ description = guard.reason or f"tool '{tool_name}' requires approval"
151
+ model = self._config.permissions.approval.smart_model
152
+ return await smart_approve(tool_name, command, description, self._provider, model=model)
153
+
154
+ async def _manual_approval_flow(
155
+ self,
156
+ tool_name: str,
157
+ arguments: dict[str, Any],
158
+ sender_id: str,
159
+ channel: str,
160
+ event: InboundEvent | None,
161
+ running: bool,
162
+ guard: GuardDecision,
163
+ approved_actions: frozenset[str],
164
+ session_key: str,
165
+ pattern_key: str,
166
+ ) -> ApprovalCheck:
167
+ approval_req = self._approval.request_approval(
168
+ tool_name, tool_name=tool_name, params=arguments, user_id=sender_id,
169
+ )
170
+ if approval_req.status == ApprovalStatus.DENIED:
171
+ return ApprovalCheck(ToolResult(
172
+ success=False,
173
+ error=f"Tool '{tool_name}' denied by approval policy: {approval_req.reason}",
174
+ ))
175
+ if approval_req.status == ApprovalStatus.APPROVED:
176
+ return ApprovalCheck(approved_actions=approved_actions)
177
+
178
+ if event is not None:
179
+ await self._publish_approval_request(event, approval_req.id, tool_name, guard, pattern_key)
180
+
181
+ if not running and channel in {"cli", "direct", ""}:
182
+ return ApprovalCheck(ToolResult(
183
+ success=False,
184
+ error=(
185
+ f"Approval required before executing '{tool_name}'. "
186
+ f"Request id: {approval_req.id}."
187
+ ),
188
+ metadata={"approval_request_id": approval_req.id},
189
+ ))
190
+
191
+ decided = await self._approval.wait_for_decision(
192
+ approval_req.id,
193
+ timeout_seconds=self._config.permissions.approval.wait_timeout_seconds,
194
+ )
195
+ if decided and decided.status == ApprovalStatus.APPROVED:
196
+ level = self._parse_approval_level(decided.reason)
197
+ self._allowlist.approve(session_key, pattern_key, level)
198
+ return ApprovalCheck(approved_actions=approved_actions)
199
+ if decided and decided.status == ApprovalStatus.DENIED:
200
+ return ApprovalCheck(ToolResult(
201
+ success=False,
202
+ error=f"Tool '{tool_name}' denied: {decided.reason}",
203
+ metadata={"approval_request_id": approval_req.id},
204
+ ))
205
+ return ApprovalCheck(ToolResult(
206
+ success=False,
207
+ error=(
208
+ f"Approval timed out for '{tool_name}'. "
209
+ f"Request id: {approval_req.id}. "
210
+ f"Reply `/approve {approval_req.id}` or `/deny {approval_req.id} <reason>`."
211
+ ),
212
+ metadata={"approval_request_id": approval_req.id},
213
+ ))
214
+
215
+ # PLACEHOLDER_HELPERS
216
+
217
+ async def _publish_approval_request(
218
+ self,
219
+ event: InboundEvent,
220
+ request_id: str,
221
+ tool_name: str,
222
+ guard: GuardDecision,
223
+ pattern_key: str,
224
+ ) -> None:
225
+ reason = guard.reason or "approval policy requires confirmation"
226
+ text = (
227
+ f"⚠️ 需要确认执行: {tool_name}\n"
228
+ f"原因: {reason}\n\n"
229
+ f"回复:\n"
230
+ f" /approve {request_id} — 允许本次\n"
231
+ f" /approve {request_id} session — 本会话内允许同类操作\n"
232
+ f" /approve {request_id} always — 永久允许同类操作\n"
233
+ f" /deny {request_id} [原因] — 拒绝"
234
+ )
235
+ out = OutboundEvent.text_reply(
236
+ channel=event.channel,
237
+ chat_id=event.chat_id,
238
+ text=text,
239
+ reply_to_id=event.reply_to_id,
240
+ )
241
+ out.metadata = dict(event.metadata)
242
+ out.metadata["_approval_request"] = True
243
+ out.metadata["_inbound_event_id"] = event.event_id
244
+ await self._bus.publish_outbound(out)
245
+
246
+ def _approval_required(self, tool_name: str, guard: GuardDecision, risk: RiskLevel) -> bool:
247
+ approval_cfg = self._config.permissions.approval
248
+ if tool_name in approval_cfg.auto_deny:
249
+ return True
250
+ if guard.needs_approval:
251
+ return True
252
+ if self._inference.needs_confirmation(tool_name):
253
+ return True
254
+ if tool_name in approval_cfg.require_approval:
255
+ return True
256
+ if risk == RiskLevel.DANGEROUS:
257
+ return True
258
+ return False
259
+
260
+ def _should_auto_approve_cli(self, channel: str) -> bool:
261
+ approval_cfg = self._config.permissions.approval
262
+ return (
263
+ self._config.security.profile == "personal_cli"
264
+ and approval_cfg.cli_auto_approve
265
+ and channel in {"cli", "direct", ""}
266
+ )
267
+
268
+ def _is_trusted_channel(self, channel: str) -> bool:
269
+ return channel in self._config.permissions.approval.trusted_channels
270
+
271
+ def _is_unattended(self, event: InboundEvent | None, channel: str) -> bool:
272
+ if event and event.metadata.get("_unattended"):
273
+ return True
274
+ return channel in {"cron", "scheduler"}
275
+
276
+ def _resolve_unattended(
277
+ self, tool_name: str, risk: RiskLevel, session_key: str, pattern_key: str, guard: GuardDecision,
278
+ ) -> ApprovalCheck:
279
+ policy = self._config.permissions.approval.unattended_policy
280
+ approved = self._approved_actions(tool_name, guard)
281
+ if policy == "allow_safe":
282
+ if risk == RiskLevel.WRITE:
283
+ return ApprovalCheck(approved_actions=approved)
284
+ if risk == RiskLevel.EXEC and self._allowlist.is_approved(session_key, pattern_key):
285
+ return ApprovalCheck(approved_actions=approved)
286
+ return ApprovalCheck(ToolResult(
287
+ success=False,
288
+ error=f"Tool '{tool_name}' requires approval but no user is available (unattended mode).",
289
+ ))
290
+
291
+ @staticmethod
292
+ def _approved_actions(tool_name: str, guard: GuardDecision) -> frozenset[str]:
293
+ actions = {tool_name}
294
+ if guard.pattern_key:
295
+ actions.add(guard.pattern_key)
296
+ if guard.approval_action:
297
+ actions.add(guard.approval_action)
298
+ return frozenset(actions)
299
+
300
+ @staticmethod
301
+ def _parse_approval_level(reason: str) -> ApprovalLevel:
302
+ if reason:
303
+ lower = reason.strip().lower()
304
+ if lower == "always":
305
+ return ApprovalLevel.ALWAYS
306
+ if lower == "session":
307
+ return ApprovalLevel.SESSION
308
+ return ApprovalLevel.ONCE
309
+
310
+ def _requires_elevated(self, tool_name: str) -> bool:
311
+ if tool_name not in {"exec", "execute_code", "process"}:
312
+ return False
313
+ host = self._config.tools.exec.host
314
+ if host == "auto":
315
+ host = self._config.execution.default_executor
316
+ return host in {"local", "remote"} or self._config.tools.exec.security == "full"
317
+
318
+ def _elevated_allowed(self, channel: str, sender_id: str) -> bool:
319
+ elevated = self._config.permissions.elevated
320
+ if not elevated.enabled:
321
+ return False
322
+ allow_from = elevated.allow_from or {}
323
+ candidates = set(allow_from.get("*", [])) | set(allow_from.get(channel, []))
324
+ if "*" in candidates or sender_id in candidates:
325
+ return True
326
+ return sender_id in (self._config.permissions.admin_users or [])
@@ -0,0 +1,14 @@
1
+ """Context compression engine — multi-phase intelligent context management."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from echo_agent.agent.compression.compressor import ConversationCompressor
6
+ from echo_agent.agent.compression.engine import ContextEngine
7
+ from echo_agent.agent.compression.types import CompressionResult, CompressionStats
8
+
9
+ __all__ = [
10
+ "ContextEngine",
11
+ "ConversationCompressor",
12
+ "CompressionResult",
13
+ "CompressionStats",
14
+ ]