@tape.effect and @tape.outbox_tool⌗
The two decorators tools wear. Use @tape.effect when the tool body performs IO against an
idempotent upstream; use @tape.outbox_tool (sugar for @tape.effect(dispatch="outbox", ...))
when the upstream can't be made naturally idempotent and the outbox reactor should own the
dispatch.
The decoration-time safety rule for non-idempotent effects is enforced here, and the Rust
server enforces the same contract at BeginEffect-time.
tape.effect.effect
⌗
effect(*, compensate: Optional[Callable] = None, status_check: Optional[Callable] = None, key_from: Optional[Callable] = None, compensation_payload: Optional[Callable] = None, retry: Optional[RetryPolicy] = None, max_attempts: int = 0, semantics: str = 'idempotent', dispatch: str = 'inline', connector: str = '', business_key: Any = None, allow_unsafe: bool = False) -> Callable
Declare an effect.
semantics is one of "idempotent" (default — the counterparty dedupes on
our key; blind retry is safe), "non_idempotent" (a second call would land
twice — Tape refuses inline dispatch), or "observe_only" (no side effects).
dispatch is "inline" (the tool body runs and calls the counterparty —
the v1 model) or "outbox" (the tool body records intent; the outbox
reactor calls the counterparty via a registered connector).
connector names the routing key the outbox reactor matches on (e.g.
"bank.wire"). Required when dispatch="outbox".
business_key is either a static string or a callable (tool_args,
tool_context) -> str that yields the cross-run business-level dedupe key
— what the counterparty itself would use to recognise the same logical
operation. When set, the server enforces uniqueness on
(connector, business_key) across all runs.
tape.effect.outbox_tool
⌗
outbox_tool(*, connector: str, semantics: str = 'non_idempotent', business_key: Any = None, compensate: Optional[Callable] = None, status_check: Optional[Callable] = None, max_attempts: int = 0, allow_unsafe: bool = False) -> Callable
Sugar for the non-idempotent + outbox pattern.
@tape.outbox_tool(connector="bank.wire", semantics="non_idempotent",
business_key=lambda account, amount, date: f"{account}:{amount}:{date}",
compensate=reverse_wire, status_check=find_wire)
def wire_money(account, amount, beneficiary, date):
return {"account": account, "amount": amount,
"beneficiary": beneficiary, "date": date}
The tool body only builds the intent payload — it must not perform
external IO. TapePlugin records the intent via BeginEffect and returns a
synthetic {tape_status: "accepted", ...} result to ADK without running
the body. The outbox reactor picks it up and the registered connector
performs the call exactly once, with explicit ambiguity handling.
Tool-context helpers⌗
tape.effect.idempotency_key
⌗
Inside a tool body: the idempotency key Tape minted for this call.
Pass it to the counterparty (bank.wire(..., idempotency_key=key)) so a
re-run after a crash is deduped at the floor.
tape.effect.business_key
⌗
The business-level dedupe key Tape derived (from the decorator's
business_key= callable) for the in-flight effect. Empty if no key was
declared.
tape.effect.external_ref
⌗
The counterparty's identifier for the operation (a wire_id, a payment intent id), once Tape has observed it. Empty until known — typically only populated on re-drive after a connector + reconciler round-trip.
tape.effect.effect_semantics
⌗
The declared semantics of the in-flight effect: 'idempotent', 'non_idempotent', or 'observe_only'. '' if not set.