From 4897143b8d4716f818c9d92ca14961c238550f25 Mon Sep 17 00:00:00 2001 From: Louis Choquel Date: Sat, 26 Sep 2026 19:33:54 +0200 Subject: [PATCH 1/6] pin `pipelex-sdk` at ffcb5c6e for the sprint `pipelex-starter-python` takes `pipelex-sdk` from `pipelex-sdk-python`'s feature/Failed-run-report-python, written at [tool.uv.sources] of pyproject.toml with the lock regenerated in the same commit (P1). The collapse before this branch merges is `wt unpin _pipelex-starter-python--failed-run-reason pipelex-sdk-python --to ` (P2, P7). --- pyproject.toml | 3 +++ uv.lock | 16 ++++++---------- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c9f1428..494bd85 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -259,3 +259,6 @@ select = [ "E501", "I", ] + +[tool.uv.sources] +pipelex-sdk = { git = "https://github.com/Pipelex/pipelex-sdk-python.git", rev = "ffcb5c6e6cefa9f5f63b9de80c87d13c64735932" } diff --git a/uv.lock b/uv.lock index 3e5da48..d1282d2 100644 --- a/uv.lock +++ b/uv.lock @@ -192,7 +192,7 @@ wheels = [ [[package]] name = "mthds" -version = "0.14.0" +version = "0.15.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -201,9 +201,9 @@ dependencies = [ { name = "tomlkit" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/92/59/4ca9539571a2030f9427aeddb01bc334910953ebdfa4ab7db2051a6b8ae7/mthds-0.14.0.tar.gz", hash = "sha256:d2b4a9cd064004dfd5b802bb71c9894601fba9e598426f96e4bede16d52c3900", size = 221186, upload-time = "2026-09-06T21:44:54.585Z" } +sdist = { url = "https://files.pythonhosted.org/packages/54/16/1aa1219f44bc469213018478f582eb4eb28f69ea43a09fe73afc9cfb4e34/mthds-0.15.0.tar.gz", hash = "sha256:2e612174b089cae799eb236f5b1bcd183b8847a62a06a265f72f0e50b4c89555", size = 250552, upload-time = "2026-09-18T20:23:09.888Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/28/0b/32908eeed33396c5aafd4bcb2a1adc38d0ab8d85c2fde3af0bf4c2f73745/mthds-0.14.0-py3-none-any.whl", hash = "sha256:59a706205b6e6df47345caac01038588d21ab9e9c246afc765ee50b5f27f05a8", size = 87192, upload-time = "2026-09-06T21:44:53.022Z" }, + { url = "https://files.pythonhosted.org/packages/c0/5c/7526815d8bc8cd8690c8cf4de3819e3f322886280af05bf9aed7bdce450c/mthds-0.15.0-py3-none-any.whl", hash = "sha256:98670ca1f97fc2211ace0f404acd416ce5882edb728845d48440d6e73eedc34c", size = 99711, upload-time = "2026-09-18T20:23:08.283Z" }, ] [[package]] @@ -283,18 +283,14 @@ wheels = [ [[package]] name = "pipelex-sdk" -version = "0.10.2" -source = { registry = "https://pypi.org/simple" } +version = "0.12.0" +source = { git = "https://github.com/Pipelex/pipelex-sdk-python.git?rev=ffcb5c6e6cefa9f5f63b9de80c87d13c64735932#ffcb5c6e6cefa9f5f63b9de80c87d13c64735932" } dependencies = [ { name = "httpx" }, { name = "mthds" }, { name = "pydantic" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/76/75/05a0f85f93c5cf67c18af19a5f994a3c3f948dae18092cf096bb2dc2d7bb/pipelex_sdk-0.10.2.tar.gz", hash = "sha256:96392a3361066d400f6401749e7783092e928e5242f41f07db38f4fc3c84c1fa", size = 355371, upload-time = "2026-09-22T09:15:47.013Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b4/a7/ad571bb94d94bf7b7b5a953962d2bc6c1eb4adcdbb96f46706e97fcbd6b0/pipelex_sdk-0.10.2-py3-none-any.whl", hash = "sha256:d8e009679d767a3a08380a4b41d773ab7772a4923a77f6a96009a00994f3135a", size = 117470, upload-time = "2026-09-22T09:15:45.499Z" }, -] [[package]] name = "pipelex-tools" @@ -657,7 +653,7 @@ requires-dist = [ { name = "httpx", specifier = ">=0.27.0" }, { name = "mthds", specifier = ">=0.14.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = "==1.19.1" }, - { name = "pipelex-sdk", specifier = ">=0.10.2" }, + { name = "pipelex-sdk", git = "https://github.com/Pipelex/pipelex-sdk-python.git?rev=ffcb5c6e6cefa9f5f63b9de80c87d13c64735932" }, { name = "pipelex-tools", marker = "extra == 'dev'", specifier = ">=0.7.2" }, { name = "pyright", marker = "extra == 'dev'", specifier = ">=1.1.411" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.3" }, From 1a5ba48c259bd9ccf958bcfdc4c5b77f569fda77 Mon Sep 17 00:00:00 2001 From: Louis Choquel Date: Sat, 26 Sep 2026 19:43:31 +0200 Subject: [PATCH 2/6] test: give each fake DownloadedArtifact the found_at the pinned SDK requires The pinned `pipelex-sdk` carries 0.12.0's `DownloadedArtifact.found_at`, a required field, so the stub download fixture and the artifact tests now build their fake verdict items with it. Co-Authored-By: Claude Opus 5.5 --- tests/conftest.py | 2 +- tests/unit/test_artifacts.py | 16 +++++++++++----- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 2cea829..ecc37b8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -46,7 +46,7 @@ def stub_download(mocker: MockerFixture) -> str: which is the shape the hosted runtime really returns for an image — must take this fixture or it will reach for the network. Returns the path the stub pretends it wrote, for assertions. """ - artifact = DownloadedArtifact(uri=STUB_ARTIFACT_URI, path=STUB_ARTIFACT_PATH, content_type="image/png", size=3) + artifact = DownloadedArtifact(uri=STUB_ARTIFACT_URI, found_at=["$.url"], path=STUB_ARTIFACT_PATH, content_type="image/png", size=3) result = DownloadArtifactsResult(scope=ArtifactScope.MAIN_STUFF, artifacts=[artifact], saved_paths=[STUB_ARTIFACT_PATH], all_saved=True) fake_client = mocker.AsyncMock() fake_client.download_artifacts.return_value = result diff --git a/tests/unit/test_artifacts.py b/tests/unit/test_artifacts.py index 243c16b..86d33df 100644 --- a/tests/unit/test_artifacts.py +++ b/tests/unit/test_artifacts.py @@ -61,7 +61,9 @@ async def test_a_text_output_stays_offline_and_opens_no_client(self, mocker: Moc client.assert_not_called() async def test_an_output_referencing_a_file_goes_through_the_client(self, mocker: MockerFixture, tmp_path: Path): - artifact = DownloadedArtifact(uri="pipelex-storage://run-1/cat.png", path=str(tmp_path / "cat.png"), content_type="image/png", size=3) + artifact = DownloadedArtifact( + uri="pipelex-storage://run-1/cat.png", found_at=["$.url"], path=str(tmp_path / "cat.png"), content_type="image/png", size=3 + ) fake_client = mocker.AsyncMock() fake_client.download_artifacts.return_value = _verdict(artifact) async_cm = mocker.MagicMock() @@ -84,11 +86,13 @@ def test_says_nothing_when_the_run_produced_no_file(self): assert _render(None) == "" def test_names_each_saved_file(self): - rendered = _render(_verdict(DownloadedArtifact(uri="pipelex-storage://run-1/cat.png", path="/tmp/out/cat.png", size=3))) + rendered = _render(_verdict(DownloadedArtifact(uri="pipelex-storage://run-1/cat.png", found_at=["$.url"], path="/tmp/out/cat.png", size=3))) assert "/tmp/out/cat.png" in rendered def test_names_a_reference_that_did_not_come_down(self): - failed = DownloadedArtifact(uri="pipelex-storage://run-1/cat.png", error=ArtifactItemError(code="forbidden", detail="Not your run.")) + failed = DownloadedArtifact( + uri="pipelex-storage://run-1/cat.png", found_at=["$.url"], error=ArtifactItemError(code="forbidden", detail="Not your run.") + ) rendered = _render(_verdict(failed)) # The reference, the machine code and the sentence a person reads — a failed reference is # reported rather than raised, so the message is the only place it surfaces. @@ -97,8 +101,10 @@ def test_names_a_reference_that_did_not_come_down(self): assert "Not your run." in rendered def test_reports_both_arms_of_a_partial_download(self): - saved = DownloadedArtifact(uri="pipelex-storage://run-1/ok.png", path="/tmp/out/ok.png", size=3) - failed = DownloadedArtifact(uri="pipelex-storage://run-1/bad.png", error=ArtifactItemError(code="write_failed", detail="Disk full.")) + saved = DownloadedArtifact(uri="pipelex-storage://run-1/ok.png", found_at=["$[0].url"], path="/tmp/out/ok.png", size=3) + failed = DownloadedArtifact( + uri="pipelex-storage://run-1/bad.png", found_at=["$[1].url"], error=ArtifactItemError(code="write_failed", detail="Disk full.") + ) rendered = _render(_verdict(saved, failed)) assert "/tmp/out/ok.png" in rendered assert "Disk full." in rendered From 1dfd27fddaa5a70e6fce4ad0c7f78dca208a8603 Mon Sep 17 00:00:00 2001 From: Louis Choquel Date: Sat, 26 Sep 2026 19:43:31 +0200 Subject: [PATCH 3/6] fix: a failed run prints the reason, next step and retry advice from its stored report A durable run that ended without a result used to print its status twice and hint at `status`, which printed it a third time. `widget/errors.py` now presents such a run from the error report the SDK carries on `RunFailedError`, `RunResultFailed` and `RunRead`: the reason (title and message), the next step and the retry advice, the same lines under `detached status`. A run with no stored report says that no reason was recorded, keeps the platform's sentence and hints at what is left. `print_error` prints every presentation and escapes the server's text so Rich never reads it as markup. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 + CLAUDE.md | 2 +- README.md | 2 +- docs/cli-architecture.md | 15 ++- tests/unit/test_attended_cli.py | 23 ++++- tests/unit/test_detached_cli.py | 67 ++++++++++++++ tests/unit/test_errors.py | 103 +++++++++++++++++++-- widget/attended/cli.py | 7 +- widget/blocking/cli.py | 7 +- widget/detached/cli.py | 28 ++++-- widget/errors.py | 157 ++++++++++++++++++++++++++++++-- 11 files changed, 378 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c5b9884..8bd065c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ - **The demos name their pipe by its qualified reference**: every demo command, in every mode, sends `pipe_code` as `domain.pipe_code` (`extract_entities.extract_entities`) rather than the bare code, the exact key the runtime resolves. A bare code is searched for across every domain of the bundle and fails as ambiguous once two domains declare it, so code copied from a demo keeps working as its bundle grows; rename a bundle's `domain` and its call sites together. +### Fixed + +- **A failed run says why**: a durable run that ended without a result, whether met by `widget attended …`, `widget detached wait` or `widget detached result`, now prints the reason the runner stored for it (its title and message), the next step it advises and whether running it again can succeed, instead of repeating its status; `widget detached status` prints the same lines under the status. A run that ended with no stored report, such as a cancelled one, says that no reason was recorded, keeps the platform's own sentence and says what is left to do. +- **Server text in an error is printed as it came**: a bracketed span in an error message or a stored report, such as a provider's `[/x]`, is no longer read as Rich markup, so it neither disappears nor crashes the print. + ## [v0.1.0] - 2026-09-22 ### Highlights diff --git a/CLAUDE.md b/CLAUDE.md index 335d299..a13723a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,7 +41,7 @@ This starter calls the **hosted Pipelex API** via the `pipelex-sdk` package (`Pi - Credentials/endpoint come from `PIPELEX_BASE_URL` / `PIPELEX_API_KEY` (see `.env.example`). `python-dotenv` loads `.env` when running the CLI or tests. - **The execution mode is the command group, not an option.** There are exactly three, each a self-contained Typer sub-package: `widget blocking …` (`client.execute` — one call, dies at the hosted ~30s cap), `widget attended …` (`client.start` + `client.wait_for_result` — durable, you wait), `widget detached …` (`client.start` only — durable, you collect it later with `widget detached status|result|wait `). Attended and detached start the *same* durable run; the axis they name is who waits. There is no default mode and no `--mode` option: `widget/cli.py` is a thin assembler (`load_dotenv` callback + three `add_typer` calls in reading order) and nothing else. -- **Each mode file is a copy-paste unit; lifecycle code is never shared.** `widget//cli.py` holds that mode's whole story: its `typer.Typer`, its consoles (results → stdout, progress → stderr), its one public lifecycle helper (`execute_pipe` / `start_and_wait` / `start_pipe`, plus `attend_run` + the fetchers in detached; every result-producing one returns the SDK's `RunResults`, blocking included, through `results_from_execute`), its demo commands, and a private `_run()` that wraps `asyncio.run` and catches SDK errors once. The **only** shared modules are those orthogonal to execution: `widget/inputs.py` (text-or-file input with a built-in **sample fallback** so every demo runs with zero arguments — `read_text_input` returns `TextInput(text, is_sample)` and the demo prints a stderr notice when the sample was used; plus file → `{"concept": "Document", "content": …}` envelope — `upload_document_input` uploads the file to hosted storage with `client.upload_file` and wraps the returned `pipelex-storage://` URI, `build_document_input(path, uri)` being the pure envelope builder — and the `SAMPLE_*` constants), `widget/errors.py` (SDK error → message + hint, hints naming the mode groups; it reads the RFC 7807 **problem+json** body off raw protocol-route `httpx.HTTPStatusError`s and branches on the structured `error_type`, e.g. `StartRequiresAsyncOrchestration` → "use `widget blocking`"; it also hints the file-upload error family and the artifact-download one, whose hints never say to rerun because the run was already paid for), `widget/usage.py` (cost report: `print_cost_report` renders `pipelex_sdk.usage.summarize_usage(results)` to **stderr** — the SDK owns the folding rules and this module re-derives none of them), `widget/artifacts.py` (produced files: `collect_artifacts` answers offline whether the output references any, `download_artifacts` saves them under `DEFAULT_DOWNLOAD_DIR` with links minted fresh rather than the expiring `public_url`), and `widget/outputs.py` (`list_items`, the one place a **plural** output's two wire shapes — a bare array or an `items` envelope, which of them you get depends on the execution path rather than on the method — are read as one; the Python twin of `pipelex-starter-js`'s `wireListOutput`, and a workaround with an expiry). Do not introduce a shared runner — the dispatch indirection is exactly what this layout removed. See `docs/cli-architecture.md`. +- **Each mode file is a copy-paste unit; lifecycle code is never shared.** `widget//cli.py` holds that mode's whole story: its `typer.Typer`, its consoles (results → stdout, progress → stderr), its one public lifecycle helper (`execute_pipe` / `start_and_wait` / `start_pipe`, plus `attend_run` + the fetchers in detached; every result-producing one returns the SDK's `RunResults`, blocking included, through `results_from_execute`), its demo commands, and a private `_run()` that wraps `asyncio.run` and catches SDK errors once. The **only** shared modules are those orthogonal to execution: `widget/inputs.py` (text-or-file input with a built-in **sample fallback** so every demo runs with zero arguments — `read_text_input` returns `TextInput(text, is_sample)` and the demo prints a stderr notice when the sample was used; plus file → `{"concept": "Document", "content": …}` envelope — `upload_document_input` uploads the file to hosted storage with `client.upload_file` and wraps the returned `pipelex-storage://` URI, `build_document_input(path, uri)` being the pure envelope builder — and the `SAMPLE_*` constants), `widget/errors.py` (SDK error → message + hint, hints naming the mode groups; it reads the RFC 7807 **problem+json** body off raw protocol-route `httpx.HTTPStatusError`s and branches on the structured `error_type`, e.g. `StartRequiresAsyncOrchestration` → "use `widget blocking`"; it also hints the file-upload error family and the artifact-download one, whose hints never say to rerun because the run was already paid for; a run that ended without a result is presented from its stored error report — `present_failed_run` and `report_lines` read out the reason, the next step and the retry advice, the same lines `widget detached status` and `result` print — and `print_error` escapes the server's text before Rich sees it), `widget/usage.py` (cost report: `print_cost_report` renders `pipelex_sdk.usage.summarize_usage(results)` to **stderr** — the SDK owns the folding rules and this module re-derives none of them), `widget/artifacts.py` (produced files: `collect_artifacts` answers offline whether the output references any, `download_artifacts` saves them under `DEFAULT_DOWNLOAD_DIR` with links minted fresh rather than the expiring `public_url`), and `widget/outputs.py` (`list_items`, the one place a **plural** output's two wire shapes — a bare array or an `items` envelope, which of them you get depends on the execution path rather than on the method — are read as one; the Python twin of `pipelex-starter-js`'s `wireListOutput`, and a workaround with an expiry). Do not introduce a shared runner — the dispatch indirection is exactly what this layout removed. See `docs/cli-architecture.md`. - **Full demo matrix, guarded.** All three demos exist in all three modes: `extract-entities` (text in), `summarize-pdf` (a *file* in), `generate-image` (prompt in). `generate-image` is the deliberate slow case that overruns the ~30s blocking cap — `widget blocking generate-image` is *expected to fail*, and that is the teaching moment for the durable modes. The near-duplication across mode files is the pedagogy (diff two mode files and only the lifecycle helper differs); `tests/unit/test_mode_symmetry.py` keeps it from drifting. `samples/sample-invoice.pdf` is shipped for `summarize-pdf`. - The SDK resolves the main output on every result-producing path (`client.execute` returns a `PipelexExecuteResult`, the durable path a `RunResults`, both exposing a resolved `.main_stuff`, typed `Any`; a completed run with no main stuff raises `MissingMainStuffError`). So the result-producing lifecycle helpers (`execute_pipe`, `start_and_wait`, detached's `attend_run`) all return the SDK's `RunResults` — one object carrying the resolved output, what the run consumed and the references to the files it produced. The blocking mode reaches it through `pipelex_sdk.execute_result.results_from_execute`, the SDK's public lift (0.10.2), so no mode reads the runner's raw `pipe_output`. The blocking/attended demo commands narrow `results.main_stuff` inline — e.g. `ExtractedEntities.model_validate(results.main_stuff)` — into the generated model, then hand `results` to the cost report and the download. Detached is the exception by design: `start_pipe` returns only the run id (the demos print it bare, no cost — the run isn't done), and the run-id commands (`wait`/`result`) print the output generically **and** its produced files and cost report — no model narrowing, since at collection time the command doesn't know which method the run executed. There is no per-example wrapper layer. - The modes spell out lifecycles the SDK could hide: `client.start_and_wait()` is a self-healing one-liner that picks the path for you (the production shortcut). The starter writes them out because teaching the difference is the point. diff --git a/README.md b/README.md index dc1e989..2ae86ce 100644 --- a/README.md +++ b/README.md @@ -233,7 +233,7 @@ Same durable run, but `widget` exits as soon as it has the id — on stdout, so ```bash RUN_ID=$(uv run widget detached generate-image "a fox reading under a tree") -uv run widget detached status $RUN_ID # where is it now? (no waiting) +uv run widget detached status $RUN_ID # where is it now, and why did it fail if it did? (no waiting) uv run widget detached result $RUN_ID # its result, if it is done (no waiting) uv run widget detached wait $RUN_ID # block until it is done, then print the result ``` diff --git a/docs/cli-architecture.md b/docs/cli-architecture.md index e231133..43033e7 100644 --- a/docs/cli-architecture.md +++ b/docs/cli-architecture.md @@ -69,11 +69,22 @@ Three SDK capabilities show up in every result-producing path: **stdout is the result; stderr is everything else.** Progress spinners, run ids in attended mode, error messages, and hints all go to stderr, so stdout stays pipeable. In detached mode the run id *is* the result, so it goes to stdout bare (`print`, not Rich) — `RUN_ID=$(widget detached generate-image "…")` just works. -**SDK errors are presented once, at the root of the command.** `_run()` catches `PipelineRequestError` (the base of every error the SDK client raises) and the raw `httpx.HTTPStatusError` its protocol routes surface, maps it to a `(message, hint)` pair via `widget/errors.py`, and exits non-zero. Ctrl-C is handled separately: the durable lifecycle helpers catch the cancellation just long enough to print the resume hint before re-raising, and `_run()` maps the resulting `KeyboardInterrupt` to exit 130. Beyond those two, nothing is caught: an unexpected exception crashes loudly with its traceback, which is what you want while you are building. +**SDK errors are presented once, at the root of the command.** `_run()` catches `PipelineRequestError` (the base of every error the SDK client raises) and the raw `httpx.HTTPStatusError` its protocol routes surface, maps it to an `ErrorPresentation` (a message, the lines that explain it, and a hint) via `widget/errors.py`, prints it with `print_error`, and exits non-zero. `print_error` escapes every piece before it reaches Rich, because the message and its lines carry the server's text, and a bracketed span in it would otherwise be read as markup. Ctrl-C is handled separately: the durable lifecycle helpers catch the cancellation just long enough to print the resume hint before re-raising, and `_run()` maps the resulting `KeyboardInterrupt` to exit 130. Beyond those two, nothing is caught: an unexpected exception crashes loudly with its traceback, which is what you want while you are building. The protocol routes (`execute`/`start`/`runs/*`) surface a non-2xx as a raw `httpx.HTTPStatusError`, whose default string is useless (`Client error '400 Bad Request' for url …` + an MDN link). `widget/errors.py` instead reads the API's RFC 7807 **problem+json** body and shows the server's own `detail`, branching on the structured `error_type` (never the transport status) for the cases worth a hint — a `/start` against a synchronous-only runner (`StartRequiresAsyncOrchestration`) is presented with a hint pointing at `widget blocking`. -The hints name the mode *groups*, because the fix for a failed run is usually another group: a blocking run that hit the ~30s cap tells you to rerun it with `widget attended`; a run that timed out while you waited tells you to resume it with `widget detached wait `; a durable run against a runner that can't do them tells you to use `widget blocking`. +**A failed run says why.** A durable run that ended without a result carries the error report the runner stored when it failed, which the SDK hands back typed as `RunErrorReport` on `RunFailedError.error` (out of `wait_for_result`, `start_and_wait` and an artifact download), on the failed arm of `get_run_result`, and on the status read's `RunRead.error`. `present_failed_run` presents all three the same way, and `report_lines` reads the report out as the lines a person reads: + +```text +Error: Run 3f2a… failed. + Reason: LLM completion — The model refused the request. + Next step: Rephrase the prompt, or pick another model. + Retry: running it again may succeed. +``` + +The reason is the report's `title` and `message` (its `error_type` when it carries neither), the next step its `user_action` (the advice's own words, or a sentence for its `kind` when it gives none), and the retry line its `retryable`, left unsaid when that is `None`, which means unknown rather than no. There is no hint under a report: the next step is the advice. `widget detached status ` prints the same lines under the status, so a failed run reads the same whichever command you met it with. A run that ended with no stored report — a cancelled or timed-out one, or one the platform finalized itself — says that no reason was recorded, keeps the platform's own sentence (on a stored result the platform refuses to serve, that sentence is the only thing that says what happened), and hints at what is left: support for a failure, starting it again for a run that was stopped. The report is the runner's verbose one, so a provider's raw text can reach the terminal; that is right for a developer's tool, and an application in front of end users decides what of it they see. + +The other hints name the mode *groups*, because when a mode cannot carry a run, the fix is usually another group: a blocking run that hit the ~30s cap tells you to rerun it with `widget attended`; a run that timed out while you waited tells you to resume it with `widget detached wait `; a durable run against a runner that can't do them tells you to use `widget blocking`. **Every demo runs with zero arguments.** When you give neither an argument nor `--file`, the input helper returns a bundled sample (`widget/inputs.py`'s `SAMPLE_*` constants), and the command prints a one-line notice on stderr saying so. A fresh clone shows a working result on its very first command once your API key is set; stdout stays the clean, pipeable result because the notice is on stderr. Sample data is orthogonal to execution, so like input encoding it is shared, not duplicated per mode. diff --git a/tests/unit/test_attended_cli.py b/tests/unit/test_attended_cli.py index c9aa3a3..36239b9 100644 --- a/tests/unit/test_attended_cli.py +++ b/tests/unit/test_attended_cli.py @@ -1,6 +1,8 @@ from pathlib import Path -from pipelex_sdk.runs import RunResults +from pipelex_sdk.error_models import RunErrorReport, UserAction +from pipelex_sdk.errors import RunFailedError +from pipelex_sdk.runs import RunResults, RunStatus from pytest_mock import MockerFixture from typer.testing import CliRunner @@ -110,3 +112,22 @@ def test_generate_image_sends_the_prompt(self, mocker: MockerFixture, stub_downl assert stub_download in result.output assert attended_mock.await_args is not None assert attended_mock.await_args.kwargs["inputs"] == {"image_prompt": "a cat wearing a hat"} + + def test_a_failed_run_prints_the_reason_the_next_step_and_the_retry_advice(self, mocker: MockerFixture): + report = RunErrorReport( + title="LLM completion", + message="The model refused the request.", + retryable=False, + user_action=UserAction(kind="change_input", detail="Rephrase the prompt, or pick another model."), + ) + error = RunFailedError( + "Run finished with status FAILED: The model refused the request.", run_id="run-1", status=RunStatus.FAILED, error=report + ) + mocker.patch("widget.attended.cli.start_and_wait", side_effect=error) + result = runner.invoke(app, ["attended", "extract-entities", "some text"]) + assert result.exit_code == 1 + output = " ".join(result.output.split()) + assert "Run run-1 failed." in output + assert "Reason: LLM completion — The model refused the request." in output + assert "Next step: Rephrase the prompt, or pick another model." in output + assert "Retry: running it again will fail the same way until the cause is fixed." in output diff --git a/tests/unit/test_detached_cli.py b/tests/unit/test_detached_cli.py index 8ee2c9c..c0b3951 100644 --- a/tests/unit/test_detached_cli.py +++ b/tests/unit/test_detached_cli.py @@ -1,5 +1,7 @@ from pathlib import Path +from pipelex_sdk.error_models import RunErrorReport, UserAction +from pipelex_sdk.errors import RunFailedError from pipelex_sdk.runs import RunRead, RunResultCompleted, RunResultFailed, RunResultRunning, RunResults, RunStatus from pytest_mock import MockerFixture from typer.testing import CliRunner @@ -12,6 +14,14 @@ # walks, plus the short-lived signed link beside it, which is never what gets downloaded. IMAGE_CONTENT = {"url": "pipelex-storage://run-1/cat.png", "public_url": "https://cdn.example.com/signed/cat.png"} RUN_ID = "run-abc123" +# A failed run's stored report, and the platform's sentence about the run that carries it. +MODEL_REPORT = RunErrorReport( + title="LLM completion", + message="The model refused the request.", + retryable=True, + user_action=UserAction(kind="change_input", detail="Rephrase the prompt, or pick another model."), +) +REPORTED_DETAIL = "Run finished with status FAILED: The model refused the request." # The lifecycle helpers hand back a whole `RunResults`; these offline tests carry no usage, so the @@ -169,3 +179,60 @@ def test_result_exits_non_zero_when_the_run_failed(self, mocker: MockerFixture): result = runner.invoke(app, ["detached", "result", RUN_ID]) assert result.exit_code == 1 assert "the pipe blew up" in result.output + + def test_result_of_a_failed_run_reads_out_its_stored_report(self, mocker: MockerFixture): + failed = RunResultFailed(pipeline_run_id=RUN_ID, status=RunStatus.FAILED, message=REPORTED_DETAIL, error=MODEL_REPORT) + mocker.patch("widget.detached.cli.fetch_run_result", return_value=failed) + result = runner.invoke(app, ["detached", "result", RUN_ID]) + assert result.exit_code == 1 + _assert_reads_out_the_report(result.output) + + def test_wait_on_a_failed_run_reads_out_its_stored_report(self, mocker: MockerFixture): + error = RunFailedError(REPORTED_DETAIL, run_id=RUN_ID, status=RunStatus.FAILED, error=MODEL_REPORT) + mocker.patch("widget.detached.cli.attend_run", side_effect=error) + result = runner.invoke(app, ["detached", "wait", RUN_ID]) + assert result.exit_code == 1 + _assert_reads_out_the_report(result.output) + + def test_status_of_a_failed_run_reads_out_its_stored_report(self, mocker: MockerFixture): + run = RunRead(pipeline_run_id=RUN_ID, status=RunStatus.FAILED, created_at="2026-07-13T10:00:00Z", error=MODEL_REPORT) + mocker.patch("widget.detached.cli.fetch_run_status", return_value=run) + result = runner.invoke(app, ["detached", "status", RUN_ID]) + assert result.exit_code == 0 + assert "FAILED" in result.stdout + # The report is what the status read answered, so it is the command's output: stdout. + _assert_reads_out_the_report(result.stdout) + + def test_status_of_a_failed_run_without_a_report_says_no_reason_was_recorded(self, mocker: MockerFixture): + run = RunRead(pipeline_run_id=RUN_ID, status=RunStatus.FAILED, created_at="2026-07-13T10:00:00Z") + mocker.patch("widget.detached.cli.fetch_run_status", return_value=run) + result = runner.invoke(app, ["detached", "status", RUN_ID]) + assert result.exit_code == 0 + output = " ".join(result.output.split()) + assert "No reason was recorded for this run." in output + assert "support" in output + + def test_status_of_a_cancelled_run_without_a_report_says_to_start_again(self, mocker: MockerFixture): + run = RunRead(pipeline_run_id=RUN_ID, status=RunStatus.CANCELLED, created_at="2026-07-13T10:00:00Z") + mocker.patch("widget.detached.cli.fetch_run_status", return_value=run) + result = runner.invoke(app, ["detached", "status", RUN_ID]) + assert result.exit_code == 0 + output = " ".join(result.output.split()) + assert "No reason was recorded for this run." in output + assert "start it again" in output.lower() + + def test_status_of_a_run_in_flight_says_nothing_about_a_reason(self, mocker: MockerFixture): + run = RunRead(pipeline_run_id=RUN_ID, status=RunStatus.RUNNING, created_at="2026-07-13T10:00:00Z") + mocker.patch("widget.detached.cli.fetch_run_status", return_value=run) + result = runner.invoke(app, ["detached", "status", RUN_ID]) + assert result.exit_code == 0 + assert "reason" not in result.output.lower() + assert "Hint" not in result.output + + +def _assert_reads_out_the_report(output: str) -> None: + """The report's title and message, its next step and its retry advice, whatever the console wrapped.""" + flattened = " ".join(output.split()) + assert "Reason: LLM completion — The model refused the request." in flattened + assert "Next step: Rephrase the prompt, or pick another model." in flattened + assert "Retry: running it again may succeed." in flattened diff --git a/tests/unit/test_errors.py b/tests/unit/test_errors.py index 231e766..1b419f0 100644 --- a/tests/unit/test_errors.py +++ b/tests/unit/test_errors.py @@ -1,7 +1,10 @@ +import io from collections.abc import Mapping import httpx +import pytest from pipelex_sdk.artifact_models import ArtifactScope, DownloadArtifactsResult +from pipelex_sdk.error_models import RunErrorReport, UserAction from pipelex_sdk.errors import ( ApiUnreachableError, ArtifactAuthenticationError, @@ -16,8 +19,22 @@ UploadAuthenticationError, ) from pipelex_sdk.runs import RunStatus - -from widget.errors import present_error +from rich.console import Console + +from widget.errors import ErrorPresentation, present_error, print_error, report_lines + +# A failed run's stored report as the runner writes it for an inference failure, and the platform's +# sentence about the run with and without one (`Run finished with status : `). +MODEL_REPORT = RunErrorReport( + error_type="LLMCompletionError", + title="LLM completion", + message="The model refused the request.", + error_domain="runtime", + retryable=True, + user_action=UserAction(kind="change_input", detail="Rephrase the prompt, or pick another model."), +) +REPORTED_DETAIL = "Run finished with status FAILED: The model refused the request." +UNREPORTED_DETAIL = "Run finished with status FAILED; no result available" def _http_status_error(status_code: int, *, problem: Mapping[str, object] | None = None) -> httpx.HTTPStatusError: @@ -87,11 +104,38 @@ def test_unreachable_hints_base_url(self): assert presentation.hint is not None assert "PIPELEX_BASE_URL" in presentation.hint - def test_run_failed_names_run_id(self): - presentation = present_error(RunFailedError("run failed", run_id="run-9", status=RunStatus.FAILED)) - assert "run-9" in presentation.message + def test_run_failed_reads_out_the_stored_report(self): + presentation = present_error(RunFailedError(REPORTED_DETAIL, run_id="run-9", status=RunStatus.FAILED, error=MODEL_REPORT)) + assert presentation.message == "Run run-9 failed." + assert presentation.details == ( + "Reason: LLM completion — The model refused the request.", + "Next step: Rephrase the prompt, or pick another model.", + "Retry: running it again may succeed.", + ) + # The next step is the advice, so no hint sends the reader to a command that prints the same lines again. + assert presentation.hint is None + + def test_run_failed_without_a_stored_report_still_says_what_happened(self): + presentation = present_error(RunFailedError(UNREPORTED_DETAIL, run_id="run-9", status=RunStatus.FAILED)) + assert presentation.message == "Run run-9 failed, and no reason was recorded for it." + # The platform's own sentence is kept: on a refused stored result it is the only thing that says what happened. + assert presentation.details == (f"The platform said: {UNREPORTED_DETAIL}",) assert presentation.hint is not None - assert "widget detached status run-9" in presentation.hint + assert "support" in presentation.hint + assert "run-9" in presentation.hint + + def test_a_cancelled_run_without_a_report_is_told_to_start_again(self): + presentation = present_error( + RunFailedError("Run finished with status CANCELLED; no result available", run_id="run-9", status=RunStatus.CANCELLED) + ) + assert presentation.message == "Run run-9 was cancelled, and no reason was recorded for it." + assert presentation.hint is not None + assert "start it again" in presentation.hint.lower() + + def test_a_report_carrying_nothing_to_read_out_is_treated_as_no_report(self): + presentation = present_error(RunFailedError(UNREPORTED_DETAIL, run_id="run-9", status=RunStatus.TIMED_OUT, error=RunErrorReport())) + assert presentation.message == "Run run-9 timed out, and no reason was recorded for it." + assert presentation.details == (f"The platform said: {UNREPORTED_DETAIL}",) def test_run_timeout_hints_wait(self): presentation = present_error(RunTimeoutError("too slow", run_id="run-9", timeout_seconds=1200.0)) @@ -135,3 +179,50 @@ def test_artifact_operation_hints_the_download_directory_without_saying_rerun(se assert "download directory" in presentation.hint # The run itself succeeded — a hint that sent the reader back to rerun it would cost them. assert "rerun" not in presentation.hint.lower() + + @pytest.mark.parametrize( + ("report", "expected_lines"), + [ + pytest.param(None, (), id="no report"), + pytest.param(RunErrorReport(message="The input 'text' is empty."), ("Reason: The input 'text' is empty.",), id="message alone"), + pytest.param(RunErrorReport(title="LLM completion"), ("Reason: LLM completion",), id="title alone"), + pytest.param(RunErrorReport(error_type="SandboxProvisioningError"), ("Reason: SandboxProvisioningError",), id="class as last resort"), + pytest.param( + RunErrorReport(message="Rate limited.", user_action=UserAction(kind="wait_and_retry")), + ("Reason: Rate limited.", "Next step: Wait a moment, then run it again."), + id="kind speaks without detail", + ), + pytest.param( + RunErrorReport(message="Something broke.", user_action=UserAction(kind="unknown")), + ("Reason: Something broke.",), + id="unknown kind without detail", + ), + pytest.param( + RunErrorReport(message="The model does not exist.", retryable=False), + ("Reason: The model does not exist.", "Retry: running it again will fail the same way until the cause is fixed."), + id="not retryable", + ), + # `None` means the runner does not know, never "no" — so nothing is claimed either way. + pytest.param(RunErrorReport(message="Something broke."), ("Reason: Something broke.",), id="retry advice unknown"), + ], + ) + def test_report_lines_read_out_what_the_report_carries(self, report: RunErrorReport | None, expected_lines: tuple[str, ...]): + assert report_lines(report) == expected_lines + + def test_print_error_prints_the_message_the_details_and_the_hint(self): + buffer = io.StringIO() + print_error(Console(file=buffer, width=200), ErrorPresentation(message="Run run-9 failed.", hint="Do this.", details=("Reason: it broke.",))) + rendered = buffer.getvalue() + assert "Error: Run run-9 failed." in rendered + assert "Reason: it broke." in rendered + assert "Hint: Do this." in rendered + + def test_print_error_prints_server_text_verbatim_rather_than_as_markup(self): + # A report's message is the runner's text, provider wording included: a bracketed span in it + # must neither vanish as a style tag nor crash the print as an unmatched closing tag. + buffer = io.StringIO() + presentation = ErrorPresentation(message="Bad value [x] in [/y].", hint=None, details=("Reason: list [1, 2] [bold]",)) + print_error(Console(file=buffer, width=200), presentation) + rendered = buffer.getvalue() + assert "Bad value [x] in [/y]." in rendered + assert "Reason: list [1, 2] [bold]" in rendered diff --git a/widget/attended/cli.py b/widget/attended/cli.py index 8b65648..84ea3db 100644 --- a/widget/attended/cli.py +++ b/widget/attended/cli.py @@ -31,7 +31,7 @@ # add-method:imports — `make add-method` inserts a scaffolded method's generated-model import # into the block below, in sorted position. Keep the token; the prose after it is free. from widget.artifacts import DEFAULT_DOWNLOAD_DIR, download_produced_files, print_downloads -from widget.errors import present_error +from widget.errors import present_error, print_error from widget.generated.extract_entities.models import ExtractedEntities from widget.generated.generate_image.models import Image from widget.generated.summarize_pdf.models import DocumentSummary @@ -174,10 +174,7 @@ def _run(coro: Coroutine[Any, Any, ResultT]) -> ResultT: try: return asyncio.run(coro) except (PipelineRequestError, httpx.HTTPStatusError) as exc: - presentation = present_error(exc) - progress_console.print(f"[red]Error:[/red] {presentation.message}") - if presentation.hint: - progress_console.print(f"\n[yellow]Hint:[/yellow] {presentation.hint}") + print_error(progress_console, present_error(exc)) raise typer.Exit(1) from exc except KeyboardInterrupt as exc: # The resume hint was already printed by `start_and_wait`; the run keeps executing server-side. diff --git a/widget/blocking/cli.py b/widget/blocking/cli.py index 8a6cec5..3ff99f0 100644 --- a/widget/blocking/cli.py +++ b/widget/blocking/cli.py @@ -25,7 +25,7 @@ # add-method:imports — `make add-method` inserts a scaffolded method's generated-model import # into the block below, in sorted position. Keep the token; the prose after it is free. from widget.artifacts import DEFAULT_DOWNLOAD_DIR, download_produced_files, print_downloads -from widget.errors import present_error +from widget.errors import present_error, print_error from widget.generated.extract_entities.models import ExtractedEntities from widget.generated.generate_image.models import Image from widget.generated.summarize_pdf.models import DocumentSummary @@ -154,10 +154,7 @@ def _run(coro: Coroutine[Any, Any, ResultT]) -> ResultT: try: return asyncio.run(coro) except (PipelineRequestError, httpx.HTTPStatusError) as exc: - presentation = present_error(exc) - progress_console.print(f"[red]Error:[/red] {presentation.message}") - if presentation.hint: - progress_console.print(f"\n[yellow]Hint:[/yellow] {presentation.hint}") + print_error(progress_console, present_error(exc)) raise typer.Exit(1) from exc except KeyboardInterrupt as exc: raise typer.Exit(130) from exc diff --git a/widget/detached/cli.py b/widget/detached/cli.py index a3a96db..31c2945 100644 --- a/widget/detached/cli.py +++ b/widget/detached/cli.py @@ -6,7 +6,7 @@ from another terminal, another machine, another day: - `widget detached wait ` — poll it to completion and print its result. -- `widget detached status ` — where is it right now, without waiting. +- `widget detached status ` — where is it right now, without waiting, and why it failed if it did. - `widget detached result ` — its result if it is done, without waiting. Same durable run as `widget attended`; the only difference is who waits. @@ -37,11 +37,12 @@ WaitForResultOptions, ) from rich.console import Console +from rich.markup import escape # add-method:imports — `make add-method` inserts a scaffolded method's generated-model import # into the block below, in sorted position. Keep the token; the prose after it is free. from widget.artifacts import DEFAULT_DOWNLOAD_DIR, download_produced_files, print_downloads -from widget.errors import present_error +from widget.errors import present_error, present_failed_run, print_error from widget.inputs import SAMPLE_ENTITIES_TEXT, SAMPLE_IMAGE_PROMPT, SAMPLE_INVOICE, read_text_input, upload_document_input from widget.usage import print_cost_report @@ -177,12 +178,20 @@ def wait( @app.command(name="status") def status(run_id: Annotated[str, typer.Argument(help="The pipeline run id printed when the run started.")]) -> None: - """Show a run's coarse status without waiting.""" + """Show a run's coarse status without waiting — and, for a run that ended without a result, why.""" run = _run(fetch_run_status(run_id)) pipe_part = f" (pipe: {run.pipe_code})" if run.pipe_code else "" - output_console.print(f"{run.pipeline_run_id}: [bold]{run.status}[/bold]{pipe_part}") + output_console.print(f"{run.pipeline_run_id}: [bold]{run.status}[/bold]{escape(pipe_part)}") if run.degraded: output_console.print("[yellow]Status is degraded — last-known value, the status backend was unreachable; retry shortly.[/yellow]") + if run.status.is_terminal and not run.status.is_success: + # The status read carries the run's stored error report, read out as a failed `wait` reads it. + # The reason is part of the answer (stdout); the hint, for a run that recorded none, is chatter (stderr). + failure = present_failed_run(run_id=run.pipeline_run_id, status=run.status, report=run.error, platform_message=None) + for line in failure.details or ("No reason was recorded for this run.",): + output_console.print(escape(line)) + if failure.hint: + progress_console.print(f"[yellow]Hint:[/yellow] {escape(failure.hint)}") @app.command(name="result") @@ -201,7 +210,11 @@ def result( case RunResultCompleted(): _print_results(state.result) case RunResultFailed(): - progress_console.print(f"[red]Run {state.pipeline_run_id} ended with status {state.status}: {state.message}[/red]") + # The failed arm carries what `RunFailedError` carries, so it reads exactly as a failed `wait` does. + print_error( + progress_console, + present_failed_run(run_id=state.pipeline_run_id, status=state.status, report=state.error, platform_message=state.message), + ) raise typer.Exit(1) @@ -237,10 +250,7 @@ def _run(coro: Coroutine[Any, Any, ResultT]) -> ResultT: try: return asyncio.run(coro) except (PipelineRequestError, httpx.HTTPStatusError) as exc: - presentation = present_error(exc) - progress_console.print(f"[red]Error:[/red] {presentation.message}") - if presentation.hint: - progress_console.print(f"\n[yellow]Hint:[/yellow] {presentation.hint}") + print_error(progress_console, present_error(exc)) raise typer.Exit(1) from exc except KeyboardInterrupt as exc: # The resume hint was already printed by `attend_run`; the run keeps executing server-side. diff --git a/widget/errors.py b/widget/errors.py index 11a99ef..8937ba2 100644 --- a/widget/errors.py +++ b/widget/errors.py @@ -3,9 +3,16 @@ This module defines no exception classes — it is a presentation mapper. Each mode package's `_run()` wrapper (`widget/blocking/cli.py`, `widget/attended/cli.py`, `widget/detached/cli.py`) catches `PipelineRequestError` (the base of every error the -`pipelex-sdk` client raises) exactly once, turns it into a `(message, hint)` pair here, -and exits non-zero. Unexpected exceptions are deliberately NOT caught anywhere: they -crash loudly with a full traceback. +`pipelex-sdk` client raises) exactly once, turns it into an `ErrorPresentation` here — +a message, the lines that explain it, and a hint — prints it with `print_error`, and +exits non-zero. Unexpected exceptions are deliberately NOT caught anywhere: they crash +loudly with a full traceback. + +A run that ended without a result is presented from the error report the runner stored +when it failed, which the SDK hands back typed as `RunErrorReport`: `report_lines` reads +out its reason, its next step and its retry advice, and the detached `status` and +`result` commands print the same lines, so a failed run reads the same wherever you +meet it. Error presentation is orthogonal to execution mode, so it is shared — but the hints name the mode *groups*, since the fix for a timed-out blocking run is to rerun it under @@ -17,6 +24,7 @@ import httpx from mthds.protocol.exceptions import PipelineRequestError +from pipelex_sdk.error_models import RunErrorReport, UserAction from pipelex_sdk.errors import ( ApiResponseError, ApiUnreachableError, @@ -32,13 +40,30 @@ UnsupportedUploadCapabilityError, UploadAuthenticationError, ) +from pipelex_sdk.runs import RunStatus +from rich.console import Console +from rich.markup import escape + +#: A sentence for each kind of advice the runner names, for a report whose `user_action` carries no +#: `detail`. The kinds are an open set on the wire, so a kind missing here prints no next step rather +#: than a guess — `unknown` among them. +_NEXT_STEP_BY_KIND: dict[str, str] = { + "wait_and_retry": "Wait a moment, then run it again.", + "check_billing": "Check your plan and credits.", + "check_credentials": "Check the credentials the failing call uses.", + "change_input": "Change the inputs, then run it again.", + "change_model": "Change the model the failing pipe uses.", + "contact_support": "Contact support with the run id.", +} class ErrorPresentation(NamedTuple): - """What the CLI shows for a failed command: the error and what to do about it.""" + """What the CLI shows for a failed command: the error, the lines that explain it, and what to do about it.""" message: str hint: str | None + #: Lines printed under the message — for a failed run, its stored report read out field by field. + details: tuple[str, ...] = () def present_error(exc: PipelineRequestError | httpx.HTTPStatusError) -> ErrorPresentation: @@ -77,10 +102,7 @@ def present_error(exc: PipelineRequestError | httpx.HTTPStatusError) -> ErrorPre hint="Check PIPELEX_BASE_URL — and if you self-host, make sure your runner is up.", ) if isinstance(exc, RunFailedError): - return ErrorPresentation( - message=f"Run {exc.run_id} ended with status {exc.status}: {exc}", - hint=f"Inspect it with `widget detached status {exc.run_id}`.", - ) + return present_failed_run(run_id=exc.run_id, status=exc.status, report=exc.error, platform_message=str(exc)) if isinstance(exc, RunTimeoutError): return ErrorPresentation( message=f"Gave up waiting for run {exc.run_id} after {exc.timeout_seconds:.0f}s — the run is still executing server-side.", @@ -95,6 +117,125 @@ def present_error(exc: PipelineRequestError | httpx.HTTPStatusError) -> ErrorPre return ErrorPresentation(message=str(exc), hint=None) +def present_failed_run(*, run_id: str, status: RunStatus, report: RunErrorReport | None, platform_message: str | None) -> ErrorPresentation: + """Present a run that ended without a result, from the report the runner stored when it failed. + + The SDK hands the same three things back wherever such a run surfaces — `RunFailedError` out of + `wait_for_result`, `start_and_wait` or an artifact download, the failed arm of `get_run_result`, and + the status read: the terminal `status`, the stored `report` (`None` when the run ended with none, + as a cancelled run does), and, on the first two, the platform's one sentence about the run. + + With a report, the lines under the message read it out and there is no hint: the report's next + step is the advice, and a hint pointing at `widget detached status` would only print the same + lines again. Without one, the platform's sentence is kept — on a stored result the platform + refuses to serve it is the only thing that says what happened — and the hint says what is left. + """ + how_it_ended = _how_the_run_ended(status) + lines = report_lines(report) + if lines: + return ErrorPresentation(message=f"Run {run_id} {how_it_ended}.", hint=None, details=lines) + platform_lines = (f"The platform said: {platform_message}",) if platform_message else () + return ErrorPresentation( + message=f"Run {run_id} {how_it_ended}, and no reason was recorded for it.", + hint=_hint_without_a_reason(run_id=run_id, status=status), + details=platform_lines, + ) + + +def report_lines(report: RunErrorReport | None) -> tuple[str, ...]: + """A failed run's stored report as the lines a person reads: the reason, the next step, the retry advice. + + The reason is the report's `title` and `message` (its `error_type`, the runner's exception class, + when it carries neither); the next step is its `user_action`; the retry advice is its `retryable`, + left unsaid when that is `None`, which means unknown rather than no. Empty when there is no report + or it carries none of these, so the caller can tell a report worth reading from its absence. + + The report is the runner's verbose one, so `message` can hold a provider's raw text. This is a + developer's tool, so it is printed as it came; an application in front of end users decides what + of it they see. + """ + if report is None: + return () + lines: list[str] = [] + reason = _reason(report) + if reason: + lines.append(f"Reason: {reason}") + next_step = _next_step(report.user_action) + if next_step: + lines.append(f"Next step: {next_step}") + match report.retryable: + case True: + lines.append("Retry: running it again may succeed.") + case False: + lines.append("Retry: running it again will fail the same way until the cause is fixed.") + case None: + pass + return tuple(lines) + + +def print_error(console: Console, presentation: ErrorPresentation) -> None: + """Print a presentation: the message, its detail lines, then the hint. + + Every piece is escaped before it reaches Rich, because the message and the details carry the + server's text: a bracketed span in a provider's message would otherwise be read as a style tag, + swallowed, or crash the print as an unmatched closing tag. + """ + console.print(f"[red]Error:[/red] {escape(presentation.message)}") + for line in presentation.details: + console.print(f" {escape(line)}") + if presentation.hint: + console.print(f"\n[yellow]Hint:[/yellow] {escape(presentation.hint)}") + + +def _how_the_run_ended(status: RunStatus) -> str: + match status: + case RunStatus.FAILED: + return "failed" + case RunStatus.CANCELLED: + return "was cancelled" + case RunStatus.TERMINATED: + return "was terminated" + case RunStatus.TIMED_OUT: + return "timed out" + case RunStatus.PENDING | RunStatus.STARTED | RunStatus.RUNNING | RunStatus.COMPLETED: + # Not an ending without a result, so it is named as the status rather than worded as one. + return f"ended with status {status}" + + +def _hint_without_a_reason(*, run_id: str, status: RunStatus) -> str: + match status: + case RunStatus.FAILED: + return f"Nothing more is recorded about this failure — contact support with the run id {run_id}." + case RunStatus.CANCELLED | RunStatus.TERMINATED | RunStatus.TIMED_OUT: + return "The run stopped before it produced a result — start it again to get one." + case RunStatus.PENDING | RunStatus.STARTED | RunStatus.RUNNING | RunStatus.COMPLETED: + return f"Check it again with `widget detached status {run_id}`." + + +def _reason(report: RunErrorReport) -> str | None: + """The report's title and message, whichever it carries, or its exception class as a last resort.""" + title = _text(report.title) + message = _text(report.message) + if title and message: + return f"{title} — {message}" + return title or message or _text(report.error_type) + + +def _next_step(user_action: UserAction | None) -> str | None: + """The advice's own words, or a sentence for its kind when it gives none.""" + if user_action is None: + return None + return _text(user_action.detail) or _NEXT_STEP_BY_KIND.get(user_action.kind or "") + + +def _text(value: str | None) -> str | None: + """A report field as printable text: `None` for a missing or blank one.""" + if value is None: + return None + stripped = value.strip() + return stripped or None + + def _present_upload_error(exc: InputPreparationError) -> ErrorPresentation: """Present a file-upload (input-preparation) failure. The SDK already gives each a clear message; here we add the actionable hint per semantic category.""" From 6e748773386ccfee4ae5af88e73ba77dbd4baa0e Mon Sep 17 00:00:00 2001 From: Louis Choquel Date: Sat, 26 Sep 2026 19:51:21 +0200 Subject: [PATCH 4/6] docs: scope the markup changelog entry to what the branch escapes The entry claimed every server error message was printed as it came, while download errors and the usage assembly error still go through Rich markup; it now names the error presentation and the status read-out, which are what this branch escapes. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bd065c..706d384 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ ### Fixed - **A failed run says why**: a durable run that ended without a result, whether met by `widget attended …`, `widget detached wait` or `widget detached result`, now prints the reason the runner stored for it (its title and message), the next step it advises and whether running it again can succeed, instead of repeating its status; `widget detached status` prints the same lines under the status. A run that ended with no stored report, such as a cancelled one, says that no reason was recorded, keeps the platform's own sentence and says what is left to do. -- **Server text in an error is printed as it came**: a bracketed span in an error message or a stored report, such as a provider's `[/x]`, is no longer read as Rich markup, so it neither disappears nor crashes the print. +- **An error that stops a command prints the server's text as it came**: a bracketed span in the message, its explanation or its hint, and in a failed run's stored report read out by `widget detached status`, such as a provider's `[/x]`, is no longer read as Rich markup, so it neither disappears nor crashes the print. ## [v0.1.0] - 2026-09-22 From 1641e068a8eef3b55eb59db6eb5f08d76c1260d2 Mon Sep 17 00:00:00 2001 From: Louis Choquel Date: Sun, 27 Sep 2026 12:41:51 +0200 Subject: [PATCH 5/6] collapse the `pipelex-sdk` pin onto 0.13.0 `pipelex-starter-python` takes `pipelex-sdk` from the registry again, with the lock regenerated in the same commit. The pin stood at ffcb5c6e of `pipelex-sdk-python` (P7). --- pyproject.toml | 5 +---- uv.lock | 16 ++++++++++------ 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 494bd85..fc6d1a3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,7 @@ classifiers = [ dependencies = [ "httpx>=0.27.0", "mthds>=0.14.0", - "pipelex-sdk>=0.10.2", + "pipelex-sdk>=0.13.0", "python-dotenv>=1.0.0", "rich>=13.0.0", "typer>=0.15.0", @@ -259,6 +259,3 @@ select = [ "E501", "I", ] - -[tool.uv.sources] -pipelex-sdk = { git = "https://github.com/Pipelex/pipelex-sdk-python.git", rev = "ffcb5c6e6cefa9f5f63b9de80c87d13c64735932" } diff --git a/uv.lock b/uv.lock index d1282d2..7d11cee 100644 --- a/uv.lock +++ b/uv.lock @@ -192,7 +192,7 @@ wheels = [ [[package]] name = "mthds" -version = "0.15.0" +version = "0.16.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -201,9 +201,9 @@ dependencies = [ { name = "tomlkit" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/54/16/1aa1219f44bc469213018478f582eb4eb28f69ea43a09fe73afc9cfb4e34/mthds-0.15.0.tar.gz", hash = "sha256:2e612174b089cae799eb236f5b1bcd183b8847a62a06a265f72f0e50b4c89555", size = 250552, upload-time = "2026-09-18T20:23:09.888Z" } +sdist = { url = "https://files.pythonhosted.org/packages/25/03/4aca733536f741f741d0cba3ae3d23dfd56f691c53a0d7e3e7baea371585/mthds-0.16.0.tar.gz", hash = "sha256:cc8f54bc76c9ed13273e7cd2e1c66767e5478fc3640a26481bf099451d56aa29", size = 259394, upload-time = "2026-09-23T12:13:31.752Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c0/5c/7526815d8bc8cd8690c8cf4de3819e3f322886280af05bf9aed7bdce450c/mthds-0.15.0-py3-none-any.whl", hash = "sha256:98670ca1f97fc2211ace0f404acd416ce5882edb728845d48440d6e73eedc34c", size = 99711, upload-time = "2026-09-18T20:23:08.283Z" }, + { url = "https://files.pythonhosted.org/packages/d0/00/a80f90f0ed7ddff9886f925fbb8dd0061ec0b0e1af49901971664f1a3e12/mthds-0.16.0-py3-none-any.whl", hash = "sha256:67468669451278e8c17409c5fed60eafab8453fd5cd92f333d788b21f7500ebe", size = 103813, upload-time = "2026-09-23T12:13:30.288Z" }, ] [[package]] @@ -283,14 +283,18 @@ wheels = [ [[package]] name = "pipelex-sdk" -version = "0.12.0" -source = { git = "https://github.com/Pipelex/pipelex-sdk-python.git?rev=ffcb5c6e6cefa9f5f63b9de80c87d13c64735932#ffcb5c6e6cefa9f5f63b9de80c87d13c64735932" } +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, { name = "mthds" }, { name = "pydantic" }, { name = "typing-extensions" }, ] +sdist = { url = "https://files.pythonhosted.org/packages/0b/47/0baf77c28bd66fd76d7dfe41c56a2040a99a2f1fbdf12c81fd7538cba423/pipelex_sdk-0.13.0.tar.gz", hash = "sha256:2ad0a9f619742c99b23df7dc5a39fb1feb33ef633d249674e7c2958b03150107", size = 391622, upload-time = "2026-09-27T10:38:31.576Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/5c/1929a77768c58a3e9917a7b811ed8cc2999abfdac3345f2252724baab0bd/pipelex_sdk-0.13.0-py3-none-any.whl", hash = "sha256:75b1fe64c4b4d8db56d602300da4da70c97d98b116f09fd17753232c7bc4498d", size = 132322, upload-time = "2026-09-27T10:38:30.21Z" }, +] [[package]] name = "pipelex-tools" @@ -653,7 +657,7 @@ requires-dist = [ { name = "httpx", specifier = ">=0.27.0" }, { name = "mthds", specifier = ">=0.14.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = "==1.19.1" }, - { name = "pipelex-sdk", git = "https://github.com/Pipelex/pipelex-sdk-python.git?rev=ffcb5c6e6cefa9f5f63b9de80c87d13c64735932" }, + { name = "pipelex-sdk", specifier = ">=0.13.0" }, { name = "pipelex-tools", marker = "extra == 'dev'", specifier = ">=0.7.2" }, { name = "pyright", marker = "extra == 'dev'", specifier = ">=1.1.411" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.3" }, From 911ac6cdbeab65ffe6ae86cd881285cbe08e4338 Mon Sep 17 00:00:00 2001 From: Louis Choquel Date: Sun, 27 Sep 2026 12:43:27 +0200 Subject: [PATCH 6/6] chore: raise the mthds floor to 0.16.0 and note the new floors pipelex-sdk 0.13.0 pins mthds exactly at 0.16.0, and the manifest's comment holds the mthds floor at the version the SDK pins, so the floor follows it. The changelog names both floors, since the failed-run presentation needs the SDK's typed error report that first shipped in 0.13.0. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 706d384..a235dce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Changed - **The demos name their pipe by its qualified reference**: every demo command, in every mode, sends `pipe_code` as `domain.pipe_code` (`extract_entities.extract_entities`) rather than the bare code, the exact key the runtime resolves. A bare code is searched for across every domain of the bundle and fails as ambiguous once two domains declare it, so code copied from a demo keeps working as its bundle grows; rename a bundle's `domain` and its call sites together. +- **`pipelex-sdk` 0.13.0 and `mthds` 0.16.0 are the floors**: the failed-run presentation reads the SDK's typed error report, which first shipped in `pipelex-sdk` 0.13.0, and `mthds` follows the version that release pins exactly. ### Fixed diff --git a/pyproject.toml b/pyproject.toml index fc6d1a3..79b5ebe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,7 @@ classifiers = [ # pin always satisfies it, so bumping the SDK never has to be matched here. dependencies = [ "httpx>=0.27.0", - "mthds>=0.14.0", + "mthds>=0.16.0", "pipelex-sdk>=0.13.0", "python-dotenv>=1.0.0", "rich>=13.0.0", diff --git a/uv.lock b/uv.lock index 7d11cee..a3321f4 100644 --- a/uv.lock +++ b/uv.lock @@ -655,7 +655,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "httpx", specifier = ">=0.27.0" }, - { name = "mthds", specifier = ">=0.14.0" }, + { name = "mthds", specifier = ">=0.16.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = "==1.19.1" }, { name = "pipelex-sdk", specifier = ">=0.13.0" }, { name = "pipelex-tools", marker = "extra == 'dev'", specifier = ">=0.7.2" },