Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,6 @@ jobs:
- name: Check shell scripts
run: |
# Basic syntax check for shell scripts
bash -n a2a
bash -n a2a-spawn
bash -n install.sh
bash -n smoke_test.sh
Expand Down
237 changes: 235 additions & 2 deletions a2a.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
import time
from pathlib import Path

from a2a_common import MAX_GROUP_NAME_LENGTH

SCHEMA = """
CREATE TABLE IF NOT EXISTS agents (
id TEXT PRIMARY KEY,
Expand Down Expand Up @@ -53,6 +55,15 @@
CREATE INDEX IF NOT EXISTS idx_messages_recipient ON messages(recipient);
CREATE INDEX IF NOT EXISTS idx_messages_thread ON messages(thread_id);
CREATE INDEX IF NOT EXISTS idx_messages_created ON messages(created_at);

CREATE TABLE IF NOT EXISTS agent_groups (
name TEXT NOT NULL,
member_id TEXT NOT NULL,
created_at REAL NOT NULL,
PRIMARY KEY (name, member_id)
);
CREATE INDEX IF NOT EXISTS idx_agent_groups_name ON agent_groups(name);
CREATE INDEX IF NOT EXISTS idx_agent_groups_member ON agent_groups(member_id);
"""


Expand Down Expand Up @@ -88,6 +99,34 @@ def project_name(explicit: str | None) -> str:
return name


def _validate_group_name(name: str) -> str:
"""Validate and normalize a group name.

Strips whitespace, checks length and character constraints.
Exits via die() on failure.
Returns the normalized (stripped) name on success.
"""
import re
name = name.strip()
if not name:
die("group name must not be empty")
if len(name) > MAX_GROUP_NAME_LENGTH:
die(f"group name too long ({len(name)} chars, max {MAX_GROUP_NAME_LENGTH})")
if not re.match(r'^[a-zA-Z0-9_-]+$', name):
die("group name must contain only alphanumeric characters, dashes, or underscores")
return name


def _resolve_group_name(raw: str) -> str:
"""Strip leading '@' and whitespace, then validate the group name.

Returns the normalized group name.
"""
name = raw.strip().lstrip('@').strip()
name = _validate_group_name(name)
return name
Comment thread
coderabbitai[bot] marked this conversation as resolved.


def _validate_project_name(name: str) -> None:
"""Reject project names that could cause path traversal or directory escape."""
if "/" in name or "\\" in name or name[0] == ".":
Expand Down Expand Up @@ -328,10 +367,10 @@ def cmd_send(args) -> None:
if not recipient:
conn.close()
die("recipient must not be empty — use 'all' for broadcast")
if len(recipient) > MAX_ID_LENGTH:
if not recipient.startswith('@') and len(recipient) > MAX_ID_LENGTH:
conn.close()
die(f"agent id too long ({len(recipient)} chars, max {MAX_ID_LENGTH})")
if not conn.execute("SELECT 1 FROM agents WHERE id=?", (recipient,)).fetchone():
if not recipient.startswith('@') and not conn.execute("SELECT 1 FROM agents WHERE id=?", (recipient,)).fetchone():
conn.close()
die(f"unknown recipient '{recipient}' — register them first")
body = args.body
Expand All @@ -350,6 +389,33 @@ def cmd_send(args) -> None:
die(f"--thread too long ({len(thread_id)} chars, max {MAX_THREAD_ID_LENGTH})")
if len(body) > MAX_BODY_LENGTH:
die(f"message body too long ({len(body)} chars, max {MAX_BODY_LENGTH})")
if recipient is not None and recipient.startswith('@'):
group_name = _resolve_group_name(recipient)
members = conn.execute(
'SELECT member_id FROM agent_groups WHERE name=? AND member_id != ?', (group_name, '__group__')
).fetchall()
if not members:
conn.close()
die(f"group '@{group_name}' not found or has no members")
ts_now = now()
first_id = None
for m in members:
cur = conn.execute(
'INSERT INTO messages(sender,recipient,body,thread_id,ttl_seconds,created_at)'
' VALUES (?,?,?,?,?,?)',
(sender, m['member_id'], body, thread_id, ttl, ts_now)
)
if first_id is None:
first_id = cur.lastrowid
_touch(conn, sender)
conn.commit()
conn.close()
target = f"@{group_name} ({len(members)} members)"
if getattr(args, 'json', False):
print(json.dumps({'id': first_id, 'sender': sender, 'recipient': target}, indent=2))
else:
print(f"#{first_id} {sender} -> {target}")
return
cur = conn.execute(
"INSERT INTO messages(sender, recipient, body, thread_id, ttl_seconds, created_at) "
"VALUES (?,?,?,?,?,?)",
Expand Down Expand Up @@ -675,6 +741,138 @@ def cmd_wait(args) -> None:
time.sleep(0.5)


# ---------- group commands ----------

def cmd_group_create(args) -> None:
"""Create a named agent group.

Persists a sentinel row in agent_groups so the group exists even before
members are added.
"""
name = _resolve_group_name(args.name)
_, conn = _open(args)
ts = now()
conn.execute(
"INSERT OR IGNORE INTO agent_groups(name, member_id, created_at) VALUES (?,?,?)",
(name, '__group__', ts),
)
conn.commit()
Comment thread
coderabbitai[bot] marked this conversation as resolved.
conn.close()
if getattr(args, 'json', False):
print(json.dumps({"group": name, "created": True}, indent=2))
Comment on lines +753 to +762
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Persist groups created by the CLI

In the CLI path, a2a group create team only validates the name and then reports success; because no row is stored for empty groups, a2a group list immediately shows no groups and a2a send @team ... still errors until group add implicitly creates membership rows. For a public create command, either persist group metadata or avoid reporting that the group was created.

Useful? React with 👍 / 👎.

else:
print(f"group '@{name}' ready")


def cmd_group_add(args) -> None:
"""Add members to an existing group.

Skips unregistered agents with a warning.
"""
name = _resolve_group_name(args.name)
_, conn = _open(args)
ts = now()
added = 0
for member in args.members:
member = member.strip()
if not member:
continue
if not conn.execute("SELECT 1 FROM agents WHERE id=?", (member,)).fetchone():
print(f"a2a: warning: agent '{member}' not registered — skipping", file=sys.stderr)
continue
try:
conn.execute(
"INSERT OR IGNORE INTO agent_groups(name, member_id, created_at) VALUES (?,?,?)",
(name, member, ts),
)
added += conn.execute(
"SELECT changes()"
).fetchone()[0]
except sqlite3.IntegrityError:
pass
conn.commit()
conn.close()
if getattr(args, 'json', False):
print(json.dumps({"group": name, "added": added}, indent=2))
else:
print(f"added {added} member(s) to '@{name}'")


def cmd_group_remove(args) -> None:
"""Remove a single member from a group."""
name = _resolve_group_name(args.name)
member = args.member.strip()
if not member:
die("member must not be empty")
_, conn = _open(args)
cur = conn.execute(
"DELETE FROM agent_groups WHERE name=? AND member_id=?", (name, member)
)
conn.commit()
n = cur.rowcount
conn.close()
if getattr(args, 'json', False):
print(json.dumps({"group": name, "removed": member, "rows": n}, indent=2))
else:
print(f"removed '{member}' from '@{name}' ({n} row(s))")


def cmd_group_delete(args) -> None:
"""Delete an entire group, removing all its member associations."""
name = _resolve_group_name(args.name)
_, conn = _open(args)
cur = conn.execute("DELETE FROM agent_groups WHERE name=?", (name,))
conn.commit()
n = cur.rowcount
conn.close()
if getattr(args, 'json', False):
print(json.dumps({"group": name, "deleted": n}, indent=2))
else:
print(f"deleted group '@{name}' ({n} row(s))")


def cmd_group_list(args) -> None:
"""List all groups with their member counts."""
_, conn = _open(args)
rows = conn.execute(
"SELECT name, COUNT(*) as member_count FROM agent_groups WHERE member_id != '__group__' GROUP BY name ORDER BY name"
).fetchall()
conn.close()
if getattr(args, 'json', False):
print(json.dumps([dict(r) for r in rows], indent=2))
return
if not rows:
print("(no groups)")
return
print(f"{'GROUP':<32} {'MEMBERS':<8}")
for r in rows:
print(f"@{r['name']:<31} {r['member_count']:<8}")


def cmd_group_show(args) -> None:
"""Show all members of a specific group."""
name = _resolve_group_name(args.name)
_, conn = _open(args)
rows = conn.execute(
"SELECT member_id FROM agent_groups WHERE name=? AND member_id != '__group__' ORDER BY member_id", (name,)
).fetchall()
conn.close()
if getattr(args, 'json', False):
print(json.dumps({"group": name, "members": [r['member_id'] for r in rows]}, indent=2))
return
if not rows:
print(f"(group '@{name}' is empty or does not exist)")
return
print(f"@{name}:")
for r in rows:
print(f" {r['member_id']}")


def cmd_group(args) -> None:
"""Dispatch to the appropriate group sub-command."""
args.group_func(args)


# ---------- arg parsing ----------

def build_parser() -> argparse.ArgumentParser:
Expand Down Expand Up @@ -789,6 +987,41 @@ def build_parser() -> argparse.ArgumentParser:
s.add_argument("--yes", action="store_true")
s.set_defaults(func=cmd_clear)

sg = sub.add_parser("group", help="manage named agent groups")
sg.set_defaults(func=cmd_group)
gsub = sg.add_subparsers(dest="group_cmd", required=True)

s = gsub.add_parser("create", help="create a group")
s.add_argument("name", metavar="NAME", help="Group name (alphanumeric, dashes, underscores)")
s.add_argument("--json", action="store_true")
s.set_defaults(group_func=cmd_group_create)

s = gsub.add_parser("add", help="add members to a group")
s.add_argument("name", metavar="NAME", help="Group name (alphanumeric, dashes, underscores)")
s.add_argument("members", nargs="+", metavar="MEMBER", help="Agent ID(s) to add")
s.add_argument("--json", action="store_true")
s.set_defaults(group_func=cmd_group_add)

s = gsub.add_parser("remove", help="remove a member from a group")
s.add_argument("name", metavar="NAME", help="Group name (alphanumeric, dashes, underscores)")
s.add_argument("member", metavar="MEMBER", help="Agent ID to remove")
s.add_argument("--json", action="store_true")
s.set_defaults(group_func=cmd_group_remove)

s = gsub.add_parser("delete", help="delete an entire group")
s.add_argument("name", metavar="NAME", help="Group name (alphanumeric, dashes, underscores)")
s.add_argument("--json", action="store_true")
s.set_defaults(group_func=cmd_group_delete)

s = gsub.add_parser("list", help="list all groups")
s.add_argument("--json", action="store_true")
s.set_defaults(group_func=cmd_group_list)

s = gsub.add_parser("show", help="show members of a group")
s.add_argument("name", metavar="NAME", help="Group name (alphanumeric, dashes, underscores)")
s.add_argument("--json", action="store_true")
s.set_defaults(group_func=cmd_group_show)

return p


Expand Down
Loading
Loading