velophex 0.2.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.
- velophex-0.2.0/.gitignore +7 -0
- velophex-0.2.0/LICENSE +21 -0
- velophex-0.2.0/PKG-INFO +313 -0
- velophex-0.2.0/README.md +285 -0
- velophex-0.2.0/agent-runner/README.md +30 -0
- velophex-0.2.0/pyproject.toml +112 -0
- velophex-0.2.0/src/velophex/__init__.py +38 -0
- velophex-0.2.0/src/velophex/_json.py +297 -0
- velophex-0.2.0/src/velophex/agents/__init__.py +130 -0
- velophex-0.2.0/src/velophex/agents/_errors.py +75 -0
- velophex-0.2.0/src/velophex/agents/_loop.py +436 -0
- velophex-0.2.0/src/velophex/agents/_tools.py +168 -0
- velophex-0.2.0/src/velophex/agents/_tracing.py +87 -0
- velophex-0.2.0/src/velophex/agents/testing.py +249 -0
- velophex-0.2.0/src/velophex/cli/__init__.py +5 -0
- velophex-0.2.0/src/velophex/cli/main.py +548 -0
- velophex-0.2.0/src/velophex/cli/publish.py +152 -0
- velophex-0.2.0/src/velophex/cli/sync.py +90 -0
- velophex-0.2.0/src/velophex/cli/templates.py +126 -0
- velophex-0.2.0/src/velophex/contracts/__init__.py +103 -0
- velophex-0.2.0/src/velophex/contracts/_model.py +26 -0
- velophex-0.2.0/src/velophex/contracts/agents.py +110 -0
- velophex-0.2.0/src/velophex/contracts/diagnostics.py +157 -0
- velophex-0.2.0/src/velophex/contracts/executor.py +1921 -0
- velophex-0.2.0/src/velophex/contracts/package.py +391 -0
- velophex-0.2.0/src/velophex/contracts/project.py +1008 -0
- velophex-0.2.0/src/velophex/contracts/runtimes.py +226 -0
- velophex-0.2.0/src/velophex/contracts/signin.py +53 -0
- velophex-0.2.0/src/velophex/contracts/validation.py +158 -0
- velophex-0.2.0/src/velophex/entrypoints.py +329 -0
- velophex-0.2.0/src/velophex/errors.py +44 -0
- velophex-0.2.0/src/velophex/packaging.py +465 -0
- velophex-0.2.0/src/velophex/py.typed +0 -0
- velophex-0.2.0/src/velophex/runtime/__init__.py +471 -0
- velophex-0.2.0/src/velophex/runtime/_session.py +192 -0
- velophex-0.2.0/src/velophex/runtime/client.py +957 -0
- velophex-0.2.0/src/velophex/runtime/models.py +326 -0
- velophex-0.2.0/src/velophex/signing.py +425 -0
- velophex-0.2.0/src/velophex/worker/__init__.py +27 -0
- velophex-0.2.0/src/velophex/worker/__main__.py +118 -0
- velophex-0.2.0/src/velophex/worker/framing.py +248 -0
- velophex-0.2.0/src/velophex/worker/loader.py +105 -0
- velophex-0.2.0/src/velophex/worker/outbox.py +147 -0
- velophex-0.2.0/src/velophex/worker/session.py +519 -0
velophex-0.2.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) VeloPhex
|
|
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.
|
velophex-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: velophex
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: VeloPhex Python SDK: author, run, pack and publish Python automations for the VeloPhex platform.
|
|
5
|
+
Project-URL: Homepage, https://velophex.com
|
|
6
|
+
Author: VeloPhex
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Typing :: Typed
|
|
16
|
+
Requires-Python: ==3.12.*
|
|
17
|
+
Requires-Dist: httpx<0.29,>=0.27
|
|
18
|
+
Requires-Dist: pydantic<3,>=2.9
|
|
19
|
+
Requires-Dist: typer<0.16,>=0.12
|
|
20
|
+
Provides-Extra: agents
|
|
21
|
+
Requires-Dist: jsonschema<5,>=4.23; extra == 'agents'
|
|
22
|
+
Requires-Dist: langchain-core<2,>=1.6.6; extra == 'agents'
|
|
23
|
+
Requires-Dist: langchain-openai<2,>=1.6.7; extra == 'agents'
|
|
24
|
+
Requires-Dist: langgraph<1.3,>=1.2.12; extra == 'agents'
|
|
25
|
+
Provides-Extra: signing
|
|
26
|
+
Requires-Dist: cryptography<47,>=43; extra == 'signing'
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# VeloPhex.Python — the `velophex` SDK
|
|
30
|
+
|
|
31
|
+
The Python half of the VeloPhex hybrid platform: the library a Python automation is written
|
|
32
|
+
against, the worker the Runner executes it with, and the CLI that packs and publishes it.
|
|
33
|
+
|
|
34
|
+
A Python automation is an ordinary [uv](https://docs.astral.sh/uv/) project with a
|
|
35
|
+
`project.json` and one or more functions marked `@velophex.entrypoint`. `velophex pack` turns
|
|
36
|
+
it into the same signed `.nupkg` a Studio workflow produces, so the Orchestrator stores, places
|
|
37
|
+
and starts both the same way.
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
import velophex
|
|
41
|
+
from velophex import runtime
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@velophex.entrypoint(id="main", display_name="Total an invoice")
|
|
45
|
+
def run(pdf_path: str, pages: int = 1) -> dict[str, float]:
|
|
46
|
+
"""Total one invoice."""
|
|
47
|
+
if not pdf_path.endswith(".pdf"):
|
|
48
|
+
raise velophex.BusinessError("Only PDF invoices are accepted.", code="INVOICE_REJECTED")
|
|
49
|
+
|
|
50
|
+
api_key = runtime.secret("ErpApiKey")
|
|
51
|
+
return {"total": 42.5}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Files go through Orchestrator storage buckets, the twins of the workflow storage activities:
|
|
55
|
+
`runtime.upload_file`, `download_file`, `list_files` (prefix, `*.pdf;*.xlsx` patterns, recursive),
|
|
56
|
+
`delete_file`, `read_text` and `write_text`. A bucket is named; `default` always exists.
|
|
57
|
+
|
|
58
|
+
## Verifying this component
|
|
59
|
+
|
|
60
|
+
```powershell
|
|
61
|
+
cd VeloPhex.Python
|
|
62
|
+
uv sync # creates .venv and writes uv.lock (Python 3.12)
|
|
63
|
+
uv run pytest # the whole suite
|
|
64
|
+
uv run ruff check # lint
|
|
65
|
+
uv run ruff format --check # formatting
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`uv` picks the interpreter from `requires-python` (`>=3.12,<3.13`, ADR-P008). The contract
|
|
69
|
+
conformance tests read `VeloPhex.Platform.Contracts` from the sibling folder; if the components
|
|
70
|
+
are checked out apart, point at it:
|
|
71
|
+
|
|
72
|
+
```powershell
|
|
73
|
+
$env:VELOPHEX_CONTRACTS_DIR = "<path>\VeloPhex.Platform.Contracts"
|
|
74
|
+
uv run pytest
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Tests that need it skip with a message rather than failing.
|
|
78
|
+
|
|
79
|
+
## What is where
|
|
80
|
+
|
|
81
|
+
| Path | What it is |
|
|
82
|
+
|---|---|
|
|
83
|
+
| `src/velophex/_json.py` | JSON that is byte-identical to `PlatformJson` in .NET. The escaping, the layout and the number literals are all part of the contract |
|
|
84
|
+
| `src/velophex/entrypoints.py` | `@entrypoint`; derives input/output JSON Schema from type hints with pydantic `TypeAdapter` |
|
|
85
|
+
| `src/velophex/errors.py` | `BusinessError` (a `Business` fault, never retried) and `VelophexError` |
|
|
86
|
+
| `src/velophex/contracts/` | `project.json`, package manifest v2, executor protocol messages, runtime kinds, the lexical rules — mirrors `VeloPhex.Platform.Contracts` |
|
|
87
|
+
| `src/velophex/worker/` | `python -m velophex.worker`: framing, the session state machine, the `logging` bridge and the handler loader |
|
|
88
|
+
| `src/velophex/runtime/` | `stop_requested()`, `context()` and the httpx client for `/api/v1/runtime/*` |
|
|
89
|
+
| `src/velophex/packaging.py` | The deterministic `.nupkg` builder, the twin of the engine's `AutomationPackageBuilder` |
|
|
90
|
+
| `src/velophex/cli/` | `velophex init \| run \| pack \| publish` (typer) |
|
|
91
|
+
| `src/velophex/agents/` | `velophex.agents.run`: the agent tool-calling loop (extra `velophex[agents]`); `testing.py` has the fake gateway and fake tools |
|
|
92
|
+
| `agent-runner/` | The `Agent.Runner` package every agent runs; see its README for how CI builds and signs it |
|
|
93
|
+
| `tests/data/sample_project/` | A Python project whose `project.json` was written by the **.NET** serializer |
|
|
94
|
+
| `tests/data/golden/` | The package the **.NET** `AutomationPackageBuilder` produced from that project |
|
|
95
|
+
|
|
96
|
+
## Quickstart: your first local run
|
|
97
|
+
|
|
98
|
+
The SDK is not on a public package index yet, so build its wheel once and point uv at it. Then
|
|
99
|
+
the steps after `init` are exactly what `velophex init` prints. This transcript was run in an
|
|
100
|
+
empty folder outside the repository with an empty uv cache, in Windows PowerShell 5.1:
|
|
101
|
+
|
|
102
|
+
```powershell
|
|
103
|
+
<checkout>\VeloPhex.Python\build\Build.ps1 # -> VeloPhex.Python\artifacts\dist
|
|
104
|
+
$env:UV_FIND_LINKS = "<checkout>\VeloPhex.Python\artifacts\dist"
|
|
105
|
+
|
|
106
|
+
uvx --python 3.12 --from velophex velophex init Hello.World
|
|
107
|
+
cd Hello.World
|
|
108
|
+
uv sync
|
|
109
|
+
echo '{"message": "Hi"}' > inputs.json
|
|
110
|
+
uv run velophex run main --inputs-file inputs.json
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
The last command logs `INFO hello_world.main: Hi` and prints `{"message": "Hi"}`. The
|
|
114
|
+
generated handler, `src/hello_world/main.py`, takes one input, `message`; `uv sync` writes
|
|
115
|
+
`uv.lock`, which every package must carry. `--inputs '{"message": "Hi"}'` works too where the
|
|
116
|
+
shell passes the quotes through (PowerShell 7.3+, bash); Windows PowerShell 5.1 strips them,
|
|
117
|
+
which is why the transcript uses a file. Exit codes: `0` success, `1` failed, `2` unusable
|
|
118
|
+
inputs, `3` a business rule; `--verbose` adds the traceback to an input error.
|
|
119
|
+
|
|
120
|
+
`init` found the SDK wheel through `UV_FIND_LINKS` and copied it into `wheels/`, so the same
|
|
121
|
+
project, packed and published as below, also restores and runs on a Robot with the Python
|
|
122
|
+
runtime - see "How a Robot gets the SDK".
|
|
123
|
+
|
|
124
|
+
## Publishing to an Orchestrator
|
|
125
|
+
|
|
126
|
+
From the project folder, with a personal access token that has `packages.create`:
|
|
127
|
+
|
|
128
|
+
```powershell
|
|
129
|
+
uv run velophex pack # -> artifacts\Hello.World.1.0.0.nupkg
|
|
130
|
+
$env:VELOPHEX_API_KEY = "vpx_pat_..." # or --api-key-stdin, or be prompted on a terminal
|
|
131
|
+
uv run velophex publish artifacts\Hello.World.1.0.0.nupkg --orchestrator https://orchestrator.example.com --tenant <tenantId>
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### How a Robot gets the SDK
|
|
135
|
+
|
|
136
|
+
A Robot restores the package's environment with `uv sync --frozen`, which installs from exactly
|
|
137
|
+
the sources `uv.lock` records and ignores any index or `--find-links` given at sync time. A lock
|
|
138
|
+
made against a wheel *folder* records that folder's absolute path, and it only restores where
|
|
139
|
+
the folder exists. So `init` **vendors** the SDK instead: it copies this SDK's own wheel into
|
|
140
|
+
the project's `wheels/` folder (from `--sdk-wheel`, or found in the `UV_FIND_LINKS` folders) and
|
|
141
|
+
writes
|
|
142
|
+
|
|
143
|
+
```toml
|
|
144
|
+
[tool.uv.sources]
|
|
145
|
+
velophex = { path = "wheels/velophex-<version>-py3-none-any.whl" }
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
`uv.lock` then records the wheel by a path relative to the project and by its SHA-256, and
|
|
149
|
+
`velophex pack` carries `wheels/` in the package like any other project file (hashed into the
|
|
150
|
+
manifest and signed). The Robot's `uv sync --frozen` restores the SDK from the unpacked package
|
|
151
|
+
itself, with no index and no Robot configuration, and it never asks any index for a package
|
|
152
|
+
named `velophex` - which is what closes the dependency-confusion gap on the unclaimed PyPI name
|
|
153
|
+
(PY-02). The SDK's own dependencies (pydantic, httpx, typer) still come from the Robot's index
|
|
154
|
+
(`Python:IndexUrl`, PyPI by default) or its wheelhouse (`Python:WheelhousePath`).
|
|
155
|
+
|
|
156
|
+
Commit `wheels/` with the project. To move a project to a newer SDK, replace the wheel, update
|
|
157
|
+
the path in `pyproject.toml` and run `uv lock`. When `init` finds no wheel it falls back to a
|
|
158
|
+
plain `velophex` dependency and warns that a Robot cannot restore the project until the SDK is
|
|
159
|
+
on an index it can reach.
|
|
160
|
+
|
|
161
|
+
Why not ship the wheel with the Robot instead: under `--frozen` the lock's recorded path wins,
|
|
162
|
+
so a wheel in the Robot's payload would only be used if every developer machine had it at the
|
|
163
|
+
same absolute path. Verified with uv 0.12.15; vendoring needs no Robot change at all.
|
|
164
|
+
|
|
165
|
+
## Building and publishing the SDK
|
|
166
|
+
|
|
167
|
+
```powershell
|
|
168
|
+
build\Build.ps1 # wheel + sdist into artifacts\dist, built twice and compared byte for byte, plus SHA256SUMS
|
|
169
|
+
build\Publish.ps1 # dry run: checks SHA256SUMS and runs `uv publish --dry-run`; uploads nothing
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
The version has one source, `__version__` in `src/velophex/__init__.py`; hatchling reads it.
|
|
173
|
+
Publishing is an outward action that needs the owner's approval: `Publish.ps1 -Publish`
|
|
174
|
+
uploads (`velophex` is on PyPI since 0.2.0), and refuses a public index if `pyproject.toml`
|
|
175
|
+
carries the `Private :: Do Not Upload` classifier (PyPI rejects that classifier too). Credentials come
|
|
176
|
+
from `UV_PUBLISH_TOKEN` or trusted publishing, never from a parameter.
|
|
177
|
+
|
|
178
|
+
The token is never a command-line option (it would land in shell history and the process
|
|
179
|
+
list); `--api-key` is refused with an error that says where to put it instead.
|
|
180
|
+
|
|
181
|
+
`pack` refreshes `project.json` from the decorated handlers before it builds (`--no-sync` to
|
|
182
|
+
skip), requires `uv.lock`, writes the manifest with the SHA-512 of every content file, and
|
|
183
|
+
computes `requiredRuntimes` as this project's runtime plus every `automationDependencies`
|
|
184
|
+
runtime — which is what the Orchestrator matches against a runner's reported runtimes.
|
|
185
|
+
|
|
186
|
+
`publish` is a `PUT {publicBaseUrl}/api/v1/tenants/{tenantId}/feed/v3/package` with the
|
|
187
|
+
package as multipart and `X-NuGet-ApiKey: vpx_pat_…`. A `409` is success only when the
|
|
188
|
+
feed answers `package_binary_duplicate` with a `sha256` equal to the local package's: these
|
|
189
|
+
exact bytes are already published. Any other `409` - different bytes under a published version -
|
|
190
|
+
fails, naming the package and version: a published version never changes, so increment the
|
|
191
|
+
version in `project.json` and pack again. Without a path, `publish` takes the one package in
|
|
192
|
+
`--output` (`artifacts`) and refuses to guess when there are several. The URL and tenant may come from
|
|
193
|
+
`VELOPHEX_ORCHESTRATOR_URL` and `VELOPHEX_TENANT_ID`; the key comes only from
|
|
194
|
+
`VELOPHEX_API_KEY`, stdin (`--api-key-stdin`) or a hidden prompt.
|
|
195
|
+
|
|
196
|
+
## How the worker runs an automation
|
|
197
|
+
|
|
198
|
+
The Runner prepares the environment with `uv sync --frozen --no-dev` and then starts
|
|
199
|
+
`<env>\Scripts\python.exe -m velophex.worker` (ADR-P008). Parent and worker speak the executor
|
|
200
|
+
protocol (ADR-P005) over the process's stdin and stdout:
|
|
201
|
+
|
|
202
|
+
1. The worker takes the session key out of `VELOPHEX_EXECUTOR_SESSION_KEY` and **removes the
|
|
203
|
+
variable**, before any automation code is imported. It ignores `VELOPHEX_EXECUTOR_FEED_CREDENTIALS`
|
|
204
|
+
(executor protocol §1): a Python run restores no NuGet packages, and the Runner never sets it for one.
|
|
205
|
+
2. The frame pipe moves off standard output to a private, non-inheritable descriptor, and fd 1,
|
|
206
|
+
the Win32 standard output handle and `sys.stdout` all point at `stderr`. A `print()`, a child
|
|
207
|
+
process (`os.system`, `subprocess`) or a `WriteFile` on the standard handle therefore cannot
|
|
208
|
+
corrupt the frame stream, and a detached grandchild cannot keep the pipe open after the worker exits.
|
|
209
|
+
The parent's pipe moves off standard input the same way, and fd 0, the Win32 standard input
|
|
210
|
+
handle and `sys.stdin` become the null device: an automation or a child process that reads
|
|
211
|
+
standard input gets end-of-file instead of consuming the parent's frames.
|
|
212
|
+
3. `hello` → `hello-ack` (runtime `python`, no capabilities: the environment is already
|
|
213
|
+
prepared, and there is no Python debugger over this protocol yet).
|
|
214
|
+
4. `start` names a package root and an entry point id; `project.json` turns the id into
|
|
215
|
+
`module:function`, the module is imported and the inputs are validated against the
|
|
216
|
+
handler's signature before it is called.
|
|
217
|
+
5. While it runs, `logging` records become `log` messages and the parent's `stop` sets
|
|
218
|
+
`velophex.runtime.stop_requested()`.
|
|
219
|
+
6. Exactly one terminal message: `result`, `canceled` or `fault`.
|
|
220
|
+
|
|
221
|
+
Fault mapping, which decides retries:
|
|
222
|
+
|
|
223
|
+
| Raised | Fault |
|
|
224
|
+
|---|---|
|
|
225
|
+
| `velophex.BusinessError` | `Business` / the error's `code`, never retried |
|
|
226
|
+
| inputs that do not fit the handler | `Application` / `INVALID_INPUT`, not retried |
|
|
227
|
+
| a package or handler that will not load | `Application` / `INVALID_PACKAGE` |
|
|
228
|
+
| anything else | `Application` / `UNHANDLED_EXCEPTION` |
|
|
229
|
+
| a return value with no JSON form (`datetime`, a set, NaN) | `Application` / `RESULT_NOT_SERIALIZABLE`, not retried |
|
|
230
|
+
| outputs over the frame limit (16 MiB) | `Application` / `RESULT_TOO_LARGE`, not retried |
|
|
231
|
+
|
|
232
|
+
The last two are checked before the terminal frame is written, so the parent always gets a
|
|
233
|
+
terminal message rather than a crash it would have to report (and retry) as
|
|
234
|
+
`EXECUTOR_CRASHED`. Both are entries in the shared `FaultCodes` table (Contracts 0.1.0-preview.4), so the workflow executor uses the same spellings.
|
|
235
|
+
|
|
236
|
+
A stop is cooperative: the flag is set and the handler is expected to notice. When the grace
|
|
237
|
+
period passes, an `async` handler's task is cancelled (it unwinds through `finally`); a
|
|
238
|
+
synchronous handler cannot be interrupted safely from outside, so the parent's kill is the
|
|
239
|
+
answer, and the worker logs a warning saying so. A handler that returns *after* a stop was
|
|
240
|
+
asked for ends the run as `canceled`, not `result`.
|
|
241
|
+
|
|
242
|
+
## What "the same package as .NET" means
|
|
243
|
+
|
|
244
|
+
`tests/test_pack_parity.py` compares what `velophex pack` builds against
|
|
245
|
+
`tests/data/golden/Sample.Automation.1.2.0.nupkg`, which the engine's
|
|
246
|
+
`AutomationPackageBuilder` produced from `tests/data/sample_project`. Parity is asserted on
|
|
247
|
+
the archive's *content*: the same entry names in the same order, the same fixed
|
|
248
|
+
`2000-01-01T00:00:00` timestamp, and the same bytes in every entry — `content/project.json`,
|
|
249
|
+
`velophex/package-manifest.json`, the `.nuspec` and `[Content_Types].xml`.
|
|
250
|
+
|
|
251
|
+
The *compressed* bytes are deliberately not compared. A .NET `ZipArchive` and Python's `zlib`
|
|
252
|
+
do not emit the same deflate stream, and nothing in the platform depends on it: what is hashed
|
|
253
|
+
and signed is the manifest and the files the manifest lists, never the archive's compression.
|
|
254
|
+
|
|
255
|
+
Regenerate the golden only for an intentional packaging change, by running the .NET builder
|
|
256
|
+
over the same fixture directory.
|
|
257
|
+
|
|
258
|
+
## The JSON, and why it is not `json.dumps`
|
|
259
|
+
|
|
260
|
+
The package manifest carries the SHA-512 of `content/project.json`, so a manifest written here
|
|
261
|
+
and a manifest written by Studio only verify against each other when both writers agree byte
|
|
262
|
+
for byte. `System.Text.Json` and Python's `json` do not agree, in three ways that all matter
|
|
263
|
+
and are all pinned by tests:
|
|
264
|
+
|
|
265
|
+
* The default .NET encoder escapes `"`, `&`, `'`, `+`, `<`, `>`, `` ` `` and everything
|
|
266
|
+
outside printable ASCII as `\uXXXX` with **upper-case** hex. So `">=3.12,<3.13"` is written
|
|
267
|
+
`">=3.12,<3.13"`.
|
|
268
|
+
* A `JsonElement` — an entry point's JSON Schema, say — is re-emitted with its **original
|
|
269
|
+
number literals**: `1.0` stays `1.0`, `1e2` stays `1e2`.
|
|
270
|
+
* A `DateTimeOffset` goes through a typed fast path that never reaches the encoder, so the
|
|
271
|
+
`+` of its offset stays a `+` where the same text in a plain string property would become
|
|
272
|
+
`+`. The executor frame vectors pin exactly this.
|
|
273
|
+
|
|
274
|
+
## Agents: `velophex[agents]`
|
|
275
|
+
|
|
276
|
+
`velophex.agents.run(definition, inputs)` runs an agent definition (`agent-definition` v1,
|
|
277
|
+
`VeloPhex.Platform.Contracts/schemas/agent-definition.v1.schema.json`) and returns its validated
|
|
278
|
+
output. The extra brings LangGraph, `langchain-openai` and `jsonschema` from PyPI; LangGraph is
|
|
279
|
+
pinned to one minor and only `velophex/agents/_loop.py` imports it.
|
|
280
|
+
|
|
281
|
+
* **Model calls** go to the Orchestrator's LLM gateway, `POST <runtime>/ai/chat/completions`, in
|
|
282
|
+
the OpenAI chat completions shape, non-streaming, with the job token and the agent's binding
|
|
283
|
+
(`agent:default`) as the model. Outside a job, `VELOPHEX_AI_BASE_URL`, `VELOPHEX_AI_API_KEY`
|
|
284
|
+
and `VELOPHEX_AI_MODEL` name any OpenAI-compatible endpoint; nothing is traced then.
|
|
285
|
+
* **Tools:** `process` runs a published automation **in this job** (`velophex.runtime.invoke_automation`,
|
|
286
|
+
executor `sub-invoke`: same machine, same lease, no extra robot slot); `mcp`, `http`,
|
|
287
|
+
`vector_search` and `action_task` go through the Runtime API. Every call carries an
|
|
288
|
+
idempotency key, and tool and retrieved content reaches the model fenced as untrusted data.
|
|
289
|
+
* **Approvals** (`approval: before_call`) are bounded waits in the job: at most 900 s, and never
|
|
290
|
+
past the run's own timeout. A rejected or expired approval means the call is not made.
|
|
291
|
+
* **Limits:** 12 model calls, 50 000 tokens and 15 minutes per run, temperature 0.2, unless the
|
|
292
|
+
definition says otherwise. A limit ends the run with `agent_limit_exceeded`; an answer that
|
|
293
|
+
fails the output schema gets one repair turn, then `agent_output_invalid`.
|
|
294
|
+
* **Faults keep their code.** A gateway refusal raises `AiBudgetExhaustedError`,
|
|
295
|
+
`AiPolicyDeniedError`, `AiProviderUnavailableError` or `AiRequestInvalidError` (all
|
|
296
|
+
`RuntimeApiError`), and the worker reports it under that code with the error envelope (cause,
|
|
297
|
+
fix, request id, retry scope, docs URL), not as `UNHANDLED_EXCEPTION`. A run that fails after a
|
|
298
|
+
tool call succeeded reports `effectsCommitted` and is never retried automatically; an unknown
|
|
299
|
+
tool outcome is `tool_outcome_unknown`, reconciled by a person.
|
|
300
|
+
* **Testing:** `velophex.agents.testing.FakeChatServer` (a scripted gateway on 127.0.0.1),
|
|
301
|
+
`FakeTools` and `RecordingTracer`; `tests/test_agents.py` shows the patterns.
|
|
302
|
+
|
|
303
|
+
`velophex run` and the worker shape outputs with the same rule (`normalize_outputs`): `None` is
|
|
304
|
+
`{}`, a scalar or list is `{"value": ...}`, and a value with no JSON form is
|
|
305
|
+
`RESULT_NOT_SERIALIZABLE` in both.
|
|
306
|
+
|
|
307
|
+
## Contract changes
|
|
308
|
+
|
|
309
|
+
`project.json`, the package manifest, the runner protocol and the executor protocol are
|
|
310
|
+
platform contracts. They change only through the `velophex-contract-change` skill, which
|
|
311
|
+
updates the engine, Studio, the Activities, the Orchestrator, the Runner and this SDK together.
|
|
312
|
+
Nothing in `src/velophex/contracts/` may drift on its own; the conformance tests against
|
|
313
|
+
`VeloPhex.Platform.Contracts/schemas/` are there to make drift fail loudly.
|
velophex-0.2.0/README.md
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
# VeloPhex.Python — the `velophex` SDK
|
|
2
|
+
|
|
3
|
+
The Python half of the VeloPhex hybrid platform: the library a Python automation is written
|
|
4
|
+
against, the worker the Runner executes it with, and the CLI that packs and publishes it.
|
|
5
|
+
|
|
6
|
+
A Python automation is an ordinary [uv](https://docs.astral.sh/uv/) project with a
|
|
7
|
+
`project.json` and one or more functions marked `@velophex.entrypoint`. `velophex pack` turns
|
|
8
|
+
it into the same signed `.nupkg` a Studio workflow produces, so the Orchestrator stores, places
|
|
9
|
+
and starts both the same way.
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
import velophex
|
|
13
|
+
from velophex import runtime
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@velophex.entrypoint(id="main", display_name="Total an invoice")
|
|
17
|
+
def run(pdf_path: str, pages: int = 1) -> dict[str, float]:
|
|
18
|
+
"""Total one invoice."""
|
|
19
|
+
if not pdf_path.endswith(".pdf"):
|
|
20
|
+
raise velophex.BusinessError("Only PDF invoices are accepted.", code="INVOICE_REJECTED")
|
|
21
|
+
|
|
22
|
+
api_key = runtime.secret("ErpApiKey")
|
|
23
|
+
return {"total": 42.5}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Files go through Orchestrator storage buckets, the twins of the workflow storage activities:
|
|
27
|
+
`runtime.upload_file`, `download_file`, `list_files` (prefix, `*.pdf;*.xlsx` patterns, recursive),
|
|
28
|
+
`delete_file`, `read_text` and `write_text`. A bucket is named; `default` always exists.
|
|
29
|
+
|
|
30
|
+
## Verifying this component
|
|
31
|
+
|
|
32
|
+
```powershell
|
|
33
|
+
cd VeloPhex.Python
|
|
34
|
+
uv sync # creates .venv and writes uv.lock (Python 3.12)
|
|
35
|
+
uv run pytest # the whole suite
|
|
36
|
+
uv run ruff check # lint
|
|
37
|
+
uv run ruff format --check # formatting
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
`uv` picks the interpreter from `requires-python` (`>=3.12,<3.13`, ADR-P008). The contract
|
|
41
|
+
conformance tests read `VeloPhex.Platform.Contracts` from the sibling folder; if the components
|
|
42
|
+
are checked out apart, point at it:
|
|
43
|
+
|
|
44
|
+
```powershell
|
|
45
|
+
$env:VELOPHEX_CONTRACTS_DIR = "<path>\VeloPhex.Platform.Contracts"
|
|
46
|
+
uv run pytest
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Tests that need it skip with a message rather than failing.
|
|
50
|
+
|
|
51
|
+
## What is where
|
|
52
|
+
|
|
53
|
+
| Path | What it is |
|
|
54
|
+
|---|---|
|
|
55
|
+
| `src/velophex/_json.py` | JSON that is byte-identical to `PlatformJson` in .NET. The escaping, the layout and the number literals are all part of the contract |
|
|
56
|
+
| `src/velophex/entrypoints.py` | `@entrypoint`; derives input/output JSON Schema from type hints with pydantic `TypeAdapter` |
|
|
57
|
+
| `src/velophex/errors.py` | `BusinessError` (a `Business` fault, never retried) and `VelophexError` |
|
|
58
|
+
| `src/velophex/contracts/` | `project.json`, package manifest v2, executor protocol messages, runtime kinds, the lexical rules — mirrors `VeloPhex.Platform.Contracts` |
|
|
59
|
+
| `src/velophex/worker/` | `python -m velophex.worker`: framing, the session state machine, the `logging` bridge and the handler loader |
|
|
60
|
+
| `src/velophex/runtime/` | `stop_requested()`, `context()` and the httpx client for `/api/v1/runtime/*` |
|
|
61
|
+
| `src/velophex/packaging.py` | The deterministic `.nupkg` builder, the twin of the engine's `AutomationPackageBuilder` |
|
|
62
|
+
| `src/velophex/cli/` | `velophex init \| run \| pack \| publish` (typer) |
|
|
63
|
+
| `src/velophex/agents/` | `velophex.agents.run`: the agent tool-calling loop (extra `velophex[agents]`); `testing.py` has the fake gateway and fake tools |
|
|
64
|
+
| `agent-runner/` | The `Agent.Runner` package every agent runs; see its README for how CI builds and signs it |
|
|
65
|
+
| `tests/data/sample_project/` | A Python project whose `project.json` was written by the **.NET** serializer |
|
|
66
|
+
| `tests/data/golden/` | The package the **.NET** `AutomationPackageBuilder` produced from that project |
|
|
67
|
+
|
|
68
|
+
## Quickstart: your first local run
|
|
69
|
+
|
|
70
|
+
The SDK is not on a public package index yet, so build its wheel once and point uv at it. Then
|
|
71
|
+
the steps after `init` are exactly what `velophex init` prints. This transcript was run in an
|
|
72
|
+
empty folder outside the repository with an empty uv cache, in Windows PowerShell 5.1:
|
|
73
|
+
|
|
74
|
+
```powershell
|
|
75
|
+
<checkout>\VeloPhex.Python\build\Build.ps1 # -> VeloPhex.Python\artifacts\dist
|
|
76
|
+
$env:UV_FIND_LINKS = "<checkout>\VeloPhex.Python\artifacts\dist"
|
|
77
|
+
|
|
78
|
+
uvx --python 3.12 --from velophex velophex init Hello.World
|
|
79
|
+
cd Hello.World
|
|
80
|
+
uv sync
|
|
81
|
+
echo '{"message": "Hi"}' > inputs.json
|
|
82
|
+
uv run velophex run main --inputs-file inputs.json
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
The last command logs `INFO hello_world.main: Hi` and prints `{"message": "Hi"}`. The
|
|
86
|
+
generated handler, `src/hello_world/main.py`, takes one input, `message`; `uv sync` writes
|
|
87
|
+
`uv.lock`, which every package must carry. `--inputs '{"message": "Hi"}'` works too where the
|
|
88
|
+
shell passes the quotes through (PowerShell 7.3+, bash); Windows PowerShell 5.1 strips them,
|
|
89
|
+
which is why the transcript uses a file. Exit codes: `0` success, `1` failed, `2` unusable
|
|
90
|
+
inputs, `3` a business rule; `--verbose` adds the traceback to an input error.
|
|
91
|
+
|
|
92
|
+
`init` found the SDK wheel through `UV_FIND_LINKS` and copied it into `wheels/`, so the same
|
|
93
|
+
project, packed and published as below, also restores and runs on a Robot with the Python
|
|
94
|
+
runtime - see "How a Robot gets the SDK".
|
|
95
|
+
|
|
96
|
+
## Publishing to an Orchestrator
|
|
97
|
+
|
|
98
|
+
From the project folder, with a personal access token that has `packages.create`:
|
|
99
|
+
|
|
100
|
+
```powershell
|
|
101
|
+
uv run velophex pack # -> artifacts\Hello.World.1.0.0.nupkg
|
|
102
|
+
$env:VELOPHEX_API_KEY = "vpx_pat_..." # or --api-key-stdin, or be prompted on a terminal
|
|
103
|
+
uv run velophex publish artifacts\Hello.World.1.0.0.nupkg --orchestrator https://orchestrator.example.com --tenant <tenantId>
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### How a Robot gets the SDK
|
|
107
|
+
|
|
108
|
+
A Robot restores the package's environment with `uv sync --frozen`, which installs from exactly
|
|
109
|
+
the sources `uv.lock` records and ignores any index or `--find-links` given at sync time. A lock
|
|
110
|
+
made against a wheel *folder* records that folder's absolute path, and it only restores where
|
|
111
|
+
the folder exists. So `init` **vendors** the SDK instead: it copies this SDK's own wheel into
|
|
112
|
+
the project's `wheels/` folder (from `--sdk-wheel`, or found in the `UV_FIND_LINKS` folders) and
|
|
113
|
+
writes
|
|
114
|
+
|
|
115
|
+
```toml
|
|
116
|
+
[tool.uv.sources]
|
|
117
|
+
velophex = { path = "wheels/velophex-<version>-py3-none-any.whl" }
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
`uv.lock` then records the wheel by a path relative to the project and by its SHA-256, and
|
|
121
|
+
`velophex pack` carries `wheels/` in the package like any other project file (hashed into the
|
|
122
|
+
manifest and signed). The Robot's `uv sync --frozen` restores the SDK from the unpacked package
|
|
123
|
+
itself, with no index and no Robot configuration, and it never asks any index for a package
|
|
124
|
+
named `velophex` - which is what closes the dependency-confusion gap on the unclaimed PyPI name
|
|
125
|
+
(PY-02). The SDK's own dependencies (pydantic, httpx, typer) still come from the Robot's index
|
|
126
|
+
(`Python:IndexUrl`, PyPI by default) or its wheelhouse (`Python:WheelhousePath`).
|
|
127
|
+
|
|
128
|
+
Commit `wheels/` with the project. To move a project to a newer SDK, replace the wheel, update
|
|
129
|
+
the path in `pyproject.toml` and run `uv lock`. When `init` finds no wheel it falls back to a
|
|
130
|
+
plain `velophex` dependency and warns that a Robot cannot restore the project until the SDK is
|
|
131
|
+
on an index it can reach.
|
|
132
|
+
|
|
133
|
+
Why not ship the wheel with the Robot instead: under `--frozen` the lock's recorded path wins,
|
|
134
|
+
so a wheel in the Robot's payload would only be used if every developer machine had it at the
|
|
135
|
+
same absolute path. Verified with uv 0.12.15; vendoring needs no Robot change at all.
|
|
136
|
+
|
|
137
|
+
## Building and publishing the SDK
|
|
138
|
+
|
|
139
|
+
```powershell
|
|
140
|
+
build\Build.ps1 # wheel + sdist into artifacts\dist, built twice and compared byte for byte, plus SHA256SUMS
|
|
141
|
+
build\Publish.ps1 # dry run: checks SHA256SUMS and runs `uv publish --dry-run`; uploads nothing
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
The version has one source, `__version__` in `src/velophex/__init__.py`; hatchling reads it.
|
|
145
|
+
Publishing is an outward action that needs the owner's approval: `Publish.ps1 -Publish`
|
|
146
|
+
uploads (`velophex` is on PyPI since 0.2.0), and refuses a public index if `pyproject.toml`
|
|
147
|
+
carries the `Private :: Do Not Upload` classifier (PyPI rejects that classifier too). Credentials come
|
|
148
|
+
from `UV_PUBLISH_TOKEN` or trusted publishing, never from a parameter.
|
|
149
|
+
|
|
150
|
+
The token is never a command-line option (it would land in shell history and the process
|
|
151
|
+
list); `--api-key` is refused with an error that says where to put it instead.
|
|
152
|
+
|
|
153
|
+
`pack` refreshes `project.json` from the decorated handlers before it builds (`--no-sync` to
|
|
154
|
+
skip), requires `uv.lock`, writes the manifest with the SHA-512 of every content file, and
|
|
155
|
+
computes `requiredRuntimes` as this project's runtime plus every `automationDependencies`
|
|
156
|
+
runtime — which is what the Orchestrator matches against a runner's reported runtimes.
|
|
157
|
+
|
|
158
|
+
`publish` is a `PUT {publicBaseUrl}/api/v1/tenants/{tenantId}/feed/v3/package` with the
|
|
159
|
+
package as multipart and `X-NuGet-ApiKey: vpx_pat_…`. A `409` is success only when the
|
|
160
|
+
feed answers `package_binary_duplicate` with a `sha256` equal to the local package's: these
|
|
161
|
+
exact bytes are already published. Any other `409` - different bytes under a published version -
|
|
162
|
+
fails, naming the package and version: a published version never changes, so increment the
|
|
163
|
+
version in `project.json` and pack again. Without a path, `publish` takes the one package in
|
|
164
|
+
`--output` (`artifacts`) and refuses to guess when there are several. The URL and tenant may come from
|
|
165
|
+
`VELOPHEX_ORCHESTRATOR_URL` and `VELOPHEX_TENANT_ID`; the key comes only from
|
|
166
|
+
`VELOPHEX_API_KEY`, stdin (`--api-key-stdin`) or a hidden prompt.
|
|
167
|
+
|
|
168
|
+
## How the worker runs an automation
|
|
169
|
+
|
|
170
|
+
The Runner prepares the environment with `uv sync --frozen --no-dev` and then starts
|
|
171
|
+
`<env>\Scripts\python.exe -m velophex.worker` (ADR-P008). Parent and worker speak the executor
|
|
172
|
+
protocol (ADR-P005) over the process's stdin and stdout:
|
|
173
|
+
|
|
174
|
+
1. The worker takes the session key out of `VELOPHEX_EXECUTOR_SESSION_KEY` and **removes the
|
|
175
|
+
variable**, before any automation code is imported. It ignores `VELOPHEX_EXECUTOR_FEED_CREDENTIALS`
|
|
176
|
+
(executor protocol §1): a Python run restores no NuGet packages, and the Runner never sets it for one.
|
|
177
|
+
2. The frame pipe moves off standard output to a private, non-inheritable descriptor, and fd 1,
|
|
178
|
+
the Win32 standard output handle and `sys.stdout` all point at `stderr`. A `print()`, a child
|
|
179
|
+
process (`os.system`, `subprocess`) or a `WriteFile` on the standard handle therefore cannot
|
|
180
|
+
corrupt the frame stream, and a detached grandchild cannot keep the pipe open after the worker exits.
|
|
181
|
+
The parent's pipe moves off standard input the same way, and fd 0, the Win32 standard input
|
|
182
|
+
handle and `sys.stdin` become the null device: an automation or a child process that reads
|
|
183
|
+
standard input gets end-of-file instead of consuming the parent's frames.
|
|
184
|
+
3. `hello` → `hello-ack` (runtime `python`, no capabilities: the environment is already
|
|
185
|
+
prepared, and there is no Python debugger over this protocol yet).
|
|
186
|
+
4. `start` names a package root and an entry point id; `project.json` turns the id into
|
|
187
|
+
`module:function`, the module is imported and the inputs are validated against the
|
|
188
|
+
handler's signature before it is called.
|
|
189
|
+
5. While it runs, `logging` records become `log` messages and the parent's `stop` sets
|
|
190
|
+
`velophex.runtime.stop_requested()`.
|
|
191
|
+
6. Exactly one terminal message: `result`, `canceled` or `fault`.
|
|
192
|
+
|
|
193
|
+
Fault mapping, which decides retries:
|
|
194
|
+
|
|
195
|
+
| Raised | Fault |
|
|
196
|
+
|---|---|
|
|
197
|
+
| `velophex.BusinessError` | `Business` / the error's `code`, never retried |
|
|
198
|
+
| inputs that do not fit the handler | `Application` / `INVALID_INPUT`, not retried |
|
|
199
|
+
| a package or handler that will not load | `Application` / `INVALID_PACKAGE` |
|
|
200
|
+
| anything else | `Application` / `UNHANDLED_EXCEPTION` |
|
|
201
|
+
| a return value with no JSON form (`datetime`, a set, NaN) | `Application` / `RESULT_NOT_SERIALIZABLE`, not retried |
|
|
202
|
+
| outputs over the frame limit (16 MiB) | `Application` / `RESULT_TOO_LARGE`, not retried |
|
|
203
|
+
|
|
204
|
+
The last two are checked before the terminal frame is written, so the parent always gets a
|
|
205
|
+
terminal message rather than a crash it would have to report (and retry) as
|
|
206
|
+
`EXECUTOR_CRASHED`. Both are entries in the shared `FaultCodes` table (Contracts 0.1.0-preview.4), so the workflow executor uses the same spellings.
|
|
207
|
+
|
|
208
|
+
A stop is cooperative: the flag is set and the handler is expected to notice. When the grace
|
|
209
|
+
period passes, an `async` handler's task is cancelled (it unwinds through `finally`); a
|
|
210
|
+
synchronous handler cannot be interrupted safely from outside, so the parent's kill is the
|
|
211
|
+
answer, and the worker logs a warning saying so. A handler that returns *after* a stop was
|
|
212
|
+
asked for ends the run as `canceled`, not `result`.
|
|
213
|
+
|
|
214
|
+
## What "the same package as .NET" means
|
|
215
|
+
|
|
216
|
+
`tests/test_pack_parity.py` compares what `velophex pack` builds against
|
|
217
|
+
`tests/data/golden/Sample.Automation.1.2.0.nupkg`, which the engine's
|
|
218
|
+
`AutomationPackageBuilder` produced from `tests/data/sample_project`. Parity is asserted on
|
|
219
|
+
the archive's *content*: the same entry names in the same order, the same fixed
|
|
220
|
+
`2000-01-01T00:00:00` timestamp, and the same bytes in every entry — `content/project.json`,
|
|
221
|
+
`velophex/package-manifest.json`, the `.nuspec` and `[Content_Types].xml`.
|
|
222
|
+
|
|
223
|
+
The *compressed* bytes are deliberately not compared. A .NET `ZipArchive` and Python's `zlib`
|
|
224
|
+
do not emit the same deflate stream, and nothing in the platform depends on it: what is hashed
|
|
225
|
+
and signed is the manifest and the files the manifest lists, never the archive's compression.
|
|
226
|
+
|
|
227
|
+
Regenerate the golden only for an intentional packaging change, by running the .NET builder
|
|
228
|
+
over the same fixture directory.
|
|
229
|
+
|
|
230
|
+
## The JSON, and why it is not `json.dumps`
|
|
231
|
+
|
|
232
|
+
The package manifest carries the SHA-512 of `content/project.json`, so a manifest written here
|
|
233
|
+
and a manifest written by Studio only verify against each other when both writers agree byte
|
|
234
|
+
for byte. `System.Text.Json` and Python's `json` do not agree, in three ways that all matter
|
|
235
|
+
and are all pinned by tests:
|
|
236
|
+
|
|
237
|
+
* The default .NET encoder escapes `"`, `&`, `'`, `+`, `<`, `>`, `` ` `` and everything
|
|
238
|
+
outside printable ASCII as `\uXXXX` with **upper-case** hex. So `">=3.12,<3.13"` is written
|
|
239
|
+
`">=3.12,<3.13"`.
|
|
240
|
+
* A `JsonElement` — an entry point's JSON Schema, say — is re-emitted with its **original
|
|
241
|
+
number literals**: `1.0` stays `1.0`, `1e2` stays `1e2`.
|
|
242
|
+
* A `DateTimeOffset` goes through a typed fast path that never reaches the encoder, so the
|
|
243
|
+
`+` of its offset stays a `+` where the same text in a plain string property would become
|
|
244
|
+
`+`. The executor frame vectors pin exactly this.
|
|
245
|
+
|
|
246
|
+
## Agents: `velophex[agents]`
|
|
247
|
+
|
|
248
|
+
`velophex.agents.run(definition, inputs)` runs an agent definition (`agent-definition` v1,
|
|
249
|
+
`VeloPhex.Platform.Contracts/schemas/agent-definition.v1.schema.json`) and returns its validated
|
|
250
|
+
output. The extra brings LangGraph, `langchain-openai` and `jsonschema` from PyPI; LangGraph is
|
|
251
|
+
pinned to one minor and only `velophex/agents/_loop.py` imports it.
|
|
252
|
+
|
|
253
|
+
* **Model calls** go to the Orchestrator's LLM gateway, `POST <runtime>/ai/chat/completions`, in
|
|
254
|
+
the OpenAI chat completions shape, non-streaming, with the job token and the agent's binding
|
|
255
|
+
(`agent:default`) as the model. Outside a job, `VELOPHEX_AI_BASE_URL`, `VELOPHEX_AI_API_KEY`
|
|
256
|
+
and `VELOPHEX_AI_MODEL` name any OpenAI-compatible endpoint; nothing is traced then.
|
|
257
|
+
* **Tools:** `process` runs a published automation **in this job** (`velophex.runtime.invoke_automation`,
|
|
258
|
+
executor `sub-invoke`: same machine, same lease, no extra robot slot); `mcp`, `http`,
|
|
259
|
+
`vector_search` and `action_task` go through the Runtime API. Every call carries an
|
|
260
|
+
idempotency key, and tool and retrieved content reaches the model fenced as untrusted data.
|
|
261
|
+
* **Approvals** (`approval: before_call`) are bounded waits in the job: at most 900 s, and never
|
|
262
|
+
past the run's own timeout. A rejected or expired approval means the call is not made.
|
|
263
|
+
* **Limits:** 12 model calls, 50 000 tokens and 15 minutes per run, temperature 0.2, unless the
|
|
264
|
+
definition says otherwise. A limit ends the run with `agent_limit_exceeded`; an answer that
|
|
265
|
+
fails the output schema gets one repair turn, then `agent_output_invalid`.
|
|
266
|
+
* **Faults keep their code.** A gateway refusal raises `AiBudgetExhaustedError`,
|
|
267
|
+
`AiPolicyDeniedError`, `AiProviderUnavailableError` or `AiRequestInvalidError` (all
|
|
268
|
+
`RuntimeApiError`), and the worker reports it under that code with the error envelope (cause,
|
|
269
|
+
fix, request id, retry scope, docs URL), not as `UNHANDLED_EXCEPTION`. A run that fails after a
|
|
270
|
+
tool call succeeded reports `effectsCommitted` and is never retried automatically; an unknown
|
|
271
|
+
tool outcome is `tool_outcome_unknown`, reconciled by a person.
|
|
272
|
+
* **Testing:** `velophex.agents.testing.FakeChatServer` (a scripted gateway on 127.0.0.1),
|
|
273
|
+
`FakeTools` and `RecordingTracer`; `tests/test_agents.py` shows the patterns.
|
|
274
|
+
|
|
275
|
+
`velophex run` and the worker shape outputs with the same rule (`normalize_outputs`): `None` is
|
|
276
|
+
`{}`, a scalar or list is `{"value": ...}`, and a value with no JSON form is
|
|
277
|
+
`RESULT_NOT_SERIALIZABLE` in both.
|
|
278
|
+
|
|
279
|
+
## Contract changes
|
|
280
|
+
|
|
281
|
+
`project.json`, the package manifest, the runner protocol and the executor protocol are
|
|
282
|
+
platform contracts. They change only through the `velophex-contract-change` skill, which
|
|
283
|
+
updates the engine, Studio, the Activities, the Orchestrator, the Runner and this SDK together.
|
|
284
|
+
Nothing in `src/velophex/contracts/` may drift on its own; the conformance tests against
|
|
285
|
+
`VeloPhex.Platform.Contracts/schemas/` are there to make drift fail loudly.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Agent.Runner
|
|
2
|
+
|
|
3
|
+
The one Python package every agent runs. A job of this package names an agent candidate
|
|
4
|
+
(`{"agentKey", "candidateId", "inputs"}`); the `agent` entry point fetches the candidate's
|
|
5
|
+
immutable definition from `GET <runtime>/agents/{agentKey}/candidates/{candidateId}` under the
|
|
6
|
+
job token and runs it with `velophex.agents`. The entry point declares its schemas, so
|
|
7
|
+
`velophex pack` never rewrites them.
|
|
8
|
+
|
|
9
|
+
Its version is the SDK's (`__version__`): a new SDK, Runner or LangGraph is a new Runner
|
|
10
|
+
version, and a published agent keeps the Runner its candidate was certified with.
|
|
11
|
+
|
|
12
|
+
## How CI builds and signs it
|
|
13
|
+
|
|
14
|
+
No key is in this repository. The CI job that releases the SDK also releases the Runner:
|
|
15
|
+
|
|
16
|
+
1. `VeloPhex.Python\build\Build.ps1` builds the SDK wheel reproducibly into `artifacts\dist`.
|
|
17
|
+
2. Copy `velophex-<version>-py3-none-any.whl` into `agent-runner\wheels\` (ignored by git).
|
|
18
|
+
3. Check that the wheel's SHA-256 (`artifacts\dist\SHA256SUMS`) is the one `uv.lock` records
|
|
19
|
+
for `wheels/velophex-<version>-py3-none-any.whl`. The wheel is reproducible, so it is for the
|
|
20
|
+
SDK the lock was made from. If not, the SDK changed: run
|
|
21
|
+
`uv lock --project agent-runner --upgrade-package velophex` and commit the lock (`uv lock
|
|
22
|
+
--locked` does not notice a changed path wheel of the same version, so it is not the check).
|
|
23
|
+
4. `uv run velophex pack --directory agent-runner --output <out> --sign <signing.pfx> --sign-password-stdin`,
|
|
24
|
+
with the publishing certificate the pipeline holds as a secret. The Orchestrator and the Robot
|
|
25
|
+
trust it by its SHA-256 thumbprint (ADR-P012).
|
|
26
|
+
5. Publish `Agent.Runner.<version>.nupkg` to the release feed; the Orchestrator registers it as
|
|
27
|
+
the process agent jobs run.
|
|
28
|
+
|
|
29
|
+
Locally, steps 1, 2 and 4 without `--sign` give an unsigned package for a development
|
|
30
|
+
Orchestrator that does not require signed packages.
|