agent-teams-cli 0.1.1__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.
- agent_teams_cli-0.1.1/PKG-INFO +50 -0
- agent_teams_cli-0.1.1/README.md +41 -0
- agent_teams_cli-0.1.1/pyproject.toml +19 -0
- agent_teams_cli-0.1.1/setup.cfg +4 -0
- agent_teams_cli-0.1.1/src/agent_teams/__init__.py +2 -0
- agent_teams_cli-0.1.1/src/agent_teams/cli.py +137 -0
- agent_teams_cli-0.1.1/src/agent_teams/core.py +303 -0
- agent_teams_cli-0.1.1/src/agent_teams_cli.egg-info/PKG-INFO +50 -0
- agent_teams_cli-0.1.1/src/agent_teams_cli.egg-info/SOURCES.txt +11 -0
- agent_teams_cli-0.1.1/src/agent_teams_cli.egg-info/dependency_links.txt +1 -0
- agent_teams_cli-0.1.1/src/agent_teams_cli.egg-info/entry_points.txt +2 -0
- agent_teams_cli-0.1.1/src/agent_teams_cli.egg-info/top_level.txt +1 -0
- agent_teams_cli-0.1.1/tests/test_core.py +147 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agent-teams-cli
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: File-based agent team coordination for OpenClaw
|
|
5
|
+
Author: Marcus
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
|
|
10
|
+
# agent-teams
|
|
11
|
+
|
|
12
|
+
File-based agent team coordination for [OpenClaw](https://github.com/nichochar/openclaw). Inspired by Claude Code's teams architecture.
|
|
13
|
+
|
|
14
|
+
## Features
|
|
15
|
+
|
|
16
|
+
- **Team management** — create/delete teams, add/remove members
|
|
17
|
+
- **Mailbox IPC** — file-based inboxes with cursor-based polling
|
|
18
|
+
- **Shared task list** — file-locked atomic task claims
|
|
19
|
+
- **XML context injection** — poll output formatted for agent prompt injection
|
|
20
|
+
- **Zero dependencies** — pure Python, no servers, no databases
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install -e .
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Quick Start
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
# Create a team
|
|
32
|
+
agent-teams create my-team --members lead researcher builder
|
|
33
|
+
|
|
34
|
+
# Send a message
|
|
35
|
+
agent-teams send lead@my-team researcher@my-team --text "Find papers on X"
|
|
36
|
+
|
|
37
|
+
# Poll inbox (XML for context injection)
|
|
38
|
+
agent-teams poll researcher@my-team
|
|
39
|
+
|
|
40
|
+
# Create and manage tasks
|
|
41
|
+
agent-teams task-create my-team -s "Research task" --assign-to researcher --assign-by lead
|
|
42
|
+
agent-teams task-claim my-team 1 researcher
|
|
43
|
+
agent-teams task-complete my-team 1 researcher -r "Done!"
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
See [SKILL.md](../../skills/agent-teams/SKILL.md) for full documentation.
|
|
47
|
+
|
|
48
|
+
## License
|
|
49
|
+
|
|
50
|
+
MIT
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# agent-teams
|
|
2
|
+
|
|
3
|
+
File-based agent team coordination for [OpenClaw](https://github.com/nichochar/openclaw). Inspired by Claude Code's teams architecture.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Team management** — create/delete teams, add/remove members
|
|
8
|
+
- **Mailbox IPC** — file-based inboxes with cursor-based polling
|
|
9
|
+
- **Shared task list** — file-locked atomic task claims
|
|
10
|
+
- **XML context injection** — poll output formatted for agent prompt injection
|
|
11
|
+
- **Zero dependencies** — pure Python, no servers, no databases
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install -e .
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Quick Start
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
# Create a team
|
|
23
|
+
agent-teams create my-team --members lead researcher builder
|
|
24
|
+
|
|
25
|
+
# Send a message
|
|
26
|
+
agent-teams send lead@my-team researcher@my-team --text "Find papers on X"
|
|
27
|
+
|
|
28
|
+
# Poll inbox (XML for context injection)
|
|
29
|
+
agent-teams poll researcher@my-team
|
|
30
|
+
|
|
31
|
+
# Create and manage tasks
|
|
32
|
+
agent-teams task-create my-team -s "Research task" --assign-to researcher --assign-by lead
|
|
33
|
+
agent-teams task-claim my-team 1 researcher
|
|
34
|
+
agent-teams task-complete my-team 1 researcher -r "Done!"
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
See [SKILL.md](../../skills/agent-teams/SKILL.md) for full documentation.
|
|
38
|
+
|
|
39
|
+
## License
|
|
40
|
+
|
|
41
|
+
MIT
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "agent-teams-cli"
|
|
7
|
+
version = "0.1.1"
|
|
8
|
+
description = "File-based agent team coordination for OpenClaw"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = {text = "MIT"}
|
|
12
|
+
authors = [{name = "Marcus"}]
|
|
13
|
+
dependencies = []
|
|
14
|
+
|
|
15
|
+
[project.scripts]
|
|
16
|
+
agent-teams = "agent_teams.cli:main"
|
|
17
|
+
|
|
18
|
+
[tool.setuptools.packages.find]
|
|
19
|
+
where = ["src"]
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""CLI for agent-teams."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from . import core
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def main():
|
|
10
|
+
parser = argparse.ArgumentParser(prog="agent-teams", description="Agent team coordination")
|
|
11
|
+
parser.add_argument("--json", action="store_true", help="JSON output")
|
|
12
|
+
sub = parser.add_subparsers(dest="command")
|
|
13
|
+
|
|
14
|
+
# team create
|
|
15
|
+
p = sub.add_parser("create", help="Create a team")
|
|
16
|
+
p.add_argument("team", help="Team name")
|
|
17
|
+
p.add_argument("--members", nargs="*", default=[], help="Initial members")
|
|
18
|
+
|
|
19
|
+
# team delete
|
|
20
|
+
p = sub.add_parser("delete", help="Delete a team")
|
|
21
|
+
p.add_argument("team")
|
|
22
|
+
|
|
23
|
+
# team list
|
|
24
|
+
sub.add_parser("list", help="List teams")
|
|
25
|
+
|
|
26
|
+
# team info
|
|
27
|
+
p = sub.add_parser("info", help="Team info")
|
|
28
|
+
p.add_argument("team")
|
|
29
|
+
|
|
30
|
+
# add-member
|
|
31
|
+
p = sub.add_parser("add-member", help="Add member to team")
|
|
32
|
+
p.add_argument("team")
|
|
33
|
+
p.add_argument("name")
|
|
34
|
+
|
|
35
|
+
# remove-member
|
|
36
|
+
p = sub.add_parser("remove-member", help="Remove member from team")
|
|
37
|
+
p.add_argument("team")
|
|
38
|
+
p.add_argument("name")
|
|
39
|
+
|
|
40
|
+
# send
|
|
41
|
+
p = sub.add_parser("send", help="Send a message")
|
|
42
|
+
p.add_argument("sender", help="sender@team")
|
|
43
|
+
p.add_argument("recipient", help="recipient@team")
|
|
44
|
+
p.add_argument("--text", "-t", required=True)
|
|
45
|
+
p.add_argument("--type", default="message", dest="msg_type")
|
|
46
|
+
|
|
47
|
+
# broadcast
|
|
48
|
+
p = sub.add_parser("broadcast", help="Broadcast to team")
|
|
49
|
+
p.add_argument("sender", help="sender@team")
|
|
50
|
+
p.add_argument("--text", "-t", required=True)
|
|
51
|
+
|
|
52
|
+
# poll
|
|
53
|
+
p = sub.add_parser("poll", help="Poll inbox for new messages")
|
|
54
|
+
p.add_argument("identity", help="agent@team")
|
|
55
|
+
p.add_argument("--format", choices=["xml", "json"], default="xml")
|
|
56
|
+
|
|
57
|
+
# inbox
|
|
58
|
+
p = sub.add_parser("inbox", help="Read full inbox")
|
|
59
|
+
p.add_argument("identity", help="agent@team")
|
|
60
|
+
|
|
61
|
+
# task create
|
|
62
|
+
p = sub.add_parser("task-create", help="Create a task")
|
|
63
|
+
p.add_argument("team")
|
|
64
|
+
p.add_argument("--subject", "-s", required=True)
|
|
65
|
+
p.add_argument("--description", "-d", default="")
|
|
66
|
+
p.add_argument("--assign-to", default="")
|
|
67
|
+
p.add_argument("--assign-by", default="")
|
|
68
|
+
|
|
69
|
+
# task claim
|
|
70
|
+
p = sub.add_parser("task-claim", help="Claim a task")
|
|
71
|
+
p.add_argument("team")
|
|
72
|
+
p.add_argument("task_id")
|
|
73
|
+
p.add_argument("agent")
|
|
74
|
+
|
|
75
|
+
# task complete
|
|
76
|
+
p = sub.add_parser("task-complete", help="Complete a task")
|
|
77
|
+
p.add_argument("team")
|
|
78
|
+
p.add_argument("task_id")
|
|
79
|
+
p.add_argument("agent")
|
|
80
|
+
p.add_argument("--result", "-r", default="")
|
|
81
|
+
|
|
82
|
+
# task list
|
|
83
|
+
p = sub.add_parser("task-list", help="List tasks")
|
|
84
|
+
p.add_argument("team")
|
|
85
|
+
p.add_argument("--status", choices=["pending", "in_progress", "completed"])
|
|
86
|
+
|
|
87
|
+
args = parser.parse_args()
|
|
88
|
+
use_json = args.json
|
|
89
|
+
|
|
90
|
+
def output(data):
|
|
91
|
+
if use_json:
|
|
92
|
+
print(json.dumps(data, indent=2))
|
|
93
|
+
else:
|
|
94
|
+
print(json.dumps(data, indent=2))
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
if args.command == "create":
|
|
98
|
+
output(core.create_team(args.team, args.members))
|
|
99
|
+
elif args.command == "delete":
|
|
100
|
+
ok = core.delete_team(args.team)
|
|
101
|
+
output({"deleted": ok})
|
|
102
|
+
elif args.command == "list":
|
|
103
|
+
output(core.list_teams())
|
|
104
|
+
elif args.command == "info":
|
|
105
|
+
output(core.team_info(args.team))
|
|
106
|
+
elif args.command == "add-member":
|
|
107
|
+
output(core.add_member(args.team, args.name))
|
|
108
|
+
elif args.command == "remove-member":
|
|
109
|
+
output(core.remove_member(args.team, args.name))
|
|
110
|
+
elif args.command == "send":
|
|
111
|
+
output(core.send_message(args.sender, args.recipient, args.text, args.msg_type))
|
|
112
|
+
elif args.command == "broadcast":
|
|
113
|
+
output(core.broadcast(args.sender, args.text))
|
|
114
|
+
elif args.command == "poll":
|
|
115
|
+
result = core.poll_inbox(args.identity, args.format)
|
|
116
|
+
if result:
|
|
117
|
+
print(result)
|
|
118
|
+
elif args.command == "inbox":
|
|
119
|
+
output(core.read_inbox(args.identity))
|
|
120
|
+
elif args.command == "task-create":
|
|
121
|
+
output(core.create_task(args.team, args.subject, args.description, args.assign_to, args.assign_by))
|
|
122
|
+
elif args.command == "task-claim":
|
|
123
|
+
output(core.claim_task(args.team, args.task_id, args.agent))
|
|
124
|
+
elif args.command == "task-complete":
|
|
125
|
+
output(core.complete_task(args.team, args.task_id, args.agent, args.result))
|
|
126
|
+
elif args.command == "task-list":
|
|
127
|
+
output(core.list_tasks(args.team, args.status))
|
|
128
|
+
else:
|
|
129
|
+
parser.print_help()
|
|
130
|
+
sys.exit(1)
|
|
131
|
+
except Exception as e:
|
|
132
|
+
print(json.dumps({"error": str(e)}), file=sys.stderr)
|
|
133
|
+
sys.exit(1)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
if __name__ == "__main__":
|
|
137
|
+
main()
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
"""Core library for agent-teams: team management, mailbox IPC, task coordination."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import fcntl
|
|
6
|
+
import time
|
|
7
|
+
import uuid
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
TEAMS_ROOT = Path.home() / ".openclaw" / "teams"
|
|
13
|
+
|
|
14
|
+
def _now() -> str:
|
|
15
|
+
return datetime.now(timezone.utc).isoformat()
|
|
16
|
+
|
|
17
|
+
def _parse_identity(identity: str) -> tuple[str, str]:
|
|
18
|
+
"""Parse 'name@team' into (name, team)."""
|
|
19
|
+
if "@" not in identity:
|
|
20
|
+
raise ValueError(f"Invalid identity '{identity}', expected name@team")
|
|
21
|
+
name, team = identity.split("@", 1)
|
|
22
|
+
return name, team
|
|
23
|
+
|
|
24
|
+
def _team_dir(team: str) -> Path:
|
|
25
|
+
return TEAMS_ROOT / team
|
|
26
|
+
|
|
27
|
+
def _inbox_path(team: str, agent: str) -> Path:
|
|
28
|
+
return _team_dir(team) / "inboxes" / f"{agent}.json"
|
|
29
|
+
|
|
30
|
+
def _tasks_dir(team: str) -> Path:
|
|
31
|
+
return _team_dir(team) / "tasks"
|
|
32
|
+
|
|
33
|
+
def _config_path(team: str) -> Path:
|
|
34
|
+
return _team_dir(team) / "config.json"
|
|
35
|
+
|
|
36
|
+
def _cursor_path(team: str, agent: str) -> Path:
|
|
37
|
+
return _team_dir(team) / "inboxes" / f".{agent}.cursor"
|
|
38
|
+
|
|
39
|
+
# ── Team Management ──
|
|
40
|
+
|
|
41
|
+
def create_team(team: str, members: Optional[list[str]] = None) -> dict:
|
|
42
|
+
d = _team_dir(team)
|
|
43
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
44
|
+
(d / "inboxes").mkdir(exist_ok=True)
|
|
45
|
+
(d / "tasks").mkdir(exist_ok=True)
|
|
46
|
+
cp = _config_path(team)
|
|
47
|
+
if cp.exists():
|
|
48
|
+
# Don't overwrite existing team config
|
|
49
|
+
config = json.loads(cp.read_text())
|
|
50
|
+
# Merge in any new members
|
|
51
|
+
for m in (members or []):
|
|
52
|
+
if m not in config["members"]:
|
|
53
|
+
config["members"].append(m)
|
|
54
|
+
cp.write_text(json.dumps(config, indent=2))
|
|
55
|
+
else:
|
|
56
|
+
config = {"name": team, "members": members or [], "created": _now()}
|
|
57
|
+
cp.write_text(json.dumps(config, indent=2))
|
|
58
|
+
# Create inboxes for initial members
|
|
59
|
+
for m in (members or []):
|
|
60
|
+
_inbox_path(team, m).write_text("[]")
|
|
61
|
+
return config
|
|
62
|
+
|
|
63
|
+
def delete_team(team: str) -> bool:
|
|
64
|
+
import shutil
|
|
65
|
+
d = _team_dir(team)
|
|
66
|
+
if d.exists():
|
|
67
|
+
shutil.rmtree(d)
|
|
68
|
+
return True
|
|
69
|
+
return False
|
|
70
|
+
|
|
71
|
+
def add_member(team: str, name: str) -> dict:
|
|
72
|
+
cp = _config_path(team)
|
|
73
|
+
config = json.loads(cp.read_text())
|
|
74
|
+
if name not in config["members"]:
|
|
75
|
+
config["members"].append(name)
|
|
76
|
+
cp.write_text(json.dumps(config, indent=2))
|
|
77
|
+
_inbox_path(team, name).write_text("[]")
|
|
78
|
+
return config
|
|
79
|
+
|
|
80
|
+
def remove_member(team: str, name: str) -> dict:
|
|
81
|
+
cp = _config_path(team)
|
|
82
|
+
config = json.loads(cp.read_text())
|
|
83
|
+
config["members"] = [m for m in config["members"] if m != name]
|
|
84
|
+
cp.write_text(json.dumps(config, indent=2))
|
|
85
|
+
inbox = _inbox_path(team, name)
|
|
86
|
+
if inbox.exists():
|
|
87
|
+
inbox.unlink()
|
|
88
|
+
cursor = _cursor_path(team, name)
|
|
89
|
+
if cursor.exists():
|
|
90
|
+
cursor.unlink()
|
|
91
|
+
return config
|
|
92
|
+
|
|
93
|
+
def list_teams() -> list[dict]:
|
|
94
|
+
if not TEAMS_ROOT.exists():
|
|
95
|
+
return []
|
|
96
|
+
teams = []
|
|
97
|
+
for d in sorted(TEAMS_ROOT.iterdir()):
|
|
98
|
+
cp = d / "config.json"
|
|
99
|
+
if cp.exists():
|
|
100
|
+
teams.append(json.loads(cp.read_text()))
|
|
101
|
+
return teams
|
|
102
|
+
|
|
103
|
+
def team_info(team: str) -> dict:
|
|
104
|
+
return json.loads(_config_path(team).read_text())
|
|
105
|
+
|
|
106
|
+
# ── Mailbox ──
|
|
107
|
+
|
|
108
|
+
def send_message(from_id: str, to_id: str, text: str, msg_type: str = "message") -> dict:
|
|
109
|
+
from_name, from_team = _parse_identity(from_id)
|
|
110
|
+
to_name, to_team = _parse_identity(to_id)
|
|
111
|
+
if from_team != to_team:
|
|
112
|
+
raise ValueError("Cross-team messaging not supported")
|
|
113
|
+
msg = {
|
|
114
|
+
"id": str(uuid.uuid4())[:8],
|
|
115
|
+
"from": from_name,
|
|
116
|
+
"to": to_name,
|
|
117
|
+
"type": msg_type,
|
|
118
|
+
"text": text,
|
|
119
|
+
"timestamp": _now(),
|
|
120
|
+
}
|
|
121
|
+
_append_to_inbox(to_team, to_name, msg)
|
|
122
|
+
return msg
|
|
123
|
+
|
|
124
|
+
def broadcast(from_id: str, text: str, msg_type: str = "broadcast") -> list[dict]:
|
|
125
|
+
from_name, team = _parse_identity(from_id)
|
|
126
|
+
config = team_info(team)
|
|
127
|
+
msgs = []
|
|
128
|
+
for member in config["members"]:
|
|
129
|
+
if member != from_name:
|
|
130
|
+
msg = {
|
|
131
|
+
"id": str(uuid.uuid4())[:8],
|
|
132
|
+
"from": from_name,
|
|
133
|
+
"to": member,
|
|
134
|
+
"type": msg_type,
|
|
135
|
+
"text": text,
|
|
136
|
+
"timestamp": _now(),
|
|
137
|
+
}
|
|
138
|
+
_append_to_inbox(team, member, msg)
|
|
139
|
+
msgs.append(msg)
|
|
140
|
+
return msgs
|
|
141
|
+
|
|
142
|
+
def _append_to_inbox(team: str, agent: str, msg: dict):
|
|
143
|
+
path = _inbox_path(team, agent)
|
|
144
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
145
|
+
# File-locked append
|
|
146
|
+
with open(path, "a+") as f:
|
|
147
|
+
fcntl.flock(f, fcntl.LOCK_EX)
|
|
148
|
+
try:
|
|
149
|
+
f.seek(0)
|
|
150
|
+
content = f.read().strip()
|
|
151
|
+
inbox = json.loads(content) if content else []
|
|
152
|
+
inbox.append(msg)
|
|
153
|
+
f.seek(0)
|
|
154
|
+
f.truncate()
|
|
155
|
+
f.write(json.dumps(inbox, indent=2))
|
|
156
|
+
finally:
|
|
157
|
+
fcntl.flock(f, fcntl.LOCK_UN)
|
|
158
|
+
|
|
159
|
+
def poll_inbox(identity: str, format: str = "xml") -> str:
|
|
160
|
+
"""Poll inbox for new messages since last read. Returns XML or JSON."""
|
|
161
|
+
name, team = _parse_identity(identity)
|
|
162
|
+
inbox_path = _inbox_path(team, name)
|
|
163
|
+
cursor_path = _cursor_path(team, name)
|
|
164
|
+
|
|
165
|
+
if not inbox_path.exists():
|
|
166
|
+
return "" if format == "xml" else "[]"
|
|
167
|
+
|
|
168
|
+
with open(inbox_path, "r") as f:
|
|
169
|
+
fcntl.flock(f, fcntl.LOCK_SH)
|
|
170
|
+
try:
|
|
171
|
+
content = f.read().strip()
|
|
172
|
+
messages = json.loads(content) if content else []
|
|
173
|
+
finally:
|
|
174
|
+
fcntl.flock(f, fcntl.LOCK_UN)
|
|
175
|
+
|
|
176
|
+
# Read cursor (index of last read message)
|
|
177
|
+
cursor = 0
|
|
178
|
+
if cursor_path.exists():
|
|
179
|
+
cursor = int(cursor_path.read_text().strip())
|
|
180
|
+
|
|
181
|
+
new_msgs = messages[cursor:]
|
|
182
|
+
# Update cursor
|
|
183
|
+
cursor_path.write_text(str(len(messages)))
|
|
184
|
+
|
|
185
|
+
if not new_msgs:
|
|
186
|
+
return "" if format == "xml" else "[]"
|
|
187
|
+
|
|
188
|
+
if format == "json":
|
|
189
|
+
return json.dumps(new_msgs, indent=2)
|
|
190
|
+
|
|
191
|
+
# XML format for context injection
|
|
192
|
+
parts = []
|
|
193
|
+
for msg in new_msgs:
|
|
194
|
+
parts.append(
|
|
195
|
+
f'<teammate-message from="{msg.get("from", "unknown")}" '
|
|
196
|
+
f'team="{team}" type="{msg.get("type", "message")}" '
|
|
197
|
+
f'timestamp="{msg.get("timestamp", "")}">\n'
|
|
198
|
+
f'{msg.get("text", "")}\n'
|
|
199
|
+
f'</teammate-message>'
|
|
200
|
+
)
|
|
201
|
+
return "\n".join(parts)
|
|
202
|
+
|
|
203
|
+
def read_inbox(identity: str) -> list[dict]:
|
|
204
|
+
"""Read all messages in inbox without advancing cursor."""
|
|
205
|
+
name, team = _parse_identity(identity)
|
|
206
|
+
path = _inbox_path(team, name)
|
|
207
|
+
if not path.exists():
|
|
208
|
+
return []
|
|
209
|
+
return json.loads(path.read_text() or "[]")
|
|
210
|
+
|
|
211
|
+
# ── Tasks ──
|
|
212
|
+
|
|
213
|
+
def create_task(team: str, subject: str, description: str = "", assigned_to: str = "", assigned_by: str = "") -> dict:
|
|
214
|
+
tasks_dir = _tasks_dir(team)
|
|
215
|
+
tasks_dir.mkdir(parents=True, exist_ok=True)
|
|
216
|
+
lock_path = tasks_dir / ".lock"
|
|
217
|
+
|
|
218
|
+
with open(lock_path, "w") as lock:
|
|
219
|
+
fcntl.flock(lock, fcntl.LOCK_EX)
|
|
220
|
+
try:
|
|
221
|
+
existing = [f for f in tasks_dir.glob("*.json")]
|
|
222
|
+
task_id = str(len(existing) + 1)
|
|
223
|
+
task = {
|
|
224
|
+
"id": task_id,
|
|
225
|
+
"subject": subject,
|
|
226
|
+
"description": description,
|
|
227
|
+
"status": "pending",
|
|
228
|
+
"assigned_to": assigned_to or None,
|
|
229
|
+
"assigned_by": assigned_by or None,
|
|
230
|
+
"created": _now(),
|
|
231
|
+
"claimed_at": None,
|
|
232
|
+
"completed_at": None,
|
|
233
|
+
"result": None,
|
|
234
|
+
}
|
|
235
|
+
(tasks_dir / f"{task_id}.json").write_text(json.dumps(task, indent=2))
|
|
236
|
+
finally:
|
|
237
|
+
fcntl.flock(lock, fcntl.LOCK_UN)
|
|
238
|
+
|
|
239
|
+
# If assigned, send task_assignment message
|
|
240
|
+
if assigned_to and assigned_by:
|
|
241
|
+
send_message(
|
|
242
|
+
f"{assigned_by}@{team}",
|
|
243
|
+
f"{assigned_to}@{team}",
|
|
244
|
+
json.dumps({"type": "task_assignment", "taskId": task_id, "subject": subject, "description": description}),
|
|
245
|
+
msg_type="task_assignment"
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
return task
|
|
249
|
+
|
|
250
|
+
def claim_task(team: str, task_id: str, agent: str) -> dict:
|
|
251
|
+
tasks_dir = _tasks_dir(team)
|
|
252
|
+
lock_path = tasks_dir / ".lock"
|
|
253
|
+
task_path = tasks_dir / f"{task_id}.json"
|
|
254
|
+
|
|
255
|
+
if not task_path.exists():
|
|
256
|
+
raise ValueError(f"Task {task_id} not found")
|
|
257
|
+
|
|
258
|
+
with open(lock_path, "w") as lock:
|
|
259
|
+
fcntl.flock(lock, fcntl.LOCK_EX)
|
|
260
|
+
try:
|
|
261
|
+
task = json.loads(task_path.read_text())
|
|
262
|
+
if task["status"] != "pending":
|
|
263
|
+
raise ValueError(f"Task {task_id} is {task['status']}, cannot claim")
|
|
264
|
+
task["status"] = "in_progress"
|
|
265
|
+
task["assigned_to"] = agent
|
|
266
|
+
task["claimed_at"] = _now()
|
|
267
|
+
task_path.write_text(json.dumps(task, indent=2))
|
|
268
|
+
finally:
|
|
269
|
+
fcntl.flock(lock, fcntl.LOCK_UN)
|
|
270
|
+
return task
|
|
271
|
+
|
|
272
|
+
def complete_task(team: str, task_id: str, agent: str, result: str = "") -> dict:
|
|
273
|
+
tasks_dir = _tasks_dir(team)
|
|
274
|
+
lock_path = tasks_dir / ".lock"
|
|
275
|
+
task_path = tasks_dir / f"{task_id}.json"
|
|
276
|
+
|
|
277
|
+
if not task_path.exists():
|
|
278
|
+
raise ValueError(f"Task {task_id} not found")
|
|
279
|
+
|
|
280
|
+
with open(lock_path, "w") as lock:
|
|
281
|
+
fcntl.flock(lock, fcntl.LOCK_EX)
|
|
282
|
+
try:
|
|
283
|
+
task = json.loads(task_path.read_text())
|
|
284
|
+
if task["status"] != "in_progress":
|
|
285
|
+
raise ValueError(f"Task {task_id} is {task['status']}, cannot complete")
|
|
286
|
+
task["status"] = "completed"
|
|
287
|
+
task["completed_at"] = _now()
|
|
288
|
+
task["result"] = result
|
|
289
|
+
task_path.write_text(json.dumps(task, indent=2))
|
|
290
|
+
finally:
|
|
291
|
+
fcntl.flock(lock, fcntl.LOCK_UN)
|
|
292
|
+
return task
|
|
293
|
+
|
|
294
|
+
def list_tasks(team: str, status: Optional[str] = None) -> list[dict]:
|
|
295
|
+
tasks_dir = _tasks_dir(team)
|
|
296
|
+
if not tasks_dir.exists():
|
|
297
|
+
return []
|
|
298
|
+
tasks = []
|
|
299
|
+
for f in sorted(tasks_dir.glob("*.json")):
|
|
300
|
+
task = json.loads(f.read_text())
|
|
301
|
+
if status is None or task["status"] == status:
|
|
302
|
+
tasks.append(task)
|
|
303
|
+
return tasks
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agent-teams-cli
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: File-based agent team coordination for OpenClaw
|
|
5
|
+
Author: Marcus
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
|
|
10
|
+
# agent-teams
|
|
11
|
+
|
|
12
|
+
File-based agent team coordination for [OpenClaw](https://github.com/nichochar/openclaw). Inspired by Claude Code's teams architecture.
|
|
13
|
+
|
|
14
|
+
## Features
|
|
15
|
+
|
|
16
|
+
- **Team management** — create/delete teams, add/remove members
|
|
17
|
+
- **Mailbox IPC** — file-based inboxes with cursor-based polling
|
|
18
|
+
- **Shared task list** — file-locked atomic task claims
|
|
19
|
+
- **XML context injection** — poll output formatted for agent prompt injection
|
|
20
|
+
- **Zero dependencies** — pure Python, no servers, no databases
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install -e .
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Quick Start
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
# Create a team
|
|
32
|
+
agent-teams create my-team --members lead researcher builder
|
|
33
|
+
|
|
34
|
+
# Send a message
|
|
35
|
+
agent-teams send lead@my-team researcher@my-team --text "Find papers on X"
|
|
36
|
+
|
|
37
|
+
# Poll inbox (XML for context injection)
|
|
38
|
+
agent-teams poll researcher@my-team
|
|
39
|
+
|
|
40
|
+
# Create and manage tasks
|
|
41
|
+
agent-teams task-create my-team -s "Research task" --assign-to researcher --assign-by lead
|
|
42
|
+
agent-teams task-claim my-team 1 researcher
|
|
43
|
+
agent-teams task-complete my-team 1 researcher -r "Done!"
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
See [SKILL.md](../../skills/agent-teams/SKILL.md) for full documentation.
|
|
47
|
+
|
|
48
|
+
## License
|
|
49
|
+
|
|
50
|
+
MIT
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/agent_teams/__init__.py
|
|
4
|
+
src/agent_teams/cli.py
|
|
5
|
+
src/agent_teams/core.py
|
|
6
|
+
src/agent_teams_cli.egg-info/PKG-INFO
|
|
7
|
+
src/agent_teams_cli.egg-info/SOURCES.txt
|
|
8
|
+
src/agent_teams_cli.egg-info/dependency_links.txt
|
|
9
|
+
src/agent_teams_cli.egg-info/entry_points.txt
|
|
10
|
+
src/agent_teams_cli.egg-info/top_level.txt
|
|
11
|
+
tests/test_core.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
agent_teams
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Tests for agent-teams core functionality."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import tempfile
|
|
6
|
+
import pytest
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from unittest.mock import patch
|
|
9
|
+
|
|
10
|
+
# Patch TEAMS_ROOT before importing core
|
|
11
|
+
_tmpdir = tempfile.mkdtemp()
|
|
12
|
+
import agent_teams.core as core
|
|
13
|
+
core.TEAMS_ROOT = Path(_tmpdir) / "teams"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def setup_function():
|
|
17
|
+
"""Clean up between tests."""
|
|
18
|
+
import shutil
|
|
19
|
+
if core.TEAMS_ROOT.exists():
|
|
20
|
+
shutil.rmtree(core.TEAMS_ROOT)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_create_and_info():
|
|
24
|
+
config = core.create_team("test-team", ["alice", "bob"])
|
|
25
|
+
assert config["name"] == "test-team"
|
|
26
|
+
assert set(config["members"]) == {"alice", "bob"}
|
|
27
|
+
info = core.team_info("test-team")
|
|
28
|
+
assert info["name"] == "test-team"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_list_teams():
|
|
32
|
+
core.create_team("a-team")
|
|
33
|
+
core.create_team("b-team")
|
|
34
|
+
teams = core.list_teams()
|
|
35
|
+
assert len(teams) == 2
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_add_remove_member():
|
|
39
|
+
core.create_team("test-team", ["alice"])
|
|
40
|
+
core.add_member("test-team", "bob")
|
|
41
|
+
info = core.team_info("test-team")
|
|
42
|
+
assert "bob" in info["members"]
|
|
43
|
+
core.remove_member("test-team", "bob")
|
|
44
|
+
info = core.team_info("test-team")
|
|
45
|
+
assert "bob" not in info["members"]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_delete_team():
|
|
49
|
+
core.create_team("doomed")
|
|
50
|
+
assert core.delete_team("doomed")
|
|
51
|
+
assert not core.delete_team("nonexistent")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_send_and_poll():
|
|
55
|
+
core.create_team("msg-team", ["alice", "bob"])
|
|
56
|
+
core.send_message("alice@msg-team", "bob@msg-team", "hello bob")
|
|
57
|
+
core.send_message("alice@msg-team", "bob@msg-team", "second msg")
|
|
58
|
+
|
|
59
|
+
xml = core.poll_inbox("bob@msg-team", "xml")
|
|
60
|
+
assert '<teammate-message from="alice"' in xml
|
|
61
|
+
assert "hello bob" in xml
|
|
62
|
+
assert "second msg" in xml
|
|
63
|
+
|
|
64
|
+
# Second poll should return empty (cursor advanced)
|
|
65
|
+
xml2 = core.poll_inbox("bob@msg-team", "xml")
|
|
66
|
+
assert xml2 == ""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_poll_json():
|
|
70
|
+
core.create_team("j-team", ["a", "b"])
|
|
71
|
+
core.send_message("a@j-team", "b@j-team", "test")
|
|
72
|
+
result = core.poll_inbox("b@j-team", "json")
|
|
73
|
+
msgs = json.loads(result)
|
|
74
|
+
assert len(msgs) == 1
|
|
75
|
+
assert msgs[0]["text"] == "test"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_broadcast():
|
|
79
|
+
core.create_team("bc-team", ["lead", "w1", "w2"])
|
|
80
|
+
msgs = core.broadcast("lead@bc-team", "all hands")
|
|
81
|
+
assert len(msgs) == 2
|
|
82
|
+
# Both workers got the message
|
|
83
|
+
assert core.poll_inbox("w1@bc-team") != ""
|
|
84
|
+
assert core.poll_inbox("w2@bc-team") != ""
|
|
85
|
+
# Lead didn't get it
|
|
86
|
+
assert core.poll_inbox("lead@bc-team") == ""
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_task_lifecycle():
|
|
90
|
+
core.create_team("task-team", ["lead", "worker"])
|
|
91
|
+
task = core.create_task("task-team", "Do the thing", "details here")
|
|
92
|
+
assert task["status"] == "pending"
|
|
93
|
+
assert task["id"] == "1"
|
|
94
|
+
|
|
95
|
+
claimed = core.claim_task("task-team", "1", "worker")
|
|
96
|
+
assert claimed["status"] == "in_progress"
|
|
97
|
+
|
|
98
|
+
completed = core.complete_task("task-team", "1", "worker", "done!")
|
|
99
|
+
assert completed["status"] == "completed"
|
|
100
|
+
assert completed["result"] == "done!"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_task_double_claim():
|
|
104
|
+
core.create_team("race-team", ["a", "b"])
|
|
105
|
+
core.create_task("race-team", "contested task")
|
|
106
|
+
core.claim_task("race-team", "1", "a")
|
|
107
|
+
with pytest.raises(ValueError, match="in_progress"):
|
|
108
|
+
core.claim_task("race-team", "1", "b")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_task_list_filter():
|
|
112
|
+
core.create_team("filter-team", ["w"])
|
|
113
|
+
core.create_task("filter-team", "task 1")
|
|
114
|
+
core.create_task("filter-team", "task 2")
|
|
115
|
+
core.claim_task("filter-team", "1", "w")
|
|
116
|
+
|
|
117
|
+
all_tasks = core.list_tasks("filter-team")
|
|
118
|
+
assert len(all_tasks) == 2
|
|
119
|
+
pending = core.list_tasks("filter-team", "pending")
|
|
120
|
+
assert len(pending) == 1
|
|
121
|
+
in_prog = core.list_tasks("filter-team", "in_progress")
|
|
122
|
+
assert len(in_prog) == 1
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_task_assignment_sends_message():
|
|
126
|
+
core.create_team("assign-team", ["lead", "worker"])
|
|
127
|
+
core.create_task("assign-team", "assigned task", "do it", assigned_to="worker", assigned_by="lead")
|
|
128
|
+
xml = core.poll_inbox("worker@assign-team")
|
|
129
|
+
assert "task_assignment" in xml
|
|
130
|
+
assert "assigned task" in xml
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_message_types():
|
|
134
|
+
core.create_team("type-team", ["a", "b"])
|
|
135
|
+
core.send_message("a@type-team", "b@type-team", "shutting down", "shutdown_request")
|
|
136
|
+
xml = core.poll_inbox("b@type-team")
|
|
137
|
+
assert 'type="shutdown_request"' in xml
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_read_inbox():
|
|
141
|
+
core.create_team("read-team", ["a", "b"])
|
|
142
|
+
core.send_message("a@read-team", "b@read-team", "hey")
|
|
143
|
+
msgs = core.read_inbox("b@read-team")
|
|
144
|
+
assert len(msgs) == 1
|
|
145
|
+
# read_inbox doesn't advance cursor
|
|
146
|
+
xml = core.poll_inbox("b@read-team")
|
|
147
|
+
assert "hey" in xml
|