nullprotocol 0.2.0__py3-none-any.whl

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,29 @@
1
+ """NullProtocol: focused AI operations with managed context and observable results."""
2
+
3
+ from ._agent import Agent, AsyncAgent
4
+ from ._client import AsyncNullProtocol, NullProtocol
5
+ from ._errors import APIError, NullProtocolError, ProtocolError, TransportError
6
+ from ._knowledge import AgentKnowledge, AsyncAgentKnowledge, AsyncKnowledgeLibrary, KnowledgeLibrary
7
+ from ._types import ActionContext, Execution, KnowledgeUsage, Result, Usage
8
+
9
+ __version__ = "0.2.0"
10
+ __all__ = [
11
+ "AgentKnowledge",
12
+ "KnowledgeLibrary",
13
+ "AsyncAgentKnowledge",
14
+ "AsyncKnowledgeLibrary",
15
+ "APIError",
16
+ "ActionContext",
17
+ "Agent",
18
+ "AsyncAgent",
19
+ "AsyncNullProtocol",
20
+ "Execution",
21
+ "KnowledgeUsage",
22
+ "NullProtocol",
23
+ "NullProtocolError",
24
+ "ProtocolError",
25
+ "Result",
26
+ "TransportError",
27
+ "Usage",
28
+ "__version__",
29
+ ]
nullprotocol/_agent.py ADDED
@@ -0,0 +1,518 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ import time
5
+ from copy import deepcopy
6
+ from dataclasses import replace
7
+ from typing import Any
8
+
9
+ from ._common import (
10
+ ActionChoice,
11
+ AgentState,
12
+ Knowledge,
13
+ MadeDecision,
14
+ RegisteredAction,
15
+ object_field,
16
+ operation_payload,
17
+ summary_options,
18
+ validation_options,
19
+ )
20
+ from ._errors import APIError, ProtocolError
21
+ from ._knowledge import AgentKnowledge, AsyncAgentKnowledge
22
+ from ._stores import (
23
+ AsyncContextStore,
24
+ AsyncMemoryStore,
25
+ AsyncSpaceContextStore,
26
+ ContextStore,
27
+ MemoryStore,
28
+ SpaceContextStore,
29
+ )
30
+ from ._transport import _UNSET, AsyncTransport, Transport, json_body
31
+ from ._types import ActionContext, Execution, Result
32
+
33
+
34
+ def _result(response: dict[str, Any], task_id: str, operation: str) -> Result[Any]:
35
+ result = Result.from_response(response, task_id)
36
+ if not result.success:
37
+ return result
38
+ raw = result.raw
39
+ valid = (
40
+ operation == "extract"
41
+ and "data" in raw
42
+ or operation == "summarize"
43
+ and isinstance(result.summary, str)
44
+ and isinstance(result.key_points, list)
45
+ and all(isinstance(x, str) for x in result.key_points)
46
+ or operation == "validate"
47
+ and result.recommendation in ("pass", "fail", "conditional")
48
+ and isinstance(result.score, (int, float))
49
+ and not isinstance(result.score, bool)
50
+ and 0 <= result.score <= 1
51
+ and isinstance(result.reasoning, str)
52
+ or operation == "decide"
53
+ and isinstance(result.action, str)
54
+ and isinstance(result.parameters, dict)
55
+ and isinstance(result.reasoning, str)
56
+ )
57
+ if not valid:
58
+ raise ProtocolError(
59
+ f"Invalid {operation} result in the server response.", request_id=task_id
60
+ )
61
+ return result
62
+
63
+
64
+ def _knowledge_for_decision(
65
+ context: Any,
66
+ values: dict[str, Any],
67
+ knowledge: Knowledge | None,
68
+ ) -> Knowledge | None:
69
+ if (
70
+ knowledge is True
71
+ and isinstance(values["context"], dict)
72
+ and "actionParameterSchemas" in values["context"]
73
+ ):
74
+ return {"query": context if isinstance(context, str) else json_body(context).decode()}
75
+ return knowledge
76
+
77
+
78
+ def _changes(values: dict[str, Any]) -> dict[str, Any]:
79
+ mapping = {"disabled_actions": "disabledActions", "if_revision": "ifRevision"}
80
+ body = {mapping.get(k, k): v for k, v in values.items() if v is not None}
81
+ if not any(k != "ifRevision" for k in body):
82
+ raise ValueError("Supply at least one setting to update.")
83
+ return body
84
+
85
+
86
+ def _refused(made: MadeDecision | None, code: str, message: str) -> Execution:
87
+ return Execution(
88
+ False, "refused", made.snapshot.action if made else None, error_code=code, error=message
89
+ )
90
+
91
+
92
+ def _view(result: Result[Any]) -> Result[Any]:
93
+ return Result.from_response(
94
+ {"result": result.raw, "operationId": result.operation_id}, result.request_id
95
+ )
96
+
97
+
98
+ def _check_action(
99
+ made: MadeDecision,
100
+ action: RegisteredAction | None,
101
+ settings: dict[str, Any],
102
+ ) -> Execution | None:
103
+ name = made.snapshot.action
104
+ if settings.get("paused"):
105
+ return _refused(made, "agent_paused", "This agent is paused. The handler did not run.")
106
+ if name in settings.get("disabledActions", []):
107
+ return _refused(
108
+ made, "action_disabled", "This action is disabled. The handler did not run."
109
+ )
110
+ if action is None:
111
+ return _refused(made, "action_not_registered", "No handler is registered for this action.")
112
+ if action.validator is not None:
113
+ problems = list(action.validator.iter_errors(made.snapshot.parameters))
114
+ if problems:
115
+ message = "; ".join(problem.message for problem in problems[:5])
116
+ return _refused(made, "invalid_parameters", message)
117
+ return None
118
+
119
+
120
+ def _record_body(made: MadeDecision, outcome: Execution, started: float) -> dict[str, Any]:
121
+ body = {
122
+ "name": made.snapshot.action,
123
+ "input": made.snapshot.parameters,
124
+ "outcome": outcome.outcome,
125
+ "modelCalls": 0,
126
+ "requestBytes": 0,
127
+ "durationMs": min(86400000, max(0, round((time.monotonic() - started) * 1000))),
128
+ }
129
+ if outcome.success:
130
+ body["result"] = outcome.result
131
+ if outcome.error_code is not None:
132
+ body["errorCode"] = outcome.error_code
133
+ return body
134
+
135
+
136
+ class Agent(AgentState):
137
+ """A saved hosted agent. Call methods synchronously; use AsyncAgent in async apps."""
138
+
139
+ def __init__(self, transport: Transport, agent_id: str, scope: str | None = None) -> None:
140
+ super().__init__(agent_id, scope)
141
+ self._transport = transport
142
+ self.context = ContextStore(transport, self._path + "/context", self.scope)
143
+ self.space = SpaceContextStore(transport)
144
+ self.knowledge = AgentKnowledge(transport, self.agent_id)
145
+ self.memory = MemoryStore(transport, self._path + "/memory", self.scope)
146
+
147
+ def with_scope(self, scope: str | None) -> Agent:
148
+ """Bind another scope, copying action registrations but not decisions."""
149
+ bound = Agent(self._transport, self.agent_id, scope)
150
+ bound._actions = self._actions.copy()
151
+ return bound
152
+
153
+ def settings(self) -> dict[str, Any]:
154
+ return object_field(self._transport.request("GET", self._path + "/settings"), "agent")
155
+
156
+ def update(
157
+ self,
158
+ *,
159
+ name: str | None = None,
160
+ model: str | None = None,
161
+ instructions: str | None = None,
162
+ paused: bool | None = None,
163
+ disabled_actions: list[str] | None = None,
164
+ if_revision: int | None = None,
165
+ ) -> dict[str, Any]:
166
+ body = _changes(
167
+ {
168
+ "name": name,
169
+ "model": model,
170
+ "instructions": instructions,
171
+ "paused": paused,
172
+ "disabled_actions": disabled_actions,
173
+ "if_revision": if_revision,
174
+ }
175
+ )
176
+ return object_field(
177
+ self._transport.request("PATCH", self._path + "/settings", body=body), "agent"
178
+ )
179
+
180
+ def _run(
181
+ self,
182
+ operation: str,
183
+ values: dict[str, Any],
184
+ knowledge: Knowledge | None,
185
+ context_keys: list[str] | None,
186
+ idempotency_key: str | None,
187
+ ) -> Result[Any]:
188
+ task_id = self._task_id(idempotency_key)
189
+ body = operation_payload(operation, values, self.scope, knowledge, context_keys)
190
+ return _result(
191
+ self._transport.request(
192
+ "POST",
193
+ self._path + "/run",
194
+ body=body,
195
+ task_id=task_id,
196
+ ),
197
+ task_id,
198
+ operation,
199
+ )
200
+
201
+ def extract(
202
+ self,
203
+ data: Any,
204
+ schema: dict[str, Any],
205
+ *,
206
+ knowledge: Knowledge | None = None,
207
+ context_keys: list[str] | None = None,
208
+ idempotency_key: str | None = None,
209
+ ) -> Result[Any]:
210
+ return self._run(
211
+ "extract", {"data": data, "schema": schema}, knowledge, context_keys, idempotency_key
212
+ )
213
+
214
+ def summarize(
215
+ self,
216
+ content: Any,
217
+ *,
218
+ max_length: int = 200,
219
+ focus: str | None = None,
220
+ knowledge: Knowledge | None = None,
221
+ context_keys: list[str] | None = None,
222
+ idempotency_key: str | None = None,
223
+ ) -> Result[Any]:
224
+ return self._run(
225
+ "summarize",
226
+ summary_options(content, max_length, focus),
227
+ knowledge,
228
+ context_keys,
229
+ idempotency_key,
230
+ )
231
+
232
+ def validate(
233
+ self,
234
+ criteria: str,
235
+ subject: Any,
236
+ *,
237
+ reference: Any = _UNSET,
238
+ knowledge: Knowledge | None = None,
239
+ context_keys: list[str] | None = None,
240
+ idempotency_key: str | None = None,
241
+ ) -> Result[Any]:
242
+ return self._run(
243
+ "validate",
244
+ validation_options(criteria, subject, reference, _UNSET),
245
+ knowledge,
246
+ context_keys,
247
+ idempotency_key,
248
+ )
249
+
250
+ def decide(
251
+ self,
252
+ context: Any,
253
+ actions: list[ActionChoice],
254
+ *,
255
+ knowledge: Knowledge | None = None,
256
+ context_keys: list[str] | None = None,
257
+ idempotency_key: str | None = None,
258
+ ) -> Result[Any]:
259
+ original = deepcopy(context)
260
+ values = self._choices(original, actions)
261
+ result = self._run(
262
+ "decide",
263
+ values,
264
+ _knowledge_for_decision(original, values, knowledge),
265
+ context_keys,
266
+ idempotency_key,
267
+ )
268
+ return self._remember(result, original)
269
+
270
+ def execute(self, decision: Result[Any]) -> Execution:
271
+ made, problem = self._consume(decision)
272
+ if problem:
273
+ return _refused(
274
+ made, problem, "Use an unconsumed successful decision from this agent object."
275
+ )
276
+ assert made is not None
277
+ started = time.monotonic()
278
+ action = self._actions.get(made.snapshot.action or "")
279
+ try:
280
+ outcome = _check_action(made, action, self.settings())
281
+ if outcome is None and action is not None and action.guard is not None:
282
+ allowed = action.guard(deepcopy(made.snapshot.parameters), _view(made.snapshot))
283
+ if inspect.isawaitable(allowed):
284
+ if inspect.iscoroutine(allowed):
285
+ allowed.close()
286
+ outcome = _refused(
287
+ made, "guard_error", "Use AsyncNullProtocol for async guards."
288
+ )
289
+ elif allowed is not True:
290
+ outcome = _refused(
291
+ made, "guard_rejected", "The action was rejected by its guard."
292
+ )
293
+ except Exception as exc:
294
+ outcome = _refused(
295
+ made, exc.code if isinstance(exc, APIError) else "guard_error", str(exc)
296
+ )
297
+ if outcome is None:
298
+ assert action is not None
299
+ try:
300
+ value = action.handler(
301
+ deepcopy(made.snapshot.parameters),
302
+ ActionContext(_view(made.snapshot), deepcopy(made.input)),
303
+ )
304
+ if inspect.isawaitable(value):
305
+ if inspect.iscoroutine(value):
306
+ value.close()
307
+ raise TypeError("Use AsyncNullProtocol for async handlers.")
308
+ outcome = Execution(True, "completed", made.snapshot.action, result=value)
309
+ except Exception as exc:
310
+ outcome = Execution(
311
+ False,
312
+ "failed",
313
+ made.snapshot.action,
314
+ error_code="handler_failed",
315
+ error=str(exc),
316
+ )
317
+ try:
318
+ if made.snapshot.operation_id is None:
319
+ raise ProtocolError(
320
+ "The decision has no History operation ID; execution was not recorded."
321
+ )
322
+ self._transport.request(
323
+ "POST",
324
+ self._path + f"/operations/{made.snapshot.operation_id}/actions",
325
+ body=_record_body(made, outcome, started),
326
+ )
327
+ except Exception as exc:
328
+ outcome = replace(outcome, history_error=str(exc))
329
+ return outcome
330
+
331
+
332
+ class AsyncAgent(AgentState):
333
+ """Async counterpart of Agent. Network I/O uses httpx.AsyncClient."""
334
+
335
+ def __init__(self, transport: AsyncTransport, agent_id: str, scope: str | None = None) -> None:
336
+ super().__init__(agent_id, scope)
337
+ self._transport = transport
338
+ self.context = AsyncContextStore(transport, self._path + "/context", self.scope)
339
+ self.space = AsyncSpaceContextStore(transport)
340
+ self.knowledge = AsyncAgentKnowledge(transport, self.agent_id)
341
+ self.memory = AsyncMemoryStore(transport, self._path + "/memory", self.scope)
342
+
343
+ def with_scope(self, scope: str | None) -> AsyncAgent:
344
+ bound = AsyncAgent(self._transport, self.agent_id, scope)
345
+ bound._actions = self._actions.copy()
346
+ return bound
347
+
348
+ async def settings(self) -> dict[str, Any]:
349
+ return object_field(await self._transport.request("GET", self._path + "/settings"), "agent")
350
+
351
+ async def update(
352
+ self,
353
+ *,
354
+ name: str | None = None,
355
+ model: str | None = None,
356
+ instructions: str | None = None,
357
+ paused: bool | None = None,
358
+ disabled_actions: list[str] | None = None,
359
+ if_revision: int | None = None,
360
+ ) -> dict[str, Any]:
361
+ body = _changes(
362
+ {
363
+ "name": name,
364
+ "model": model,
365
+ "instructions": instructions,
366
+ "paused": paused,
367
+ "disabled_actions": disabled_actions,
368
+ "if_revision": if_revision,
369
+ }
370
+ )
371
+ return object_field(
372
+ await self._transport.request("PATCH", self._path + "/settings", body=body), "agent"
373
+ )
374
+
375
+ async def _run(
376
+ self,
377
+ operation: str,
378
+ values: dict[str, Any],
379
+ knowledge: Knowledge | None,
380
+ context_keys: list[str] | None,
381
+ idempotency_key: str | None,
382
+ ) -> Result[Any]:
383
+ task_id = self._task_id(idempotency_key)
384
+ body = operation_payload(operation, values, self.scope, knowledge, context_keys)
385
+ return _result(
386
+ await self._transport.request(
387
+ "POST",
388
+ self._path + "/run",
389
+ body=body,
390
+ task_id=task_id,
391
+ ),
392
+ task_id,
393
+ operation,
394
+ )
395
+
396
+ async def extract(
397
+ self,
398
+ data: Any,
399
+ schema: dict[str, Any],
400
+ *,
401
+ knowledge: Knowledge | None = None,
402
+ context_keys: list[str] | None = None,
403
+ idempotency_key: str | None = None,
404
+ ) -> Result[Any]:
405
+ return await self._run(
406
+ "extract", {"data": data, "schema": schema}, knowledge, context_keys, idempotency_key
407
+ )
408
+
409
+ async def summarize(
410
+ self,
411
+ content: Any,
412
+ *,
413
+ max_length: int = 200,
414
+ focus: str | None = None,
415
+ knowledge: Knowledge | None = None,
416
+ context_keys: list[str] | None = None,
417
+ idempotency_key: str | None = None,
418
+ ) -> Result[Any]:
419
+ return await self._run(
420
+ "summarize",
421
+ summary_options(content, max_length, focus),
422
+ knowledge,
423
+ context_keys,
424
+ idempotency_key,
425
+ )
426
+
427
+ async def validate(
428
+ self,
429
+ criteria: str,
430
+ subject: Any,
431
+ *,
432
+ reference: Any = _UNSET,
433
+ knowledge: Knowledge | None = None,
434
+ context_keys: list[str] | None = None,
435
+ idempotency_key: str | None = None,
436
+ ) -> Result[Any]:
437
+ return await self._run(
438
+ "validate",
439
+ validation_options(criteria, subject, reference, _UNSET),
440
+ knowledge,
441
+ context_keys,
442
+ idempotency_key,
443
+ )
444
+
445
+ async def decide(
446
+ self,
447
+ context: Any,
448
+ actions: list[ActionChoice],
449
+ *,
450
+ knowledge: Knowledge | None = None,
451
+ context_keys: list[str] | None = None,
452
+ idempotency_key: str | None = None,
453
+ ) -> Result[Any]:
454
+ original = deepcopy(context)
455
+ values = self._choices(original, actions)
456
+ result = await self._run(
457
+ "decide",
458
+ values,
459
+ _knowledge_for_decision(original, values, knowledge),
460
+ context_keys,
461
+ idempotency_key,
462
+ )
463
+ return self._remember(result, original)
464
+
465
+ async def execute(self, decision: Result[Any]) -> Execution:
466
+ made, problem = self._consume(decision)
467
+ if problem:
468
+ return _refused(
469
+ made, problem, "Use an unconsumed successful decision from this agent object."
470
+ )
471
+ assert made is not None
472
+ started = time.monotonic()
473
+ action = self._actions.get(made.snapshot.action or "")
474
+ try:
475
+ outcome = _check_action(made, action, await self.settings())
476
+ if outcome is None and action is not None and action.guard is not None:
477
+ allowed = action.guard(deepcopy(made.snapshot.parameters), _view(made.snapshot))
478
+ if inspect.isawaitable(allowed):
479
+ allowed = await allowed
480
+ if allowed is not True:
481
+ outcome = _refused(
482
+ made, "guard_rejected", "The action was rejected by its guard."
483
+ )
484
+ except Exception as exc:
485
+ outcome = _refused(
486
+ made, exc.code if isinstance(exc, APIError) else "guard_error", str(exc)
487
+ )
488
+ if outcome is None:
489
+ assert action is not None
490
+ try:
491
+ value = action.handler(
492
+ deepcopy(made.snapshot.parameters),
493
+ ActionContext(_view(made.snapshot), deepcopy(made.input)),
494
+ )
495
+ if inspect.isawaitable(value):
496
+ value = await value
497
+ outcome = Execution(True, "completed", made.snapshot.action, result=value)
498
+ except Exception as exc:
499
+ outcome = Execution(
500
+ False,
501
+ "failed",
502
+ made.snapshot.action,
503
+ error_code="handler_failed",
504
+ error=str(exc),
505
+ )
506
+ try:
507
+ if made.snapshot.operation_id is None:
508
+ raise ProtocolError(
509
+ "The decision has no History operation ID; execution was not recorded."
510
+ )
511
+ await self._transport.request(
512
+ "POST",
513
+ self._path + f"/operations/{made.snapshot.operation_id}/actions",
514
+ body=_record_body(made, outcome, started),
515
+ )
516
+ except Exception as exc:
517
+ outcome = replace(outcome, history_error=str(exc))
518
+ return outcome
@@ -0,0 +1,108 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import httpx
6
+
7
+ from ._agent import Agent, AsyncAgent
8
+ from ._common import checked_text, object_field
9
+ from ._transport import AsyncTransport, Transport, _Config, identifier
10
+
11
+
12
+ def _create_body(
13
+ model: str,
14
+ instructions: str,
15
+ agent_id: str | None,
16
+ name: str | None,
17
+ ) -> dict[str, Any]:
18
+ checked_text(model, "model", 120)
19
+ if not isinstance(instructions, str) or len(instructions.encode()) > 16384:
20
+ raise ValueError("instructions must be text of at most 16384 UTF-8 bytes.")
21
+ body = {"provider": "nullprotocol", "model": model, "instructions": instructions}
22
+ if agent_id is not None:
23
+ body["agentId"] = identifier(agent_id, "agent ID")
24
+ if name is not None:
25
+ body["name"] = checked_text(name, "name", 80)
26
+ return body
27
+
28
+
29
+ class NullProtocol:
30
+ """Client for a Space's hosted agents. Timeouts are in seconds, not milliseconds."""
31
+
32
+ def __init__(
33
+ self,
34
+ *,
35
+ key: str | None = None,
36
+ endpoint: str | None = None,
37
+ timeout: float = 30.0,
38
+ max_retries: int = 2,
39
+ http_client: httpx.Client | None = None,
40
+ ) -> None:
41
+ self._transport = Transport(_Config(key, endpoint, timeout, max_retries), http_client)
42
+
43
+ def __enter__(self) -> NullProtocol:
44
+ return self
45
+
46
+ def __exit__(self, *_args: Any) -> None:
47
+ self.close()
48
+
49
+ def close(self) -> None:
50
+ self._transport.close()
51
+
52
+ def load(self, agent_id: str, *, scope: str | None = None) -> Agent:
53
+ agent = Agent(self._transport, agent_id, scope)
54
+ agent.settings() # Fail at setup if the agent/key is unavailable.
55
+ return agent
56
+
57
+ def create(
58
+ self,
59
+ *,
60
+ model: str,
61
+ instructions: str = "",
62
+ agent_id: str | None = None,
63
+ name: str | None = None,
64
+ ) -> Agent:
65
+ body = _create_body(model, instructions, agent_id, name)
66
+ saved = object_field(self._transport.request("POST", "/agents", body=body), "agent")
67
+ return Agent(self._transport, saved["agentId"])
68
+
69
+
70
+ class AsyncNullProtocol:
71
+ """Async client for asyncio applications. Use async with to release its HTTP pool."""
72
+
73
+ def __init__(
74
+ self,
75
+ *,
76
+ key: str | None = None,
77
+ endpoint: str | None = None,
78
+ timeout: float = 30.0,
79
+ max_retries: int = 2,
80
+ http_client: httpx.AsyncClient | None = None,
81
+ ) -> None:
82
+ self._transport = AsyncTransport(_Config(key, endpoint, timeout, max_retries), http_client)
83
+
84
+ async def __aenter__(self) -> AsyncNullProtocol:
85
+ return self
86
+
87
+ async def __aexit__(self, *_args: Any) -> None:
88
+ await self.close()
89
+
90
+ async def close(self) -> None:
91
+ await self._transport.close()
92
+
93
+ async def load(self, agent_id: str, *, scope: str | None = None) -> AsyncAgent:
94
+ agent = AsyncAgent(self._transport, agent_id, scope)
95
+ await agent.settings()
96
+ return agent
97
+
98
+ async def create(
99
+ self,
100
+ *,
101
+ model: str,
102
+ instructions: str = "",
103
+ agent_id: str | None = None,
104
+ name: str | None = None,
105
+ ) -> AsyncAgent:
106
+ body = _create_body(model, instructions, agent_id, name)
107
+ saved = object_field(await self._transport.request("POST", "/agents", body=body), "agent")
108
+ return AsyncAgent(self._transport, saved["agentId"])