a2a-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.
- a2a_agent/__init__.py +3 -0
- a2a_agent/cli.py +144 -0
- a2a_agent/client.py +61 -0
- a2a_agent/config.py +149 -0
- a2a_agent/invite.py +115 -0
- a2a_agent/protocol.py +101 -0
- a2a_agent/topics.py +60 -0
- a2a_agent-0.1.0.dist-info/METADATA +106 -0
- a2a_agent-0.1.0.dist-info/RECORD +13 -0
- a2a_agent-0.1.0.dist-info/WHEEL +5 -0
- a2a_agent-0.1.0.dist-info/entry_points.txt +2 -0
- a2a_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
- a2a_agent-0.1.0.dist-info/top_level.txt +1 -0
a2a_agent/__init__.py
ADDED
a2a_agent/cli.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Command line entry point for a2a-agent."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from a2a_agent import __version__
|
|
8
|
+
from a2a_agent.config import AgentConfig
|
|
9
|
+
from a2a_agent.config import BrokerConfig
|
|
10
|
+
from a2a_agent.config import DeviceRole
|
|
11
|
+
from a2a_agent.config import load_config
|
|
12
|
+
from a2a_agent.config import save_config
|
|
13
|
+
from a2a_agent.invite import InviteCodeError
|
|
14
|
+
from a2a_agent.invite import create_invite_code
|
|
15
|
+
from a2a_agent.invite import parse_invite_code
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
app = typer.Typer(
|
|
19
|
+
no_args_is_help=True,
|
|
20
|
+
invoke_without_command=True,
|
|
21
|
+
help="A2A messages over MQTT.",
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@app.callback()
|
|
26
|
+
def main(
|
|
27
|
+
version: bool = typer.Option(False, "--version", help="Show package version."),
|
|
28
|
+
) -> None:
|
|
29
|
+
if version:
|
|
30
|
+
typer.echo(__version__)
|
|
31
|
+
raise typer.Exit()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@app.command()
|
|
35
|
+
def init(
|
|
36
|
+
room: str = typer.Option("default", help="A2A room name."),
|
|
37
|
+
device_name: str = typer.Option("commander", help="Local device name."),
|
|
38
|
+
broker_host: str = typer.Option("localhost", help="MQTT broker host."),
|
|
39
|
+
broker_port: int = typer.Option(1883, help="MQTT broker port."),
|
|
40
|
+
tls: bool = typer.Option(False, "--tls/--no-tls", help="Use TLS for MQTT."),
|
|
41
|
+
) -> None:
|
|
42
|
+
path = save_config(
|
|
43
|
+
AgentConfig(
|
|
44
|
+
room=room,
|
|
45
|
+
device_name=device_name,
|
|
46
|
+
broker=BrokerConfig(host=broker_host, port=broker_port, tls=tls),
|
|
47
|
+
role="commander",
|
|
48
|
+
)
|
|
49
|
+
)
|
|
50
|
+
typer.echo(f"saved commander config: {path}")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@app.command()
|
|
54
|
+
def join(invite_code: str = typer.Argument(..., help="Invite code from commander.")) -> None:
|
|
55
|
+
try:
|
|
56
|
+
payload = parse_invite_code(invite_code)
|
|
57
|
+
except InviteCodeError as exc:
|
|
58
|
+
raise typer.BadParameter(str(exc)) from exc
|
|
59
|
+
|
|
60
|
+
path = save_config(
|
|
61
|
+
AgentConfig(
|
|
62
|
+
room=payload["room"],
|
|
63
|
+
device_name=payload["device_name"],
|
|
64
|
+
broker=BrokerConfig(
|
|
65
|
+
host=payload["broker"]["host"],
|
|
66
|
+
port=payload["broker"]["port"],
|
|
67
|
+
tls=payload["tls"],
|
|
68
|
+
),
|
|
69
|
+
role="worker",
|
|
70
|
+
token=payload["token"],
|
|
71
|
+
)
|
|
72
|
+
)
|
|
73
|
+
typer.echo(f"saved worker config: {path}")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@app.command()
|
|
77
|
+
def invite(device_name: str = typer.Argument(..., help="Device name to invite.")) -> None:
|
|
78
|
+
config = load_config()
|
|
79
|
+
typer.echo(
|
|
80
|
+
create_invite_code(
|
|
81
|
+
broker_host=config.broker.host,
|
|
82
|
+
broker_port=config.broker.port,
|
|
83
|
+
room=config.room,
|
|
84
|
+
device_name=device_name,
|
|
85
|
+
tls=config.broker.tls,
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@app.command("set-role")
|
|
91
|
+
def set_role(
|
|
92
|
+
device_name: str = typer.Argument(..., help="Worker device name."),
|
|
93
|
+
role: str = typer.Argument(..., help="Role name managed by commander."),
|
|
94
|
+
description: str = typer.Argument(..., help="Role description."),
|
|
95
|
+
) -> None:
|
|
96
|
+
config = load_config()
|
|
97
|
+
if config.role != "commander":
|
|
98
|
+
raise typer.BadParameter("set-role requires commander config")
|
|
99
|
+
|
|
100
|
+
devices = dict(config.devices)
|
|
101
|
+
devices[device_name] = DeviceRole(role=role, description=description)
|
|
102
|
+
path = save_config(
|
|
103
|
+
AgentConfig(
|
|
104
|
+
room=config.room,
|
|
105
|
+
device_name=config.device_name,
|
|
106
|
+
broker=config.broker,
|
|
107
|
+
role=config.role,
|
|
108
|
+
token=config.token,
|
|
109
|
+
devices=devices,
|
|
110
|
+
)
|
|
111
|
+
)
|
|
112
|
+
typer.echo(f"saved role for {device_name}: {path}")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@app.command()
|
|
116
|
+
def send(
|
|
117
|
+
device_name: str = typer.Argument(..., help="Worker device name."),
|
|
118
|
+
text: str = typer.Argument(..., help="Message text."),
|
|
119
|
+
) -> None:
|
|
120
|
+
typer.echo(f"stub: send task to device={device_name} text={text}")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@app.command()
|
|
124
|
+
def status() -> None:
|
|
125
|
+
config = load_config()
|
|
126
|
+
typer.echo(f"role: {config.role}")
|
|
127
|
+
typer.echo(f"room: {config.room}")
|
|
128
|
+
typer.echo(f"device_name: {config.device_name}")
|
|
129
|
+
typer.echo(
|
|
130
|
+
"broker: "
|
|
131
|
+
f"{config.broker.host}:{config.broker.port} "
|
|
132
|
+
f"tls={str(config.broker.tls).lower()}"
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
if not config.devices:
|
|
136
|
+
typer.echo("registered devices: none")
|
|
137
|
+
return
|
|
138
|
+
|
|
139
|
+
typer.echo("registered devices:")
|
|
140
|
+
for device_name, role in sorted(config.devices.items()):
|
|
141
|
+
typer.echo(
|
|
142
|
+
f" - {device_name}: role={role.role} "
|
|
143
|
+
f"description={role.description}"
|
|
144
|
+
)
|
a2a_agent/client.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Thin paho-mqtt wrapper.
|
|
2
|
+
|
|
3
|
+
Importing this module does not connect to a broker. Network access starts only
|
|
4
|
+
when MqttClient.connect() is called.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
|
|
11
|
+
import paho.mqtt.client as mqtt
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
MessageCallback = Callable[[str, bytes, mqtt.MQTTMessage], None]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class MqttClient:
|
|
18
|
+
def __init__(self) -> None:
|
|
19
|
+
self._client = mqtt.Client(
|
|
20
|
+
callback_api_version=mqtt.CallbackAPIVersion.VERSION2
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
def connect(
|
|
24
|
+
self,
|
|
25
|
+
host: str,
|
|
26
|
+
port: int,
|
|
27
|
+
username: str | None = None,
|
|
28
|
+
password: str | None = None,
|
|
29
|
+
) -> None:
|
|
30
|
+
if username is not None:
|
|
31
|
+
self._client.username_pw_set(username, password)
|
|
32
|
+
self._client.connect(host, port)
|
|
33
|
+
|
|
34
|
+
def publish(
|
|
35
|
+
self,
|
|
36
|
+
topic: str,
|
|
37
|
+
payload: str | bytes,
|
|
38
|
+
qos: int = 1,
|
|
39
|
+
retain: bool = False,
|
|
40
|
+
) -> mqtt.MQTTMessageInfo:
|
|
41
|
+
return self._client.publish(topic, payload=payload, qos=qos, retain=retain)
|
|
42
|
+
|
|
43
|
+
def subscribe(self, topic: str, callback: MessageCallback) -> None:
|
|
44
|
+
def _handle_message(
|
|
45
|
+
client: mqtt.Client,
|
|
46
|
+
userdata: object,
|
|
47
|
+
message: mqtt.MQTTMessage,
|
|
48
|
+
) -> None:
|
|
49
|
+
callback(message.topic, message.payload, message)
|
|
50
|
+
|
|
51
|
+
self._client.message_callback_add(topic, _handle_message)
|
|
52
|
+
self._client.subscribe(topic, qos=1)
|
|
53
|
+
|
|
54
|
+
def loop_start(self) -> None:
|
|
55
|
+
self._client.loop_start()
|
|
56
|
+
|
|
57
|
+
def loop_stop(self) -> None:
|
|
58
|
+
self._client.loop_stop()
|
|
59
|
+
|
|
60
|
+
def disconnect(self) -> None:
|
|
61
|
+
self._client.disconnect()
|
a2a_agent/config.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Local configuration file handling for a2a-agent."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from dataclasses import field
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import platform
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import yaml
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
CONFIG_PATH = Path.home() / ".a2a-agent" / "config.yaml"
|
|
16
|
+
VALID_ROLES = {"commander", "worker"}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class BrokerConfig:
|
|
21
|
+
host: str = "localhost"
|
|
22
|
+
port: int = 1883
|
|
23
|
+
tls: bool = False
|
|
24
|
+
|
|
25
|
+
def to_dict(self) -> dict[str, Any]:
|
|
26
|
+
return {"host": self.host, "port": self.port, "tls": self.tls}
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def from_dict(cls, data: dict[str, Any] | None) -> "BrokerConfig":
|
|
30
|
+
data = data or {}
|
|
31
|
+
if not isinstance(data, dict):
|
|
32
|
+
raise ValueError("broker must be a mapping")
|
|
33
|
+
return cls(
|
|
34
|
+
host=str(data.get("host", "localhost")),
|
|
35
|
+
port=int(data.get("port", 1883)),
|
|
36
|
+
tls=_bool_from_config(data.get("tls", False), "broker.tls"),
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class DeviceRole:
|
|
42
|
+
role: str
|
|
43
|
+
description: str = ""
|
|
44
|
+
|
|
45
|
+
def to_dict(self) -> dict[str, Any]:
|
|
46
|
+
return {"role": self.role, "description": self.description}
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def from_dict(cls, data: dict[str, Any]) -> "DeviceRole":
|
|
50
|
+
return cls(
|
|
51
|
+
role=str(data.get("role", "")),
|
|
52
|
+
description=str(data.get("description", "")),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class AgentConfig:
|
|
58
|
+
room: str = "default"
|
|
59
|
+
device_name: str = platform.node() or "device"
|
|
60
|
+
broker: BrokerConfig = BrokerConfig()
|
|
61
|
+
role: str = "worker"
|
|
62
|
+
token: str | None = None
|
|
63
|
+
devices: dict[str, DeviceRole] = field(default_factory=dict)
|
|
64
|
+
|
|
65
|
+
def __post_init__(self) -> None:
|
|
66
|
+
if self.role not in VALID_ROLES:
|
|
67
|
+
allowed = ", ".join(sorted(VALID_ROLES))
|
|
68
|
+
raise ValueError(f"role must be one of: {allowed}")
|
|
69
|
+
|
|
70
|
+
def to_dict(self) -> dict[str, Any]:
|
|
71
|
+
data = {
|
|
72
|
+
"room": self.room,
|
|
73
|
+
"device_name": self.device_name,
|
|
74
|
+
"broker": self.broker.to_dict(),
|
|
75
|
+
"role": self.role,
|
|
76
|
+
"devices": {
|
|
77
|
+
name: role.to_dict()
|
|
78
|
+
for name, role in sorted(self.devices.items())
|
|
79
|
+
},
|
|
80
|
+
}
|
|
81
|
+
if self.token is not None:
|
|
82
|
+
data["token"] = self.token
|
|
83
|
+
return data
|
|
84
|
+
|
|
85
|
+
@classmethod
|
|
86
|
+
def from_dict(cls, data: dict[str, Any] | None) -> "AgentConfig":
|
|
87
|
+
data = data or {}
|
|
88
|
+
if not isinstance(data, dict):
|
|
89
|
+
raise ValueError("config must be a mapping")
|
|
90
|
+
devices = data.get("devices", {})
|
|
91
|
+
if devices is None:
|
|
92
|
+
devices = {}
|
|
93
|
+
if not isinstance(devices, dict):
|
|
94
|
+
raise ValueError("devices must be a mapping")
|
|
95
|
+
|
|
96
|
+
return cls(
|
|
97
|
+
room=str(data.get("room", "default")),
|
|
98
|
+
device_name=str(data.get("device_name", platform.node() or "device")),
|
|
99
|
+
broker=BrokerConfig.from_dict(data.get("broker")),
|
|
100
|
+
role=str(data.get("role", "worker")),
|
|
101
|
+
token=data.get("token"),
|
|
102
|
+
devices={
|
|
103
|
+
str(name): DeviceRole.from_dict(_role_data(role_data))
|
|
104
|
+
for name, role_data in devices.items()
|
|
105
|
+
},
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def load_config(path: Path | None = None) -> AgentConfig:
|
|
110
|
+
config_path = path or CONFIG_PATH
|
|
111
|
+
if not config_path.exists():
|
|
112
|
+
return AgentConfig()
|
|
113
|
+
|
|
114
|
+
raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
|
|
115
|
+
return AgentConfig.from_dict(raw)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def save_config(config: AgentConfig, path: Path | None = None) -> Path:
|
|
119
|
+
config_path = path or CONFIG_PATH
|
|
120
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
121
|
+
payload = yaml.safe_dump(config.to_dict(), allow_unicode=True, sort_keys=True)
|
|
122
|
+
if config_path.exists():
|
|
123
|
+
config_path.chmod(0o600)
|
|
124
|
+
|
|
125
|
+
fd = os.open(config_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
126
|
+
with os.fdopen(fd, "w", encoding="utf-8") as file:
|
|
127
|
+
file.write(payload)
|
|
128
|
+
config_path.chmod(0o600)
|
|
129
|
+
return config_path
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _role_data(data: Any) -> dict[str, Any]:
|
|
133
|
+
if data is None:
|
|
134
|
+
return {}
|
|
135
|
+
if not isinstance(data, dict):
|
|
136
|
+
raise ValueError("device role entry must be a mapping")
|
|
137
|
+
return data
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _bool_from_config(value: Any, name: str) -> bool:
|
|
141
|
+
if isinstance(value, bool):
|
|
142
|
+
return value
|
|
143
|
+
if isinstance(value, str):
|
|
144
|
+
lowered = value.lower()
|
|
145
|
+
if lowered in {"true", "yes", "1"}:
|
|
146
|
+
return True
|
|
147
|
+
if lowered in {"false", "no", "0"}:
|
|
148
|
+
return False
|
|
149
|
+
raise ValueError(f"{name} must be a boolean")
|
a2a_agent/invite.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Invite code helpers for worker onboarding."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import binascii
|
|
7
|
+
import json
|
|
8
|
+
import secrets
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class InviteCodeError(ValueError):
|
|
13
|
+
"""Raised when an invite code cannot be decoded or validated."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def create_invite_code(
|
|
17
|
+
*,
|
|
18
|
+
broker_host: str,
|
|
19
|
+
broker_port: int,
|
|
20
|
+
room: str,
|
|
21
|
+
device_name: str,
|
|
22
|
+
tls: bool = False,
|
|
23
|
+
) -> str:
|
|
24
|
+
payload = {
|
|
25
|
+
"broker": {
|
|
26
|
+
"host": _require_non_empty("broker.host", broker_host),
|
|
27
|
+
"port": _require_port(broker_port),
|
|
28
|
+
},
|
|
29
|
+
"room": _require_non_empty("room", room),
|
|
30
|
+
"device_name": _require_non_empty("device_name", device_name),
|
|
31
|
+
"token": secrets.token_urlsafe(32),
|
|
32
|
+
"tls": bool(tls),
|
|
33
|
+
}
|
|
34
|
+
raw = json.dumps(
|
|
35
|
+
payload,
|
|
36
|
+
ensure_ascii=False,
|
|
37
|
+
separators=(",", ":"),
|
|
38
|
+
sort_keys=True,
|
|
39
|
+
).encode("utf-8")
|
|
40
|
+
return base64.urlsafe_b64encode(raw).decode("ascii")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def parse_invite_code(invite_code: str) -> dict[str, Any]:
|
|
44
|
+
try:
|
|
45
|
+
raw = base64.b64decode(
|
|
46
|
+
invite_code.encode("ascii"),
|
|
47
|
+
altchars=b"-_",
|
|
48
|
+
validate=True,
|
|
49
|
+
)
|
|
50
|
+
except (UnicodeEncodeError, binascii.Error) as exc:
|
|
51
|
+
raise InviteCodeError("invite code is not valid urlsafe base64") from exc
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
payload = json.loads(raw.decode("utf-8"))
|
|
55
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
56
|
+
raise InviteCodeError("invite code does not contain valid JSON") from exc
|
|
57
|
+
|
|
58
|
+
return _normalize_payload(payload)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _normalize_payload(payload: Any) -> dict[str, Any]:
|
|
62
|
+
if not isinstance(payload, dict):
|
|
63
|
+
raise InviteCodeError("invite payload must be a JSON object")
|
|
64
|
+
|
|
65
|
+
broker = payload.get("broker")
|
|
66
|
+
if not isinstance(broker, dict):
|
|
67
|
+
raise InviteCodeError("invite payload must include broker.host and broker.port")
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
"broker": {
|
|
71
|
+
"host": _require_string_field(broker, "host", "broker.host"),
|
|
72
|
+
"port": _require_int_field(broker, "port", "broker.port"),
|
|
73
|
+
},
|
|
74
|
+
"room": _require_string_field(payload, "room", "room"),
|
|
75
|
+
"device_name": _require_string_field(payload, "device_name", "device_name"),
|
|
76
|
+
"token": _require_string_field(payload, "token", "token"),
|
|
77
|
+
"tls": _require_bool_field(payload, "tls", "tls"),
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _require_non_empty(name: str, value: str) -> str:
|
|
82
|
+
if not isinstance(value, str) or not value:
|
|
83
|
+
raise ValueError(f"{name} must be a non-empty string")
|
|
84
|
+
return value
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _require_port(value: int) -> int:
|
|
88
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
89
|
+
raise ValueError("broker.port must be an integer")
|
|
90
|
+
if value < 1 or value > 65535:
|
|
91
|
+
raise ValueError("broker.port must be between 1 and 65535")
|
|
92
|
+
return value
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _require_string_field(data: dict[str, Any], key: str, name: str) -> str:
|
|
96
|
+
value = data.get(key)
|
|
97
|
+
if not isinstance(value, str) or not value:
|
|
98
|
+
raise InviteCodeError(f"invite payload field {name} must be a non-empty string")
|
|
99
|
+
return value
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _require_int_field(data: dict[str, Any], key: str, name: str) -> int:
|
|
103
|
+
value = data.get(key)
|
|
104
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
105
|
+
raise InviteCodeError(f"invite payload field {name} must be an integer")
|
|
106
|
+
if value < 1 or value > 65535:
|
|
107
|
+
raise InviteCodeError(f"invite payload field {name} is outside the valid port range")
|
|
108
|
+
return value
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _require_bool_field(data: dict[str, Any], key: str, name: str) -> bool:
|
|
112
|
+
value = data.get(key)
|
|
113
|
+
if not isinstance(value, bool):
|
|
114
|
+
raise InviteCodeError(f"invite payload field {name} must be a boolean")
|
|
115
|
+
return value
|
a2a_agent/protocol.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Small A2A-compatible message models used by the MQTT transport."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
import json
|
|
7
|
+
from typing import Any, ClassVar
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
JsonDict = dict[str, Any]
|
|
11
|
+
VALID_TASK_STATUSES = {"pending", "accepted", "working", "completed", "failed"}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class AgentCard:
|
|
16
|
+
name: str
|
|
17
|
+
|
|
18
|
+
def to_dict(self) -> JsonDict:
|
|
19
|
+
return {"name": self.name}
|
|
20
|
+
|
|
21
|
+
@classmethod
|
|
22
|
+
def from_dict(cls, data: JsonDict) -> "AgentCard":
|
|
23
|
+
return cls(name=str(data["name"]))
|
|
24
|
+
|
|
25
|
+
def to_json(self) -> str:
|
|
26
|
+
return json.dumps(self.to_dict(), ensure_ascii=False, sort_keys=True)
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def from_json(cls, payload: str | bytes) -> "AgentCard":
|
|
30
|
+
return cls.from_dict(json.loads(payload))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class Message:
|
|
35
|
+
role: str
|
|
36
|
+
parts: list[JsonDict]
|
|
37
|
+
|
|
38
|
+
def to_dict(self) -> JsonDict:
|
|
39
|
+
return {
|
|
40
|
+
"role": self.role,
|
|
41
|
+
"parts": [dict(part) for part in self.parts],
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def from_dict(cls, data: JsonDict) -> "Message":
|
|
46
|
+
return cls(
|
|
47
|
+
role=str(data["role"]),
|
|
48
|
+
parts=[dict(part) for part in data.get("parts", [])],
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
def to_json(self) -> str:
|
|
52
|
+
return json.dumps(self.to_dict(), ensure_ascii=False, sort_keys=True)
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def from_json(cls, payload: str | bytes) -> "Message":
|
|
56
|
+
return cls.from_dict(json.loads(payload))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class Task:
|
|
61
|
+
id: str
|
|
62
|
+
from_: str
|
|
63
|
+
to: str
|
|
64
|
+
status: str
|
|
65
|
+
messages: list[Message]
|
|
66
|
+
|
|
67
|
+
valid_statuses: ClassVar[set[str]] = VALID_TASK_STATUSES
|
|
68
|
+
|
|
69
|
+
def __post_init__(self) -> None:
|
|
70
|
+
if self.status not in self.valid_statuses:
|
|
71
|
+
allowed = ", ".join(sorted(self.valid_statuses))
|
|
72
|
+
raise ValueError(f"status must be one of: {allowed}")
|
|
73
|
+
|
|
74
|
+
def to_dict(self) -> JsonDict:
|
|
75
|
+
return {
|
|
76
|
+
"id": self.id,
|
|
77
|
+
"from": self.from_,
|
|
78
|
+
"to": self.to,
|
|
79
|
+
"status": self.status,
|
|
80
|
+
"messages": [message.to_dict() for message in self.messages],
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
@classmethod
|
|
84
|
+
def from_dict(cls, data: JsonDict) -> "Task":
|
|
85
|
+
return cls(
|
|
86
|
+
id=str(data["id"]),
|
|
87
|
+
from_=str(data["from"]),
|
|
88
|
+
to=str(data["to"]),
|
|
89
|
+
status=str(data["status"]),
|
|
90
|
+
messages=[
|
|
91
|
+
Message.from_dict(message)
|
|
92
|
+
for message in data.get("messages", [])
|
|
93
|
+
],
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
def to_json(self) -> str:
|
|
97
|
+
return json.dumps(self.to_dict(), ensure_ascii=False, sort_keys=True)
|
|
98
|
+
|
|
99
|
+
@classmethod
|
|
100
|
+
def from_json(cls, payload: str | bytes) -> "Task":
|
|
101
|
+
return cls.from_dict(json.loads(payload))
|
a2a_agent/topics.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""MQTT topic helpers for the A2A namespace."""
|
|
2
|
+
|
|
3
|
+
CONFIG_DEVICES = "a2a/{room}/config/devices"
|
|
4
|
+
CONFIG_COMMANDER = "a2a/{room}/config/commander"
|
|
5
|
+
AGENT_CARD = "a2a/{room}/agents/{device}/card"
|
|
6
|
+
AGENT_STATUS = "a2a/{room}/agents/{device}/status"
|
|
7
|
+
CMD_REQUEST = "a2a/{room}/cmd/{device}/request"
|
|
8
|
+
CMD_RESPONSE = "a2a/{room}/cmd/{device}/response"
|
|
9
|
+
CMD_TASK_STATUS = "a2a/{room}/cmd/{device}/task-status"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _segment(value: str, name: str) -> str:
|
|
13
|
+
if not value:
|
|
14
|
+
raise ValueError(f"{name} must not be empty")
|
|
15
|
+
if "/" in value:
|
|
16
|
+
raise ValueError(f"{name} must not contain '/'")
|
|
17
|
+
return value
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def config_devices_topic(room: str) -> str:
|
|
21
|
+
return CONFIG_DEVICES.format(room=_segment(room, "room"))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def config_commander_topic(room: str) -> str:
|
|
25
|
+
return CONFIG_COMMANDER.format(room=_segment(room, "room"))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def agent_card_topic(room: str, device: str) -> str:
|
|
29
|
+
return AGENT_CARD.format(
|
|
30
|
+
room=_segment(room, "room"),
|
|
31
|
+
device=_segment(device, "device"),
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def agent_status_topic(room: str, device: str) -> str:
|
|
36
|
+
return AGENT_STATUS.format(
|
|
37
|
+
room=_segment(room, "room"),
|
|
38
|
+
device=_segment(device, "device"),
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def cmd_request_topic(room: str, device: str) -> str:
|
|
43
|
+
return CMD_REQUEST.format(
|
|
44
|
+
room=_segment(room, "room"),
|
|
45
|
+
device=_segment(device, "device"),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def cmd_response_topic(room: str, device: str) -> str:
|
|
50
|
+
return CMD_RESPONSE.format(
|
|
51
|
+
room=_segment(room, "room"),
|
|
52
|
+
device=_segment(device, "device"),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def cmd_task_status_topic(room: str, device: str) -> str:
|
|
57
|
+
return CMD_TASK_STATUS.format(
|
|
58
|
+
room=_segment(room, "room"),
|
|
59
|
+
device=_segment(device, "device"),
|
|
60
|
+
)
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: a2a-agent
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MQTT 위에서 도는 커맨더-워커 A2A 오케스트레이션 에이전트
|
|
5
|
+
Author: Judvika
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Classifier: Development Status :: 3 - Alpha
|
|
8
|
+
Classifier: Environment :: Console
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Topic :: Communications
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Requires-Dist: paho-mqtt
|
|
20
|
+
Requires-Dist: typer
|
|
21
|
+
Requires-Dist: pyyaml
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# a2a-agent
|
|
25
|
+
|
|
26
|
+
`a2a-agent`는 MQTT 위에서 커맨더와 워커가 A2A 형식 메시지를 주고받기 위한 작은 명령줄 도구입니다.
|
|
27
|
+
|
|
28
|
+
현재 `0.1.0`은 설정 파일 저장, 초대 코드 생성과 참여, 워커 역할 기록, 상태 확인을 지원합니다. 실제 작업 전송 명령인 `send`는 아직 자리 표시자입니다.
|
|
29
|
+
|
|
30
|
+
## 설치
|
|
31
|
+
|
|
32
|
+
PyPI 배포 후에는 다음처럼 설치할 수 있습니다.
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
python -m pip install a2a-agent
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
개발 환경에서 소스 코드로 설치하려면 저장소 루트에서 실행합니다.
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
python -m venv .venv
|
|
42
|
+
source .venv/bin/activate
|
|
43
|
+
python -m pip install -e .
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## 기본 사용
|
|
47
|
+
|
|
48
|
+
커맨더 설정을 만듭니다.
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
a2a-agent init --room team --device-name commander --broker-host broker.example.com
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
워커 초대 코드를 만듭니다.
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
a2a-agent invite device-a
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
워커 기기에서는 초대 코드로 참여합니다.
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
a2a-agent join "초대코드"
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
커맨더에서 워커 역할을 기록합니다.
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
a2a-agent set-role device-a research "collect project context"
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
현재 설정을 확인합니다.
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
a2a-agent status
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## 구조
|
|
79
|
+
|
|
80
|
+
```text
|
|
81
|
+
a2a-agent/
|
|
82
|
+
├── pyproject.toml
|
|
83
|
+
├── README.md
|
|
84
|
+
├── LICENSE
|
|
85
|
+
├── src/a2a_agent/
|
|
86
|
+
│ ├── cli.py
|
|
87
|
+
│ ├── client.py
|
|
88
|
+
│ ├── config.py
|
|
89
|
+
│ ├── invite.py
|
|
90
|
+
│ ├── protocol.py
|
|
91
|
+
│ └── topics.py
|
|
92
|
+
└── tests/
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
주요 모듈은 다음 역할을 맡습니다.
|
|
96
|
+
|
|
97
|
+
- `cli.py`: 명령줄 진입점
|
|
98
|
+
- `config.py`: 로컬 설정 파일 저장과 읽기
|
|
99
|
+
- `invite.py`: 초대 코드 생성과 파싱
|
|
100
|
+
- `protocol.py`: A2A 메시지 자료 구조
|
|
101
|
+
- `topics.py`: MQTT 토픽 문자열 생성
|
|
102
|
+
- `client.py`: `paho-mqtt` 래퍼
|
|
103
|
+
|
|
104
|
+
## 라이선스
|
|
105
|
+
|
|
106
|
+
MIT 라이선스를 따릅니다.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
a2a_agent/__init__.py,sha256=61JlJBmQp4TFIL4bmAr5aZcTplRljCI9dVECa_ov4hw,48
|
|
2
|
+
a2a_agent/cli.py,sha256=iPmVwVP4QCoWuF0wrJ89IGAc5XfnNYfDweK9Ia1lPC8,4279
|
|
3
|
+
a2a_agent/client.py,sha256=nQd7YupO3mcvaAth5DLEv0yYlTr1prcfigZwgWS02nw,1631
|
|
4
|
+
a2a_agent/config.py,sha256=Ik4OE8_0G4Cm8zoUANNYmZOLzToVWwCnOpZNHwgO6J4,4564
|
|
5
|
+
a2a_agent/invite.py,sha256=h96nAGTskktHy4AqPDSHTgBhxZgaoxmM6YT7vfLYrY4,3721
|
|
6
|
+
a2a_agent/protocol.py,sha256=M6X3XceZPWntSU6_Bgp12NRFHuiXhq84mIbXSF8Hvcs,2714
|
|
7
|
+
a2a_agent/topics.py,sha256=hnADHo1U2Qq2ROzpokWiLKQe-Ff3eHWJk4IMhnuwEmI,1703
|
|
8
|
+
a2a_agent-0.1.0.dist-info/licenses/LICENSE,sha256=FokqgYgJvTQ8U21M3QhYkGIncxanoPrtX8qxqmH54MU,1064
|
|
9
|
+
a2a_agent-0.1.0.dist-info/METADATA,sha256=a4QRdyh6RLHgUKHkJRISFw46c-vq6XZE0hDHKrXgjP0,2585
|
|
10
|
+
a2a_agent-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
11
|
+
a2a_agent-0.1.0.dist-info/entry_points.txt,sha256=trYs4s_zCZVUw2bv1GuE23Yw7c2SZQ-w_LFBGhK8_qo,48
|
|
12
|
+
a2a_agent-0.1.0.dist-info/top_level.txt,sha256=H_H27T9-2PU7ThwK3Y4FJVwbZT147EdWY9KecZJLm1s,10
|
|
13
|
+
a2a_agent-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Judvika
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
a2a_agent
|