--- eip: 8301 title: AI Agent Execution description: An on-chain execution interface for AI agent task dispatch, orchestration, and verifiable step execution author: JimmyShi22 (@JimmyShi22) discussions-to: https://ethereum-magicians.org/t/erc-8301-ai-agent-execution/28785 status: Draft type: Standards Track category: ERC created: 2026-06-15 --- ## Abstract This ERC defines `IAgentWorkflow`: a standard on-chain interface for AI agent task execution. It pursues three goals: a universal execution interface so agents can be developed independently of any application framework and compete across protocols; an on-chain orchestration framework so reliability mechanisms such as BFT voting, competitive selection, and commit-reveal are enforceable by contract rather than assumed from off-chain coordination — and deterministic pipelines where every step is pre-declared and auditable; and a full execution evidence chain so trust in results derives from structure — any party can traverse the on-chain `AgentTask` / `AgentReply` chain from `run()` to `result()` and verify every step independently, without trusting any individual agent. ## Motivation **1. A universal execution interface frees agents from application frameworks.** The emerging on-chain AI agent ecosystem has produced complementary standards — agent identity ([ERC-8004](./eip-8004.md)), agentic commerce ([ERC-8183](./eip-8183.md)), skill registry ([ERC-8257](./eip-8257.md)) — each well-scoped to its layer. But every protocol that needs to invoke an agent defines its own invocation format in isolation: ERC-8183 encodes tasks as a free-form `description` string; ERC-8004 delegates execution entirely to off-chain conventions. Without a shared interface, agent builders must re-implement adapters for each protocol, agents are locked to the frameworks that built them, and competition between agents across protocols is structurally impossible. **2. An on-chain orchestration framework enables reliable distributed execution.** Off-chain orchestration frameworks (LangGraph, AutoGen, CrewAI) coordinate agents for efficiency, but their results are only as trustworthy as the agents themselves. On-chain, the execution structure can itself be the trust mechanism: when workflow logic lives in a contract, every stage transition is gate-enforced and no agent can bypass or reorder it. Patterns such as commit-reveal, BFT voting, and competitive selection are reliability mechanisms — they only compose correctly when the workflow is enforced by a contract rather than coordinated off-chain. For deterministic pipelines (audit trail, compliance, regulatory evidence), a static FSM with pre-declared transitions provides the same gate-enforced integrity with full omission detectability. **3. Trust through structure: on-chain evidence chain makes results independently verifiable.** Even when agents are permissionless and unknown, the execution itself can be trusted if every step is anchored on-chain. The bidirectional `prevReplyHashes` / `prevTaskHashes` chain means that any party — not just the caller — can traverse from `result().finalTaskHash` back to `run()` and verify every transition was gate-approved and every reply was submitted by the claimed replier. Trust in the result does not require trusting any individual agent; it requires only trusting the workflow contract's gate logic. **This ERC fills the execution primitive in the agent base layer.** It defines `IAgentWorkflow` to: 1. **Provide a universal agent execution interface** — agents implement once and run across any conformant protocol, decoupled from application frameworks and free to compete on quality. 2. **Define an on-chain orchestration and coordination framework** — the workflow contract is a finite state machine that enforces every stage transition, making distributed reliability patterns (BFT, competitive selection, commit-reveal) structurally sound rather than off-chain assumptions. 3. **Establish a full on-chain execution evidence chain** — every task and reply is anchored and linked; results are verifiable by anyone from `run()` to `result()` without trusting any individual agent. | Primitive | Blockchain Analogue | ERC | |-----------|--------------------|----| | Identity | Address | ERC-8004 | | **Execution** | Smart Contract | **This ERC** | | Verify | Consensus | AI Inference Proof Verification | | Anchor | On-chain State | TruthAnchor | | Settlement | Value Transfer | Agent Escrow & Settlement | | Prove | Logs / Audit Trail | OCP + WYRIWE | ## Specification The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119 and RFC 8174. --- ### Data Envelopes Four types carry all information between the contract, agents, and external callers. **`RunStatus`** ```solidity enum RunStatus { Pending, Success, Failed } ``` - `Pending` — run is active; no result yet - `Success` — FSM reached a terminal stage with all gates satisfied; `finalTaskHash` is non-zero - `Failed` — run aborted; `finalTaskHash` is `bytes32(0)` **`AgentTask` — task dispatched by the contract** ```solidity struct AgentTask { uint8 stage; // FSM stage (developer-defined enum cast to uint8) uint256 taskSeq; // Per-run monotonic counter; starts at 0 bytes32 inputHash; // keccak256(input) bytes input; // Input plaintext; MAY be empty if conveyed off-chain uint256 timestamp; // block.timestamp at emission uint256 expiresAt; // Unix timestamp after which this task is no longer valid bytes32[] prevReplyHashes; // Replies that triggered this task; empty for the initial task bytes32 workflowRunId; // Run identifier } ``` The contract stores one `AgentTask` per `taskHash` and MUST compute `taskHash` as follows (`input` plaintext is excluded): ``` taskHash = keccak256(abi.encode( task.stage, task.taskSeq, task.inputHash, task.timestamp, task.expiresAt, keccak256(abi.encodePacked(task.prevReplyHashes)), task.workflowRunId )) ``` `prevReplyHashes` records which `AgentReply` submissions triggered this task via gate logic; it is empty for the initial task emitted by `run()`. When `prevReplyHashes` is empty, the inner term `keccak256(abi.encodePacked(prevReplyHashes))` MUST resolve to `keccak256(abi.encodePacked([]))` i.e. `keccak256("")` = `0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470`. Implementations MUST NOT special-case the empty array to `bytes32(0)`. This pins the initial `taskHash` so two independent implementations cannot diverge at the entry point of the evidence chain. **`AgentReply` — reply submitted by an agent** ```solidity struct AgentReply { bytes32 operationId; // Opaque logical operation identifier; stable across retries bytes32 outputHash; // keccak256(output) bytes output; // Reply output plaintext; MAY be empty uint256 timestamp; // Off-chain execution time (Unix) address replier; // Agent address; MUST equal msg.sender when submitted bytes32[] prevTaskHashes; // Tasks this reply responds to; MUST be non-empty bytes32 workflowRunId; // Run identifier } ``` The contract stores one `AgentReply` per `replyHash` and MUST compute `replyHash` as follows: ``` replyHash = keccak256(abi.encode( reply.operationId, reply.outputHash, reply.timestamp, reply.replier, keccak256(abi.encodePacked(reply.prevTaskHashes)), reply.workflowRunId )) ``` `operationId` identifies the logical operation that produced the reply. It MUST be non-zero, opaque, assigned by the logical operation's originator before the first submission, and carried unchanged by the replier across retries. A uniformly random `bytes32` value is RECOMMENDED. An intentional new operation over the same input or output MUST use a new `operationId`. Because `operationId` is part of `replyHash`, distinct logical replies remain distinguishable even when every other hashed field is identical — for example, multiple replies from the same replier to the same tasks, with the same output, submitted within the same one-second `timestamp` resolution. Implementations MUST NOT derive `operationId` from reply content: content equality does not imply operation identity. For idempotency, the contract MUST scope an `operationId` to the run and replier. The following formula is RECOMMENDED; an equivalent nested mapping is conformant: ``` operationKey = keccak256(abi.encode( reply.workflowRunId, reply.replier, reply.operationId )) ``` The contract MUST also bind the operation to stable reply content. Because a reconstructed retry may carry a later `timestamp`, the operation digest excludes `timestamp`: ``` replyDigest = keccak256(abi.encode( reply.outputHash, reply.replier, keccak256(abi.encodePacked(reply.prevTaskHashes)), reply.workflowRunId )) ``` The first accepted submission binds `operationKey` immutably to `(replyDigest, replyHash)`. `prevTaskHashes` records which tasks this reply responds to, completing the bidirectional evidence chain (see [Evidence Chain](#evidence-chain)). --- ### `IAgentWorkflow` — Core Interface A conformant workflow contract MUST implement the following interface: ```solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; enum RunStatus { Pending, Success, Failed } struct AgentTask { uint8 stage; uint256 taskSeq; bytes32 inputHash; bytes input; // MAY be empty uint256 timestamp; // block.timestamp at emission uint256 expiresAt; bytes32[] prevReplyHashes; // empty for the initial task bytes32 workflowRunId; } struct AgentReply { bytes32 operationId; // MUST be non-zero and stable across retries bytes32 outputHash; bytes output; // MAY be empty uint256 timestamp; // Off-chain execution time (Unix) address replier; // MUST equal msg.sender when submitted bytes32[] prevTaskHashes; // MUST be non-empty bytes32 workflowRunId; } interface IAgentWorkflow { // ── Invocation ─────────────────────────────────────────────────────────── /// @notice Start a new workflow run. /// @param inputHash keccak256 of the input /// @param input Input plaintext; MAY be empty if conveyed off-chain /// @param expiresAt Unix timestamp after which the initial task expires /// @return workflowRunId Contract-generated unique run identifier function run( bytes32 inputHash, bytes calldata input, uint256 expiresAt ) external returns (bytes32 workflowRunId); /// @notice Query the result of a run. /// @return status Pending | Success | Failed /// @return finalTaskHash taskHash of the terminal AgentTask; bytes32(0) if not Success /// @return completedAt block.timestamp at completion; 0 if not Success function result(bytes32 workflowRunId) external view returns ( RunStatus status, bytes32 finalTaskHash, uint256 completedAt ); // ── Query ──────────────────────────────────────────────────────────────── /// @notice Returns the stored AgentTask and its proven status. /// @dev MUST revert if taskHash is unknown. /// proven=true iff prevReplyHashes is empty OR all prevReplyHashes are proven. function getAgentTask(bytes32 taskHash) external view returns (AgentTask memory task, bool proven); /// @notice Returns the stored AgentReply and its verification status. /// @dev MUST revert if replyHash is unknown. function getAgentReply(bytes32 replyHash) external view returns ( AgentReply memory reply, address verifier, bool proven, bytes32 verificationDigest ); /// @notice Resolve a logical reply operation to its canonical replyHash. /// @return replyHash The first anchored replyHash; bytes32(0) if unknown function getReplyHashByOperation( bytes32 workflowRunId, address replier, bytes32 operationId ) external view returns (bytes32 replyHash); // ── Agent Callbacks ────────────────────────────────────────────────────── /// @notice Agent submits a reply to a dispatched task. /// @param reply See AgentReply; reply.replier MUST equal msg.sender /// @return replyHash The canonical replyHash for this logical operation function onAgentReply(AgentReply calldata reply) external returns (bytes32 replyHash); /// @notice Submit a cryptographic proof covering one or more anchored replies. /// @param replyHashes Each MUST have been anchored by onAgentReply /// @param proof Proof bytes covering all listed replies; encoding is verifier-specific function onAgentProve(bytes32[] calldata replyHashes, bytes calldata proof) external; // ── Events ─────────────────────────────────────────────────────────────── /// @notice Emitted when a new task is dispatched. Full details via getAgentTask(taskHash). event NewAgentTask( bytes32 indexed workflowRunId, uint8 indexed stage, bytes32 indexed taskHash ); /// @notice Emitted when a reply is anchored, before gate evaluation. /// Fires for every profile — transparent, confidential, attested — at the /// same anchor point. A consumer can observe that a reply was submitted and /// retrieve it via getAgentReply(replyHash), regardless of whether the gate /// later accepts or silently discards it. event AgentReplyAnchored( bytes32 indexed workflowRunId, bytes32 indexed replyHash, address indexed replier ); /// @notice Emitted when a run reaches a terminal state. event WorkflowCompleted( bytes32 indexed workflowRunId, RunStatus status, bytes32 finalTaskHash, uint256 timestamp ); } ``` `getReplyHashByOperation` MUST use the same `(workflowRunId, replier, operationId)` scope as `onAgentReply`. It MUST return the canonical `replyHash` for an accepted operation and `bytes32(0)` when no binding exists. --- ### Execution Model A conformant `IAgentWorkflow` contract is a **finite state machine (FSM)**. The developer defines: - A **stage enum** — the complete set of states the workflow can be in. Stages MAY include loops; the set is finite and fixed at deploy time. - **Gate logic** — conditions for stage transition, evaluated after `onAgentReply` (async mode) or `onAgentProve` (sync mode). - **Proof policy** — which stages require proof, whether proof is sync or async, and which proof verifier to invoke (IProofVerifier or IAgentVerifier from AI Inference Proof Verification). MAY vary per stage. - **FSM topology** — whether the workflow graph is static (all transitions hardcoded at deploy time) or dynamic (path emerges at runtime from gate logic and replies). See [Static vs Dynamic FSM](#static-vs-dynamic-fsm). The contract drives all stage transitions. Agents react to emitted tasks; they do not control sequencing. **Run lifecycle:** ``` run(inputHash, input, expiresAt) │ generates workflowRunId; emits NewAgentTask(stage=initial) ▼ Agent observes NewAgentTask; calls getAgentTask(taskHash) for details │ calls onAgentReply(...) ▼ Contract validates operationId and reply; resolves or stores canonical replyHash │ async: gate evaluates immediately after onAgentReply │ sync: gate evaluates only after onAgentProve succeeds │ (gate modes defined in Gate Logic and Stage Transition) │ ├── gate passes → FSM transitions → emit NewAgentTask(next stage) └── gate fails → retry / abort / silent (implementation-defined) ▼ ... stages repeat ... ▼ WorkflowCompleted(workflowRunId, Success | Failed, ...) ``` A single stage MAY emit multiple `NewAgentTask` events in the same block (e.g. parallel dispatch). `taskSeq` ensures each is uniquely identified. --- ### `run()` — Run Initiation The contract MUST generate `workflowRunId` deterministically. Recommended formula: ``` workflowRunId = keccak256(abi.encode(msg.sender, block.number, runNonce)) ``` where `runNonce` is a per-contract monotonic counter. The contract MUST initialise the FSM to its initial stage and emit at least one `NewAgentTask` before returning. The contract SHOULD verify `keccak256(input) == inputHash` when `input` is non-empty. Callers with sensitive inputs SHOULD pass `input = ""` and deliver plaintext to agents through a confidential channel; `inputHash` still commits to the content. --- ### `onAgentReply` — Reply Submission When an agent calls `onAgentReply`, the contract MUST enforce the following checks in order: **1. Replier and operation identity** The contract MUST verify `reply.replier == msg.sender`. This binds the stored struct to the actual submitting address, preventing fronting. The contract MUST reject `operationId == bytes32(0)`. The contract MUST compute `operationKey` and `replyDigest` according to [Data Envelopes](#data-envelopes), then look up the operation before evaluating expiry, current-stage gate eligibility, or any externally visible effect. If `operationKey` is already bound: - If the stored `replyDigest` equals the submitted `replyDigest`, the call is a logical retry. The contract MUST return the stored canonical `replyHash` without storing another reply, emitting `AgentReplyAnchored`, evaluating gate logic, invoking downstream hooks, or applying a second charge or settlement effect. The first accepted reply, including its original `timestamp`, remains canonical. - If the digests differ, the contract MUST revert and MUST NOT overwrite the existing binding. Reusing an `operationId` for different output or task context is an operation-ID conflict, not a retry. This lookup MUST be atomic with first acceptance. Concurrent calls for the same `operationKey` MUST result in exactly one accepted reply; all matching retries resolve to its canonical `replyHash`. **2. `prevTaskHashes` validity for a new operation** For every `h` in `reply.prevTaskHashes`, `getAgentTask(h)` MUST return a valid record with `task.workflowRunId == reply.workflowRunId`. The contract MUST reject replies referencing unknown or foreign task hashes. **3. Run and task eligibility for a new operation** The run MUST be `Pending`. Every referenced task MUST still be eligible to receive a new reply under the workflow's current FSM stage and gate policy. The contract MUST reject a new operation that references a completed, superseded, consumed, or otherwise ineligible task. This check does not apply to a matching retry resolved under step 1 because that retry creates no new reply or state transition. **4. Timestamp and expiry constraints for a new operation** Let `anchor_time(h)` be `block.timestamp` recorded when taskHash `h` was emitted. The submitted timestamp MUST satisfy: ``` max(anchor_time(h) for h in reply.prevTaskHashes) < reply.timestamp ≤ block.timestamp ``` The lower bound proves execution occurred after the preceding tasks were anchored. The upper bound prevents future-dated claims. Because `anchor_time` is written by the contract at emission — not by the agent — it cannot be manipulated. The contract SHOULD also verify that `block.timestamp ≤ task.expiresAt` for all tasks in `reply.prevTaskHashes` (when `expiresAt != 0`). New replies submitted after a task has expired SHOULD be rejected. A matching retry of an operation accepted before expiry returns its canonical `replyHash` under step 1. **5. Compute, bind, and store `replyHash`** After all checks pass, the contract MUST compute `replyHash` (formula in [Data Envelopes](#data-envelopes)). Before emitting events, evaluating gate logic, or calling external contracts, it MUST immutably bind `operationKey` to `(replyDigest, replyHash)`, store the full `AgentReply` struct keyed by `replyHash`, and record `block.timestamp` as its anchor time. It MUST then emit `AgentReplyAnchored(workflowRunId, replyHash, msg.sender)` and return `replyHash`. This event fires before gate evaluation and is profile-agnostic — it fires for every anchored reply regardless of verification profile (transparent, confidential, attested). It provides observability that a reply was submitted, even when the gate later silently discards it. A consumer can retrieve the full reply via `getAgentReply(replyHash)`. **6. Gate evaluation (async mode)** If the current stage is async, the contract MUST immediately evaluate gate logic and, if it passes, transition the FSM and emit the next `NewAgentTask`. --- ### `onAgentProve` — Proof Submission `onAgentProve` accepts a single proof covering one or more anchored replies. It MAY be called by the agent or any third party. **1. Retrieve stored replies** The contract MUST revert if `replyHashes` is empty or if any entry is unknown. For each `replyHash`, the contract MUST retrieve the stored `AgentReply` and derive the canonical `inputHash` from on-chain task data — never from caller-supplied data, preventing hash substitution attacks. ```solidity AgentReply memory reply = _replies[replyHashes[i]]; (AgentTask memory prevTask,) = getAgentTask(reply.prevTaskHashes[0]); bytes32 inputHash = prevTask.inputHash; ``` When `reply.prevTaskHashes` has more than one entry, `prevTaskHashes[0]` MUST be the primary task (i.e., the task carrying the input to be proven). If multiple tasks carry different inputs, the proof statement covers `prevTaskHashes[0].inputHash`; implementations that need to bind multiple inputs MUST encode them into a single composite `inputHash` at task emission time. **2. Call the proof verifier** The developer chooses one of two conformant options; the choice is implementation-defined and MAY vary per stage. **Option A — `IAgentVerifier` (outer, identity-bound):** Produces a `verificationDigest` audit anchor. Suitable when proof verification should bind to replier identity. ```solidity (bool valid, bytes32 digest) = IAgentVerifier(verifier).verify( reply.workflowRunId, bytes32(uint256(uint160(reply.replier))), inputHash, reply.outputHash, proof ); ``` **Option B — `IProofVerifier` (inner, stateless):** Pure cryptographic validity; no identity binding. Suitable for ZK, TEE, or signature-based proofs where the replier address is not part of the proof statement. ```solidity bool valid = IProofVerifier(verifier).verify( inputHash, reply.outputHash, metadata, // deployment-time config: circuit key, enclave hash, etc. proof ); ``` **3. Update proven state and evaluate gate (sync mode)** After a successful verification, the contract MUST mark the `replyHash` as proven, MUST propagate proven status to downstream tasks (see [Evidence Chain](#evidence-chain)), and MUST NOT call the verifier again for the same `replyHash`. If the current stage is sync, the contract MUST then evaluate gate logic. --- ### Gate Logic and Stage Transition Gate logic is **implementation-defined**. This ERC defines when it is evaluated, not what it checks. **Async mode** — gate evaluates immediately after `onAgentReply`: ``` onAgentReply → validate → store replyHash → gate ├─ pass → FSM.transition() → emit NewAgentTask(next stage) ├─ retry → emit NewAgentTask(same stage, incremented taskSeq) ├─ abort → emit WorkflowCompleted(Failed) └─ silent → no action; stage remains open ``` **Sync mode** — gate evaluates only after `onAgentProve` succeeds: ``` onAgentReply → validate → store replyHash → wait for proof onAgentProve → verify → proven → gate ├─ pass → FSM.transition() → emit NewAgentTask(next stage) ├─ retry → emit NewAgentTask(same stage, incremented taskSeq) ├─ abort → emit WorkflowCompleted(Failed) └─ silent → no action; stage remains open ``` The **silent** failure mode is most useful in permissionless workflows: submissions that fail the gate are discarded without disrupting the run; the stage stays open for further submissions. The `AgentReplyAnchored` event makes silent failures observable — it fires before gate evaluation, so a consumer can see that a reply was submitted even when the gate later discards it. For confidential steps, a DENY verdict emits the event and burns a nullifier, distinguishing "evaluated and rejected" from silence. In a static FSM, the gate can still be competitive (multiple replies, one winner), but the transition target is predetermined — the gate selects which reply wins, not which stage is next. Static FSM implementations MAY use revert or explicit-failure modes since the FSM path is not affected by a gate failure; a failed reply simply does not advance the run at the current stage. **Terminal stage.** The gate whose pass causes `WorkflowCompleted(Success)` to be emitted is the final gate; it MUST be sync. When the final sync gate passes, the contract MUST: 1. Create and store a final `AgentTask` representing the completion state: `stage` SHOULD be a terminal sentinel (e.g., `type(uint8).max`), `taskSeq` is the next monotonic counter, `inputHash = bytes32(0)`, `prevReplyHashes` contains all terminal reply hashes that satisfied the gate, `timestamp = block.timestamp`, and `workflowRunId` is the current run. The contract computes `finalTaskHash` via the standard `taskHash` formula (see [Data Envelopes](#data-envelopes)). This final task anchors the completion state in the evidence chain — the chain now uniformly ends with a `Task` node, consistent with every other stage transition. 2. Emit `WorkflowCompleted(workflowRunId, Success, finalTaskHash, block.timestamp)`. No `NewAgentTask` is emitted for the terminal task; the `WorkflowCompleted` event alone serves as the on-chain anchor. 3. Persist `block.timestamp` as `completedAt` in storage so that `result()` can return it as a view function. 4. Reject any subsequent new `onAgentReply` or `onAgentProve` for this `workflowRunId`. A matching retry of an already accepted `operationKey` MUST remain resolvable to its canonical `replyHash` and MUST have no effect. A run MAY terminate with `Failed` at any stage by emitting `WorkflowCompleted(workflowRunId, Failed, bytes32(0), block.timestamp)`. The same terminal callback rules apply after failure: the contract MUST reject subsequent new `onAgentReply` and `onAgentProve` calls for that run, while a matching retry of an already accepted `operationKey` MUST remain resolvable to its canonical `replyHash` without effect. --- ### Evidence Chain Every operation in a conformant workflow is stored on-chain and linked into a bidirectional chain verifiable by any party without trusting the contract's internal state. **Structure.** The chain alternates between `AgentTask` and `AgentReply` nodes: ``` run() │ ▼ AgentTask T0 (prevReplyHashes = []) ← initial; always proven │ agent responds ▼ AgentReply R0 (prevTaskHashes = [T0]) ← links back to T0 │ gate passes → FSM transitions ▼ AgentTask T1 (prevReplyHashes = [R0]) ← links back to R0 │ ▼ ... (stages repeat) ... │ ▼ AgentReply Rn (prevTaskHashes = [Tn]) ← terminal reply(s) │ terminal gate passes ▼ AgentTask Tf (prevReplyHashes = [Rn, ...]) ← final task (no NewAgentTask emitted) │ ▼ WorkflowCompleted (finalTaskHash = Tf) ``` The chain always ends with a `Task` node — the final `AgentTask` created at terminal gate pass. The `prevReplyHashes` / `prevTaskHashes` link fields (defined in [Data Envelopes](#data-envelopes)) form a tamper-evident record traversable from `result().finalTaskHash` back to the initial task. **Proven state.** Each node carries a `proven` boolean: | Node | `proven = true` when | |------|---------------------| | `AgentTask` | `prevReplyHashes` is empty (initial task), or all entries in `prevReplyHashes` are `proven` | | `AgentReply` | `onAgentProve` has succeeded for this `replyHash` | The contract MUST propagate proven status after each `onAgentProve`: after marking a `replyHash` proven, check whether all `prevReplyHashes` of any downstream task are now proven; if so, cache `task.proven = true`. This makes the sync gate check O(1): ```solidity (, bool proven) = getAgentTask(prevTaskHash); require(proven, "upstream chain not fully proven"); ``` **Verification paths.** - *Trusted (O(1)):* call `getAgentTask(taskHash)` and check `proven == true`. - *Trustless (full traversal):* start from `result().finalTaskHash`; call `getAgentTask(finalTaskHash)` to get `prevReplyHashes`, then traverse by alternating `getAgentReply(replyHash) → reply.prevTaskHashes` and `getAgentTask(taskHash) → task.prevReplyHashes` back to the initial task. Prune branches where `proven == true` is already cached. `result().finalTaskHash` is the entry point for external verification. A downstream contract MAY check `finalTaskHash != bytes32(0)` as a sufficient condition that all gates on the execution path were satisfied — provided the workflow contract is correctly implemented. ### Static vs Dynamic FSM This ERC does not mandate a specific FSM topology. The developer chooses whether the workflow is **static** (all stages and transitions are hardcoded at deploy time) or **dynamic** (the path emerges at runtime from gate logic and agent replies). Both are conformant; the choice belongs to the use case. **Dynamic FSM.** The contract enumerates all possible stages, but the actual execution path is determined at runtime — gate logic selects which reply advances the FSM, and which stage is emitted next may depend on the content of the winning reply. This is the natural shape for permissionless, competitive, or BFT workflows: - Competitive selection (multiple agents bid; gate picks the lowest bid and silently discards the rest). - Commit-reveal rounds (stages are fixed but which agents participate and which commitment wins is unknown until the reveal gate fires). - BFT voting (quorum threshold reached → transition; the set of voters is not known in advance). The trade-off: a dynamic FSM cannot guarantee **omission detectability** at the spec level. The path is not pre-declared, so an operator who steers the FSM away from a node it dislikes leaves no `NewAgentTask` for that node — and from outside, "the FSM didn't go there" and "the FSM shouldn't have gone there" are the same observation. Omission detectability in a dynamic FSM requires additional machinery: a pre-committed expected node-graph (declared before execution), an event for every stored reply before gate selection, and timeout or expected-transition semantics, so that a missing dispatched task is a visible gap against a stated expectation. **Static FSM.** All stages, transitions, and the complete execution graph are hardcoded in the contract. The operator cannot route around a node — every declared stage MUST be dispatched via `NewAgentTask` before the next transition can fire. This closes the omission gap at the implementation level: the contract itself is the pre-declared path, and a suppressed step is a contract rule violation, not an unobservable routing choice. Static FSM is appropriate for: - Auditable workflows where every step must be provably executed (compliance, regulatory evidence, multi-party settlement). - Deterministic pipelines where the topology is known in advance (input sanitization → inference → verification → settlement). - Scenarios where the beneficiary of the claim is not the operator — the static contract guarantees that a third-party skeptic can enumerate every owed step without trusting the operator's word. The static FSM still requires liveness consideration: ruling out routing-around a node is not the same as forcing the next transition to be called. Without timeout or expected-transition semantics, "the step was suppressed" and "the workflow has not advanced yet" remain the same observation from outside. Implementations SHOULD declare expected-transition windows or timeout semantics where omission detectability is load-bearing. | Property | Dynamic FSM | Static FSM | |----------|------------|------------| | Path determined | At runtime (gate logic + replies) | At deploy time (hardcoded) | | Omission detectability | Requires additional machinery | Closed at implementation level | | Best for | Competitive, BFT, open participation | Audit, compliance, deterministic pipelines | | NewAgentTask coverage | Only nodes the gate actually dispatches | Every declared node must be dispatched | | Liveness | Same as static | Same as dynamic | Both shapes use the same `IAgentWorkflow` interface. The ERC standardises the dispatch, anchoring, and verification surface; the FSM topology is an implementation decision. --- ## Rationale ### Contract-as-Workflow Earlier designs committed to an off-chain workflow definition via `agentWorkflowHash`. This creates a structural contradiction: the execution definition lives where the contract cannot read or enforce it. When an agent calls back, the contract cannot verify whether the agent followed the committed workflow. Replacing `agentWorkflowHash` with the contract itself closes this gap. The workflow contract IS the execution definition: its stage enum defines the valid state space, its gate logic enforces all transition invariants, and its internal storage tracks which replies have been anchored at each stage. Different workflows are different contracts; the ERC standardises only the interface. The `AgentVerifier` or `IProofVerifier` is responsible only for proof verification — it no longer carries workflow state. ### FSM Model The v0.1 design modelled coordination as a DAG of replies. This expresses data dependencies but has no concept of execution state: the contract cannot distinguish a new coordination round from a competing bid or a consensus reveal without reading an off-chain document. The FSM model adds a `stage` dimension. Each `NewAgentTask` carries a `stage` value that unambiguously identifies the current phase. Agents can determine what is expected of them from the event alone; the contract enforces valid transitions. Patterns like commit-reveal and BFT voting become expressible on-chain without off-chain coordination: the contract emits `stage=Commit` tasks, agents commit, the gate transitions to `stage=Reveal`, and no agent needs to infer the phase from context. ### Open Task Dispatch `NewAgentTask` carries no `agentId`. This enables permissionless participation: any conformant agent may monitor the event stream and respond to tasks matching its capabilities, supporting competitive selection, BFT voting, and open agent markets. Permissioned workflows restrict participation inside `onAgentReply` by checking `msg.sender`; the interface does not change. `taskSeq` ensures each emitted task is uniquely identified even when multiple tasks are dispatched in the same block and stage. ### Evidence Chain Design The symmetric `prevReplyHashes` / `prevTaskHashes` linking design makes the entire execution history verifiable from `result().finalTaskHash` alone. Rather than trusting the contract's `proven` cache, any observer can traverse the alternating task-reply chain back to the initial task and independently verify every anchored hash. The cached `proven` flag is an optimisation — the O(1) sync gate check — not a source of truth. The incremental propagation rule (a task becomes proven when all its `prevReplyHashes` are proven) ensures the chain is self-consistent: a proven task implies a proven upstream chain back to `run()`. ### Logical Reply Idempotency Transaction and account nonces prevent replay of the same signed envelope, but they cannot identify two separately signed calls as retries of the same logical operation. `operationId` supplies that missing caller intent. It is scoped by `workflowRunId` and `replier`, so independent agents and runs may safely reuse the same opaque value. Content hashes cannot replace `operationId`: submitting the same artifact twice may intentionally request two independent reviews. Conversely, a reconstructed retry may change the off-chain `timestamp` while preserving the same output and task context. The immutable `operationKey → (replyDigest, replyHash)` binding therefore preserves the first accepted reply as canonical without collapsing intentional operations that use different IDs. This mechanism provides transport-level idempotency only. Whether two distinct operations describe the same real-world event is workflow-specific and remains the responsibility of gate or application logic. ### Sync and Async Gate Modes Two gate modes address two fundamentally different verification shapes: **Post-hoc attestation (async):** the agent executes, submits a result via `onAgentReply`, and proof is submitted afterward. The FSM advances before verification completes. Appropriate for most use cases — the proof provides an auditable record; fraud is detectable after the fact. **Pre-action gating (sync):** the verifier must approve before the FSM emits the next task. Required when acting on an incorrect or unauthorized input is irreversible (e.g., a task that triggers an on-chain token transfer). The sync-mode WYRIWE (What You Read Is What You Execute) stage exemplifies this: the FSM will not emit the inference task until the input sanitization proof passes, so the gateway cannot substitute an unauthorized transform after seeing the input. Both shapes are expressible through the same interface. The choice belongs to the workflow contract's proof policy, not to the dispatch interface. ### FSM Anchor and OCP Temporal Anchor The `taskAnchorTime[taskHash] = block.timestamp` write that occurs at `NewAgentTask` emission and the OCP `record()` call are complementary, not competing: - **FSM anchor** is per-task: it records the block timestamp at which a specific task was dispatched. It is written automatically by the workflow contract and cannot be influenced by the caller. - **OCP anchor** is per-input: it commits the input content to an external log, with an independently checkable timestamp, before execution begins. They compose naturally: OCP anchors the input before the task is dispatched; the FSM anchor records when the task was emitted. A verifier can check both independently — that the input was committed before the task existed (`ocp.timestamp < taskAnchorTime`) and that all subsequent replies carry `timestamp > taskAnchorTime`. Neither anchor subsumes the other. --- ## Backwards Compatibility This ERC introduces new interfaces with no conflicts with existing ERCs. Agent identity, agentic commerce, proof verification, on-chain anchoring, OCP, WYRIWE, and settlement layers integrate as optional dependencies; none require modification. This ERC v0.3 is not backwards-compatible with v0.1 or v0.2. The `IAgentCaller` / `IAgentHandler` split of v0.1 and the `agentWorkflowHash` commitment model are replaced by the unified `IAgentWorkflow` interface. Implementations of prior versions must be redeployed. The addition of `AgentReply.operationId` changes the tuple ABI and the canonical `replyHash` preimage. Implementations and clients built against an earlier `AgentReply` layout must update their ABI, hash computation, and stored-reply handling. The `AgentReplyAnchored` event signature is unchanged. The minimum conforming path — `run()` → `onAgentReply` (terminal stage) → `onAgentProve` ✓ — requires no additional infrastructure and is equivalent in behavior to the simplest existing agent invocation patterns. --- ## Reference Implementation A full reference implementation will be provided in a separate repository and linked here once available. ### Call Sequences In all diagrams, `T0`, `T1`, … denote `taskHash` values from `NewAgentTask` events; `R0`, `R1`, … denote canonical `replyHash` values returned by the contract in `onAgentReply`. Each illustrated new reply uses a fresh non-zero `operationId` unless explicitly labelled as a retry. --- **Scenario 0 — Single-Stage (Baseline)** The simplest conforming workflow: one task, one reply, one proof. ``` run(inputHash, input, expiresAt) │ emit NewAgentTask(stage=Execute, taskHash=T0) ▼ onAgentReply({prevTaskHashes:[T0], outputHash:H1, replier:A, ...}) │ contract stores; replyHash=R0 │ terminal stage is sync → waits ▼ onAgentProve([R0], proof) ✓ │ create & store final AgentTask Tf (prevReplyHashes=[R0]) │ emit WorkflowCompleted(Success, finalTaskHash=Tf) ``` --- **Scenario 1 — Input Provenance** The input a caller commits to on-chain (`inputHash`) and the input a model actually processes may differ due to sanitization transforms, template injection, or RAG context assembly. The `taskHash` and `replyHash` chains make this gap provable through three composable patterns: 1. **WYRIWE attestation:** an agent submits a reply whose `outputHash` equals the sanitized input hash. The FSM transitions to the inference stage, emitting a `NewAgentTask` with `inputHash = sanitizedHash`. All subsequent inference replies reference this task in `prevTaskHashes`, proving on-chain that inference happened after the WYRIWE attestation and used the attested input. 2. **OCP temporal anchoring:** the input commitment is submitted as a reply in a dedicated stage; the FSM only advances after the OCP `record()` call is confirmed, proving on-chain that the input was anchored before execution began. 3. **Pre-action gate:** the input provenance stage is declared sync mode; the verifier must approve before the FSM emits the inference task. ``` run(inputHash=H0, input, expiresAt) │ emit NewAgentTask(stage=Sanitize, taskHash=T0) │ // getAgentTask(T0) → {stage=Sanitize, inputHash=H0, ...} ▼ onAgentReply({prevTaskHashes:[T0], outputHash:sanitizedHash, replier:GW, ...}) │ contract stores; replyHash=R0 │ stage=Sanitize is sync → waits for proof ▼ onAgentProve([R0], proof) ✓ ← sync gate passes; R0 proven │ FSM transitions to Infer │ emit NewAgentTask(stage=Infer, taskHash=T1) │ // getAgentTask(T1) → {stage=Infer, inputHash=sanitizedHash, │ // prevReplyHashes=[R0], ...} ▼ onAgentReply({prevTaskHashes:[T1], outputHash:inferHash, replier:A, ...}) │ contract stores; replyHash=R1 │ terminal stage is sync → waits ▼ onAgentProve([R1], proof) ✓ │ create & store final AgentTask Tf (prevReplyHashes=[R1]) │ emit WorkflowCompleted(Success, finalTaskHash=Tf) ``` --- **Scenario 2 — Node Selection (Competitive, Dynamic FSM)** Agents compete; gate selects one winner and silently discards the rest. ``` run(inputHash, input, expiresAt) │ emit NewAgentTask(stage=Bid, taskHash=T0) ├──────────────────┬──────────────────┐ ▼ ▼ ▼ onAgentReply(A,[T0]) onAgentReply(B,[T0]) onAgentReply(C,[T0]) replyHash=RA replyHash=RB replyHash=RC bid=100 bid=80 (lowest) bid=120 [gate selects B: lowest bid; A and C are silently discarded] │ FSM transitions to Execute │ emit NewAgentTask(stage=Execute, taskHash=T1) │ // getAgentTask(T1) → {inputHash=B.outputHash, prevReplyHashes=[RB], ...} ▼ onAgentReply(B, {prevTaskHashes:[T1], ...}) │ contract stores; replyHash=R1 │ terminal stage is sync → waits ▼ onAgentProve([R1], proof) ✓ │ create & store final AgentTask Tf (prevReplyHashes=[R1]) │ emit WorkflowCompleted(Success, finalTaskHash=Tf) ``` --- **Scenario 3 — Sequential Pipeline (A → B → C)** Each stage passes its output as the next stage's input via `inputHash`. ``` run(inputHash=H0, ...) │ emit NewAgentTask(stage=StepA, taskHash=T0) │ // getAgentTask(T0) → {inputHash=H0, ...} ▼ onAgentReply(A, {prevTaskHashes:[T0], outputHash:H1, ...}) │ contract stores; replyHash=R0 │ gate passes (async) → FSM to StepB │ emit NewAgentTask(stage=StepB, taskHash=T1) │ // getAgentTask(T1) → {inputHash=H1, prevReplyHashes=[R0], ...} ▼ onAgentReply(B, {prevTaskHashes:[T1], outputHash:H2, ...}) │ contract stores; replyHash=R1 │ gate passes (async) → FSM to StepC │ emit NewAgentTask(stage=StepC, taskHash=T2) │ // getAgentTask(T2) → {inputHash=H2, prevReplyHashes=[R1], ...} ▼ onAgentReply(C, {prevTaskHashes:[T2], outputHash:H3, ...}) │ contract stores; replyHash=R2 │ terminal stage is sync → waits ▼ onAgentProve([R2], proof) ✓ │ create & store final AgentTask Tf (prevReplyHashes=[R2]) │ emit WorkflowCompleted(Success, finalTaskHash=Tf) ``` --- **Scenario 4 — BFT Commit-Reveal (Dynamic FSM)** Agents commit to a hidden result, then reveal simultaneously. Prevents last-mover advantage. ``` run(inputHash, ...) │ emit NewAgentTask(stage=Commit, taskHash=T0) ├──────────────────────┐ ▼ ▼ onAgentReply(A,[T0]) onAgentReply(B,[T0]) ← commit: outputHash=H(result‖nonce) replyHash=RcA replyHash=RcB [gate: quorum of commits → FSM to Reveal] │ emit NewAgentTask(stage=Reveal, taskHash=T1) │ // getAgentTask(T1) → {prevReplyHashes=[RcA,RcB], ...} ├──────────────────────┐ ▼ ▼ onAgentReply(A,[T1]) onAgentReply(B,[T1]) ← reveal: output=result‖nonce replyHash=RrA replyHash=RrB [gate: majority result determined → FSM to Finalize (terminal, sync)] │ emit NewAgentTask(stage=Finalize, taskHash=T2) │ // getAgentTask(T2) → {prevReplyHashes=[RrA,RrB], ...} ▼ onAgentReply(AGG, {prevTaskHashes:[T2], outputHash:majorityHash, ...}) │ contract stores; replyHash=Rf │ sync mode → waits ▼ onAgentProve([Rf], proof) ✓ │ create & store final AgentTask Tf (prevReplyHashes=[Rf]) │ emit WorkflowCompleted(Success, finalTaskHash=Tf) ``` --- **Scenario 5 — Parallel Multi-Agent Audit (Static FSM)** A contract is submitted for audit across three independent dimensions: Gas, Security, and Logic. The workflow hardcodes three stages — Audit → Qualify → Settle — with no way to skip any of them. The Audit stage dispatches all three tasks in parallel; the gate only advances when every dimension has replied. ``` run(inputHash=H_code, input, expiresAt) │ emit NewAgentTask(stage=Audit, taskHash=T_gas) │ emit NewAgentTask(stage=Audit, taskHash=T_sec) │ emit NewAgentTask(stage=Audit, taskHash=T_logic) │ // Parallel dispatch: same stage, same block, three agents ├──────────────────┬──────────────────┐ ▼ ▼ ▼ onAgentReply(G, [T_gas]) onAgentReply(S, [T_sec]) onAgentReply(L, [T_logic]) replyHash=R_gas replyHash=R_sec replyHash=R_logic [Gate: all three replies received → FSM transitions to Qualify (hardcoded)] │ emit NewAgentTask(stage=Qualify, taskHash=T_qual) │ // getAgentTask(T_qual) → {prevReplyHashes=[R_gas,R_sec,R_logic], ...} ▼ onAgentReply(Q, {prevTaskHashes:[T_qual], outputHash:qualHash, ...}) │ Qualification agent checks auditor credentials and proof validity. │ contract stores; replyHash=R_qual │ stage=Qualify is sync → waits for proof ▼ onAgentProve([R_qual], proof) ✓ ← sync gate passes │ FSM transitions to Settle (hardcoded, terminal) │ emit NewAgentTask(stage=Settle, taskHash=T_settle) ▼ onAgentReply(Set, {prevTaskHashes:[T_settle], outputHash:settleHash, ...}) │ contract stores; replyHash=R_settle ▼ onAgentProve([R_settle], proof) ✓ │ create & store final AgentTask Tf (prevReplyHashes=[R_settle]) │ emit WorkflowCompleted(Success, finalTaskHash=Tf) ``` The three-stage graph (Audit → Qualify → Settle) is hardcoded at deploy time. Each audit dimension is pre-declared; the operator cannot skip the security audit by routing the FSM around it — an omitted dimension leaves the gate unsatisfied and the run pending. The parallelism is structural — the three audits are independent — rather than coordinated off-chain. A skeptic can enumerate every owed step from the contract source alone: 3 audit tasks + 1 qualification + 1 settlement = 5 guaranteed dispatch points. --- ## Security Considerations **`inputHash` integrity.** The caller computes `inputHash = keccak256(input)` off-chain. The contract SHOULD verify `keccak256(input) == inputHash` when `input` is provided. Sensitive inputs SHOULD be withheld from the event and delivered to agents through a confidential channel. **Gate logic is implementation-defined.** This ERC standardises the interface, not the gate logic. A malicious or negligent workflow contract may define gates that always pass or emit `WorkflowCompleted(Success)` without verification. Callers SHOULD audit the workflow contract before integrating. A non-zero `finalTaskHash` in `result()` proves gate-level enforcement only if the contract is correctly implemented. **Executor identity is `msg.sender`.** `replyHash` commits to `reply.replier` which is validated against `msg.sender`. No external registry is required by the base protocol. Permissioned implementations MAY check `msg.sender` against an allowlist or integrate ERC-8004 inside gate logic. **`taskAnchorTime` must not be agent-writable.** The timestamp constraint relies on `taskAnchorTime[taskHash]` being written by the contract at `NewAgentTask` emission. If agents can influence this value, the ordering guarantee breaks. The contract MUST ensure only internal logic writes to `taskAnchorTime`. **Replay attacks and logical retries.** `workflowRunId` is contract-generated and unique per `run()` call. `taskHash` commits to `(stage, taskSeq, inputHash, timestamp, expiresAt, prevReplyHashes, workflowRunId)`; `replyHash` commits to `(operationId, outputHash, timestamp, replier, prevTaskHashes, workflowRunId)`. A transaction or account nonce prevents replay of the same signed envelope, while `operationId` identifies separately signed submissions that represent the same logical reply. A matching retry MUST resolve to the first canonical `replyHash` without emitting another anchor event or repeating gate and downstream effects. Reusing the scoped `operationId` with a different `replyDigest` MUST revert and MUST NOT overwrite the canonical binding. **Operation IDs are not semantic deduplication.** An agent can use a new `operationId` to submit an intentional new operation over identical content. Workflow-specific rules such as preventing the same external source record from being accepted twice MUST use a domain identifier in gate or application logic. Implementations MUST NOT treat `operationId` as proof that two payloads describe the same real-world event. **Permissionless spam resistance.** In permissionless workflows, any agent may submit replies. In a dynamic FSM, gate logic SHOULD use **silent** failure mode rather than reverting, so malformed submissions do not block the run — the stage stays open for further submissions. In a static FSM, silent failure is not load-bearing for omission, since the transition target is predetermined; implementations MAY use explicit-failure or revert modes. Sybil resistance and quorum thresholds are the workflow contract's responsibility. **`expiresAt` is not automatically enforced.** The contract does not self-terminate when `expiresAt` passes. Workflow contracts SHOULD expose a timeout function (e.g., `replyTimeout(bytes32 taskHash)`) callable by any address once `expiresAt` has passed, which retries or aborts the run. Implementations SHOULD also validate `expiresAt > block.timestamp` at `run()` time and reject `expiresAt = 0` unless the intent is "no expiry." **Reentrancy.** `onAgentReply` evaluates gate logic which may emit new tasks or call downstream hooks. `onAgentProve` calls an external verifier contract mid-execution. Both MUST follow the checks-effects-interactions pattern: complete all state writes (binding the operation and anchoring the reply / marking proven) before emitting events or calling external contracts. Implementations SHOULD apply a standard reentrancy guard (e.g., OpenZeppelin `ReentrancyGuard`) to both functions. **Race condition — concurrent replies to the same stage.** In async mode, multiple agents may call `onAgentReply` in the same block. Concurrent submissions with the same `operationKey` MUST atomically resolve to one canonical reply. If the gate passes for the first new operation and transitions the FSM, the implementation MUST ensure subsequent new operations at the now-completed stage are rejected. The FSM stage state MUST be updated before emitting `NewAgentTask` for the next stage, so that any racing `onAgentReply` call either resolves as a matching retry or finds the stage already advanced and reverts or is silently dropped. **Empty proof array.** The contract MUST revert if `onAgentProve` is called with `replyHashes.length == 0`. An empty array would skip the retrieval loop and potentially trigger gate evaluation without verifying anything. **Griefing on proof submission.** `onAgentProve` MAY be called by any third party. An attacker may front-run the legitimate agent's proof with a malformed or zero-length proof, causing the verifier to reject it. Implementations MUST allow proof resubmission for a `replyHash` after a failed `onAgentProve` (i.e., a failed proof MUST NOT permanently mark the reply as "proven=false" in a way that blocks future attempts). Only a successful `onAgentProve` MUST update state. **Meta-transaction / relayer compatibility.** The `reply.replier == msg.sender` check binds the reply to the transaction sender. If the contract is deployed behind a meta-transaction relayer (e.g., [ERC-2771](./eip-2771.md)), `msg.sender` is the relayer contract, not the agent EOA. Implementations that wish to support meta-transactions MUST explicitly handle `_msgSender()` substitution and use the same effective sender for both `replier` validation and operation scoping. [ERC-2771](./eip-2771.md) integration is OPTIONAL; the base protocol uses raw `msg.sender`. **`workflowRunId` collision.** The recommended `runNonce` counter MUST be a private, monotonically increasing storage variable that only `run()` can increment. If `runNonce` is publicly settable, an attacker could reset it to produce a `workflowRunId` that collides with a prior run. **Unknown `workflowRunId` in `result()`.** `result()` MUST return `(Pending, bytes32(0), 0)` for an unrecognised `workflowRunId` — it MUST NOT revert. Callers SHOULD treat a `Pending` result with `completedAt == 0` as either a live run or an unknown run; they SHOULD independently verify the run was started by checking for the originating `NewAgentTask` event. --- ## Copyright Copyright and related rights waived via [CC0](../LICENSE.md).