parley-agents 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.
Files changed (65) hide show
  1. parley_agents-0.1.0/.gitignore +8 -0
  2. parley_agents-0.1.0/PKG-INFO +164 -0
  3. parley_agents-0.1.0/README.md +137 -0
  4. parley_agents-0.1.0/pyproject.toml +41 -0
  5. parley_agents-0.1.0/src/parley/__init__.py +5 -0
  6. parley_agents-0.1.0/src/parley/cli/__init__.py +0 -0
  7. parley_agents-0.1.0/src/parley/cli/__main__.py +261 -0
  8. parley_agents-0.1.0/src/parley/cli/init_config.py +35 -0
  9. parley_agents-0.1.0/src/parley/client/__init__.py +67 -0
  10. parley_agents-0.1.0/src/parley/core/__init__.py +0 -0
  11. parley_agents-0.1.0/src/parley/core/identity.py +36 -0
  12. parley_agents-0.1.0/src/parley/core/model.py +29 -0
  13. parley_agents-0.1.0/src/parley/core/store.py +45 -0
  14. parley_agents-0.1.0/src/parley/core/tokens.py +6 -0
  15. parley_agents-0.1.0/src/parley/gateway/__init__.py +0 -0
  16. parley_agents-0.1.0/src/parley/gateway/app.py +130 -0
  17. parley_agents-0.1.0/src/parley/gateway/identity.py +28 -0
  18. parley_agents-0.1.0/src/parley/hooks/__init__.py +0 -0
  19. parley_agents-0.1.0/src/parley/hooks/stop_hook.py +68 -0
  20. parley_agents-0.1.0/src/parley/mcp/__init__.py +0 -0
  21. parley_agents-0.1.0/src/parley/mcp/server.py +109 -0
  22. parley_agents-0.1.0/src/parley/notify/__init__.py +0 -0
  23. parley_agents-0.1.0/src/parley/notify/daemon.py +62 -0
  24. parley_agents-0.1.0/src/parley/stores/__init__.py +0 -0
  25. parley_agents-0.1.0/src/parley/stores/factory.py +12 -0
  26. parley_agents-0.1.0/src/parley/stores/postgres.py +219 -0
  27. parley_agents-0.1.0/src/parley/stores/sqlite.py +234 -0
  28. parley_agents-0.1.0/src/parley/transports/__init__.py +0 -0
  29. parley_agents-0.1.0/src/parley/transports/base.py +13 -0
  30. parley_agents-0.1.0/src/parley/transports/factory.py +21 -0
  31. parley_agents-0.1.0/src/parley/transports/fake.py +42 -0
  32. parley_agents-0.1.0/src/parley/transports/nats.py +54 -0
  33. parley_agents-0.1.0/src/parley/transports/polling.py +18 -0
  34. parley_agents-0.1.0/src/parley/transports/redis.py +55 -0
  35. parley_agents-0.1.0/src/parley/transports/tyomq.py +107 -0
  36. parley_agents-0.1.0/tests/pg_util.py +28 -0
  37. parley_agents-0.1.0/tests/test_admin_mint.py +64 -0
  38. parley_agents-0.1.0/tests/test_cli.py +24 -0
  39. parley_agents-0.1.0/tests/test_cli_plan2.py +11 -0
  40. parley_agents-0.1.0/tests/test_client.py +31 -0
  41. parley_agents-0.1.0/tests/test_client_listen.py +38 -0
  42. parley_agents-0.1.0/tests/test_delivery_cursor.py +53 -0
  43. parley_agents-0.1.0/tests/test_e2e.py +37 -0
  44. parley_agents-0.1.0/tests/test_e2e_mcp.py +39 -0
  45. parley_agents-0.1.0/tests/test_e2e_postgres.py +33 -0
  46. parley_agents-0.1.0/tests/test_e2e_push.py +41 -0
  47. parley_agents-0.1.0/tests/test_gateway.py +77 -0
  48. parley_agents-0.1.0/tests/test_identity.py +38 -0
  49. parley_agents-0.1.0/tests/test_identity_resolution.py +48 -0
  50. parley_agents-0.1.0/tests/test_init_config.py +22 -0
  51. parley_agents-0.1.0/tests/test_mcp_server.py +44 -0
  52. parley_agents-0.1.0/tests/test_notifier.py +57 -0
  53. parley_agents-0.1.0/tests/test_polling_transport.py +14 -0
  54. parley_agents-0.1.0/tests/test_postgres_store.py +70 -0
  55. parley_agents-0.1.0/tests/test_redis_nats_transport.py +41 -0
  56. parley_agents-0.1.0/tests/test_smoke.py +4 -0
  57. parley_agents-0.1.0/tests/test_sqlite_directory.py +30 -0
  58. parley_agents-0.1.0/tests/test_sqlite_poll.py +77 -0
  59. parley_agents-0.1.0/tests/test_sqlite_rooms.py +49 -0
  60. parley_agents-0.1.0/tests/test_sqlite_tokens.py +24 -0
  61. parley_agents-0.1.0/tests/test_stop_hook.py +22 -0
  62. parley_agents-0.1.0/tests/test_store_factory.py +11 -0
  63. parley_agents-0.1.0/tests/test_transport_conformance.py +38 -0
  64. parley_agents-0.1.0/tests/test_transport_factory.py +16 -0
  65. parley_agents-0.1.0/tests/test_tyomq_transport.py +46 -0
@@ -0,0 +1,8 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .pytest_cache/
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ *.db
8
+ .venv/
@@ -0,0 +1,164 @@
1
+ Metadata-Version: 2.5
2
+ Name: parley-agents
3
+ Version: 0.1.0
4
+ Summary: Broker-agnostic messaging layer that lets AI agents (and humans) on different machines talk in named rooms and wake each other.
5
+ Author: TYO Lab
6
+ License: MIT
7
+ Requires-Python: >=3.11
8
+ Requires-Dist: aiosqlite>=0.20
9
+ Requires-Dist: fastapi>=0.115
10
+ Requires-Dist: httpx>=0.27
11
+ Requires-Dist: mcp>=1.2
12
+ Requires-Dist: pyyaml>=6.0
13
+ Requires-Dist: uvicorn>=0.30
14
+ Provides-Extra: dev
15
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
16
+ Requires-Dist: pytest>=8.0; extra == 'dev'
17
+ Requires-Dist: ruff>=0.6; extra == 'dev'
18
+ Provides-Extra: nats
19
+ Requires-Dist: nats-py>=2.6; extra == 'nats'
20
+ Provides-Extra: postgres
21
+ Requires-Dist: asyncpg>=0.30; extra == 'postgres'
22
+ Provides-Extra: redis
23
+ Requires-Dist: redis>=5.0; extra == 'redis'
24
+ Provides-Extra: tyomq
25
+ Requires-Dist: tyo-mq-client>=0.3.0; extra == 'tyomq'
26
+ Description-Content-Type: text/markdown
27
+
28
+ # Parley
29
+
30
+ Parley is broker-agnostic messaging for AI agents, and humans, working across different machines. Agents join named rooms, post and poll for messages, and wake each other up when something new arrives. It runs with zero infrastructure to start: a SQLite file and simple polling, no message broker, no database server to stand up.
31
+
32
+ ## Install
33
+
34
+ ```bash
35
+ pip install parley-agents
36
+ ```
37
+
38
+ The package name is `parley-agents`, but the import and CLI name is `parley`:
39
+
40
+ ```bash
41
+ parley serve
42
+ python -c "import parley"
43
+ ```
44
+
45
+ The optional extras `[postgres]`, `[tyomq]`, `[redis]`, and `[nats]` are declared in `pyproject.toml` for future releases. This MVP ships with only the SQLite store and the polling transport; the other backends are not implemented yet, so installing those extras today pulls in dependencies with no adapter behind them.
46
+
47
+ ## Quickstart (2 minutes)
48
+
49
+ ```bash
50
+ # terminal 1
51
+ parley serve # gateway on 127.0.0.1:8790, SQLite at ~/.parley/parley.db
52
+
53
+ # terminal 2 — create a room and post as alice
54
+ python -c "import asyncio, parley; asyncio.run(parley.Client(agent='alice').create_room('general'))"
55
+ PARLEY_AGENT=bob parley join general
56
+ PARLEY_AGENT=bob parley watch general # live-tails the room
57
+
58
+ # terminal 3
59
+ PARLEY_AGENT=alice parley say general "hi bob" # bob's watch prints it
60
+ ```
61
+
62
+ ## Python SDK
63
+
64
+ ```python
65
+ import asyncio
66
+ from parley import Client
67
+
68
+ async def main():
69
+ alice = Client(agent="alice")
70
+ bob = Client(agent="bob")
71
+
72
+ await alice.create_room("standup", title="Daily")
73
+ await bob.join("standup")
74
+
75
+ await alice.say("standup", "what did you ship?")
76
+ await bob.say("standup", "the poll cursor")
77
+
78
+ # each hears the other's messages, not their own
79
+ for conv in await alice.poll():
80
+ for msg in conv["messages"]:
81
+ print(msg["body"])
82
+
83
+ await alice.close()
84
+ await bob.close()
85
+
86
+ asyncio.run(main())
87
+ ```
88
+
89
+ ## MCP (any agent)
90
+
91
+ Any MCP-capable agent (not just Python) can join Parley rooms without the SDK, using the bundled MCP server. The gateway serves both the REST API and an MCP endpoint side by side:
92
+
93
+ 1. `parley serve --token <admin-secret>` starts the gateway on `127.0.0.1:8790` and now also serves the MCP app on `port + 1` (8791 by default).
94
+ 2. `parley token --gw http://host:8790 --admin-token <admin-secret> --box <box>` mints a per-agent token, scoped to a box, and prints it. This is an admin operation: only the holder of the admin secret can mint tokens.
95
+ 3. `parley init --url http://host:8791/mcp --token <agent-token>` writes the Parley MCP server into the agent's config, `~/.claude.json` by default (override with `--file`). It adds an entry under `mcpServers` carrying the bearer token and an `X-Parley-Agent` header templated from an environment variable.
96
+ 4. Set `PARLEY_AGENT` per session, e.g. `work3-agent#1`, so each session on a box has a distinct handle. The gateway composes the effective identity from `box + handle`, and the box always comes from the authenticated token, never from a header the client controls.
97
+
98
+ A token authenticates a box, not a single session. A box token may assume any handle within its own box namespace (`<box>-*`), so treat it as a box-level secret: a leaked box token can impersonate every session on that box. Mint one token per box and keep it on that box.
99
+
100
+ ## How it works
101
+
102
+ Parley has three parts:
103
+
104
+ - A gateway (FastAPI) exposing rooms, messages, and polling over HTTP.
105
+ - A pluggable Store, the source of truth for rooms, membership, and message history. SQLite is the zero-config default; a Postgres store is also available for production (see below).
106
+ - A pluggable Transport, used only to carry a nudge signal ("something changed in room X") so a client knows when to poll again. The MVP ships polling (no real transport, just cheap re-checks); push transports such as tyo-mq, Redis, and NATS are planned. The transport never carries message bodies, so swapping it in or out changes nothing about durability or correctness.
107
+
108
+ Each call to `poll()` advances a per-room read cursor for that agent, so messages are delivered once. Distinct identities always hear each other. A bare box (no explicit agent handle) hears its own same-box sessions by default; suppressing that is an opt-in delivery mode, not the default.
109
+
110
+ ## Postgres (production)
111
+
112
+ SQLite is the zero-config default: it's a single file, no server to run, fine for one writer at a time. For durable, multi-writer deployments, set `PARLEY_DB` to a Postgres DSN and `parley serve` runs on Postgres instead:
113
+
114
+ ```bash
115
+ PARLEY_DB=postgresql://user:pass@host/db parley serve
116
+ ```
117
+
118
+ Install the extra to pull in the Postgres driver:
119
+
120
+ ```bash
121
+ pip install parley-agents[postgres]
122
+ ```
123
+
124
+ By default Parley keeps all of its tables in a dedicated `parley` schema, so it never collides with other tables in the same database. Override the schema name with `PARLEY_PG_SCHEMA` if you need a different one.
125
+
126
+ The Postgres store is a drop-in adapter: rooms, membership, message history, the read cursor, the separate delivery cursor, and identity tokens all work exactly the same as on SQLite. What Postgres adds is durability and safe concurrency: per-conversation advisory locks keep message ordering correct even with multiple writers hitting the same room at once.
127
+
128
+ ## Push delivery
129
+
130
+ Polling is the zero-broker default: no push transport means `parley watch` just re-checks the gateway on a fixed interval. Wiring up a real transport turns on push instead, selected with `PARLEY_TRANSPORT`:
131
+
132
+ ```bash
133
+ PARLEY_TRANSPORT=tyomq # PARLEY_MQ_HOST, PARLEY_MQ_PORT, MQ_TOKEN
134
+ PARLEY_TRANSPORT=redis # PARLEY_REDIS_URL
135
+ PARLEY_TRANSPORT=nats # PARLEY_NATS
136
+ ```
137
+
138
+ tyo-mq is the first-class transport; Redis and NATS are beta.
139
+
140
+ The flow: start the gateway with `PARLEY_TRANSPORT=tyomq parley serve` and every `say()` publishes a nudge to the room's topic in addition to writing the message to the store. A push-aware client, `parley watch --push <room>`, subscribes to that topic and wakes on the nudge instead of polling at a fixed interval.
141
+
142
+ Two consumers build on the same nudge:
143
+
144
+ - **The Claude Code Stop-hook.** Point Claude Code's Stop hook at `python -m parley.hooks.stop_hook`, with `PARLEY_GW`, `PARLEY_TOKEN`, and `PARLEY_AGENT` set in its environment. At each turn boundary the hook calls the gateway's catch-all `/deliver` endpoint and surfaces any queued peer messages, so a session picks up new messages without an explicit poll.
145
+ - **The idle-wake notifier.** `parley notify --room <r> --wake-cmd 'tmux send-keys -t mysession Enter'` subscribes to a room's nudge topic and runs the wake command (leading-edge debounced, so a burst of nudges only wakes the session once) to nudge a genuinely idle session back to life. It is inert under the polling transport, since there is no nudge to wake on, so it needs a real broker (`PARLEY_TRANSPORT=tyomq|redis|nats`) to do anything.
146
+
147
+ In every case the transport only ever carries a nudge signal ("something changed in room X"); it never carries message bodies. The store stays the source of truth, so a missed or duplicate nudge never causes a missed or duplicate message.
148
+
149
+ ## Security and trust
150
+
151
+ The gateway binds to loopback (`127.0.0.1`) by default. Two things to know before you expose it wider:
152
+
153
+ - Set a shared secret with `parley serve --token <secret>` (or the SDK/clients sending `Authorization: Bearer <secret>`) before exposing beyond loopback. This is the primary access control in this MVP, so treat it as mandatory.
154
+ - Identity is now anti-spoofed for token-authenticated callers. A per-agent token, minted via `parley token` or the `/admin/agents` endpoint, is bound server-side to a box; the gateway resolves the bearer token to its box itself, and a forged `X-Parley-Box` header on that request is ignored. A client can still choose its own handle via `X-Parley-Agent`, but only a handle equal to its box or prefixed `<box>-` is honored, so an agent cannot claim to be a different box's session. The trusted-header path, where a bare `X-Parley-Box` header is taken at face value, remains available only for tokenless or admin dev mode; do not rely on it once a real token is in use.
155
+
156
+ ## Roadmap
157
+
158
+ - Push transports, with tyo-mq as the first-class citizen, then Redis and NATS.
159
+ - A Postgres store for durable, multi-writer deployments.
160
+ - Claude Code Stop-hook push delivery, so a Claude Code session wakes on a new message instead of polling.
161
+
162
+ ## License
163
+
164
+ MIT.
@@ -0,0 +1,137 @@
1
+ # Parley
2
+
3
+ Parley is broker-agnostic messaging for AI agents, and humans, working across different machines. Agents join named rooms, post and poll for messages, and wake each other up when something new arrives. It runs with zero infrastructure to start: a SQLite file and simple polling, no message broker, no database server to stand up.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install parley-agents
9
+ ```
10
+
11
+ The package name is `parley-agents`, but the import and CLI name is `parley`:
12
+
13
+ ```bash
14
+ parley serve
15
+ python -c "import parley"
16
+ ```
17
+
18
+ The optional extras `[postgres]`, `[tyomq]`, `[redis]`, and `[nats]` are declared in `pyproject.toml` for future releases. This MVP ships with only the SQLite store and the polling transport; the other backends are not implemented yet, so installing those extras today pulls in dependencies with no adapter behind them.
19
+
20
+ ## Quickstart (2 minutes)
21
+
22
+ ```bash
23
+ # terminal 1
24
+ parley serve # gateway on 127.0.0.1:8790, SQLite at ~/.parley/parley.db
25
+
26
+ # terminal 2 — create a room and post as alice
27
+ python -c "import asyncio, parley; asyncio.run(parley.Client(agent='alice').create_room('general'))"
28
+ PARLEY_AGENT=bob parley join general
29
+ PARLEY_AGENT=bob parley watch general # live-tails the room
30
+
31
+ # terminal 3
32
+ PARLEY_AGENT=alice parley say general "hi bob" # bob's watch prints it
33
+ ```
34
+
35
+ ## Python SDK
36
+
37
+ ```python
38
+ import asyncio
39
+ from parley import Client
40
+
41
+ async def main():
42
+ alice = Client(agent="alice")
43
+ bob = Client(agent="bob")
44
+
45
+ await alice.create_room("standup", title="Daily")
46
+ await bob.join("standup")
47
+
48
+ await alice.say("standup", "what did you ship?")
49
+ await bob.say("standup", "the poll cursor")
50
+
51
+ # each hears the other's messages, not their own
52
+ for conv in await alice.poll():
53
+ for msg in conv["messages"]:
54
+ print(msg["body"])
55
+
56
+ await alice.close()
57
+ await bob.close()
58
+
59
+ asyncio.run(main())
60
+ ```
61
+
62
+ ## MCP (any agent)
63
+
64
+ Any MCP-capable agent (not just Python) can join Parley rooms without the SDK, using the bundled MCP server. The gateway serves both the REST API and an MCP endpoint side by side:
65
+
66
+ 1. `parley serve --token <admin-secret>` starts the gateway on `127.0.0.1:8790` and now also serves the MCP app on `port + 1` (8791 by default).
67
+ 2. `parley token --gw http://host:8790 --admin-token <admin-secret> --box <box>` mints a per-agent token, scoped to a box, and prints it. This is an admin operation: only the holder of the admin secret can mint tokens.
68
+ 3. `parley init --url http://host:8791/mcp --token <agent-token>` writes the Parley MCP server into the agent's config, `~/.claude.json` by default (override with `--file`). It adds an entry under `mcpServers` carrying the bearer token and an `X-Parley-Agent` header templated from an environment variable.
69
+ 4. Set `PARLEY_AGENT` per session, e.g. `work3-agent#1`, so each session on a box has a distinct handle. The gateway composes the effective identity from `box + handle`, and the box always comes from the authenticated token, never from a header the client controls.
70
+
71
+ A token authenticates a box, not a single session. A box token may assume any handle within its own box namespace (`<box>-*`), so treat it as a box-level secret: a leaked box token can impersonate every session on that box. Mint one token per box and keep it on that box.
72
+
73
+ ## How it works
74
+
75
+ Parley has three parts:
76
+
77
+ - A gateway (FastAPI) exposing rooms, messages, and polling over HTTP.
78
+ - A pluggable Store, the source of truth for rooms, membership, and message history. SQLite is the zero-config default; a Postgres store is also available for production (see below).
79
+ - A pluggable Transport, used only to carry a nudge signal ("something changed in room X") so a client knows when to poll again. The MVP ships polling (no real transport, just cheap re-checks); push transports such as tyo-mq, Redis, and NATS are planned. The transport never carries message bodies, so swapping it in or out changes nothing about durability or correctness.
80
+
81
+ Each call to `poll()` advances a per-room read cursor for that agent, so messages are delivered once. Distinct identities always hear each other. A bare box (no explicit agent handle) hears its own same-box sessions by default; suppressing that is an opt-in delivery mode, not the default.
82
+
83
+ ## Postgres (production)
84
+
85
+ SQLite is the zero-config default: it's a single file, no server to run, fine for one writer at a time. For durable, multi-writer deployments, set `PARLEY_DB` to a Postgres DSN and `parley serve` runs on Postgres instead:
86
+
87
+ ```bash
88
+ PARLEY_DB=postgresql://user:pass@host/db parley serve
89
+ ```
90
+
91
+ Install the extra to pull in the Postgres driver:
92
+
93
+ ```bash
94
+ pip install parley-agents[postgres]
95
+ ```
96
+
97
+ By default Parley keeps all of its tables in a dedicated `parley` schema, so it never collides with other tables in the same database. Override the schema name with `PARLEY_PG_SCHEMA` if you need a different one.
98
+
99
+ The Postgres store is a drop-in adapter: rooms, membership, message history, the read cursor, the separate delivery cursor, and identity tokens all work exactly the same as on SQLite. What Postgres adds is durability and safe concurrency: per-conversation advisory locks keep message ordering correct even with multiple writers hitting the same room at once.
100
+
101
+ ## Push delivery
102
+
103
+ Polling is the zero-broker default: no push transport means `parley watch` just re-checks the gateway on a fixed interval. Wiring up a real transport turns on push instead, selected with `PARLEY_TRANSPORT`:
104
+
105
+ ```bash
106
+ PARLEY_TRANSPORT=tyomq # PARLEY_MQ_HOST, PARLEY_MQ_PORT, MQ_TOKEN
107
+ PARLEY_TRANSPORT=redis # PARLEY_REDIS_URL
108
+ PARLEY_TRANSPORT=nats # PARLEY_NATS
109
+ ```
110
+
111
+ tyo-mq is the first-class transport; Redis and NATS are beta.
112
+
113
+ The flow: start the gateway with `PARLEY_TRANSPORT=tyomq parley serve` and every `say()` publishes a nudge to the room's topic in addition to writing the message to the store. A push-aware client, `parley watch --push <room>`, subscribes to that topic and wakes on the nudge instead of polling at a fixed interval.
114
+
115
+ Two consumers build on the same nudge:
116
+
117
+ - **The Claude Code Stop-hook.** Point Claude Code's Stop hook at `python -m parley.hooks.stop_hook`, with `PARLEY_GW`, `PARLEY_TOKEN`, and `PARLEY_AGENT` set in its environment. At each turn boundary the hook calls the gateway's catch-all `/deliver` endpoint and surfaces any queued peer messages, so a session picks up new messages without an explicit poll.
118
+ - **The idle-wake notifier.** `parley notify --room <r> --wake-cmd 'tmux send-keys -t mysession Enter'` subscribes to a room's nudge topic and runs the wake command (leading-edge debounced, so a burst of nudges only wakes the session once) to nudge a genuinely idle session back to life. It is inert under the polling transport, since there is no nudge to wake on, so it needs a real broker (`PARLEY_TRANSPORT=tyomq|redis|nats`) to do anything.
119
+
120
+ In every case the transport only ever carries a nudge signal ("something changed in room X"); it never carries message bodies. The store stays the source of truth, so a missed or duplicate nudge never causes a missed or duplicate message.
121
+
122
+ ## Security and trust
123
+
124
+ The gateway binds to loopback (`127.0.0.1`) by default. Two things to know before you expose it wider:
125
+
126
+ - Set a shared secret with `parley serve --token <secret>` (or the SDK/clients sending `Authorization: Bearer <secret>`) before exposing beyond loopback. This is the primary access control in this MVP, so treat it as mandatory.
127
+ - Identity is now anti-spoofed for token-authenticated callers. A per-agent token, minted via `parley token` or the `/admin/agents` endpoint, is bound server-side to a box; the gateway resolves the bearer token to its box itself, and a forged `X-Parley-Box` header on that request is ignored. A client can still choose its own handle via `X-Parley-Agent`, but only a handle equal to its box or prefixed `<box>-` is honored, so an agent cannot claim to be a different box's session. The trusted-header path, where a bare `X-Parley-Box` header is taken at face value, remains available only for tokenless or admin dev mode; do not rely on it once a real token is in use.
128
+
129
+ ## Roadmap
130
+
131
+ - Push transports, with tyo-mq as the first-class citizen, then Redis and NATS.
132
+ - A Postgres store for durable, multi-writer deployments.
133
+ - Claude Code Stop-hook push delivery, so a Claude Code session wakes on a new message instead of polling.
134
+
135
+ ## License
136
+
137
+ MIT.
@@ -0,0 +1,41 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "parley-agents"
7
+ version = "0.1.0"
8
+ description = "Broker-agnostic messaging layer that lets AI agents (and humans) on different machines talk in named rooms and wake each other."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "TYO Lab" }]
13
+ dependencies = [
14
+ "fastapi>=0.115",
15
+ "uvicorn>=0.30",
16
+ "httpx>=0.27",
17
+ "aiosqlite>=0.20",
18
+ "pyyaml>=6.0",
19
+ "mcp>=1.2",
20
+ ]
21
+
22
+ [project.optional-dependencies]
23
+ postgres = ["asyncpg>=0.30"]
24
+ tyomq = ["tyo-mq-client>=0.3.0"]
25
+ redis = ["redis>=5.0"]
26
+ nats = ["nats-py>=2.6"]
27
+ dev = ["pytest>=8.0", "pytest-asyncio>=0.24", "ruff>=0.6"]
28
+
29
+ [project.scripts]
30
+ parley = "parley.cli.__main__:main"
31
+
32
+ [tool.hatch.build.targets.wheel]
33
+ packages = ["src/parley"]
34
+
35
+ [tool.pytest.ini_options]
36
+ asyncio_mode = "auto"
37
+ testpaths = ["tests"]
38
+ pythonpath = ["src"]
39
+
40
+ [tool.ruff]
41
+ line-length = 100
@@ -0,0 +1,5 @@
1
+ __version__ = "0.1.0"
2
+
3
+ from parley.client import Client
4
+
5
+ __all__ = ["Client", "__version__"]
File without changes
@@ -0,0 +1,261 @@
1
+ import argparse
2
+ import asyncio
3
+ import os
4
+ import socket
5
+
6
+
7
+ def build_parser() -> argparse.ArgumentParser:
8
+ p = argparse.ArgumentParser(prog="parley", description="Parley agent messaging")
9
+ sub = p.add_subparsers(dest="cmd", required=True)
10
+
11
+ s = sub.add_parser("serve", help="run the gateway")
12
+ s.add_argument("--host", default="127.0.0.1")
13
+ s.add_argument("--port", type=int, default=8790)
14
+ s.add_argument("--db", default=None)
15
+ s.add_argument("--token", default=None)
16
+ s.add_argument("--mcp-port", type=int, default=None)
17
+ s.add_argument("--no-mcp", action="store_true")
18
+
19
+ for name, help_ in [("join", "join a room"), ("watch", "watch/participate in rooms")]:
20
+ c = sub.add_parser(name, help=help_)
21
+ c.add_argument("room", nargs="?" if name == "watch" else None)
22
+ c.add_argument("--gw", default=None)
23
+ c.add_argument("--agent", default=None)
24
+ if name == "watch":
25
+ c.add_argument("--interval", type=float, default=2.0)
26
+ c.add_argument("--push", action="store_true")
27
+
28
+ sy = sub.add_parser("say", help="post one message")
29
+ sy.add_argument("room")
30
+ sy.add_argument("text")
31
+ sy.add_argument("--gw", default=None)
32
+ sy.add_argument("--agent", default=None)
33
+
34
+ tk = sub.add_parser("token", help="mint a per-agent token (admin)")
35
+ tk.add_argument("--gw", default=None)
36
+ tk.add_argument("--admin-token", required=True)
37
+ tk.add_argument("--box", required=True)
38
+ tk.add_argument("--label", default=None)
39
+
40
+ ini = sub.add_parser("init", help="write the Parley MCP server into an agent config")
41
+ ini.add_argument("--url", required=True, help="the gateway MCP URL, e.g. http://host:8791/mcp")
42
+ ini.add_argument("--token", required=True, help="a per-agent token (from `parley token`)")
43
+ ini.add_argument("--name", default="parley")
44
+ ini.add_argument("--file", default=os.path.expanduser("~/.claude.json"))
45
+
46
+ nt = sub.add_parser("notify", help="run the idle-wake notifier daemon")
47
+ nt.add_argument("--room", action="append", required=True, help="room to watch (repeatable)")
48
+ nt.add_argument("--wake-cmd", required=True, help="command template, {box}/{room}/{from}")
49
+ nt.add_argument("--box", default="")
50
+ nt.add_argument("--debounce", type=float, default=0.5)
51
+ return p
52
+
53
+
54
+ def resolve_config(args) -> dict:
55
+ gw = getattr(args, "gw", None) or os.environ.get("PARLEY_GW") or "http://127.0.0.1:8790"
56
+ agent = getattr(args, "agent", None) or os.environ.get("PARLEY_AGENT") or socket.gethostname()
57
+ return {"gw": gw, "agent": agent}
58
+
59
+
60
+ def _db_path(args) -> str:
61
+ path = args.db or os.environ.get("PARLEY_DB")
62
+ if not path:
63
+ d = os.path.expanduser("~/.parley")
64
+ os.makedirs(d, exist_ok=True)
65
+ path = os.path.join(d, "parley.db")
66
+ return path
67
+
68
+
69
+ def _serve(args):
70
+ import contextlib
71
+ import signal
72
+
73
+ import uvicorn
74
+
75
+ from parley.gateway.app import build_app
76
+ from parley.mcp.server import build_mcp_app
77
+
78
+ async def _run():
79
+ import os
80
+
81
+ from parley.stores.factory import is_pg_dsn, make_store
82
+ db = os.environ.get("PARLEY_DB")
83
+ location = db if is_pg_dsn(db) else _db_path(args)
84
+ store = await make_store(location, schema=os.environ.get("PARLEY_PG_SCHEMA", "parley"))
85
+
86
+ from parley.transports.factory import make_transport
87
+ transport = make_transport(
88
+ os.environ.get("PARLEY_TRANSPORT", "polling"),
89
+ host=os.environ.get("PARLEY_MQ_HOST", "localhost"),
90
+ port=os.environ.get("PARLEY_MQ_PORT", "17352"),
91
+ token=os.environ.get("MQ_TOKEN"),
92
+ url=os.environ.get("PARLEY_REDIS_URL", "redis://127.0.0.1:6379"),
93
+ servers=os.environ.get("PARLEY_NATS", "nats://127.0.0.1:4222"))
94
+ rest = build_app(store, transport, admin_token=args.token)
95
+ servers = [uvicorn.Server(uvicorn.Config(
96
+ rest, host=args.host, port=args.port, log_level="info"))]
97
+ if not args.no_mcp:
98
+ mcp_port = args.mcp_port or (args.port + 1)
99
+ mcp_app = build_mcp_app(store, admin_token=args.token)
100
+ servers.append(uvicorn.Server(uvicorn.Config(
101
+ mcp_app, host=args.host, port=mcp_port, log_level="info")))
102
+ # Both servers run on one loop. Suppress each server's own signal capture --
103
+ # with two servers the last handler installed would win, leaving the other
104
+ # running forever on SIGINT/SIGTERM -- and drive a single shared shutdown so
105
+ # a managed process (systemd/container) stops cleanly.
106
+ for s in servers:
107
+ s.capture_signals = contextlib.nullcontext
108
+
109
+ def _shutdown():
110
+ for s in servers:
111
+ s.should_exit = True
112
+
113
+ loop = asyncio.get_running_loop()
114
+ for sig in (signal.SIGINT, signal.SIGTERM):
115
+ with contextlib.suppress(NotImplementedError):
116
+ loop.add_signal_handler(sig, _shutdown)
117
+ try:
118
+ await asyncio.gather(*(s.serve() for s in servers))
119
+ finally:
120
+ await store.close()
121
+
122
+ asyncio.run(_run())
123
+
124
+
125
+ async def _say(cfg, room, text):
126
+ from parley.client import Client
127
+ c = Client(base_url=cfg["gw"], agent=cfg["agent"])
128
+ try:
129
+ print(await c.say(room, text))
130
+ finally:
131
+ await c.close()
132
+
133
+
134
+ async def _join(cfg, room):
135
+ from parley.client import Client
136
+ c = Client(base_url=cfg["gw"], agent=cfg["agent"])
137
+ try:
138
+ print(await c.join(room))
139
+ finally:
140
+ await c.close()
141
+
142
+
143
+ async def _token(args):
144
+ import httpx
145
+ gw = args.gw or os.environ.get("PARLEY_GW") or "http://127.0.0.1:8790"
146
+ async with httpx.AsyncClient(base_url=gw) as c:
147
+ r = await c.post("/admin/agents",
148
+ json={"box": args.box, "label": args.label},
149
+ headers={"Authorization": f"Bearer {args.admin_token}"})
150
+ r.raise_for_status()
151
+ print(r.json()["token"])
152
+
153
+
154
+ def _init(args):
155
+ from parley.cli.init_config import merge_mcp_entry
156
+ merge_mcp_entry(args.file, name=args.name, url=args.url, token=args.token)
157
+ print(f"[parley init] wrote '{args.name}' -> {args.url} into {args.file}")
158
+
159
+
160
+ def _notify(args):
161
+ import os
162
+
163
+ from parley.notify.daemon import Notifier
164
+ from parley.transports.factory import make_transport
165
+
166
+ async def _run():
167
+ # Build the transport INSIDE the running loop: a thread-bridged adapter
168
+ # (tyo-mq) captures the running loop at construction, so constructing it
169
+ # before asyncio.run() would bind a dead loop and the wake would never fire.
170
+ kind = os.environ.get("PARLEY_TRANSPORT", "polling")
171
+ transport = make_transport(
172
+ kind, host=os.environ.get("PARLEY_MQ_HOST", "localhost"),
173
+ port=os.environ.get("PARLEY_MQ_PORT", "17352"),
174
+ token=os.environ.get("MQ_TOKEN"),
175
+ url=os.environ.get("PARLEY_REDIS_URL", "redis://127.0.0.1:6379"),
176
+ servers=os.environ.get("PARLEY_NATS", "nats://127.0.0.1:4222"))
177
+ n = Notifier(transport, rooms=args.room, wake_cmd=args.wake_cmd, box=args.box,
178
+ debounce_s=args.debounce)
179
+ await n.start()
180
+ print(f"[parley notify] watching {args.room} (Ctrl-C to stop)")
181
+ try:
182
+ while True:
183
+ await asyncio.sleep(3600)
184
+ finally:
185
+ await n.stop()
186
+ try:
187
+ asyncio.run(_run())
188
+ except KeyboardInterrupt:
189
+ print("\n[parley notify] stopped")
190
+
191
+
192
+ async def _watch(cfg, room, interval, push=False):
193
+ from parley.client import Client
194
+ c = Client(base_url=cfg["gw"], agent=cfg["agent"])
195
+
196
+ async def _drain():
197
+ for conv in await c.poll(room):
198
+ for m in conv["messages"]:
199
+ print(f"[{conv['conv']}] {m['from']}: {m['body']}")
200
+
201
+ if push and room:
202
+ import os
203
+
204
+ from parley.transports.factory import make_transport
205
+ transport = make_transport(
206
+ os.environ.get("PARLEY_TRANSPORT", "polling"),
207
+ host=os.environ.get("PARLEY_MQ_HOST", "localhost"),
208
+ port=os.environ.get("PARLEY_MQ_PORT", "17352"),
209
+ token=os.environ.get("MQ_TOKEN"),
210
+ url=os.environ.get("PARLEY_REDIS_URL", "redis://127.0.0.1:6379"),
211
+ servers=os.environ.get("PARLEY_NATS", "nats://127.0.0.1:4222"))
212
+ print(f"[parley] watching {room} as {cfg['agent']} (push; Ctrl-C to stop)")
213
+ await _drain() # catch up on anything already waiting
214
+ subs = await c.listen(transport, [room], lambda sig: _drain())
215
+ try:
216
+ while True:
217
+ await asyncio.sleep(3600)
218
+ finally:
219
+ for s in subs:
220
+ await s.close()
221
+ await transport.close()
222
+ await c.close()
223
+ return
224
+
225
+ print(f"[parley] watching as {cfg['agent']} (Ctrl-C to stop)")
226
+ try:
227
+ while True:
228
+ await _drain()
229
+ await asyncio.sleep(interval)
230
+ finally:
231
+ await c.close()
232
+
233
+
234
+ def main(argv=None):
235
+ args = build_parser().parse_args(argv)
236
+ if args.cmd == "serve":
237
+ _serve(args)
238
+ return
239
+ if args.cmd == "token":
240
+ asyncio.run(_token(args))
241
+ return
242
+ if args.cmd == "init":
243
+ _init(args)
244
+ return
245
+ if args.cmd == "notify":
246
+ _notify(args)
247
+ return
248
+ cfg = resolve_config(args)
249
+ if args.cmd == "say":
250
+ asyncio.run(_say(cfg, args.room, args.text))
251
+ elif args.cmd == "join":
252
+ asyncio.run(_join(cfg, args.room))
253
+ elif args.cmd == "watch":
254
+ try:
255
+ asyncio.run(_watch(cfg, args.room, args.interval, getattr(args, "push", False)))
256
+ except KeyboardInterrupt:
257
+ print("\n[parley] stopped")
258
+
259
+
260
+ if __name__ == "__main__":
261
+ main()
@@ -0,0 +1,35 @@
1
+ import json
2
+ import os
3
+ import tempfile
4
+
5
+
6
+ def merge_mcp_entry(path: str, *, name: str, url: str, token: str,
7
+ handle_env: str = "PARLEY_AGENT") -> None:
8
+ """Idempotently add/replace an HTTP MCP server entry, preserving all other keys.
9
+ Stdlib only. Atomic write via a temp file + os.replace."""
10
+ path = os.path.expanduser(path)
11
+ data = {}
12
+ if os.path.exists(path) and os.path.getsize(path) > 0:
13
+ with open(path) as f:
14
+ data = json.load(f)
15
+ if not isinstance(data.get("mcpServers"), dict):
16
+ data["mcpServers"] = {}
17
+ data["mcpServers"][name] = {
18
+ "type": "http",
19
+ "url": url,
20
+ "headers": {
21
+ "Authorization": "Bearer " + token,
22
+ "X-Parley-Agent": "${" + handle_env + ":-}",
23
+ },
24
+ }
25
+ d = os.path.dirname(path) or "."
26
+ fd, tmp = tempfile.mkstemp(dir=d, prefix=".parley-cfg.", suffix=".tmp")
27
+ try:
28
+ with os.fdopen(fd, "w") as f:
29
+ json.dump(data, f, indent=2)
30
+ f.write("\n")
31
+ os.replace(tmp, path)
32
+ except BaseException:
33
+ if os.path.exists(tmp):
34
+ os.unlink(tmp)
35
+ raise