-
Notifications
You must be signed in to change notification settings - Fork 0
feat: agent groups — @groupname addressing and group management #2
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
318cb0b
b90b3a0
c5cacf5
6feec13
a38cd1e
6ad6558
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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, | ||
|
|
@@ -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); | ||
| """ | ||
|
|
||
|
|
||
|
|
@@ -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 | ||
|
|
||
|
|
||
| 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] == ".": | ||
|
|
@@ -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 | ||
|
|
@@ -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 (?,?,?,?,?,?)", | ||
|
|
@@ -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() | ||
|
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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
In the CLI path, 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: | ||
|
|
@@ -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 | ||
|
|
||
|
|
||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.