velophex 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.
- velophex/__init__.py +38 -0
- velophex/_json.py +297 -0
- velophex/agents/__init__.py +130 -0
- velophex/agents/_errors.py +75 -0
- velophex/agents/_loop.py +436 -0
- velophex/agents/_tools.py +168 -0
- velophex/agents/_tracing.py +87 -0
- velophex/agents/testing.py +249 -0
- velophex/cli/__init__.py +5 -0
- velophex/cli/main.py +548 -0
- velophex/cli/publish.py +152 -0
- velophex/cli/sync.py +90 -0
- velophex/cli/templates.py +126 -0
- velophex/contracts/__init__.py +103 -0
- velophex/contracts/_model.py +26 -0
- velophex/contracts/agents.py +110 -0
- velophex/contracts/diagnostics.py +157 -0
- velophex/contracts/executor.py +1921 -0
- velophex/contracts/package.py +391 -0
- velophex/contracts/project.py +1008 -0
- velophex/contracts/runtimes.py +226 -0
- velophex/contracts/signin.py +53 -0
- velophex/contracts/validation.py +158 -0
- velophex/entrypoints.py +329 -0
- velophex/errors.py +44 -0
- velophex/packaging.py +465 -0
- velophex/py.typed +0 -0
- velophex/runtime/__init__.py +471 -0
- velophex/runtime/_session.py +192 -0
- velophex/runtime/client.py +957 -0
- velophex/runtime/models.py +326 -0
- velophex/signing.py +425 -0
- velophex/worker/__init__.py +27 -0
- velophex/worker/__main__.py +118 -0
- velophex/worker/framing.py +248 -0
- velophex/worker/loader.py +105 -0
- velophex/worker/outbox.py +147 -0
- velophex/worker/session.py +519 -0
- velophex-0.2.0.dist-info/METADATA +313 -0
- velophex-0.2.0.dist-info/RECORD +43 -0
- velophex-0.2.0.dist-info/WHEEL +4 -0
- velophex-0.2.0.dist-info/entry_points.txt +2 -0
- velophex-0.2.0.dist-info/licenses/LICENSE +21 -0
velophex/__init__.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""VeloPhex Python SDK: author, run, pack and publish Python automations.
|
|
2
|
+
|
|
3
|
+
A Python automation is an ordinary package with a ``project.json`` (ADR-P002) and one or more
|
|
4
|
+
functions marked with :func:`entrypoint`. ``velophex pack`` turns it into the same signed
|
|
5
|
+
``.nupkg`` a Studio workflow produces, and the Runner executes it with ``python -m
|
|
6
|
+
velophex.worker`` over the executor protocol (ADR-P005, ADR-P008).
|
|
7
|
+
|
|
8
|
+
>>> import velophex
|
|
9
|
+
>>>
|
|
10
|
+
>>> @velophex.entrypoint(id="main")
|
|
11
|
+
... def run(pdf_path: str) -> dict[str, float]:
|
|
12
|
+
... if not pdf_path.endswith(".pdf"):
|
|
13
|
+
... raise velophex.BusinessError("Only PDF invoices are accepted.")
|
|
14
|
+
... return {"total": 42.5}
|
|
15
|
+
|
|
16
|
+
Submodules:
|
|
17
|
+
|
|
18
|
+
* :mod:`velophex.runtime` - assets, secrets, queues, child jobs and cooperative stop
|
|
19
|
+
* :mod:`velophex.contracts` - the platform contracts as Python models
|
|
20
|
+
* :mod:`velophex.worker` - the executor protocol worker; the Runner's entry point
|
|
21
|
+
* :mod:`velophex.cli` - ``velophex init | run | pack | publish``
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from velophex.entrypoints import EntryPointDefinition, EntryPointInputError, entrypoint
|
|
25
|
+
from velophex.errors import BusinessError, VelophexError
|
|
26
|
+
from velophex.runtime import should_stop
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"BusinessError",
|
|
30
|
+
"EntryPointDefinition",
|
|
31
|
+
"EntryPointInputError",
|
|
32
|
+
"VelophexError",
|
|
33
|
+
"__version__",
|
|
34
|
+
"entrypoint",
|
|
35
|
+
"should_stop",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
__version__ = "0.2.0"
|
velophex/_json.py
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
r"""Byte-exact JSON compatible with ``VeloPhex.Platform.Contracts.Json.PlatformJson``.
|
|
2
|
+
|
|
3
|
+
Every VeloPhex tool must produce the *same bytes* for the same contract document:
|
|
4
|
+
``velophex/package-manifest.json`` carries the SHA-512 of ``content/project.json``, so a
|
|
5
|
+
package written by the Python CLI and one written by Studio only verify against each other
|
|
6
|
+
when both writers agree down to the last escape. Python's :mod:`json` does not agree with
|
|
7
|
+
``System.Text.Json``, which is why this module exists rather than a call to ``json.dumps``.
|
|
8
|
+
|
|
9
|
+
The four differences that matter, all taken from .NET 10 rather than from the documentation:
|
|
10
|
+
|
|
11
|
+
* ``System.Text.Json`` uses ``JavaScriptEncoder.Default``, which escapes the HTML-sensitive
|
|
12
|
+
ASCII characters ``"`` ``&`` ``'`` ``+`` ``<`` ``>`` and the backtick, plus ``\``, the
|
|
13
|
+
controls and everything outside printable ASCII, as ``\uXXXX`` with **upper-case** hex
|
|
14
|
+
digits (non-BMP characters as their surrogate pair; a lone surrogate as ``�``).
|
|
15
|
+
Python escapes almost nothing by default.
|
|
16
|
+
* Indented output is two spaces with LF endings and no space before the ``:``.
|
|
17
|
+
* A ``JsonElement`` (an opaque subtree such as an entry point's JSON Schema) is re-emitted
|
|
18
|
+
with its **original number literals**: ``1.0`` stays ``1.0`` and ``1e2`` stays ``1e2``.
|
|
19
|
+
:class:`RawInt` and :class:`RawFloat` carry the literal through a load/dump round trip.
|
|
20
|
+
* ``DateTimeOffset``, ``Guid`` and friends are written by typed fast paths that never reach
|
|
21
|
+
the encoder, so their ``+`` is not escaped. :class:`VerbatimString` says so.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
from collections.abc import Iterator, Mapping, Sequence
|
|
28
|
+
from decimal import Decimal
|
|
29
|
+
from typing import Any, Final
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"MAXIMUM_DEPTH",
|
|
33
|
+
"JsonValue",
|
|
34
|
+
"RawFloat",
|
|
35
|
+
"RawInt",
|
|
36
|
+
"VerbatimString",
|
|
37
|
+
"dumps_document",
|
|
38
|
+
"dumps_wire",
|
|
39
|
+
"encode_wire",
|
|
40
|
+
"escape_string",
|
|
41
|
+
"loads",
|
|
42
|
+
"omit_none",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
#: Mirrors ``PlatformJson.MaximumDepth``; documents nested deeper are rejected on read.
|
|
46
|
+
MAXIMUM_DEPTH: Final = 64
|
|
47
|
+
|
|
48
|
+
JsonValue = Any
|
|
49
|
+
"""Any value the platform JSON writer accepts: ``None``, bool, int, float, str, list or dict."""
|
|
50
|
+
|
|
51
|
+
_INDENT: Final = " "
|
|
52
|
+
|
|
53
|
+
# U+FEFF, spelled as a code point so no editor can lose it to its own BOM handling.
|
|
54
|
+
_BOM: Final = chr(0xFEFF)
|
|
55
|
+
|
|
56
|
+
# Short escapes System.Text.Json emits instead of \uXXXX.
|
|
57
|
+
_SHORT_ESCAPES: Final[dict[int, str]] = {
|
|
58
|
+
0x08: "\\b",
|
|
59
|
+
0x09: "\\t",
|
|
60
|
+
0x0A: "\\n",
|
|
61
|
+
0x0C: "\\f",
|
|
62
|
+
0x0D: "\\r",
|
|
63
|
+
0x5C: "\\\\",
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
# Printable ASCII that JavaScriptEncoder.Default still escapes, because the encoder is safe
|
|
67
|
+
# for HTML and script contexts by default.
|
|
68
|
+
_HTML_SENSITIVE: Final[frozenset[int]] = frozenset({0x22, 0x26, 0x27, 0x2B, 0x3C, 0x3E, 0x60})
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class RawInt(int):
|
|
72
|
+
"""An integer JSON literal that remembers the text it was parsed from.
|
|
73
|
+
|
|
74
|
+
``System.Text.Json`` writes a ``JsonElement`` number verbatim, so a schema authored as
|
|
75
|
+
``1e2`` must not come back as ``100``. Instances behave as the number they stand for;
|
|
76
|
+
only the writer looks at :attr:`literal`.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
# No __slots__: CPython forbids a non-empty one on an int or float subclass.
|
|
80
|
+
literal: str
|
|
81
|
+
|
|
82
|
+
def __new__(cls, literal: str) -> RawInt:
|
|
83
|
+
value = super().__new__(cls, int(literal))
|
|
84
|
+
value.literal = literal
|
|
85
|
+
return value
|
|
86
|
+
|
|
87
|
+
def __repr__(self) -> str:
|
|
88
|
+
return f"RawInt({self.literal!r})"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class VerbatimString(str):
|
|
92
|
+
r"""A string .NET writes without running it through the escaping encoder.
|
|
93
|
+
|
|
94
|
+
``Utf8JsonWriter`` has typed fast paths for ``DateTimeOffset``, ``DateTime``, ``Guid`` and
|
|
95
|
+
``TimeSpan`` that format straight into the buffer. The encoder never sees them, so a
|
|
96
|
+
``DateTimeOffset`` of ``1970-01-01T00:00:00+00:00`` keeps its ``+`` where the same text in
|
|
97
|
+
a plain ``string`` property would come out as ``+``. The executor frame vectors pin
|
|
98
|
+
that difference, so the writer needs a way to say "this one is already formatted".
|
|
99
|
+
|
|
100
|
+
Only ever wrap machine-generated text: the contents are emitted between quotes as they
|
|
101
|
+
are, with no escaping at all.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class RawFloat(float):
|
|
106
|
+
"""A fractional JSON literal that remembers the text it was parsed from."""
|
|
107
|
+
|
|
108
|
+
literal: str
|
|
109
|
+
|
|
110
|
+
def __new__(cls, literal: str) -> RawFloat:
|
|
111
|
+
value = super().__new__(cls, float(literal))
|
|
112
|
+
value.literal = literal
|
|
113
|
+
return value
|
|
114
|
+
|
|
115
|
+
def __repr__(self) -> str:
|
|
116
|
+
return f"RawFloat({self.literal!r})"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def omit_none(members: dict[str, JsonValue]) -> dict[str, JsonValue]:
|
|
120
|
+
"""Drop null members, matching .NET's ``JsonIgnoreCondition.WhenWritingNull``.
|
|
121
|
+
|
|
122
|
+
Every contract body is built this way rather than by filtering afterwards, so the member
|
|
123
|
+
order of the .NET record stays visible in the source.
|
|
124
|
+
"""
|
|
125
|
+
return {name: value for name, value in members.items() if value is not None}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def escape_string(value: str) -> str:
|
|
129
|
+
"""Escape *value* exactly as ``JavaScriptEncoder.Default`` does, without the quotes."""
|
|
130
|
+
pieces: list[str] = []
|
|
131
|
+
|
|
132
|
+
for character in value:
|
|
133
|
+
code = ord(character)
|
|
134
|
+
|
|
135
|
+
if code < 0x20 or code == 0x7F or code in _HTML_SENSITIVE or code == 0x5C:
|
|
136
|
+
short = _SHORT_ESCAPES.get(code)
|
|
137
|
+
pieces.append(short if short is not None else f"\\u{code:04X}")
|
|
138
|
+
elif code < 0x7F:
|
|
139
|
+
pieces.append(character)
|
|
140
|
+
elif code > 0xFFFF:
|
|
141
|
+
# Non-BMP scalars are escaped as the UTF-16 surrogate pair, as .NET does.
|
|
142
|
+
offset = code - 0x10000
|
|
143
|
+
pieces.append(f"\\u{0xD800 + (offset >> 10):04X}\\u{0xDC00 + (offset & 0x3FF):04X}")
|
|
144
|
+
elif 0xD800 <= code <= 0xDFFF:
|
|
145
|
+
# A lone surrogate (Python keeps them, e.g. from surrogateescape) is not valid
|
|
146
|
+
# UTF-16; .NET writes U+FFFD in its place rather than failing.
|
|
147
|
+
pieces.append("\\uFFFD")
|
|
148
|
+
else:
|
|
149
|
+
pieces.append(f"\\u{code:04X}")
|
|
150
|
+
|
|
151
|
+
return "".join(pieces)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _format_number(value: object) -> str:
|
|
155
|
+
literal = getattr(value, "literal", None)
|
|
156
|
+
|
|
157
|
+
if isinstance(literal, str):
|
|
158
|
+
return literal
|
|
159
|
+
|
|
160
|
+
if isinstance(value, bool): # bool is an int; callers should never reach here
|
|
161
|
+
raise TypeError("Booleans are not numbers in JSON.")
|
|
162
|
+
|
|
163
|
+
if isinstance(value, int):
|
|
164
|
+
return str(value)
|
|
165
|
+
|
|
166
|
+
if isinstance(value, Decimal):
|
|
167
|
+
return format(value, "f")
|
|
168
|
+
|
|
169
|
+
number = float(value)
|
|
170
|
+
|
|
171
|
+
if number != number or number in (float("inf"), float("-inf")):
|
|
172
|
+
raise ValueError(f"{value!r} has no JSON representation.")
|
|
173
|
+
|
|
174
|
+
# repr() gives Python's shortest round-trip form, which matches .NET for every value the
|
|
175
|
+
# contracts carry (sizes, timeouts, schema bounds). Exponential literals differ in case
|
|
176
|
+
# and padding, so a document that needs one must keep its RawNumber.
|
|
177
|
+
return repr(number)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _write(value: JsonValue, out: list[str], indent: int | None, depth: int) -> None:
|
|
181
|
+
if depth > MAXIMUM_DEPTH:
|
|
182
|
+
raise ValueError(f"The document is nested deeper than {MAXIMUM_DEPTH} levels.")
|
|
183
|
+
|
|
184
|
+
if value is None:
|
|
185
|
+
out.append("null")
|
|
186
|
+
elif value is True:
|
|
187
|
+
out.append("true")
|
|
188
|
+
elif value is False:
|
|
189
|
+
out.append("false")
|
|
190
|
+
elif isinstance(value, VerbatimString):
|
|
191
|
+
out.append(f'"{value}"')
|
|
192
|
+
elif isinstance(value, str):
|
|
193
|
+
out.append(f'"{escape_string(value)}"')
|
|
194
|
+
elif isinstance(value, (int, float, Decimal)):
|
|
195
|
+
out.append(_format_number(value))
|
|
196
|
+
elif isinstance(value, Mapping):
|
|
197
|
+
_write_object(value, out, indent, depth)
|
|
198
|
+
elif isinstance(value, Sequence):
|
|
199
|
+
_write_array(value, out, indent, depth)
|
|
200
|
+
else:
|
|
201
|
+
raise TypeError(f"{type(value).__name__} is not a JSON value.")
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _write_object(value: Mapping[str, JsonValue], out: list[str], indent: int | None, depth: int) -> None:
|
|
205
|
+
items = list(value.items())
|
|
206
|
+
|
|
207
|
+
if not items:
|
|
208
|
+
out.append("{}")
|
|
209
|
+
return
|
|
210
|
+
|
|
211
|
+
inner = None if indent is None else indent + 1
|
|
212
|
+
out.append("{")
|
|
213
|
+
|
|
214
|
+
for index, (name, member) in enumerate(items):
|
|
215
|
+
if index:
|
|
216
|
+
out.append(",")
|
|
217
|
+
if inner is not None:
|
|
218
|
+
out.append("\n" + _INDENT * inner)
|
|
219
|
+
out.append(f'"{escape_string(str(name))}":')
|
|
220
|
+
if inner is not None:
|
|
221
|
+
out.append(" ")
|
|
222
|
+
_write(member, out, inner, depth + 1)
|
|
223
|
+
|
|
224
|
+
if indent is not None:
|
|
225
|
+
out.append("\n" + _INDENT * indent)
|
|
226
|
+
|
|
227
|
+
out.append("}")
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _write_array(value: Sequence[JsonValue], out: list[str], indent: int | None, depth: int) -> None:
|
|
231
|
+
items = list(value)
|
|
232
|
+
|
|
233
|
+
if not items:
|
|
234
|
+
out.append("[]")
|
|
235
|
+
return
|
|
236
|
+
|
|
237
|
+
inner = None if indent is None else indent + 1
|
|
238
|
+
out.append("[")
|
|
239
|
+
|
|
240
|
+
for index, member in enumerate(items):
|
|
241
|
+
if index:
|
|
242
|
+
out.append(",")
|
|
243
|
+
if inner is not None:
|
|
244
|
+
out.append("\n" + _INDENT * inner)
|
|
245
|
+
_write(member, out, inner, depth + 1)
|
|
246
|
+
|
|
247
|
+
if indent is not None:
|
|
248
|
+
out.append("\n" + _INDENT * indent)
|
|
249
|
+
|
|
250
|
+
out.append("]")
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def dumps_document(value: JsonValue) -> str:
|
|
254
|
+
"""Serialize *value* the way the platform writes files: 2-space indent, LF, no trailing newline."""
|
|
255
|
+
out: list[str] = []
|
|
256
|
+
_write(value, out, 0, 0)
|
|
257
|
+
return "".join(out)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def dumps_wire(value: JsonValue) -> str:
|
|
261
|
+
"""Serialize *value* the way the platform writes protocol and HTTP payloads: compact."""
|
|
262
|
+
out: list[str] = []
|
|
263
|
+
_write(value, out, None, 0)
|
|
264
|
+
return "".join(out)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def encode_wire(value: JsonValue) -> bytes:
|
|
268
|
+
"""UTF-8 bytes of :func:`dumps_wire`."""
|
|
269
|
+
return dumps_wire(value).encode("utf-8")
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def loads(payload: str | bytes | bytearray) -> JsonValue:
|
|
273
|
+
"""Parse JSON, keeping number literals intact and rejecting documents that are too deep.
|
|
274
|
+
|
|
275
|
+
A UTF-8 byte order mark is tolerated because editors on Windows add one, which is what
|
|
276
|
+
``ContractDocumentReader`` does too.
|
|
277
|
+
"""
|
|
278
|
+
text = bytes(payload).decode("utf-8-sig") if isinstance(payload, (bytes, bytearray)) else payload.lstrip(_BOM)
|
|
279
|
+
|
|
280
|
+
value = json.loads(text, parse_int=RawInt, parse_float=RawFloat)
|
|
281
|
+
_check_depth(value, 0)
|
|
282
|
+
return value
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _check_depth(value: JsonValue, depth: int) -> None:
|
|
286
|
+
if depth > MAXIMUM_DEPTH:
|
|
287
|
+
raise ValueError(f"The document is nested deeper than {MAXIMUM_DEPTH} levels.")
|
|
288
|
+
|
|
289
|
+
for child in _children(value):
|
|
290
|
+
_check_depth(child, depth + 1)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _children(value: JsonValue) -> Iterator[JsonValue]:
|
|
294
|
+
if isinstance(value, Mapping):
|
|
295
|
+
yield from value.values()
|
|
296
|
+
elif isinstance(value, list):
|
|
297
|
+
yield from value
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Run an agent definition: a model that calls tools until it has an answer.
|
|
2
|
+
|
|
3
|
+
Needs the ``agents`` extra (``velophex[agents]``). The loop is LangGraph, but nothing here
|
|
4
|
+
exposes it: a definition goes in (``agent-definition`` v1), a validated output comes out, and
|
|
5
|
+
every limit, tool call, approval and trace span goes through the platform's own layer.
|
|
6
|
+
|
|
7
|
+
>>> import velophex
|
|
8
|
+
>>> from velophex import agents
|
|
9
|
+
>>>
|
|
10
|
+
>>> @velophex.entrypoint(id="agent")
|
|
11
|
+
... def run(question: str) -> dict:
|
|
12
|
+
... return agents.run(DEFINITION, {"question": question})
|
|
13
|
+
|
|
14
|
+
Inside a job the model is called through the Orchestrator's LLM gateway with the job token,
|
|
15
|
+
tools reach the platform under the same token (process tools run in this job, through the
|
|
16
|
+
Robot), and each step is traced. Outside one (``velophex run``) ``VELOPHEX_AI_BASE_URL``,
|
|
17
|
+
``VELOPHEX_AI_API_KEY`` and optionally ``VELOPHEX_AI_MODEL`` name an OpenAI-compatible
|
|
18
|
+
endpoint, and nothing is traced.
|
|
19
|
+
|
|
20
|
+
Defaults: 12 model calls, 50 000 tokens and 15 minutes per run, temperature 0.2. A run that
|
|
21
|
+
reaches a limit fails with ``agent_limit_exceeded``; an answer that does not satisfy the output
|
|
22
|
+
schema gets one repair turn, then fails with ``agent_output_invalid``. A run that fails after a
|
|
23
|
+
tool call succeeded is never retried automatically.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import os
|
|
29
|
+
import time
|
|
30
|
+
import uuid
|
|
31
|
+
from collections.abc import Mapping
|
|
32
|
+
from datetime import UTC, datetime
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
from velophex.agents._errors import (
|
|
36
|
+
AgentDefinitionError,
|
|
37
|
+
AgentError,
|
|
38
|
+
AgentInputError,
|
|
39
|
+
AgentLimitExceededError,
|
|
40
|
+
AgentOutputInvalidError,
|
|
41
|
+
ToolOutcomeUnknownError,
|
|
42
|
+
)
|
|
43
|
+
from velophex.agents._loop import SUPPORTED_LANGGRAPH_MINOR, ChatEndpoint, run_definition
|
|
44
|
+
from velophex.agents._tools import APPROVAL_TIMEOUT_SECONDS, RuntimeTools, ToolClient, ToolOutcome, fence
|
|
45
|
+
from velophex.agents._tracing import NullTracer, RuntimeTracer, Tracer
|
|
46
|
+
from velophex.contracts.agents import AgentDefinition, AgentTool
|
|
47
|
+
from velophex.runtime import current_session, runtime_base_url
|
|
48
|
+
|
|
49
|
+
__all__ = [
|
|
50
|
+
"APPROVAL_TIMEOUT_SECONDS",
|
|
51
|
+
"SUPPORTED_LANGGRAPH_MINOR",
|
|
52
|
+
"AgentDefinition",
|
|
53
|
+
"AgentDefinitionError",
|
|
54
|
+
"AgentError",
|
|
55
|
+
"AgentInputError",
|
|
56
|
+
"AgentLimitExceededError",
|
|
57
|
+
"AgentOutputInvalidError",
|
|
58
|
+
"AgentTool",
|
|
59
|
+
"ChatEndpoint",
|
|
60
|
+
"NullTracer",
|
|
61
|
+
"RuntimeTools",
|
|
62
|
+
"RuntimeTracer",
|
|
63
|
+
"ToolClient",
|
|
64
|
+
"ToolOutcome",
|
|
65
|
+
"ToolOutcomeUnknownError",
|
|
66
|
+
"Tracer",
|
|
67
|
+
"fence",
|
|
68
|
+
"run",
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
#: Seconds kept back from the job's deadline, so the run ends on its own terms rather than killed.
|
|
72
|
+
_DEADLINE_MARGIN_SECONDS = 15
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def run(
|
|
76
|
+
definition: AgentDefinition | Mapping[str, Any],
|
|
77
|
+
inputs: Mapping[str, Any] | None = None,
|
|
78
|
+
*,
|
|
79
|
+
chat: ChatEndpoint | None = None,
|
|
80
|
+
tools: ToolClient | None = None,
|
|
81
|
+
tracer: Tracer | None = None,
|
|
82
|
+
) -> dict[str, Any]:
|
|
83
|
+
"""Run ``definition`` on ``inputs`` and return its validated output.
|
|
84
|
+
|
|
85
|
+
``chat``, ``tools`` and ``tracer`` default to the job's (the gateway, the Runtime API and
|
|
86
|
+
the Robot, the job's trace); a test passes the fakes in :mod:`velophex.agents.testing`.
|
|
87
|
+
"""
|
|
88
|
+
spec = definition if isinstance(definition, AgentDefinition) else AgentDefinition.model_validate(dict(definition))
|
|
89
|
+
session = current_session()
|
|
90
|
+
context = session.context if session is not None else None
|
|
91
|
+
in_job = context is not None and bool(context.orchestrator_url) and bool(context.runtime_token)
|
|
92
|
+
|
|
93
|
+
deadline = time.monotonic() + spec.guardrails.timeout_seconds
|
|
94
|
+
|
|
95
|
+
if context is not None and context.deadline is not None:
|
|
96
|
+
left = (context.deadline - datetime.now(UTC)).total_seconds() - _DEADLINE_MARGIN_SECONDS
|
|
97
|
+
deadline = min(deadline, time.monotonic() + left)
|
|
98
|
+
|
|
99
|
+
if tracer is None:
|
|
100
|
+
tracer = RuntimeTracer(session.client()) if in_job and session is not None else NullTracer()
|
|
101
|
+
|
|
102
|
+
return run_definition(
|
|
103
|
+
spec,
|
|
104
|
+
dict(inputs or {}),
|
|
105
|
+
chat=chat or _chat_endpoint(),
|
|
106
|
+
tools=tools or RuntimeTools(),
|
|
107
|
+
tracer=tracer,
|
|
108
|
+
run_id=str(context.job_id) if context is not None and context.job_id else uuid.uuid4().hex,
|
|
109
|
+
deadline=deadline,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _chat_endpoint() -> ChatEndpoint:
|
|
114
|
+
"""The job's LLM gateway, or the local endpoint the environment names."""
|
|
115
|
+
session = current_session()
|
|
116
|
+
context = session.context if session is not None else None
|
|
117
|
+
|
|
118
|
+
if context is not None and context.orchestrator_url and context.runtime_token:
|
|
119
|
+
headers = {"traceparent": context.trace_parent} if context.trace_parent else {}
|
|
120
|
+
return ChatEndpoint(runtime_base_url(context.orchestrator_url) + "/ai", context.runtime_token, headers=headers)
|
|
121
|
+
|
|
122
|
+
base_url = os.environ.get("VELOPHEX_AI_BASE_URL")
|
|
123
|
+
|
|
124
|
+
if not base_url:
|
|
125
|
+
raise AgentError(
|
|
126
|
+
"There is no model to call: run the agent in a job, or set VELOPHEX_AI_BASE_URL "
|
|
127
|
+
"(and VELOPHEX_AI_API_KEY) to an OpenAI-compatible endpoint for a local run."
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
return ChatEndpoint(base_url, os.environ.get("VELOPHEX_AI_API_KEY") or "none", os.environ.get("VELOPHEX_AI_MODEL"))
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""The errors an agent run ends with, each reported under its own fault code."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from velophex.contracts.executor import FAULT_ENVELOPE_VERSION, FaultInfo, FaultRetryScopes
|
|
6
|
+
from velophex.contracts.runtimes import FaultCodes, FaultKinds
|
|
7
|
+
from velophex.errors import VelophexError
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"AgentDefinitionError",
|
|
11
|
+
"AgentError",
|
|
12
|
+
"AgentInputError",
|
|
13
|
+
"AgentLimitExceededError",
|
|
14
|
+
"AgentOutputInvalidError",
|
|
15
|
+
"ToolOutcomeUnknownError",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AgentError(VelophexError):
|
|
20
|
+
"""Base of the errors an agent run raises itself. Never retried automatically."""
|
|
21
|
+
|
|
22
|
+
code: str = FaultCodes.UNHANDLED_EXCEPTION
|
|
23
|
+
fix: str | None = None
|
|
24
|
+
retry_scope: str = FaultRetryScopes.RUN
|
|
25
|
+
|
|
26
|
+
def to_fault(self) -> FaultInfo:
|
|
27
|
+
"""Reported under :attr:`code`, as an ``Application`` fault that is not retried."""
|
|
28
|
+
return FaultInfo(
|
|
29
|
+
kind=FaultKinds.APPLICATION,
|
|
30
|
+
code=self.code,
|
|
31
|
+
message=str(self),
|
|
32
|
+
retryable=False,
|
|
33
|
+
version=FAULT_ENVELOPE_VERSION,
|
|
34
|
+
fix=self.fix,
|
|
35
|
+
retry_scope=self.retry_scope,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class AgentDefinitionError(AgentError):
|
|
40
|
+
"""The definition cannot run on this SDK (a member version 1 does not have, a duplicate tool)."""
|
|
41
|
+
|
|
42
|
+
code = FaultCodes.INVALID_PACKAGE
|
|
43
|
+
fix = "Correct the agent definition and create a new candidate."
|
|
44
|
+
retry_scope = FaultRetryScopes.NONE
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class AgentInputError(AgentError):
|
|
48
|
+
"""The run's inputs do not satisfy the definition's input schema."""
|
|
49
|
+
|
|
50
|
+
code = FaultCodes.INVALID_INPUT
|
|
51
|
+
fix = "Start the run with inputs that match the agent's input schema."
|
|
52
|
+
retry_scope = FaultRetryScopes.NONE
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class AgentOutputInvalidError(AgentError):
|
|
56
|
+
"""The answer did not satisfy the output schema, after one repair turn."""
|
|
57
|
+
|
|
58
|
+
code = FaultCodes.AGENT_OUTPUT_INVALID
|
|
59
|
+
fix = "Make the instructions or the output schema clearer, then run again."
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class AgentLimitExceededError(AgentError):
|
|
63
|
+
"""The run reached a guardrail: its steps, its tokens or its timeout."""
|
|
64
|
+
|
|
65
|
+
code = FaultCodes.AGENT_LIMIT_EXCEEDED
|
|
66
|
+
fix = "Raise the agent's limits, or narrow its task, then run again."
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class ToolOutcomeUnknownError(AgentError):
|
|
70
|
+
"""A tool call ended without a known outcome. It is reconciled by a person, never replayed."""
|
|
71
|
+
|
|
72
|
+
code = FaultCodes.TOOL_OUTCOME_UNKNOWN
|
|
73
|
+
fix = "Check the system the tool writes to before running the agent again."
|
|
74
|
+
retry_scope = FaultRetryScopes.NONE
|
|
75
|
+
effects_committed = True
|