nullprotocol 0.2.0__tar.gz → 0.3.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.
- {nullprotocol-0.2.0 → nullprotocol-0.3.0}/CHANGELOG.md +4 -0
- {nullprotocol-0.2.0 → nullprotocol-0.3.0}/PKG-INFO +15 -1
- {nullprotocol-0.2.0 → nullprotocol-0.3.0}/README.md +14 -0
- nullprotocol-0.3.0/examples/photos.py +26 -0
- {nullprotocol-0.2.0 → nullprotocol-0.3.0}/pyproject.toml +1 -1
- {nullprotocol-0.2.0 → nullprotocol-0.3.0}/src/nullprotocol/_agent.py +30 -4
- {nullprotocol-0.2.0 → nullprotocol-0.3.0}/src/nullprotocol/_common.py +13 -0
- nullprotocol-0.3.0/tests/test_images.py +67 -0
- {nullprotocol-0.2.0 → nullprotocol-0.3.0}/.github/workflows/test.yml +0 -0
- {nullprotocol-0.2.0 → nullprotocol-0.3.0}/.gitignore +0 -0
- {nullprotocol-0.2.0 → nullprotocol-0.3.0}/LICENSE +0 -0
- {nullprotocol-0.2.0 → nullprotocol-0.3.0}/RELEASING.md +0 -0
- {nullprotocol-0.2.0 → nullprotocol-0.3.0}/examples/actions.py +0 -0
- {nullprotocol-0.2.0 → nullprotocol-0.3.0}/examples/async_basic.py +0 -0
- {nullprotocol-0.2.0 → nullprotocol-0.3.0}/examples/basic.py +0 -0
- {nullprotocol-0.2.0 → nullprotocol-0.3.0}/examples/knowledge.py +0 -0
- {nullprotocol-0.2.0 → nullprotocol-0.3.0}/examples/knowledge_management.py +0 -0
- {nullprotocol-0.2.0 → nullprotocol-0.3.0}/examples/scoped_data.py +0 -0
- {nullprotocol-0.2.0 → nullprotocol-0.3.0}/src/nullprotocol/__init__.py +0 -0
- {nullprotocol-0.2.0 → nullprotocol-0.3.0}/src/nullprotocol/_client.py +0 -0
- {nullprotocol-0.2.0 → nullprotocol-0.3.0}/src/nullprotocol/_errors.py +0 -0
- {nullprotocol-0.2.0 → nullprotocol-0.3.0}/src/nullprotocol/_knowledge.py +0 -0
- {nullprotocol-0.2.0 → nullprotocol-0.3.0}/src/nullprotocol/_stores.py +0 -0
- {nullprotocol-0.2.0 → nullprotocol-0.3.0}/src/nullprotocol/_transport.py +0 -0
- {nullprotocol-0.2.0 → nullprotocol-0.3.0}/src/nullprotocol/_types.py +0 -0
- {nullprotocol-0.2.0 → nullprotocol-0.3.0}/src/nullprotocol/py.typed +0 -0
- {nullprotocol-0.2.0 → nullprotocol-0.3.0}/tests/conftest.py +0 -0
- {nullprotocol-0.2.0 → nullprotocol-0.3.0}/tests/test_knowledge.py +0 -0
- {nullprotocol-0.2.0 → nullprotocol-0.3.0}/tests/test_sdk.py +0 -0
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
- Add images to all four sync/async operations, with HTTPS URL validation, explicit model capability refusals and unchanged text requests.
|
|
6
|
+
|
|
3
7
|
## 0.2.0 — 2026-11-06
|
|
4
8
|
|
|
5
9
|
- Agent and Space Knowledge management, including source CRUD, state, preparation retry, connections and embedding usage.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: nullprotocol
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Focused AI tasks with managed context, Knowledge and observable results
|
|
5
5
|
Project-URL: Homepage, https://nullprotocol.ai
|
|
6
6
|
Project-URL: Documentation, https://nullprotocol.ai/docs/python
|
|
@@ -266,3 +266,17 @@ mypy src
|
|
|
266
266
|
```
|
|
267
267
|
|
|
268
268
|
Tests use a scripted HTTP fixture, not paid providers. The workspace integration proof also runs this client against the actual API/gateway and an isolated PostgreSQL database with scripted generation/embeddings. These checks verify integration, not model accuracy.
|
|
269
|
+
|
|
270
|
+
## Photo input
|
|
271
|
+
|
|
272
|
+
```python
|
|
273
|
+
result = agent.extract(
|
|
274
|
+
"Identify the landmark.",
|
|
275
|
+
{"landmark": "string"},
|
|
276
|
+
images=["https://images.unsplash.com/photo-1502602898657-3e91760cbb34?w=800&q=80"],
|
|
277
|
+
)
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
Use the loaded agent inside your client context. All four operations support up to four reachable HTTPS photo URLs of at most 2048 UTF-16 characters. Async clients accept the same keyword. Select a Vision-capable model. Empty text is allowed for photo-only input; validate still needs criteria and decide needs its actions. Inline base64, browser blob URLs and local files are not supported.
|
|
281
|
+
|
|
282
|
+
History retains URLs, not immutable copies of remote images. Knowledge uses a text query, not the photo or its URL; supply an explicit query for a photo-only search. Generation usage includes provider-reported image tokens. The gateway conservatively holds credit against the model context ceiling for photo calls, settles actual reported usage and releases the unused reserve. [Photo guide](https://nullprotocol.ai/docs/images?lang=python).
|
|
@@ -236,3 +236,17 @@ mypy src
|
|
|
236
236
|
```
|
|
237
237
|
|
|
238
238
|
Tests use a scripted HTTP fixture, not paid providers. The workspace integration proof also runs this client against the actual API/gateway and an isolated PostgreSQL database with scripted generation/embeddings. These checks verify integration, not model accuracy.
|
|
239
|
+
|
|
240
|
+
## Photo input
|
|
241
|
+
|
|
242
|
+
```python
|
|
243
|
+
result = agent.extract(
|
|
244
|
+
"Identify the landmark.",
|
|
245
|
+
{"landmark": "string"},
|
|
246
|
+
images=["https://images.unsplash.com/photo-1502602898657-3e91760cbb34?w=800&q=80"],
|
|
247
|
+
)
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
Use the loaded agent inside your client context. All four operations support up to four reachable HTTPS photo URLs of at most 2048 UTF-16 characters. Async clients accept the same keyword. Select a Vision-capable model. Empty text is allowed for photo-only input; validate still needs criteria and decide needs its actions. Inline base64, browser blob URLs and local files are not supported.
|
|
251
|
+
|
|
252
|
+
History retains URLs, not immutable copies of remote images. Knowledge uses a text query, not the photo or its URL; supply an explicit query for a photo-only search. Generation usage includes provider-reported image tokens. The gateway conservatively holds credit against the model context ceiling for photo calls, settles actual reported usage and releases the unused reserve. [Photo guide](https://nullprotocol.ai/docs/images?lang=python).
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Run a saved Vision agent. Set NULLPROTOCOL_KEY on your backend."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
from nullprotocol import NullProtocol
|
|
6
|
+
|
|
7
|
+
with NullProtocol() as client:
|
|
8
|
+
agent = client.load(os.environ.get("AGENT_ID", "photo-reader"))
|
|
9
|
+
result = agent.extract(
|
|
10
|
+
"Identify the landmark in the photo.",
|
|
11
|
+
{"landmark": "string"},
|
|
12
|
+
images=[
|
|
13
|
+
os.environ.get(
|
|
14
|
+
"PHOTO_URL",
|
|
15
|
+
"https://images.unsplash.com/photo-1502602898657-3e91760cbb34?w=800&q=80",
|
|
16
|
+
)
|
|
17
|
+
],
|
|
18
|
+
)
|
|
19
|
+
if not result.success:
|
|
20
|
+
print(result.error_code, result.error)
|
|
21
|
+
raise SystemExit(1)
|
|
22
|
+
print(result.data)
|
|
23
|
+
print(result.usage)
|
|
24
|
+
if result.history_error:
|
|
25
|
+
print("History was not saved:", result.history_error)
|
|
26
|
+
raise SystemExit(1)
|
|
@@ -184,9 +184,10 @@ class Agent(AgentState):
|
|
|
184
184
|
knowledge: Knowledge | None,
|
|
185
185
|
context_keys: list[str] | None,
|
|
186
186
|
idempotency_key: str | None,
|
|
187
|
+
images: list[str] | None = None,
|
|
187
188
|
) -> Result[Any]:
|
|
188
189
|
task_id = self._task_id(idempotency_key)
|
|
189
|
-
body = operation_payload(operation, values, self.scope, knowledge, context_keys)
|
|
190
|
+
body = operation_payload(operation, values, self.scope, knowledge, context_keys, images)
|
|
190
191
|
return _result(
|
|
191
192
|
self._transport.request(
|
|
192
193
|
"POST",
|
|
@@ -203,12 +204,18 @@ class Agent(AgentState):
|
|
|
203
204
|
data: Any,
|
|
204
205
|
schema: dict[str, Any],
|
|
205
206
|
*,
|
|
207
|
+
images: list[str] | None = None,
|
|
206
208
|
knowledge: Knowledge | None = None,
|
|
207
209
|
context_keys: list[str] | None = None,
|
|
208
210
|
idempotency_key: str | None = None,
|
|
209
211
|
) -> Result[Any]:
|
|
210
212
|
return self._run(
|
|
211
|
-
"extract",
|
|
213
|
+
"extract",
|
|
214
|
+
{"data": data, "schema": schema},
|
|
215
|
+
knowledge,
|
|
216
|
+
context_keys,
|
|
217
|
+
idempotency_key,
|
|
218
|
+
images,
|
|
212
219
|
)
|
|
213
220
|
|
|
214
221
|
def summarize(
|
|
@@ -217,6 +224,7 @@ class Agent(AgentState):
|
|
|
217
224
|
*,
|
|
218
225
|
max_length: int = 200,
|
|
219
226
|
focus: str | None = None,
|
|
227
|
+
images: list[str] | None = None,
|
|
220
228
|
knowledge: Knowledge | None = None,
|
|
221
229
|
context_keys: list[str] | None = None,
|
|
222
230
|
idempotency_key: str | None = None,
|
|
@@ -227,6 +235,7 @@ class Agent(AgentState):
|
|
|
227
235
|
knowledge,
|
|
228
236
|
context_keys,
|
|
229
237
|
idempotency_key,
|
|
238
|
+
images,
|
|
230
239
|
)
|
|
231
240
|
|
|
232
241
|
def validate(
|
|
@@ -235,6 +244,7 @@ class Agent(AgentState):
|
|
|
235
244
|
subject: Any,
|
|
236
245
|
*,
|
|
237
246
|
reference: Any = _UNSET,
|
|
247
|
+
images: list[str] | None = None,
|
|
238
248
|
knowledge: Knowledge | None = None,
|
|
239
249
|
context_keys: list[str] | None = None,
|
|
240
250
|
idempotency_key: str | None = None,
|
|
@@ -245,6 +255,7 @@ class Agent(AgentState):
|
|
|
245
255
|
knowledge,
|
|
246
256
|
context_keys,
|
|
247
257
|
idempotency_key,
|
|
258
|
+
images,
|
|
248
259
|
)
|
|
249
260
|
|
|
250
261
|
def decide(
|
|
@@ -252,6 +263,7 @@ class Agent(AgentState):
|
|
|
252
263
|
context: Any,
|
|
253
264
|
actions: list[ActionChoice],
|
|
254
265
|
*,
|
|
266
|
+
images: list[str] | None = None,
|
|
255
267
|
knowledge: Knowledge | None = None,
|
|
256
268
|
context_keys: list[str] | None = None,
|
|
257
269
|
idempotency_key: str | None = None,
|
|
@@ -264,6 +276,7 @@ class Agent(AgentState):
|
|
|
264
276
|
_knowledge_for_decision(original, values, knowledge),
|
|
265
277
|
context_keys,
|
|
266
278
|
idempotency_key,
|
|
279
|
+
images,
|
|
267
280
|
)
|
|
268
281
|
return self._remember(result, original)
|
|
269
282
|
|
|
@@ -379,9 +392,10 @@ class AsyncAgent(AgentState):
|
|
|
379
392
|
knowledge: Knowledge | None,
|
|
380
393
|
context_keys: list[str] | None,
|
|
381
394
|
idempotency_key: str | None,
|
|
395
|
+
images: list[str] | None = None,
|
|
382
396
|
) -> Result[Any]:
|
|
383
397
|
task_id = self._task_id(idempotency_key)
|
|
384
|
-
body = operation_payload(operation, values, self.scope, knowledge, context_keys)
|
|
398
|
+
body = operation_payload(operation, values, self.scope, knowledge, context_keys, images)
|
|
385
399
|
return _result(
|
|
386
400
|
await self._transport.request(
|
|
387
401
|
"POST",
|
|
@@ -398,12 +412,18 @@ class AsyncAgent(AgentState):
|
|
|
398
412
|
data: Any,
|
|
399
413
|
schema: dict[str, Any],
|
|
400
414
|
*,
|
|
415
|
+
images: list[str] | None = None,
|
|
401
416
|
knowledge: Knowledge | None = None,
|
|
402
417
|
context_keys: list[str] | None = None,
|
|
403
418
|
idempotency_key: str | None = None,
|
|
404
419
|
) -> Result[Any]:
|
|
405
420
|
return await self._run(
|
|
406
|
-
"extract",
|
|
421
|
+
"extract",
|
|
422
|
+
{"data": data, "schema": schema},
|
|
423
|
+
knowledge,
|
|
424
|
+
context_keys,
|
|
425
|
+
idempotency_key,
|
|
426
|
+
images,
|
|
407
427
|
)
|
|
408
428
|
|
|
409
429
|
async def summarize(
|
|
@@ -412,6 +432,7 @@ class AsyncAgent(AgentState):
|
|
|
412
432
|
*,
|
|
413
433
|
max_length: int = 200,
|
|
414
434
|
focus: str | None = None,
|
|
435
|
+
images: list[str] | None = None,
|
|
415
436
|
knowledge: Knowledge | None = None,
|
|
416
437
|
context_keys: list[str] | None = None,
|
|
417
438
|
idempotency_key: str | None = None,
|
|
@@ -422,6 +443,7 @@ class AsyncAgent(AgentState):
|
|
|
422
443
|
knowledge,
|
|
423
444
|
context_keys,
|
|
424
445
|
idempotency_key,
|
|
446
|
+
images,
|
|
425
447
|
)
|
|
426
448
|
|
|
427
449
|
async def validate(
|
|
@@ -430,6 +452,7 @@ class AsyncAgent(AgentState):
|
|
|
430
452
|
subject: Any,
|
|
431
453
|
*,
|
|
432
454
|
reference: Any = _UNSET,
|
|
455
|
+
images: list[str] | None = None,
|
|
433
456
|
knowledge: Knowledge | None = None,
|
|
434
457
|
context_keys: list[str] | None = None,
|
|
435
458
|
idempotency_key: str | None = None,
|
|
@@ -440,6 +463,7 @@ class AsyncAgent(AgentState):
|
|
|
440
463
|
knowledge,
|
|
441
464
|
context_keys,
|
|
442
465
|
idempotency_key,
|
|
466
|
+
images,
|
|
443
467
|
)
|
|
444
468
|
|
|
445
469
|
async def decide(
|
|
@@ -447,6 +471,7 @@ class AsyncAgent(AgentState):
|
|
|
447
471
|
context: Any,
|
|
448
472
|
actions: list[ActionChoice],
|
|
449
473
|
*,
|
|
474
|
+
images: list[str] | None = None,
|
|
450
475
|
knowledge: Knowledge | None = None,
|
|
451
476
|
context_keys: list[str] | None = None,
|
|
452
477
|
idempotency_key: str | None = None,
|
|
@@ -459,6 +484,7 @@ class AsyncAgent(AgentState):
|
|
|
459
484
|
_knowledge_for_decision(original, values, knowledge),
|
|
460
485
|
context_keys,
|
|
461
486
|
idempotency_key,
|
|
487
|
+
images,
|
|
462
488
|
)
|
|
463
489
|
return self._remember(result, original)
|
|
464
490
|
|
|
@@ -6,6 +6,7 @@ from copy import deepcopy
|
|
|
6
6
|
from dataclasses import dataclass
|
|
7
7
|
from threading import Lock
|
|
8
8
|
from typing import Any, TypeVar
|
|
9
|
+
from urllib.parse import urlsplit
|
|
9
10
|
from uuid import UUID, uuid4
|
|
10
11
|
from weakref import WeakKeyDictionary, WeakValueDictionary
|
|
11
12
|
|
|
@@ -60,6 +61,7 @@ def operation_payload(
|
|
|
60
61
|
scope: str | None,
|
|
61
62
|
knowledge: Knowledge | None,
|
|
62
63
|
context_keys: list[str] | None,
|
|
64
|
+
images: list[str] | None = None,
|
|
63
65
|
) -> dict[str, Any]:
|
|
64
66
|
body = {"operation": operation, **values}
|
|
65
67
|
if scope is not None:
|
|
@@ -85,6 +87,17 @@ def operation_payload(
|
|
|
85
87
|
if not isinstance(context_keys, list) or len(context_keys) > 16:
|
|
86
88
|
raise ValueError("context_keys must be a list of at most 16 keys.")
|
|
87
89
|
body["contextKeys"] = [identifier(key, "context key") for key in context_keys]
|
|
90
|
+
if images is not None:
|
|
91
|
+
if not isinstance(images, list) or len(images) > 4:
|
|
92
|
+
raise ValueError("images must be a list of at most four HTTPS photo URLs.")
|
|
93
|
+
for address in images:
|
|
94
|
+
if not isinstance(address, str) or utf16_length(address) > 2048:
|
|
95
|
+
raise ValueError("Each photo needs an HTTPS URL of at most 2048 characters.")
|
|
96
|
+
url = urlsplit(address)
|
|
97
|
+
if url.scheme != "https" or not url.hostname or url.username or url.password:
|
|
98
|
+
raise ValueError("Photo URLs must use HTTPS without embedded credentials.")
|
|
99
|
+
if images:
|
|
100
|
+
body["images"] = list(images)
|
|
88
101
|
if len(json_body(body)) > 131072:
|
|
89
102
|
raise ValueError("The HTTP operation body exceeds 131072 UTF-8 bytes.")
|
|
90
103
|
return body
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
|
|
4
|
+
import httpx
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from nullprotocol import AsyncNullProtocol, NullProtocol
|
|
8
|
+
|
|
9
|
+
PHOTO = "https://images.example.test/mug.jpg"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_photos_survive_every_operation_and_idempotency(api):
|
|
13
|
+
with NullProtocol(
|
|
14
|
+
key="fixture", http_client=httpx.Client(transport=httpx.MockTransport(api))
|
|
15
|
+
) as client:
|
|
16
|
+
agent = client.load("support")
|
|
17
|
+
images = [PHOTO]
|
|
18
|
+
assert agent.extract("", {"days": "number"}, images=images).success
|
|
19
|
+
assert agent.summarize("Describe the mug.", images=images).success
|
|
20
|
+
assert agent.validate("There is damage.", "", images=images).success
|
|
21
|
+
assert agent.decide("What next?", ["send_message"], images=images).success
|
|
22
|
+
agent.extract("", {"days": "number"}, images=images, idempotency_key="photo-42")
|
|
23
|
+
agent.extract("", {"days": "number"}, images=images, idempotency_key="photo-42")
|
|
24
|
+
assert api.model_calls == 5
|
|
25
|
+
bodies = [json.loads(r.content) for r in api.requests if r.url.path.endswith("/run")]
|
|
26
|
+
assert all(body["images"] == [PHOTO] for body in bodies)
|
|
27
|
+
assert bodies[0]["data"] == ""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@pytest.mark.parametrize(
|
|
31
|
+
"images",
|
|
32
|
+
[
|
|
33
|
+
"photo.jpg",
|
|
34
|
+
["http://example.test/photo.jpg"],
|
|
35
|
+
["data:image/png;base64,AAAA"],
|
|
36
|
+
["https://user:password@example.test/photo.jpg"],
|
|
37
|
+
[PHOTO] * 5,
|
|
38
|
+
],
|
|
39
|
+
)
|
|
40
|
+
def test_invalid_photos_never_reach_api(api, images):
|
|
41
|
+
with NullProtocol(
|
|
42
|
+
key="fixture", http_client=httpx.Client(transport=httpx.MockTransport(api))
|
|
43
|
+
) as client:
|
|
44
|
+
agent = client.load("support")
|
|
45
|
+
before = len(api.requests)
|
|
46
|
+
with pytest.raises(ValueError):
|
|
47
|
+
agent.extract("", {"days": "number"}, images=images)
|
|
48
|
+
assert len(api.requests) == before
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_async_photos(api):
|
|
52
|
+
async def run():
|
|
53
|
+
async with AsyncNullProtocol(
|
|
54
|
+
key="fixture", http_client=httpx.AsyncClient(transport=httpx.MockTransport(api))
|
|
55
|
+
) as client:
|
|
56
|
+
agent = await client.load("support")
|
|
57
|
+
assert (await agent.extract("", {"days": "number"}, images=[PHOTO])).success
|
|
58
|
+
assert (await agent.summarize("Describe.", images=[PHOTO])).success
|
|
59
|
+
assert (await agent.validate("Damaged.", "", images=[PHOTO])).success
|
|
60
|
+
assert (await agent.decide("Next?", ["send_message"], images=[PHOTO])).success
|
|
61
|
+
|
|
62
|
+
asyncio.run(run())
|
|
63
|
+
assert all(
|
|
64
|
+
json.loads(r.content)["images"] == [PHOTO]
|
|
65
|
+
for r in api.requests
|
|
66
|
+
if r.url.path.endswith("/run")
|
|
67
|
+
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|