prism-ai-human-plus 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,5 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ dist/
5
+ *.egg-info/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Particle Academy
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,231 @@
1
+ Metadata-Version: 2.5
2
+ Name: prism-ai-human-plus
3
+ Version: 0.1.0
4
+ Summary: Humans and agents sharing one surface, across a trust boundary. Python.
5
+ Author: Particle Academy
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Keywords: agents,ai,human-in-the-loop,llm,mcp,prism
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
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: Programming Language :: Python :: 3.13
16
+ Classifier: Typing :: Typed
17
+ Requires-Python: >=3.10
18
+ Provides-Extra: dev
19
+ Requires-Dist: mypy>=1.11; extra == 'dev'
20
+ Requires-Dist: pytest>=8.0; extra == 'dev'
21
+ Requires-Dist: ruff>=0.6; extra == 'dev'
22
+ Description-Content-Type: text/markdown
23
+
24
+ # Prism Human+ for Python
25
+
26
+ Humans and agents sharing one application surface, across a trust boundary. The
27
+ Python port of [`particle-academy/prism-human-plus`](https://github.com/Particle-Academy/prism-human-plus).
28
+
29
+ Zero runtime dependencies. Python 3.10+.
30
+
31
+ ```
32
+ pip install prism-ai-human-plus
33
+ ```
34
+
35
+ An agent joins a running application surface, such as a spreadsheet a person
36
+ has open, through the invitation that surface issued. It sees the surface's
37
+ tools, calls the ones it is trusted with, and announces what it is doing to the
38
+ people watching.
39
+
40
+ ```python
41
+ from prism_human_plus import (
42
+ HumanPlusManager,
43
+ HumanPlusToolset,
44
+ InMemoryAttachmentStore,
45
+ Participant,
46
+ SsePostRelayTransport,
47
+ SurfaceInvitation,
48
+ TrustPolicy,
49
+ )
50
+
51
+ transport = SsePostRelayTransport(
52
+ my_http,
53
+ allowed_relay_hosts=["relay.example.com"],
54
+ egress_proxy="http://egress-proxy.internal:3128",
55
+ )
56
+
57
+ human_plus = HumanPlusManager(
58
+ transport,
59
+ InMemoryAttachmentStore(),
60
+ TrustPolicy.allowing(["sheet_read", "sheet_propose_edit"]),
61
+ )
62
+
63
+ attachment = human_plus.attach(
64
+ session,
65
+ SurfaceInvitation(
66
+ relay_base_url="https://relay.example.com",
67
+ session_id=invitation["session_id"],
68
+ token=invitation["token"],
69
+ surface_id=invitation["surface_id"],
70
+ application="sheets",
71
+ ),
72
+ Participant(id="agent-1", name="Assistant", color="#7c3aed"),
73
+ )
74
+
75
+ tools = HumanPlusToolset(human_plus).for_attachment(
76
+ session, attachment.id, approval_tools=["sheet_propose_edit"]
77
+ )
78
+ ```
79
+
80
+ `my_http` is your HTTP client, behind the `RelayHttp` protocol (`post` and
81
+ `stream`). `session` is the owner: a string, or anything with a `key()` method,
82
+ such as a `prism-ai-harness` session.
83
+
84
+ ## Trust
85
+
86
+ `TrustPolicy` decides which of the surface's tools the agent may see and call.
87
+
88
+ - **`TrustPolicy.undeclared()`** refuses discovery itself: no request of any
89
+ kind reaches the surface, so no tool description reaches the model.
90
+ - **`TrustPolicy.allowing([...])`** allows the named tools.
91
+ - **`TrustPolicy.every_tool()`** allows every tool the surface offers.
92
+ - **Pins.** Pass `pins={name: digest}` to any of them. A pin is
93
+ `ToolDefinition.digest()`, a fingerprint of the tool's name, description and
94
+ schema. A tool whose definition no longer matches its pin is refused.
95
+ - **Confirmation tools belong to the human.** A tool named `confirm`, `reject`,
96
+ `accept`, `approve` or `deny`, or ending in `_confirm`, `_reject` and so on, is
97
+ refused under every policy, including `every_tool()`. The name is normalised
98
+ first, so a trailing space or an invisible character does not get around it.
99
+ - A malformed tool name is refused.
100
+
101
+ Every refusal raises `ToolRefused`.
102
+
103
+ **Approval is your decision, not the surface's.** `HumanPlusToolset` marks a
104
+ tool `requires_approval` only if you list it in `approval_tools`. The surface's
105
+ own annotations are not consulted.
106
+
107
+ ## Results
108
+
109
+ Every tool result passes through a `ResultGuard`:
110
+
111
+ - A result over the byte budget (65,536 by default) is refused, not truncated.
112
+ - The text is wrapped in an `<untrusted-tool-output>` tag with a random id, so
113
+ the surface's output cannot close the wrapper.
114
+
115
+ The wrapper makes a prompt injection harder. It does not make one impossible.
116
+
117
+ ## Attachments
118
+
119
+ - An attachment id locates state; it is not a credential. Every call re-presents
120
+ the owner, and an attachment belonging to another owner raises
121
+ `AttachmentUnauthorized`.
122
+ - `SurfaceInvitation` is validated when built: an HTTPS relay URL, a
123
+ well-formed session id and a token of at least 16 characters.
124
+ - A surface that has gone (`410 session_gone`) raises `SurfaceUnavailable`. One
125
+ that was never authorized (`401`) raises `AttachmentUnauthorized`. Each is
126
+ recorded as its own terminal state, and neither is retried.
127
+ - `AttachmentStore` writes carry a generation, so two workers cannot overwrite
128
+ each other's state. `InMemoryAttachmentStore` is for tests and single
129
+ processes.
130
+
131
+ Every failure subclasses `HumanPlusError`.
132
+
133
+ ## The relay transport
134
+
135
+ `SsePostRelayTransport` speaks the Fancy relay: a POST, then a bounded
136
+ server-sent-events stream.
137
+
138
+ - The relay URL is checked on every call against `allowed_relay_hosts` and
139
+ `allowed_relay_ports` (443 by default). A literal private address is refused.
140
+ - It refuses to run without an `egress_proxy` unless you pass
141
+ `allow_unverified_egress=True`, which is for isolated local testing only.
142
+ - Declaring `egress_proxy` does not route traffic by itself. Your `RelayHttp`
143
+ must send its requests through it. Host names are not resolved here, so the
144
+ proxy is what stops a public name that resolves to a private address.
145
+ - A frame over `max_frame_bytes` (262,144 by default) is refused.
146
+
147
+ ## Two writers, and what changed since my last turn
148
+
149
+ **A human editing the same surface as the agent used to lose their work in
150
+ silence.** The agent read, the person committed, the agent wrote, and both
151
+ writes succeeded — which is exactly what a lost update looks like from the
152
+ inside.
153
+
154
+ Two mechanisms, and they answer different questions.
155
+
156
+ ### The revision: did the world move under me
157
+
158
+ A tool result may carry a marker for the state it just showed. It is stored on
159
+ the attachment and **pinned to every later call** — reads included, because this
160
+ package cannot tell a read from a write and MCP's `readOnlyHint` is explicitly a
161
+ hint the spec says not to trust for security decisions.
162
+
163
+ If the surface says the marker is stale the call is refused with
164
+ `SurfaceChangedUnderYou`, **nothing is written**, and the stored marker is
165
+ dropped so the agent can read again.
166
+
167
+ `conflict_detection()` reports what has been OBSERVED, which is less than what
168
+ is configured:
169
+
170
+ | `ConflictDetection` | What is known |
171
+ |---|---|
172
+ | `NOT_OBSERVED` | The surface has not answered yet. |
173
+ | `UNAVAILABLE` | It mints nothing. A concurrent edit **will** be lost silently. |
174
+ | `MINTED` | It mints, so every call is pinned. Whether it **enforces** is not observable. |
175
+ | `ENFORCED` | It refused a stale pin. Proven, because it happened. |
176
+
177
+ `MINTED` is the one to read carefully. The reference's first integrator minted
178
+ on every write result and read an incoming pin nowhere, so a pinned call was
179
+ applied exactly as an unpinned one — and the boolean this replaced said `True`.
180
+
181
+ ### The change feed: what did somebody else do
182
+
183
+ A revision stops an agent overwriting a change it did not know about. It does
184
+ nothing about an agent that re-reads, sees current state, decides the surface
185
+ has drifted from what it intended, and puts it back — over a person's edit, with
186
+ nothing stale anywhere and no error at any layer.
187
+
188
+ ```python
189
+ changes = human_plus.changes_since(owner, attachment_id)
190
+
191
+ if not changes.answered():
192
+ ... # the surface has no feed; an empty list is NOT evidence of quiet
193
+
194
+ for change in changes.defer_to():
195
+ change.handle # the surface's own id
196
+ change.kind # CREATED | UPDATED | DELETED | MOVED | UNKNOWN
197
+ change.actor # HUMAN | AGENT | OTHER | UNKNOWN
198
+ ```
199
+
200
+ **The empty list is the dangerous value.** "Nothing changed since your marker"
201
+ and "I cannot answer that question" are the same empty list on the wire, so
202
+ `ChangeFeed` comes first and `nothing_changed()` is the only method that means
203
+ what an empty list looks like it means.
204
+
205
+ **Attribution is the load-bearing field.** A change is deferred to unless the
206
+ surface positively said this agent made it. On a surface where every write path
207
+ is an agent tool — the first one asked is exactly that — nothing is attributed,
208
+ so everything is deferred to: not because it is all a person's, but because none
209
+ of it can be shown to be the agent's own.
210
+
211
+ `complete` is true unless the surface says otherwise. It exists because feeds
212
+ have holes their authors know about: the first surface asked hard-deletes rows
213
+ with no tombstone, so a removal moves no revision and appears in no feed.
214
+
215
+ See the reference's README for the surface's half of the contract — the tool
216
+ names, the key names read per field, and why `change` must be sent even when
217
+ `kind` is.
218
+
219
+ ## Parity
220
+
221
+ prism-parity's `human-plus-tool-admission` corpus compares tool admission with
222
+ the PHP reference and the TypeScript port. Admission and the confirmation-tool
223
+ reservation agree on every case.
224
+
225
+ Two pins differ. A tool with no schema gets a different digest in the PHP
226
+ reference, and a schema containing an integral float such as `1.0` gets a
227
+ different digest here. Compute a pin in the language that checks it.
228
+
229
+ ## License
230
+
231
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,208 @@
1
+ # Prism Human+ for Python
2
+
3
+ Humans and agents sharing one application surface, across a trust boundary. The
4
+ Python port of [`particle-academy/prism-human-plus`](https://github.com/Particle-Academy/prism-human-plus).
5
+
6
+ Zero runtime dependencies. Python 3.10+.
7
+
8
+ ```
9
+ pip install prism-ai-human-plus
10
+ ```
11
+
12
+ An agent joins a running application surface, such as a spreadsheet a person
13
+ has open, through the invitation that surface issued. It sees the surface's
14
+ tools, calls the ones it is trusted with, and announces what it is doing to the
15
+ people watching.
16
+
17
+ ```python
18
+ from prism_human_plus import (
19
+ HumanPlusManager,
20
+ HumanPlusToolset,
21
+ InMemoryAttachmentStore,
22
+ Participant,
23
+ SsePostRelayTransport,
24
+ SurfaceInvitation,
25
+ TrustPolicy,
26
+ )
27
+
28
+ transport = SsePostRelayTransport(
29
+ my_http,
30
+ allowed_relay_hosts=["relay.example.com"],
31
+ egress_proxy="http://egress-proxy.internal:3128",
32
+ )
33
+
34
+ human_plus = HumanPlusManager(
35
+ transport,
36
+ InMemoryAttachmentStore(),
37
+ TrustPolicy.allowing(["sheet_read", "sheet_propose_edit"]),
38
+ )
39
+
40
+ attachment = human_plus.attach(
41
+ session,
42
+ SurfaceInvitation(
43
+ relay_base_url="https://relay.example.com",
44
+ session_id=invitation["session_id"],
45
+ token=invitation["token"],
46
+ surface_id=invitation["surface_id"],
47
+ application="sheets",
48
+ ),
49
+ Participant(id="agent-1", name="Assistant", color="#7c3aed"),
50
+ )
51
+
52
+ tools = HumanPlusToolset(human_plus).for_attachment(
53
+ session, attachment.id, approval_tools=["sheet_propose_edit"]
54
+ )
55
+ ```
56
+
57
+ `my_http` is your HTTP client, behind the `RelayHttp` protocol (`post` and
58
+ `stream`). `session` is the owner: a string, or anything with a `key()` method,
59
+ such as a `prism-ai-harness` session.
60
+
61
+ ## Trust
62
+
63
+ `TrustPolicy` decides which of the surface's tools the agent may see and call.
64
+
65
+ - **`TrustPolicy.undeclared()`** refuses discovery itself: no request of any
66
+ kind reaches the surface, so no tool description reaches the model.
67
+ - **`TrustPolicy.allowing([...])`** allows the named tools.
68
+ - **`TrustPolicy.every_tool()`** allows every tool the surface offers.
69
+ - **Pins.** Pass `pins={name: digest}` to any of them. A pin is
70
+ `ToolDefinition.digest()`, a fingerprint of the tool's name, description and
71
+ schema. A tool whose definition no longer matches its pin is refused.
72
+ - **Confirmation tools belong to the human.** A tool named `confirm`, `reject`,
73
+ `accept`, `approve` or `deny`, or ending in `_confirm`, `_reject` and so on, is
74
+ refused under every policy, including `every_tool()`. The name is normalised
75
+ first, so a trailing space or an invisible character does not get around it.
76
+ - A malformed tool name is refused.
77
+
78
+ Every refusal raises `ToolRefused`.
79
+
80
+ **Approval is your decision, not the surface's.** `HumanPlusToolset` marks a
81
+ tool `requires_approval` only if you list it in `approval_tools`. The surface's
82
+ own annotations are not consulted.
83
+
84
+ ## Results
85
+
86
+ Every tool result passes through a `ResultGuard`:
87
+
88
+ - A result over the byte budget (65,536 by default) is refused, not truncated.
89
+ - The text is wrapped in an `<untrusted-tool-output>` tag with a random id, so
90
+ the surface's output cannot close the wrapper.
91
+
92
+ The wrapper makes a prompt injection harder. It does not make one impossible.
93
+
94
+ ## Attachments
95
+
96
+ - An attachment id locates state; it is not a credential. Every call re-presents
97
+ the owner, and an attachment belonging to another owner raises
98
+ `AttachmentUnauthorized`.
99
+ - `SurfaceInvitation` is validated when built: an HTTPS relay URL, a
100
+ well-formed session id and a token of at least 16 characters.
101
+ - A surface that has gone (`410 session_gone`) raises `SurfaceUnavailable`. One
102
+ that was never authorized (`401`) raises `AttachmentUnauthorized`. Each is
103
+ recorded as its own terminal state, and neither is retried.
104
+ - `AttachmentStore` writes carry a generation, so two workers cannot overwrite
105
+ each other's state. `InMemoryAttachmentStore` is for tests and single
106
+ processes.
107
+
108
+ Every failure subclasses `HumanPlusError`.
109
+
110
+ ## The relay transport
111
+
112
+ `SsePostRelayTransport` speaks the Fancy relay: a POST, then a bounded
113
+ server-sent-events stream.
114
+
115
+ - The relay URL is checked on every call against `allowed_relay_hosts` and
116
+ `allowed_relay_ports` (443 by default). A literal private address is refused.
117
+ - It refuses to run without an `egress_proxy` unless you pass
118
+ `allow_unverified_egress=True`, which is for isolated local testing only.
119
+ - Declaring `egress_proxy` does not route traffic by itself. Your `RelayHttp`
120
+ must send its requests through it. Host names are not resolved here, so the
121
+ proxy is what stops a public name that resolves to a private address.
122
+ - A frame over `max_frame_bytes` (262,144 by default) is refused.
123
+
124
+ ## Two writers, and what changed since my last turn
125
+
126
+ **A human editing the same surface as the agent used to lose their work in
127
+ silence.** The agent read, the person committed, the agent wrote, and both
128
+ writes succeeded — which is exactly what a lost update looks like from the
129
+ inside.
130
+
131
+ Two mechanisms, and they answer different questions.
132
+
133
+ ### The revision: did the world move under me
134
+
135
+ A tool result may carry a marker for the state it just showed. It is stored on
136
+ the attachment and **pinned to every later call** — reads included, because this
137
+ package cannot tell a read from a write and MCP's `readOnlyHint` is explicitly a
138
+ hint the spec says not to trust for security decisions.
139
+
140
+ If the surface says the marker is stale the call is refused with
141
+ `SurfaceChangedUnderYou`, **nothing is written**, and the stored marker is
142
+ dropped so the agent can read again.
143
+
144
+ `conflict_detection()` reports what has been OBSERVED, which is less than what
145
+ is configured:
146
+
147
+ | `ConflictDetection` | What is known |
148
+ |---|---|
149
+ | `NOT_OBSERVED` | The surface has not answered yet. |
150
+ | `UNAVAILABLE` | It mints nothing. A concurrent edit **will** be lost silently. |
151
+ | `MINTED` | It mints, so every call is pinned. Whether it **enforces** is not observable. |
152
+ | `ENFORCED` | It refused a stale pin. Proven, because it happened. |
153
+
154
+ `MINTED` is the one to read carefully. The reference's first integrator minted
155
+ on every write result and read an incoming pin nowhere, so a pinned call was
156
+ applied exactly as an unpinned one — and the boolean this replaced said `True`.
157
+
158
+ ### The change feed: what did somebody else do
159
+
160
+ A revision stops an agent overwriting a change it did not know about. It does
161
+ nothing about an agent that re-reads, sees current state, decides the surface
162
+ has drifted from what it intended, and puts it back — over a person's edit, with
163
+ nothing stale anywhere and no error at any layer.
164
+
165
+ ```python
166
+ changes = human_plus.changes_since(owner, attachment_id)
167
+
168
+ if not changes.answered():
169
+ ... # the surface has no feed; an empty list is NOT evidence of quiet
170
+
171
+ for change in changes.defer_to():
172
+ change.handle # the surface's own id
173
+ change.kind # CREATED | UPDATED | DELETED | MOVED | UNKNOWN
174
+ change.actor # HUMAN | AGENT | OTHER | UNKNOWN
175
+ ```
176
+
177
+ **The empty list is the dangerous value.** "Nothing changed since your marker"
178
+ and "I cannot answer that question" are the same empty list on the wire, so
179
+ `ChangeFeed` comes first and `nothing_changed()` is the only method that means
180
+ what an empty list looks like it means.
181
+
182
+ **Attribution is the load-bearing field.** A change is deferred to unless the
183
+ surface positively said this agent made it. On a surface where every write path
184
+ is an agent tool — the first one asked is exactly that — nothing is attributed,
185
+ so everything is deferred to: not because it is all a person's, but because none
186
+ of it can be shown to be the agent's own.
187
+
188
+ `complete` is true unless the surface says otherwise. It exists because feeds
189
+ have holes their authors know about: the first surface asked hard-deletes rows
190
+ with no tombstone, so a removal moves no revision and appears in no feed.
191
+
192
+ See the reference's README for the surface's half of the contract — the tool
193
+ names, the key names read per field, and why `change` must be sent even when
194
+ `kind` is.
195
+
196
+ ## Parity
197
+
198
+ prism-parity's `human-plus-tool-admission` corpus compares tool admission with
199
+ the PHP reference and the TypeScript port. Admission and the confirmation-tool
200
+ reservation agree on every case.
201
+
202
+ Two pins differ. A tool with no schema gets a different digest in the PHP
203
+ reference, and a schema containing an integral float such as `1.0` gets a
204
+ different digest here. Compute a pin in the language that checks it.
205
+
206
+ ## License
207
+
208
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,50 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "prism-ai-human-plus"
7
+ version = "0.1.0"
8
+ description = "Humans and agents sharing one surface, across a trust boundary. Python."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.10"
13
+ authors = [{ name = "Particle Academy" }]
14
+ keywords = ["llm", "agents", "prism", "ai", "human-in-the-loop", "mcp"]
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Intended Audience :: Developers",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Typing :: Typed",
24
+ ]
25
+ dependencies = []
26
+
27
+ [project.optional-dependencies]
28
+ dev = ["pytest>=8.0", "mypy>=1.11", "ruff>=0.6"]
29
+
30
+ [tool.hatch.build.targets.wheel]
31
+ packages = ["src/prism_human_plus"]
32
+
33
+ # The source archive carries the package and its licence, and nothing else: not tests,
34
+ # CI workflows or contributor notes. Anchored with a leading slash so a pattern cannot
35
+ # match a nested file of the same name.
36
+ [tool.hatch.build.targets.sdist]
37
+ include = ["/src/prism_human_plus", "/README.md", "/LICENSE", "/pyproject.toml"]
38
+
39
+ [tool.pytest.ini_options]
40
+ testpaths = ["tests"]
41
+ pythonpath = ["src"]
42
+
43
+ [tool.ruff]
44
+ line-length = 100
45
+ target-version = "py310"
46
+
47
+ [tool.mypy]
48
+ python_version = "3.10"
49
+ strict = true
50
+ files = ["src", "tests"]