Agent docs

Contract: the runner's verbs

The interface between a module's runner and its payload, in both directions.

The interface between a module's runner and its payload, in both directions. canon/01 says the seam holds only if the runner's verb set is complete enough that a payload never grows its own lifecycle machinery; this is the verb set. It is written language-neutrally as signatures a Python or TypeScript runner template implements the same way. Every verb cites the instance that showed it was needed.

Audience: anyone writing the runner template, or a payload against it.

Runner calls payload#

One entry point per capability, registered by capability id.

run(item: Item, ctx: Context) -> Outcome
  • Item: {item_id, input} as submitted, plus any fields a prior step or the caller supplied for this capability's requires.
  • Context: everything the payload may use, below. It is the only handle the payload has on the outside world.
  • Outcome: Result | Error | NeedsReview | Skipped per canon/03-jobs-and-state.md, with output, confidence, judged_by, evidence, error_type, reason as applicable. Attempts, duration, usage refs, and timestamps are stamped by the runner, not the payload.

A capability may declare it prefers batches (run_batch(items, ctx) -> Outcomes) for payloads that call a vendor once per N items; the runner then owns the batching, the per-item outcome fan-in, and partial failure. The per-item contract is unchanged from the caller's point of view.

Non-retryable failure is a raised NonRetryableError(error_type, message); anything else raised is retryable and the runner counts the attempt (canon/04; versable-runner's NonRetryablePipelineError, lib/pipeline/types.py). A raised DeferredError(reason, retry_after?) means "try later, do not count this attempt": the runner re-queues after retry_after (or its own backoff) up to the deferral ceiling, then records error (App V5's deferred disposition, lib/tasks/task_runner.py:341-350). These three are the only runner-typed exceptions a payload raises, and the runner's Context is where the payload learns a breaker is open rather than by importing a breaker.

Payload calls runner, through Context#

VerbSignatureWhat it doesWitness
ctx.job{job_id, client_job_id, tenant, caller_id, env, capability, variant, params, settings, attribution}read-only view of the envelopeevery instance passes some of this by hand
ctx.attemptintwhich attempt this isversable-runner retry_count on process_item
ctx.loglog(level, message, data?)one structured line, scoped to job, item, attempt by the runner; the payload never writes an idspeedway createJobLogger (joblog.server.ts); walmart write_stage_log
ctx.metermeter(name, quantity, unit, detail?, cost_usd?)one usage event, idempotency key derived by the runner from job, item, attempt, and meter; never throwsspeedway recordUsage never throws; versable-runner usage/{idx}.json
ctx.tracetrace(name, fn) or a decoratorwraps a model or vendor call in a trace span tagged with the correlation ids; no-op without keyswalmart observability.py; versable-runner's Langfuse observe in lib/ (the leak this verb closes)
ctx.cachedcached(key_parts, compute, ttl?)result cache keyed by the capability's cache spec plus key_parts; marks the outcome cached on a hitversable-runner gcs_cache.py; speedway scrapecache
ctx.cancelledcancelled() -> booltrue once cancel was requested; checked between units of workspeedway cancelRequested; versable-runner cancelled.marker
ctx.on_cancelon_cancel(fn)register a best-effort vendor-side cancelspeedway abortExtractorRunDispatches
ctx.checkpointcheckpoint(cursor) and ctx.cursorpersist progress inside a long unit of work; the runner stamps the heartbeat and resumes from cursor after recoveryspeedway cursor + heartbeatAt, copied ×3; the reason this verb exists
ctx.iterateiterate(seq, page=…) -> pagesthe checkpointed iterator: yields pages, checkpoints after each, respects cancelspeedway PAGE=500 loops
ctx.wait_forwait_for(check, backoff, ceiling)poll something slow with backoff and a time ceiling; the runner owns re-enqueue and terminal transitionswalmart poll_feed_status (jobs.py:636-771), rebuilt beside the runner's own retry path, in a runner file
ctx.limitlimit(vendor) context manager or awaittakes a slot from the shared outbound rate limiter for that vendorwalmart SharedRateLimit (ratelimit.py); speedway env caps
ctx.guardedguarded(vendor, fn)runs an outbound call behind the vendor's circuit breaker and the fleet retry budget, breaker outermost; raises DeferredError when open or spentApp V5 @resilient, lib/breakers/decorator.py:1-11, lib/redis/retry_budget.py
ctx.referencereference(name) -> loaded snapshotthe versioned reference-data snapshot; the version is recorded on the outcomeservices-api assets/, data/
ctx.artifactartifact.put(bytes, name, content_type) -> refwrite an artifact under the tenant/job prefix; returns the reference the outcome carriesversable-runner image output (to S3, the lapse); walmart rehosted images
ctx.judgejudge(capability_id, output) -> scorechain a judge capability declared in the manifest; the runner runs it as its own attempt and meters it under the judgeowner ruling on confidence source
ctx.reviewconstruct NeedsReview(reason, confidence, evidence, partial_output)the payload's way to say a person should lookcanon/10; versable-runner's RESEARCH_QUALITY_LOW error is what this replaces

Everything else the payload might want (a queue client, a storage client, a status write, an env var) is not on Context, on purpose. If a payload needs it, the verb is added here first.

Runner obligations the payload can rely on#

  • Every run is called at most max_attempts times per item, with the attempt number in ctx.attempt; after exhaustion the runner writes Error with the last error type.
  • Two runs for the same item never overlap; a redelivered unit finds the outcome already written and returns without calling the payload.
  • ctx.meter and ctx.log never raise into the payload.
  • ctx.checkpoint survives process death: after recovery, ctx.cursor is the last checkpoint and ctx.attempt is unchanged for the same delivery.
  • ctx.cancelled() becomes true within one unit of work of the cancel request.
  • Duration, attempts, usage refs, correlation ids, variant, judge, and reference versions are stamped on the outcome by the runner.

The Python and TypeScript shapes#

Same names, idiomatic types. Python: Context is a dataclass with async methods; run is async def. TypeScript: Context is an interface; run returns a Promise<Outcome>. The runner template ships both, tested against the same conformance table, and a payload written against one reads like a payload written against the other.

Banned in a payload#

  • Importing a queue, storage, cache, tracing, or secrets client.
  • Reading environment variables.
  • Writing a status, a job record, or a usage record directly.
  • Sleeping in a loop to wait for a vendor; use ctx.wait_for.
  • Keeping its own cursor or heartbeat; use ctx.checkpoint / ctx.iterate.
  • Returning a Result with a confidence it did not compute or receive.
@versable-git/ui · reference, canon, and method, read in place