Workflow Run Context and Commit State#
Overview#
Workflow Run Context and Commit State describes the end-to-end mechanism by which the Agenta playground injects workflow identity into agent runs, gates what identity information is forwarded based on unsaved-edit detection, and keeps the UI in sync after a revision commit.
Four cooperating layers work together:
- Dirty-state detection (frontend) — tracks whether the current variant config matches the last-persisted revision.
- Reference forwarding (frontend → service) — conditionally sends
application_revisionreferences only when the config is clean, so the service can correctly setis_draft. - RunContext binding (service → runner) — injects workflow identity fields into tool request bodies server-side, invisible to the model.
- Post-commit sync — after a revision is saved (manually or via a self-targeting tool), refreshes playground state to point to the new revision.
This machinery primarily supports self-targeting platform tools such as commit_revision, which need accurate identity context to write back to the correct revision.
RunContext Injection (Service Side)#
Introduced in PR #4892, runContext is an optional top-level field added to /run requests. Its structure is:
| Field | Description |
|---|---|
workflow.id / slug / version / is_draft | Artifact/variant/revision identity |
trace.trace_id / span_id | Active OpenTelemetry span IDs |
session_id | Session identifier |
Keys are deliberately snake_case to match $ctx.<dotted.path> binding tokens exactly .
The run_context() function in services/oss/src/agent/tracing.py computes this per-turn context from the active OTel span and resolved references. It fails gracefully — missing or invalid contexts return None so the binding field is simply omitted .
PR #4936 added _run_context_reference_from_any() to normalize application* and evaluator* reference keys into the workflow* shape, allowing playground apps and evaluator invocations to use the same self-targeting tool pattern as native workflows.
Runner-Side Binding Resolution#
assembleBody() in services/agent/src/tools/direct.ts accepts the runContext object and processes each call.context binding with { bodyPath: "$ctx.<key>" } :
resolveCtxToken()validates the$ctx.prefix, traverses only own-properties with safe-key validation (no__proto__,constructor,prototype), and returnsundefinedfor missing keys.- Any existing value at the target
bodyPathis deep-deleted. - The resolved value is deep-set last, winning over model arguments and static body fields.
This gives a model-invisible guarantee: the model never sees or can override a bound field .
PR #4936 hardened this to fail-closed: if resolveCtxToken() returns undefined, assembleBody() now throws instead of silently no-oping . Direct-call tools that require context bindings (such as commit_revision) will surface an explicit error rather than execute with a missing target field.
Clean/Dirty State Detection#
isVariantDirtyMiddleware.ts tracks per-variant dirty state using MD5 hashes stored in a dataRef map.
Detection logic :
- After each fetch,
hashVariant()computes a hash for each variant. - If the hash differs from the stored
dataRef[variant.id],compareVariantsForDirtyState()performs a deep comparison, ignoringinputsand__isMutating. dirtyStates[variant.id]is set totruewhen the comparison detects a difference .
On mutation : the wrapped mutate() re-evaluates dataRef and dirtyStates before committing state changes, using updatedAt timestamp and revision number to decide whether the new hash supersedes the stored reference.
When hashes match, dirtyStates[variant.id] is reset to false , marking the variant as clean.
Conditional Reference Forwarding#
PR #4904 fixed a critical correctness bug: the playground was forwarding application_revision references on every run, regardless of unsaved edits. Because the service derives is_draft solely from revision is None, a dirty run with a forwarded reference was incorrectly marked non-draft.
The fix is in buildAgentRequest() in agentRequest.ts :
const isCommittedRevisionRun = !isDirty && typeof fullReferences?.application_revision?.id === "string"
const references = isCommittedRevisionRun ? fullReferences : null
| Condition | references value | Service is_draft |
|---|---|---|
| Clean + committed revision ID present | fullReferences | false |
| Dirty (unsaved edits) | null | true |
| Uncommitted local draft | null | true |
The application_id URL query is derived independently from full identity so draft runs remain app-scoped even when references is null .
Post-Commit State Synchronization#
Manual save (saveVariant)#
saveVariant() in playgroundVariantMiddleware.ts runs in three phases:
- Mark mutating — sets
variant.__isMutating = trueto signal in-progress save . - Persist — PUT
/api/variants/{variantId}/parameterswith serializedag_configand optionalcommit_message. - Refresh — calls
fetchAndProcessRevisions()withforceRefresh: true, sorts results bycreatedAtTimestampto identify the new revision , then:- Swaps
selectedto point to the new revision ID . - Stores
hashVariant(newRevision)indataRef[newRevision.id]to mark it clean immediately .
- Swaps
Event-driven refresh (tool-initiated commits)#
PR #4936 introduced a complementary path for commits triggered by self-targeting tools :
- The Vercel adapter emits a
data-committed-revisionSSE event with{ variantId, revisionId, version }whencommit_revisioncompletes — even if the resumed stream doesn't replay the original tool call. - The playground handles this event via
switchEntity(), refreshing the config without polling.
Patch endpoint safety#
To prevent sparse commit_revision calls from overwriting stored agent config, PR #4936 added /revisions/commit/patch, which uses _deep_merge_revision_data() to merge the incoming patch with the current revision, preserving omitted fields .
Note: PR #4975 (revert) subsequently removed the commit/patch router changes from the API layer. Verify against the current branch before relying on this endpoint.