a2a-agent 0.1.0__tar.gz

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,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,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,83 @@
1
+ # a2a-agent
2
+
3
+ `a2a-agent`는 MQTT 위에서 커맨더와 워커가 A2A 형식 메시지를 주고받기 위한 작은 명령줄 도구입니다.
4
+
5
+ 현재 `0.1.0`은 설정 파일 저장, 초대 코드 생성과 참여, 워커 역할 기록, 상태 확인을 지원합니다. 실제 작업 전송 명령인 `send`는 아직 자리 표시자입니다.
6
+
7
+ ## 설치
8
+
9
+ PyPI 배포 후에는 다음처럼 설치할 수 있습니다.
10
+
11
+ ```bash
12
+ python -m pip install a2a-agent
13
+ ```
14
+
15
+ 개발 환경에서 소스 코드로 설치하려면 저장소 루트에서 실행합니다.
16
+
17
+ ```bash
18
+ python -m venv .venv
19
+ source .venv/bin/activate
20
+ python -m pip install -e .
21
+ ```
22
+
23
+ ## 기본 사용
24
+
25
+ 커맨더 설정을 만듭니다.
26
+
27
+ ```bash
28
+ a2a-agent init --room team --device-name commander --broker-host broker.example.com
29
+ ```
30
+
31
+ 워커 초대 코드를 만듭니다.
32
+
33
+ ```bash
34
+ a2a-agent invite device-a
35
+ ```
36
+
37
+ 워커 기기에서는 초대 코드로 참여합니다.
38
+
39
+ ```bash
40
+ a2a-agent join "초대코드"
41
+ ```
42
+
43
+ 커맨더에서 워커 역할을 기록합니다.
44
+
45
+ ```bash
46
+ a2a-agent set-role device-a research "collect project context"
47
+ ```
48
+
49
+ 현재 설정을 확인합니다.
50
+
51
+ ```bash
52
+ a2a-agent status
53
+ ```
54
+
55
+ ## 구조
56
+
57
+ ```text
58
+ a2a-agent/
59
+ ├── pyproject.toml
60
+ ├── README.md
61
+ ├── LICENSE
62
+ ├── src/a2a_agent/
63
+ │ ├── cli.py
64
+ │ ├── client.py
65
+ │ ├── config.py
66
+ │ ├── invite.py
67
+ │ ├── protocol.py
68
+ │ └── topics.py
69
+ └── tests/
70
+ ```
71
+
72
+ 주요 모듈은 다음 역할을 맡습니다.
73
+
74
+ - `cli.py`: 명령줄 진입점
75
+ - `config.py`: 로컬 설정 파일 저장과 읽기
76
+ - `invite.py`: 초대 코드 생성과 파싱
77
+ - `protocol.py`: A2A 메시지 자료 구조
78
+ - `topics.py`: MQTT 토픽 문자열 생성
79
+ - `client.py`: `paho-mqtt` 래퍼
80
+
81
+ ## 라이선스
82
+
83
+ MIT 라이선스를 따릅니다.
@@ -0,0 +1,36 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "a2a-agent"
7
+ version = "0.1.0"
8
+ description = "MQTT 위에서 도는 커맨더-워커 A2A 오케스트레이션 에이전트"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ authors = [
13
+ { name = "Judvika" },
14
+ ]
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Environment :: Console",
18
+ "Intended Audience :: Developers",
19
+ "Operating System :: OS Independent",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Topic :: Communications",
25
+ ]
26
+ dependencies = [
27
+ "paho-mqtt",
28
+ "typer",
29
+ "pyyaml",
30
+ ]
31
+
32
+ [project.scripts]
33
+ a2a-agent = "a2a_agent.cli:app"
34
+
35
+ [tool.setuptools.packages.find]
36
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """A2A agent package."""
2
+
3
+ __version__ = "0.1.0"
@@ -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
+ )
@@ -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()
@@ -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")
@@ -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
@@ -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))
@@ -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,21 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/a2a_agent/__init__.py
5
+ src/a2a_agent/cli.py
6
+ src/a2a_agent/client.py
7
+ src/a2a_agent/config.py
8
+ src/a2a_agent/invite.py
9
+ src/a2a_agent/protocol.py
10
+ src/a2a_agent/topics.py
11
+ src/a2a_agent.egg-info/PKG-INFO
12
+ src/a2a_agent.egg-info/SOURCES.txt
13
+ src/a2a_agent.egg-info/dependency_links.txt
14
+ src/a2a_agent.egg-info/entry_points.txt
15
+ src/a2a_agent.egg-info/requires.txt
16
+ src/a2a_agent.egg-info/top_level.txt
17
+ tests/test_client.py
18
+ tests/test_config.py
19
+ tests/test_invite.py
20
+ tests/test_protocol.py
21
+ tests/test_topics.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ a2a-agent = a2a_agent.cli:app
@@ -0,0 +1,3 @@
1
+ paho-mqtt
2
+ typer
3
+ pyyaml
@@ -0,0 +1 @@
1
+ a2a_agent
@@ -0,0 +1,20 @@
1
+ import warnings
2
+
3
+ import paho.mqtt.client as mqtt
4
+
5
+ from a2a_agent.client import MqttClient
6
+
7
+
8
+ def test_mqtt_client_import_and_creation_do_not_connect(monkeypatch) -> None:
9
+ def fail_connect(*args, **kwargs) -> None:
10
+ raise AssertionError("connect must not be called during client creation")
11
+
12
+ monkeypatch.setattr(mqtt.Client, "connect", fail_connect)
13
+
14
+ MqttClient()
15
+
16
+
17
+ def test_mqtt_client_creation_has_no_deprecation_warning() -> None:
18
+ with warnings.catch_warnings():
19
+ warnings.simplefilter("error", DeprecationWarning)
20
+ MqttClient()
@@ -0,0 +1,56 @@
1
+ import stat
2
+
3
+ from a2a_agent.config import AgentConfig
4
+ from a2a_agent.config import BrokerConfig
5
+ from a2a_agent.config import DeviceRole
6
+ from a2a_agent.config import load_config
7
+ from a2a_agent.config import save_config
8
+
9
+
10
+ def test_default_config_values(tmp_path) -> None:
11
+ config = load_config(tmp_path / "missing.yaml")
12
+
13
+ assert config.room == "default"
14
+ assert config.device_name
15
+ assert config.broker == BrokerConfig()
16
+ assert config.role == "worker"
17
+ assert config.token is None
18
+ assert config.devices == {}
19
+
20
+
21
+ def test_save_load_round_trip_and_file_mode(tmp_path) -> None:
22
+ path = tmp_path / "config.yaml"
23
+ config = AgentConfig(
24
+ room="team",
25
+ device_name="commander",
26
+ broker=BrokerConfig(host="broker.example.com", port=8883, tls=True),
27
+ role="commander",
28
+ devices={
29
+ "device-b": DeviceRole(
30
+ role="research",
31
+ description="collect local context",
32
+ )
33
+ },
34
+ )
35
+
36
+ saved_path = save_config(config, path)
37
+ loaded = load_config(path)
38
+
39
+ assert saved_path == path
40
+ assert loaded == config
41
+ assert stat.S_IMODE(path.stat().st_mode) == 0o600
42
+
43
+
44
+ def test_worker_token_is_saved(tmp_path) -> None:
45
+ path = tmp_path / "config.yaml"
46
+ config = AgentConfig(
47
+ room="team",
48
+ device_name="device-b",
49
+ broker=BrokerConfig(host="broker.example.com", port=1883, tls=False),
50
+ role="worker",
51
+ token="runtime-generated-token",
52
+ )
53
+
54
+ save_config(config, path)
55
+
56
+ assert load_config(path) == config
@@ -0,0 +1,27 @@
1
+ import pytest
2
+
3
+ from a2a_agent.invite import InviteCodeError, create_invite_code, parse_invite_code
4
+
5
+
6
+ def test_invite_round_trip() -> None:
7
+ code = create_invite_code(
8
+ broker_host="broker.example.com",
9
+ broker_port=8883,
10
+ room="team",
11
+ device_name="device-b",
12
+ tls=True,
13
+ )
14
+
15
+ payload = parse_invite_code(code)
16
+
17
+ assert payload["broker"] == {"host": "broker.example.com", "port": 8883}
18
+ assert payload["room"] == "team"
19
+ assert payload["device_name"] == "device-b"
20
+ assert payload["tls"] is True
21
+ assert isinstance(payload["token"], str)
22
+ assert payload["token"]
23
+
24
+
25
+ def test_parse_invite_rejects_broken_code() -> None:
26
+ with pytest.raises(InviteCodeError, match="invite code"):
27
+ parse_invite_code("not-a-valid-code")
@@ -0,0 +1,44 @@
1
+ import json
2
+
3
+ import pytest
4
+
5
+ from a2a_agent.protocol import AgentCard, Message, Task
6
+
7
+
8
+ def test_task_round_trip_maps_from_key() -> None:
9
+ task = Task(
10
+ id="task-001",
11
+ from_="commander",
12
+ to="device-b",
13
+ status="pending",
14
+ messages=[
15
+ Message(
16
+ role="user",
17
+ parts=[{"type": "text", "text": "check project"}],
18
+ )
19
+ ],
20
+ )
21
+
22
+ payload = task.to_json()
23
+ raw = json.loads(payload)
24
+
25
+ assert raw["from"] == "commander"
26
+ assert "from_" not in raw
27
+ assert Task.from_json(payload) == task
28
+
29
+
30
+ def test_agent_card_round_trip() -> None:
31
+ card = AgentCard(name="Device B")
32
+
33
+ assert AgentCard.from_json(card.to_json()) == card
34
+
35
+
36
+ def test_task_rejects_unknown_status() -> None:
37
+ with pytest.raises(ValueError):
38
+ Task(
39
+ id="task-001",
40
+ from_="commander",
41
+ to="device-b",
42
+ status="unknown",
43
+ messages=[],
44
+ )
@@ -0,0 +1,24 @@
1
+ import pytest
2
+
3
+ from a2a_agent import topics
4
+
5
+
6
+ def test_topic_helpers_return_exact_strings() -> None:
7
+ room = "team"
8
+ device = "device-b"
9
+
10
+ assert topics.config_devices_topic(room) == "a2a/team/config/devices"
11
+ assert topics.config_commander_topic(room) == "a2a/team/config/commander"
12
+ assert topics.agent_card_topic(room, device) == "a2a/team/agents/device-b/card"
13
+ assert topics.agent_status_topic(room, device) == "a2a/team/agents/device-b/status"
14
+ assert topics.cmd_request_topic(room, device) == "a2a/team/cmd/device-b/request"
15
+ assert topics.cmd_response_topic(room, device) == "a2a/team/cmd/device-b/response"
16
+ assert topics.cmd_task_status_topic(room, device) == "a2a/team/cmd/device-b/task-status"
17
+
18
+
19
+ def test_topic_helpers_reject_slash_segments() -> None:
20
+ with pytest.raises(ValueError):
21
+ topics.cmd_request_topic("team/dev", "device-b")
22
+
23
+ with pytest.raises(ValueError):
24
+ topics.cmd_request_topic("team", "device/b")