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.
Files changed (44) hide show
  1. velophex-0.2.0/.gitignore +7 -0
  2. velophex-0.2.0/LICENSE +21 -0
  3. velophex-0.2.0/PKG-INFO +313 -0
  4. velophex-0.2.0/README.md +285 -0
  5. velophex-0.2.0/agent-runner/README.md +30 -0
  6. velophex-0.2.0/pyproject.toml +112 -0
  7. velophex-0.2.0/src/velophex/__init__.py +38 -0
  8. velophex-0.2.0/src/velophex/_json.py +297 -0
  9. velophex-0.2.0/src/velophex/agents/__init__.py +130 -0
  10. velophex-0.2.0/src/velophex/agents/_errors.py +75 -0
  11. velophex-0.2.0/src/velophex/agents/_loop.py +436 -0
  12. velophex-0.2.0/src/velophex/agents/_tools.py +168 -0
  13. velophex-0.2.0/src/velophex/agents/_tracing.py +87 -0
  14. velophex-0.2.0/src/velophex/agents/testing.py +249 -0
  15. velophex-0.2.0/src/velophex/cli/__init__.py +5 -0
  16. velophex-0.2.0/src/velophex/cli/main.py +548 -0
  17. velophex-0.2.0/src/velophex/cli/publish.py +152 -0
  18. velophex-0.2.0/src/velophex/cli/sync.py +90 -0
  19. velophex-0.2.0/src/velophex/cli/templates.py +126 -0
  20. velophex-0.2.0/src/velophex/contracts/__init__.py +103 -0
  21. velophex-0.2.0/src/velophex/contracts/_model.py +26 -0
  22. velophex-0.2.0/src/velophex/contracts/agents.py +110 -0
  23. velophex-0.2.0/src/velophex/contracts/diagnostics.py +157 -0
  24. velophex-0.2.0/src/velophex/contracts/executor.py +1921 -0
  25. velophex-0.2.0/src/velophex/contracts/package.py +391 -0
  26. velophex-0.2.0/src/velophex/contracts/project.py +1008 -0
  27. velophex-0.2.0/src/velophex/contracts/runtimes.py +226 -0
  28. velophex-0.2.0/src/velophex/contracts/signin.py +53 -0
  29. velophex-0.2.0/src/velophex/contracts/validation.py +158 -0
  30. velophex-0.2.0/src/velophex/entrypoints.py +329 -0
  31. velophex-0.2.0/src/velophex/errors.py +44 -0
  32. velophex-0.2.0/src/velophex/packaging.py +465 -0
  33. velophex-0.2.0/src/velophex/py.typed +0 -0
  34. velophex-0.2.0/src/velophex/runtime/__init__.py +471 -0
  35. velophex-0.2.0/src/velophex/runtime/_session.py +192 -0
  36. velophex-0.2.0/src/velophex/runtime/client.py +957 -0
  37. velophex-0.2.0/src/velophex/runtime/models.py +326 -0
  38. velophex-0.2.0/src/velophex/signing.py +425 -0
  39. velophex-0.2.0/src/velophex/worker/__init__.py +27 -0
  40. velophex-0.2.0/src/velophex/worker/__main__.py +118 -0
  41. velophex-0.2.0/src/velophex/worker/framing.py +248 -0
  42. velophex-0.2.0/src/velophex/worker/loader.py +105 -0
  43. velophex-0.2.0/src/velophex/worker/outbox.py +147 -0
  44. velophex-0.2.0/src/velophex/worker/session.py +519 -0
@@ -0,0 +1,7 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ dist/
7
+ artifacts/
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.
@@ -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.
@@ -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.