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.
- nullprotocol/__init__.py +29 -0
- nullprotocol/_agent.py +518 -0
- nullprotocol/_client.py +108 -0
- nullprotocol/_common.py +292 -0
- nullprotocol/_errors.py +43 -0
- nullprotocol/_knowledge.py +163 -0
- nullprotocol/_stores.py +189 -0
- nullprotocol/_transport.py +276 -0
- nullprotocol/_types.py +175 -0
- nullprotocol/py.typed +0 -0
- nullprotocol-0.2.0.dist-info/METADATA +268 -0
- nullprotocol-0.2.0.dist-info/RECORD +14 -0
- nullprotocol-0.2.0.dist-info/WHEEL +4 -0
- nullprotocol-0.2.0.dist-info/licenses/LICENSE +21 -0
nullprotocol/__init__.py
ADDED
|
@@ -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
|
nullprotocol/_client.py
ADDED
|
@@ -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"])
|