hermes-agent-a2a 3.2.2__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.
@@ -0,0 +1,5 @@
1
+ """Hermes Agent A2A plugin — auto-discovered and loaded by Hermes Agent."""
2
+
3
+ from .plugin import HermesAgentA2APlugin, __version__, register
4
+
5
+ __all__ = ["HermesAgentA2APlugin", "register", "__version__"]
@@ -0,0 +1,120 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Mode 2 ephemeral A2A worker — runs as a subprocess with the hermes-agent venv.
4
+ Reads params from stdin (JSON), writes result to stdout (JSON), errors to stderr.
5
+
6
+ Usage: python _mode2_worker.py < stdin > stdout
7
+ """
8
+ import sys
9
+ import os
10
+ import json
11
+ import uuid
12
+
13
+ MAX_STDIN_BYTES = 1 * 1024 * 1024 # 1 MB hard limit
14
+
15
+ def main():
16
+ raw = sys.stdin.buffer.read(MAX_STDIN_BYTES + 1)
17
+ if len(raw) > MAX_STDIN_BYTES:
18
+ print("ERROR: stdin exceeds 1MB limit", file=sys.stderr)
19
+ sys.exit(1)
20
+ params = json.loads(raw)
21
+
22
+ agent_home = params["agent_home"]
23
+ message = params["message"]
24
+ timeout = params.get("timeout", 300)
25
+
26
+ # Resolve HERMES_HOME from params or environment — must be explicit for Mode 2.
27
+ # Inherit HERMES_HOME from parent environment if not passed in params.
28
+ _hermes_home = params.get("hermes_home") or os.environ.get("HERMES_HOME", "")
29
+ if not _hermes_home:
30
+ print("ERROR: hermes_home not set in params or HERMES_HOME env var", file=sys.stderr)
31
+ sys.exit(1)
32
+ _hermes_agent = os.path.join(_hermes_home, "hermes-agent")
33
+ _plugin_dir = os.path.dirname(os.path.abspath(__file__))
34
+ # Move hermes-agent to front, keep plugin dir at front if present
35
+ new_path = [_hermes_agent]
36
+ for p in sys.path:
37
+ if p == _hermes_agent or p == _plugin_dir or p.startswith(_plugin_dir + os.sep):
38
+ continue
39
+ new_path.append(p)
40
+ sys.path[:] = new_path
41
+
42
+ # HERMES_HOME controls profile resolution, SOUL loading, etc.
43
+ os.environ["HERMES_HOME"] = agent_home
44
+
45
+ # Re-load hermes_constants so get_hermes_home() picks up the new HERMES_HOME
46
+ import importlib
47
+ import hermes_constants
48
+ importlib.reload(hermes_constants)
49
+
50
+ # Load target profile's .env for API keys
51
+ from hermes_cli.env_loader import load_hermes_dotenv
52
+ load_hermes_dotenv(hermes_home=agent_home)
53
+
54
+ # Read provider and model from target profile config
55
+ import yaml
56
+ profile_config_path = os.path.join(agent_home, "config.yaml")
57
+ cfg = {}
58
+ if os.path.isfile(profile_config_path):
59
+ with open(profile_config_path) as f:
60
+ cfg = yaml.safe_load(f) or {}
61
+ model_cfg = cfg.get("model", {}) or {}
62
+ target_provider = model_cfg.get("provider", "minimax-cn") if isinstance(model_cfg, dict) else "minimax-cn"
63
+ target_model = model_cfg.get("default", "MiniMax-M2.7") if isinstance(model_cfg, dict) else "MiniMax-M2.7"
64
+
65
+ if target_provider == "minimax-cn":
66
+ target_api_mode = "anthropic_messages"
67
+ elif target_provider == "anthropic":
68
+ target_api_mode = "anthropic_messages"
69
+ else:
70
+ target_api_mode = "chat_completions"
71
+
72
+ # Resolve API credentials directly from provider registry
73
+ from hermes_cli.auth import resolve_api_key_provider_credentials
74
+ creds = resolve_api_key_provider_credentials(target_provider)
75
+
76
+ # Suppress AIAgent's startup banner — it prints emoji to stdout which
77
+ # would corrupt the JSON response. Capture and discard; stderr is unaffected.
78
+ import io
79
+ _orig_stdout = sys.stdout
80
+ sys.stdout = io.StringIO()
81
+ try:
82
+ from run_agent import AIAgent
83
+ agent = AIAgent(
84
+ max_iterations=90,
85
+ skip_context_files=False,
86
+ skip_memory=True,
87
+ load_soul_identity=True,
88
+ session_id=f"a2a-m2-{uuid.uuid4().hex[:8]}",
89
+ model=target_model,
90
+ provider=target_provider,
91
+ api_mode=target_api_mode,
92
+ api_key=creds.get("api_key"),
93
+ base_url=creds.get("base_url"),
94
+ )
95
+ conv_result = agent.run_conversation(message)
96
+ finally:
97
+ sys.stdout = _orig_stdout
98
+
99
+ final = ""
100
+ if isinstance(conv_result, dict):
101
+ final = conv_result.get("final_response", "") or str(conv_result)
102
+ else:
103
+ final = str(conv_result)
104
+
105
+ result = {
106
+ "task_id": f"a2a-m2-{uuid.uuid4().hex[:8]}",
107
+ "state": "completed",
108
+ "response": final.strip(),
109
+ "source": f"ephemeral:{os.path.basename(agent_home)}",
110
+ }
111
+
112
+ print(json.dumps(result), flush=True)
113
+
114
+
115
+ if __name__ == "__main__":
116
+ try:
117
+ main()
118
+ except Exception as exc:
119
+ print(str(exc), file=sys.stderr)
120
+ sys.exit(1)
@@ -0,0 +1,67 @@
1
+ """Google A2A-shaped helpers plus Hermes metadata extensions."""
2
+
3
+ from .agent_card import (
4
+ Provider,
5
+ Skill,
6
+ AgentCapabilities,
7
+ ExtendedAgentCard,
8
+ build_extended_agent_card,
9
+ skill_names,
10
+ validate_skill,
11
+ )
12
+ from .hermes_ext import build_hermes_metadata
13
+ from .tasks import (
14
+ TERMINAL_STATES,
15
+ build_task_cancel_payload,
16
+ build_task_get_payload,
17
+ build_task_send_payload,
18
+ extract_text_from_parts,
19
+ is_terminal_state,
20
+ parse_json_rpc_error,
21
+ parse_task_result,
22
+ )
23
+ from .push import (
24
+ AuthenticationInfo,
25
+ TaskPushNotificationConfig,
26
+ TaskPushNotificationConfigList,
27
+ CreateTaskPushNotificationConfigRequest,
28
+ CreateTaskPushNotificationConfigResponse,
29
+ GetTaskPushNotificationConfigRequest,
30
+ GetTaskPushNotificationConfigResponse,
31
+ ListTaskPushNotificationConfigsRequest,
32
+ ListTaskPushNotificationConfigsResponse,
33
+ DeleteTaskPushNotificationConfigRequest,
34
+ DeleteTaskPushNotificationConfigResponse,
35
+ )
36
+
37
+ __all__ = [
38
+ "TERMINAL_STATES",
39
+ "build_hermes_metadata",
40
+ "build_task_cancel_payload",
41
+ "build_task_get_payload",
42
+ "build_task_send_payload",
43
+ "extract_text_from_parts",
44
+ "is_terminal_state",
45
+ "parse_json_rpc_error",
46
+ "parse_task_result",
47
+ # Agent Card models (T1-3)
48
+ "Provider",
49
+ "Skill",
50
+ "AgentCapabilities",
51
+ "ExtendedAgentCard",
52
+ "build_extended_agent_card",
53
+ "skill_names",
54
+ "validate_skill",
55
+ # Push notification models (T1-1a)
56
+ "AuthenticationInfo",
57
+ "TaskPushNotificationConfig",
58
+ "TaskPushNotificationConfigList",
59
+ "CreateTaskPushNotificationConfigRequest",
60
+ "CreateTaskPushNotificationConfigResponse",
61
+ "GetTaskPushNotificationConfigRequest",
62
+ "GetTaskPushNotificationConfigResponse",
63
+ "ListTaskPushNotificationConfigsRequest",
64
+ "ListTaskPushNotificationConfigsResponse",
65
+ "DeleteTaskPushNotificationConfigRequest",
66
+ "DeleteTaskPushNotificationConfigResponse",
67
+ ]
@@ -0,0 +1,112 @@
1
+ """Google A2A-shaped helpers plus Hermes metadata extensions."""
2
+
3
+ from dataclasses import dataclass, asdict
4
+ from typing import Optional, List
5
+
6
+
7
+
8
+ # ---------------------------------------------------------------------------
9
+ # ExtendedAgentCard dataclasses
10
+ # ---------------------------------------------------------------------------
11
+
12
+
13
+ @dataclass
14
+ class Provider:
15
+ """Google A2A ExtendedAgentCard Provider.
16
+
17
+ Per spec: organization (required), category (optional).
18
+ """
19
+ organization: str
20
+ category: Optional[str] = None
21
+
22
+
23
+ @dataclass
24
+ class Skill:
25
+ """Google A2A ExtendedAgentCard Skill.
26
+
27
+ Per spec: id (required), name (required), description (optional), tags (optional).
28
+ """
29
+ id: str
30
+ name: str
31
+ description: Optional[str] = None
32
+ tags: Optional[List[str]] = None
33
+
34
+
35
+ @dataclass
36
+ class AgentCapabilities:
37
+ """Google A2A ExtendedAgentCard AgentCapabilities.
38
+
39
+ Per spec: streaming, pushNotifications, stateTransitionHistory — all bool, default False.
40
+ """
41
+ streaming: bool = False
42
+ pushNotifications: bool = False
43
+ stateTransitionHistory: bool = False
44
+
45
+
46
+ @dataclass
47
+ class ExtendedAgentCard:
48
+ """Google A2A ExtendedAgentCard.
49
+
50
+ Combines standard AgentCard fields with ExtendedAgentCard-specific fields.
51
+ """
52
+ name: str
53
+ description: str
54
+ provider: Provider
55
+ agentCapabilities: AgentCapabilities
56
+ defaultInputModes: List[str]
57
+ defaultOutputModes: List[str]
58
+ url: Optional[str] = None
59
+ version: Optional[str] = None
60
+ documentationUrl: Optional[str] = None
61
+ skills: Optional[List[Skill]] = None
62
+
63
+
64
+ def build_extended_agent_card(overrides: Optional[dict] = None) -> dict:
65
+ """Build a full ExtendedAgentCard dict.
66
+
67
+ Starts with base AgentCard fields and adds ExtendedAgentCard fields.
68
+ Merges any overrides from the argument.
69
+
70
+ Default provider: organization="Hermes Fleet", category="official"
71
+ """
72
+ card = {
73
+ "name": "hermes-agent",
74
+ "description": "Hermes fleet agent with A2A HTTP/JSON-RPC protocol support — exposes A2A server, HMAC auth, push notifications, SSE streaming, and Telegram session routing.",
75
+ "url": None,
76
+ "version": None,
77
+ "documentationUrl": None,
78
+ "provider": asdict(Provider(organization="Hermes Fleet", category="official")),
79
+ "agentCapabilities": asdict(AgentCapabilities()),
80
+ "defaultInputModes": ["text"],
81
+ "defaultOutputModes": ["text"],
82
+ }
83
+ if overrides:
84
+ for key, value in overrides.items():
85
+ if key in ("provider", "agentCapabilities") and isinstance(value, dict):
86
+ card[key] = {**card[key], **value}
87
+ else:
88
+ card[key] = value
89
+ return card
90
+
91
+
92
+ # ---------------------------------------------------------------------------
93
+ # Legacy skill helpers (pre-existing)
94
+ # ---------------------------------------------------------------------------
95
+
96
+
97
+ def skill_names(agent_info: dict) -> set[str]:
98
+ if not isinstance(agent_info, dict):
99
+ return set()
100
+ known_skills = agent_info.get("metadata", {}).get("skills", []) or agent_info.get("skills", [])
101
+ return {
102
+ str(item.get("name") or item.get("id") or "").lower()
103
+ for item in known_skills
104
+ if isinstance(item, dict) and (item.get("name") or item.get("id"))
105
+ }
106
+
107
+
108
+ def validate_skill(agent_info: dict, skill: str) -> tuple[bool, list[str]]:
109
+ names = skill_names(agent_info)
110
+ if not skill or not names:
111
+ return True, sorted(names)
112
+ return skill.lower() in names, sorted(names)
@@ -0,0 +1,24 @@
1
+ """Hermes metadata extension for Google A2A-shaped task envelopes."""
2
+
3
+ from typing import Optional
4
+
5
+
6
+ def build_hermes_metadata(
7
+ route: str = "protocol",
8
+ execution: str = "remote_a2a",
9
+ delivery: Optional[str] = None,
10
+ reply_mode: Optional[str] = None,
11
+ isolation: Optional[str] = None,
12
+ ) -> dict:
13
+ metadata = {
14
+ "version": "1",
15
+ "route": route,
16
+ "execution": execution,
17
+ }
18
+ if delivery:
19
+ metadata["delivery"] = delivery
20
+ if reply_mode:
21
+ metadata["reply_mode"] = reply_mode
22
+ if isolation:
23
+ metadata["isolation"] = isolation
24
+ return metadata
@@ -0,0 +1,122 @@
1
+ """Google A2A Task Push Notification models — T1-1a.
2
+
3
+ Spec reference: Google A2A proto3 spec, tasks/pushNotification methods.
4
+ """
5
+ from dataclasses import dataclass, field
6
+ from typing import Optional, List
7
+
8
+
9
+ @dataclass
10
+ class AuthenticationInfo:
11
+ """Embedded auth info within TaskPushNotificationConfig.
12
+
13
+ Per spec: auth_type (string), auth_code (string, optional).
14
+ All fields are optional.
15
+ """
16
+ auth_type: Optional[str] = None
17
+ auth_code: Optional[str] = None
18
+
19
+
20
+ @dataclass
21
+ class TaskPushNotificationConfig:
22
+ """Per spec: a registered push notification config for a task.
23
+
24
+ Fields: id, task_id, push_transport_type, endpoint, authentication (opt), metadata (opt).
25
+ push_transport_type values: "webhook", "gcm", etc.
26
+ """
27
+ id: str
28
+ task_id: str
29
+ push_transport_type: str
30
+ endpoint: str
31
+ authentication: Optional[AuthenticationInfo] = None
32
+ metadata: Optional[dict] = None
33
+
34
+
35
+ @dataclass
36
+ class TaskPushNotificationConfigList:
37
+ """Wrapper for list responses.
38
+
39
+ Fields: items (list), has_more (bool).
40
+ """
41
+ items: List[TaskPushNotificationConfig] = field(default_factory=list)
42
+ has_more: bool = False
43
+
44
+
45
+ @dataclass
46
+ class CreateTaskPushNotificationConfigRequest:
47
+ """Create a push notification config for a task.
48
+
49
+ Fields: id, task_id, push_transport_type, endpoint, authentication (opt), metadata (opt).
50
+ """
51
+ id: str
52
+ task_id: str
53
+ push_transport_type: str
54
+ endpoint: str
55
+ authentication: Optional[AuthenticationInfo] = None
56
+ metadata: Optional[dict] = None
57
+
58
+
59
+ @dataclass
60
+ class CreateTaskPushNotificationConfigResponse:
61
+ """Response after creating a push notification config.
62
+
63
+ Fields: config (TaskPushNotificationConfig).
64
+ """
65
+ config: TaskPushNotificationConfig
66
+
67
+
68
+ @dataclass
69
+ class GetTaskPushNotificationConfigRequest:
70
+ """Request to retrieve a single push notification config.
71
+
72
+ Fields: task_id, config_id.
73
+ """
74
+ task_id: str
75
+ config_id: str
76
+
77
+
78
+ @dataclass
79
+ class GetTaskPushNotificationConfigResponse:
80
+ """Response with a single push notification config.
81
+
82
+ Fields: config (TaskPushNotificationConfig).
83
+ """
84
+ config: TaskPushNotificationConfig
85
+
86
+
87
+ @dataclass
88
+ class ListTaskPushNotificationConfigsRequest:
89
+ """Request to list all push notification configs for a task.
90
+
91
+ Fields: task_id.
92
+ """
93
+ task_id: str
94
+
95
+
96
+ @dataclass
97
+ class ListTaskPushNotificationConfigsResponse:
98
+ """Paginated list of push notification configs for a task.
99
+
100
+ Fields: items, has_more.
101
+ """
102
+ items: List[TaskPushNotificationConfig] = field(default_factory=list)
103
+ has_more: bool = False
104
+
105
+
106
+ @dataclass
107
+ class DeleteTaskPushNotificationConfigRequest:
108
+ """Request to delete a push notification config.
109
+
110
+ Fields: task_id, config_id.
111
+ """
112
+ task_id: str
113
+ config_id: str
114
+
115
+
116
+ @dataclass
117
+ class DeleteTaskPushNotificationConfigResponse:
118
+ """Response after deleting a push notification config.
119
+
120
+ Fields: config_id.
121
+ """
122
+ config_id: str
@@ -0,0 +1,201 @@
1
+ """Google A2A-shaped task payload builders and result parsers.
2
+
3
+ Google A2A v1.0 error codes:
4
+ -32700 Parse error
5
+ -32600 Invalid Request
6
+ -32603 Internal error
7
+ -38000 Task not found
8
+ -38001 Task not cancelable
9
+ -38002 Push notification not supported
10
+ -38003 Invalid state transition
11
+ -38004 Non-idempotent task
12
+ """
13
+
14
+ import uuid
15
+ from typing import Optional
16
+
17
+ # A2A-spec-compliant error codes
18
+ A2A_ERR_PARSE = -32700
19
+ A2A_ERR_INVALID_REQUEST = -32600
20
+ A2A_ERR_INTERNAL = -32603
21
+ A2A_ERR_TASK_NOT_FOUND = -38000
22
+ A2A_ERR_TASK_NOT_CANCELABLE = -38001
23
+ A2A_ERR_PUSH_NOT_SUPPORTED = -38002
24
+ A2A_ERR_INVALID_STATE_TRANSITION = -38003
25
+ A2A_ERR_NON_IDEMPOTENT = -38004
26
+
27
+ from dataclasses import dataclass
28
+
29
+
30
+ @dataclass
31
+ class TaskArtifactUpdateEvent:
32
+ """A task artifact update event.
33
+
34
+ Emitted over SSE when a task generates an artifact (partial or final).
35
+
36
+ Attributes:
37
+ context_id: REQUIRED — the context ID for this task.
38
+ task_id: REQUIRED — the task this artifact belongs to.
39
+ artifact: REQUIRED — the artifact data dict (A2A artifact shape).
40
+ metadata: optional — additional event metadata.
41
+ """
42
+ context_id: str
43
+ task_id: str
44
+ artifact: dict
45
+ metadata: Optional[dict] = None
46
+
47
+ def to_dict(self) -> dict:
48
+ """Render the event as a plain dict for SSE transport."""
49
+ return {
50
+ "kind": "artifact",
51
+ "contextId": self.context_id,
52
+ "taskId": self.task_id,
53
+ "artifact": self.artifact,
54
+ "metadata": self.metadata or {},
55
+ }
56
+
57
+
58
+ import enum
59
+
60
+
61
+ class TaskState(str, enum.Enum):
62
+ """Canonical task states per Google A2A v1.0 spec."""
63
+
64
+ SUBMITTED = "submitted"
65
+ WORKING = "working"
66
+ INPUT_REQUIRED = "input_required"
67
+ AUTH_REQUIRED = "auth_required"
68
+ COMPLETED = "completed"
69
+ FAILED = "failed"
70
+ CANCELED = "canceled"
71
+
72
+
73
+ TERMINAL_STATES = {"completed", "failed", "canceled"}
74
+ ACTIVE_STATES = {"submitted", "working", "input_required", "auth_required"}
75
+ AUTH_STATES = {"auth_required", "authenticated", "rejected"}
76
+
77
+
78
+ def is_terminal_state(state: str) -> bool:
79
+ return str(state or "").lower() in TERMINAL_STATES
80
+
81
+
82
+ def build_task_send_payload(
83
+ task_id: str,
84
+ message: str,
85
+ sender_name: str,
86
+ intent: str = "consultation",
87
+ expected_action: str = "reply",
88
+ skill: Optional[str] = None,
89
+ hermes: Optional[dict] = None,
90
+ request_id: Optional[str] = None,
91
+ ) -> dict:
92
+ metadata = {
93
+ "intent": intent,
94
+ "expected_action": expected_action,
95
+ "context_scope": "full",
96
+ "sender_name": sender_name,
97
+ }
98
+ if skill:
99
+ metadata["skill"] = skill
100
+ if hermes:
101
+ metadata["hermes"] = hermes
102
+
103
+ payload = {
104
+ "jsonrpc": "2.0",
105
+ "id": request_id or str(uuid.uuid4()),
106
+ "method": "tasks/send",
107
+ "params": {
108
+ "id": task_id,
109
+ "message": {
110
+ "message_id": str(uuid.uuid4()),
111
+ "role": "user",
112
+ "parts": [{"type": "text", "text": message}],
113
+ "metadata": metadata,
114
+ },
115
+ },
116
+ }
117
+ return payload
118
+
119
+
120
+ def build_task_get_payload(task_id: str, request_id: Optional[str] = None) -> dict:
121
+ return {
122
+ "jsonrpc": "2.0",
123
+ "id": request_id or str(uuid.uuid4()),
124
+ "method": "tasks/get",
125
+ "params": {"id": task_id},
126
+ }
127
+
128
+
129
+ def build_task_cancel_payload(task_id: str, request_id: Optional[str] = None) -> dict:
130
+ return {
131
+ "jsonrpc": "2.0",
132
+ "id": request_id or str(uuid.uuid4()),
133
+ "method": "tasks/cancel",
134
+ "params": {"id": task_id},
135
+ }
136
+
137
+
138
+ def extract_text_from_parts(parts) -> str:
139
+ chunks = []
140
+ for part in parts or []:
141
+ if not isinstance(part, dict):
142
+ continue
143
+ if part.get("type") == "text":
144
+ text = part.get("text", "")
145
+ if text:
146
+ chunks.append(str(text))
147
+ elif isinstance(part.get("text"), str):
148
+ chunks.append(part["text"])
149
+ return "\n".join(chunks).strip()
150
+
151
+
152
+ def parse_json_rpc_error(response: dict) -> str:
153
+ rpc_error = response.get("error") if isinstance(response, dict) else None
154
+ if not rpc_error:
155
+ return ""
156
+ if isinstance(rpc_error, dict):
157
+ return rpc_error.get("message") or str(rpc_error)
158
+ return str(rpc_error)
159
+
160
+
161
+ def build_error_response(code: int, message: str, data=None, id=None) -> dict:
162
+ """Build a spec-compliant JSON-RPC error response: {jsonrpc, code, message, data, id}."""
163
+ err = {"jsonrpc": "2.0", "code": code, "message": message}
164
+ if data is not None:
165
+ err["data"] = data
166
+ if id is not None:
167
+ err["id"] = id
168
+ return err
169
+
170
+
171
+ def parse_task_result(rpc_result: dict, default_task_id: str = "") -> dict:
172
+ rpc_result = rpc_result or {}
173
+ status = rpc_result.get("status", {}) if isinstance(rpc_result, dict) else {}
174
+ state = status.get("state", "unknown") if isinstance(status, dict) else "unknown"
175
+ task_id = rpc_result.get("id", default_task_id) if isinstance(rpc_result, dict) else default_task_id
176
+ chunks = []
177
+
178
+ for artifact in rpc_result.get("artifacts", []) or []:
179
+ if isinstance(artifact, dict):
180
+ text = extract_text_from_parts(artifact.get("parts", []))
181
+ if text:
182
+ chunks.append(text)
183
+
184
+ status_message = status.get("message", {}) if isinstance(status, dict) else {}
185
+ if isinstance(status_message, dict):
186
+ text = extract_text_from_parts(status_message.get("parts", []))
187
+ if text:
188
+ chunks.append(text)
189
+
190
+ direct_message = rpc_result.get("message", {}) if isinstance(rpc_result, dict) else {}
191
+ if isinstance(direct_message, dict):
192
+ text = extract_text_from_parts(direct_message.get("parts", []))
193
+ if text:
194
+ chunks.append(text)
195
+
196
+ return {
197
+ "task_id": task_id,
198
+ "state": state,
199
+ "response": "\n".join(chunks).strip(),
200
+ "raw_result": rpc_result,
201
+ }