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.
- prism_ai_human_plus-0.1.0/.gitignore +5 -0
- prism_ai_human_plus-0.1.0/LICENSE +21 -0
- prism_ai_human_plus-0.1.0/PKG-INFO +231 -0
- prism_ai_human_plus-0.1.0/README.md +208 -0
- prism_ai_human_plus-0.1.0/pyproject.toml +50 -0
- prism_ai_human_plus-0.1.0/src/prism_human_plus/__init__.py +2103 -0
- prism_ai_human_plus-0.1.0/src/prism_human_plus/py.typed +0 -0
|
@@ -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"]
|