Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/tidy-donkeys-report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"@sapiom/tools": minor
---

`contentGeneration`: an async image or video job that terminally FAILS is now distinguishable from one that is merely slow (SAP-3097).

**`wait()` fails fast — live in this release.** Every non-OK poll response used to be treated as "still generating", so a job that failed in three seconds burned the caller's whole `timeoutMs` and then threw `Image generation did not complete within 300000ms` — the opposite of what happened. `wait()` (and `video.create`, which polls the same way) now reads the queue's terminal state and throws the new `ContentGenerationFailedError` as soon as the job fails, carrying `requestId` and the provider's own `providerError`. A plain `Error` about the timeout now means only what it says: the job was still running when you stopped waiting. `ContentGenerationFailedError` is exported from `@sapiom/tools`.

A transport blip still keeps polling. A non-OK result poll is ambiguous on its own, so it is disambiguated against the status endpoint, and anything short of an explicit terminal marker keeps the poll going.

**`generationError` on the resume payload — a type, populated by the platform.** `ImageResultPayload` and `VideoResultPayload` outputs gain `generationError?: string`. A terminal provider failure has been arriving on `storageError` — the field documented as "persisting this output failed" — so a resumed workflow step concludes storage broke when in fact nothing was ever generated. The platform sends one field or the other, never both, so a step can branch without string-matching a message.

Nothing in this package produces `generationError`; it appears on the wire once the corresponding gateway change is deployed, and a step running against an older gateway still sees a generation failure on `storageError`. Check `generationError` first and fall back to `storageError` — that reads correctly on both sides of the deploy.

The type is also what makes `VIDEO_RESULT_SIGNAL`'s documented "carries the result either way (ready OR failed)" contract expressible; the payload previously had nowhere to put the failure. `IMAGE_RESULT_SIGNAL` carries the same contract.
2 changes: 1 addition & 1 deletion examples/content-repurposing-pipeline/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ that wall no longer applies.
suspends the step until the job's signal arrives. The step must also **declare**
the edge: `pause: { signal: IMAGE_RESULT_SIGNAL, resumeStep }` (or
`VIDEO_RESULT_SIGNAL`). The resumed step receives an `ImageResultPayload` /
`VideoResultPayload` (`{ outputs: [{ fileId?, downloadUrl? }] }`).
`VideoResultPayload` (`{ outputs: [{ fileId?, downloadUrl?, generationError? }] }` — the signal fires on either terminal outcome, so check `generationError` before treating a missing `fileId` as a storage problem).
- **Structured output, never a hand parse.** `repurpose` sets `output: { name, schema }`
on the `llm.run` spec — which appends that tool and forces `tool_choice` onto it — and
reads the pack back with `ctx.sapiom.llm.structuredOf(res, name)`. There is no prose to
Expand Down
8 changes: 6 additions & 2 deletions examples/content-repurposing-pipeline/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -811,9 +811,13 @@ const collectGraphic = defineStep({

const img = result.outputs?.[0];
if (!img?.fileId && !img?.downloadUrl) {
const storageError = img?.storageError ? `: ${img.storageError}` : "";
// `generationError` means the model itself failed (no asset was ever made);
// `storageError` means it was made but we couldn't keep it. Report whichever applies.
const reason = img?.generationError ?? img?.storageError;
throw new Error(
`quote graphic generation completed without a usable output for quote ${index + 1}${storageError}`,
img?.generationError
? `quote graphic generation failed for quote ${index + 1}: ${img.generationError}`
: `quote graphic generation completed without a usable output for quote ${index + 1}${reason ? `: ${reason}` : ""}`,
);
}
const graphic: Graphic = {
Expand Down
12 changes: 10 additions & 2 deletions examples/research-to-microsite/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -919,10 +919,18 @@ const collectIllustration = defineStep({
} else {
// Best-effort, same as a failed launch: no usable image for this
// section, never a failed run.
const storageError = img?.storageError ? `: ${img.storageError}` : "";
ctx.logger.warn(
"illustration generation returned no usable output; continuing without it",
{ heading: section.heading, storageError },
{
heading: section.heading,
// The model failed vs. we failed to keep what it made — distinct fields.
...(img?.generationError !== undefined && {
generationError: img.generationError,
}),
...(img?.storageError !== undefined && {
storageError: img.storageError,
}),
},
);
}

Expand Down
2 changes: 1 addition & 1 deletion examples/scene-to-video/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ decompose ─▶ keyframe ⇄ collectKeyframe ─▶ animate ⇄ collect ─▶

- An agent is `defineAgent({ entry, steps })`; each step is `defineStep({ name, next, run, ... })`. Keep exactly one `defineAgent(...)` export.
- **Capabilities come from the types.** What's available on `ctx.sapiom` is defined by `@sapiom/tools` — read the types / use autocomplete rather than guessing. A wrong capability or method name fails typecheck. One-shot LLM work uses `ctx.sapiom.llm.run({ request: { system, messages, max_tokens } })` through the gateway.
- **Async pause/resume.** A launched capability (`images.launch`, `video.launch`) returns a dispatch handle; `return pauseUntilSignal(handle, { resumeStep })` suspends the step until the job's signal arrives. The step must also **declare** the edge: `pause: { signal, resumeStep }`. The resumed step receives an `ImageResultPayload` / `VideoResultPayload` (`{ outputs: [{ fileId?, downloadUrl? }] }`).
- **Async pause/resume.** A launched capability (`images.launch`, `video.launch`) returns a dispatch handle; `return pauseUntilSignal(handle, { resumeStep })` suspends the step until the job's signal arrives. The step must also **declare** the edge: `pause: { signal, resumeStep }`. The resumed step receives an `ImageResultPayload` / `VideoResultPayload` (`{ outputs: [{ fileId?, downloadUrl?, generationError? }] }` — the signal fires on either terminal outcome, so check `generationError` before treating a missing `fileId` as a storage problem).
- **Why `keyframe`/`animate` are sequential, not one paused step per job at once.** A paused step waits on a single `(signal, correlationId)` pair. Launching every job up front and then draining would risk one finishing before we've paused on it — its resume signal would have nowhere to land. Launching shot `i` only after shot `i-1` resumes keeps a paused step always waiting before its job can complete. This is also why keyframes use `images.launch` rather than a concurrent `Promise.all` of `images.create`: the synchronous routed call holds its request open for the full generate+store, which meets Core's 30s router cap under fan-out — `launch` submits and returns as soon as the job is enqueued, so it never does.

## Validating
Expand Down
14 changes: 10 additions & 4 deletions examples/scene-to-video/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -475,9 +475,13 @@ const collectKeyframe = defineStep({

const img = result.outputs?.[0];
if (!img?.fileId && !img?.downloadUrl) {
const storageError = img?.storageError ? `: ${img.storageError}` : "";
// `generationError` means the model itself failed (no asset was ever made);
// `storageError` means it was made but we couldn't keep it. Report whichever applies.
const reason = img?.generationError ?? img?.storageError;
throw new Error(
`keyframe generation completed without a usable output for shot ${index + 1}${storageError}`,
img?.generationError
? `keyframe generation failed for shot ${index + 1}: ${img.generationError}`
: `keyframe generation completed without a usable output for shot ${index + 1}${reason ? `: ${reason}` : ""}`,
);
}
const frame: Keyframe = {
Expand Down Expand Up @@ -553,9 +557,11 @@ const collect = defineStep({

const out = result.outputs?.[0];
if (!out?.fileId && !out?.downloadUrl) {
const storageError = out?.storageError ? `: ${out.storageError}` : "";
const reason = out?.generationError ?? out?.storageError;
throw new Error(
`video generation completed without a usable output for shot ${index + 1}${storageError}`,
out?.generationError
? `video generation failed for shot ${index + 1}: ${out.generationError}`
: `video generation completed without a usable output for shot ${index + 1}${reason ? `: ${reason}` : ""}`,
);
}
const clip: Clip = {
Expand Down
65 changes: 54 additions & 11 deletions packages/tools/src/content-generation/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,17 +96,20 @@ values onto its result.
video, `ImageCreateInput` has no `timeoutMs` / `pollIntervalMs` fields, so those
are the only defaults and any override goes on the `wait()` call.

On the `wait()` path a terminal provider failure is not currently distinguishable
from a slow one: a non-OK poll is treated as "still generating", so a failed job
surfaces as the deadline `Error` (`Image generation did not complete within
…ms`) rather than a failure-specific error.
On the `wait()` path a terminal provider failure throws
`ContentGenerationFailedError` as soon as the job fails, carrying `requestId` and
the provider's own `providerError` — it does not run out the clock first. The
deadline `Error` (`Image generation did not complete within …ms`) now means only
what it says: the job was still running when you stopped waiting.

### `IMAGE_RESULT_SIGNAL`

The capability-stable signal constant (`"contentGeneration.images.result"`),
fired when the job reaches a terminal state. Declare it in the **pausing** step's
static `pause` edge — `signal` and `resumeStep` are both required — so the engine
knows which signal to listen for and which step to resume with the result:
fired when the job reaches a terminal state — **either** outcome, ready or failed.
The payload carries which one it was (`generationError` on a failure), so the
resumed step always runs and branches. Declare it in the **pausing** step's static
`pause` edge — `signal` and `resumeStep` are both required — so the engine knows
which signal to listen for and which step to resume with the result:

```typescript
import { defineStep, pauseUntilSignal, terminate } from "@sapiom/agent";
Expand Down Expand Up @@ -161,14 +164,20 @@ interface ImageResultPayload {
downloadUrl?: string; // ready-to-use short-lived URL (may have expired by resume)
downloadUrlExpiresAt?: string; // ISO expiry of downloadUrl, when present
downloadUrlUnavailable?: boolean; // fileId is set but no URL could be minted — re-fetch from fileId
storageError?: string; // present when storage was requested but failed
storageError?: string; // the asset WAS generated, but persisting it failed
generationError?: string; // the generation itself failed — no asset ever existed
}>;
}
```

The per-image `width` / `height` / `url` fields of `images.create` are not on the
resume payload — `fileId` is the durable handle to re-fetch from.

`storageError` and `generationError` are different failures, and the platform sends
one or the other, never both — see [the video payload](#videoresultpayload) for the
branch that reads correctly on both sides of the gateway deploy that starts emitting
`generationError`.

Import `ImageResultPayload` from `@sapiom/tools` to annotate the resumed step's
`input` type; import `toImageResumePayload` to map a live `ImageGenerationResult`
to this shape when wiring local tests.
Expand Down Expand Up @@ -230,7 +239,10 @@ defaults when `wait()` is called without arguments, so the two APIs stay in sync

The capability-stable signal constant — declare it in the **pausing** step's
static `pause` edge (`signal` and `resumeStep` are both required) so the engine
knows which signal to listen for and which step to resume with the result:
knows which signal to listen for and which step to resume with the result. It
fires on **either** terminal outcome, ready or failed, and the payload carries
which one it was (`generationError` on a failure), so the resumed step always
runs and branches:

```typescript
import { defineStep, pauseUntilSignal, terminate } from "@sapiom/agent";
Expand Down Expand Up @@ -276,14 +288,40 @@ interface VideoResultPayload {
downloadUrl?: string; // ready-to-use short-lived URL (may have expired by resume)
downloadUrlExpiresAt?: string; // ISO expiry of downloadUrl, when present
downloadUrlUnavailable?: boolean; // fileId is set but no URL could be minted — re-fetch from fileId
storageError?: string; // present when storage was requested but failed
storageError?: string; // the asset WAS generated, but persisting it failed
generationError?: string; // the generation itself failed — no asset ever existed
}>;
}
```

[`ImageResultPayload`](#imageresultpayload) is the same shape — `images.launch` resumes
through the same rail.

`storageError` and `generationError` are different failures, and the platform sends one or
the other, never both. Branch on `generationError` for "the model failed" — retrying the
same prompt will probably fail the same way — and on `storageError` for "the model
succeeded but we couldn't keep the result".

```typescript
const out = result.outputs[0];
if (out?.generationError)
throw new Error(`generation failed: ${out.generationError}`);
if (out?.storageError)
throw new Error(`could not persist the output: ${out.storageError}`);
```

`generationError` is populated by the platform, so it appears once the gateway change that
emits it is deployed. Until then a terminal generation failure still arrives on
`storageError`, saying the opposite of what happened — which is the reason for the split.
Checking `generationError` first and falling back to `storageError`, as above, reads
correctly on both sides of that deploy.

Import `VideoResultPayload` from `@sapiom/tools` to annotate the resumed step's
`input` type; import `toVideoResumePayload` to map a live `VideoGenerationResult`
to this shape when wiring local tests.
to this shape when wiring local tests. The mappers carry no `generationError`: a live
`wait()` throws `ContentGenerationFailedError` on a terminal failure rather than
returning one, so a `VideoGenerationResult` has no failure to map. Build that payload
literally when you want to exercise the branch.

## Input params

Expand Down Expand Up @@ -465,4 +503,9 @@ name the specific `VideoSelect` when you need `requires`.

- **Failed requests throw `ContentGenerationHttpError`** (carries `status` +
parsed `body`), exported from `@sapiom/tools`.
- **A failed _generation_ throws `ContentGenerationFailedError`** from `wait()` (and
from `video.create`), as soon as the job terminally fails rather than at `timeoutMs`.
It carries `requestId` and the provider's own `providerError`. A plain `Error`
(`"… did not complete within …ms"`) now means only what it says: the job was still
running when you stopped waiting.
- **`storage` is reserved** — a same-named field in `params` is ignored.
46 changes: 46 additions & 0 deletions packages/tools/src/content-generation/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,52 @@ export class ContentGenerationHttpError extends Error {
}
}

/**
* Error thrown when a launched media job reaches a terminal FAILED state — the provider
* job errored, was cancelled, or completed with an error, so no asset was ever produced
* (SAP-3097).
*
* Distinct from {@link ContentGenerationHttpError}, which reports an HTTP request that
* failed, and from the plain `Error` a poll throws when `timeoutMs` elapses with the job
* still in flight. Catching this one means "the generation failed"; catching the timeout
* means "it is still running and I stopped waiting".
*
* try {
* const out = await handle.wait();
* } catch (err) {
* if (err instanceof ContentGenerationFailedError) {
* // The model failed. `err.providerError` says why; retrying the same prompt
* // will probably fail the same way.
* }
* }
*/
export class ContentGenerationFailedError extends Error {
/** The queue request id of the failed job. */
readonly requestId: string;
/** The provider's own reason for the failure. */
readonly providerError: string;
/** The raw polled body the failure was read from, for programmatic inspection. */
readonly body: unknown;

/**
* @param mediaLabel How the medium is named in the message, e.g. `"Image"` / `"Video"`.
*/
constructor(
mediaLabel: string,
requestId: string,
providerError: string,
body: unknown,
) {
super(
`${mediaLabel} generation failed (request id: ${requestId}): ${providerError}`,
);
this.name = "ContentGenerationFailedError";
this.requestId = requestId;
this.providerError = providerError;
this.body = body;
}
}

/**
* Return the response when 2xx, otherwise throw a {@link ContentGenerationHttpError}.
* Parses the error body as JSON when possible; falls back to raw text.
Expand Down
Loading
Loading