Pause token middleware — JWT-based single-use tokens for elicitation resume.
The single-use enforcement is delegated to a :class:PauseTokenStore (see
pause_token_store.py). v7.1.1 ships two stores:
- :class:
InMemoryPauseTokenStore -- the default, used when no Postgres
pool is wired (test/dev) and the one previously inlined here as a class
attribute _consumed_jtis. Not safe across worker restarts.
- :class:
PostgresPauseTokenStore -- the production path, atomic across
workers via INSERT ... ON CONFLICT DO NOTHING RETURNING jti.
Ownership model (v7.1.3 — Item 10.2 fix)
The store is owned by a :class:PauseTokenManager instance held on
the SymfonicAgent. Two agents in the same process therefore have two
independent stores and cannot clobber each other (the prior class-level
ClassVar singleton was a latent multi-tenant bug).
The legacy classmethod API on :class:PauseToken
(PauseToken.consume / PauseToken.set_store /
PauseToken.reset_store_to_default) is preserved as a thin facade over
a process-wide default manager so existing test fixtures and direct
callers continue to work unchanged. New code (engine boot wiring,
multi-agent harnesses) should use a per-agent manager instead.
PauseToken
Opaque token management for elicitation resume.
Pure helper methods (hash_scope, hash_request, encode,
decode, jti_to_timestamp, load_original_request) carry
no instance state and live as @staticmethod /
@classmethod on this class.
Single-use consumption (consume / set_store /
reset_store_to_default) is now owned by
:class:PauseTokenManager; the methods below remain as a thin
facade over the process-wide default manager so existing test
fixtures and direct callers continue to work unchanged.
consume
async
classmethod
consume(
jti: str,
scope: FrameworkTenantScope,
*,
name: str = "ask_user",
) -> bool
Mark a token as consumed (single-use enforcement).
Legacy facade: delegates to the process-wide default
:class:PauseTokenManager. Multi-agent deployments should hold
a per-agent PauseTokenManager and call its instance method
instead -- two agents sharing the process-wide default still
clobber each other.
Returns True if consumption succeeded, False if already used.
Source code in src/symfonic/agent/middleware/pause_token.py
| @classmethod
async def consume(
cls,
jti: str,
scope: FrameworkTenantScope,
*,
name: str = "ask_user",
) -> bool:
"""Mark a token as consumed (single-use enforcement).
**Legacy facade**: delegates to the process-wide default
:class:`PauseTokenManager`. Multi-agent deployments should hold
a per-agent ``PauseTokenManager`` and call its instance method
instead -- two agents sharing the process-wide default still
clobber each other.
Returns True if consumption succeeded, False if already used.
"""
return await _default_manager.consume(jti, scope, name=name)
|
decode
classmethod
decode(
token: str, config: FrameworkConfig
) -> PauseTokenClaims
Decode and verify a pause token.
Requires python-jose (symfonic-core[ask-user]); raises
:class:ImportError with the install hint when absent.
Source code in src/symfonic/agent/middleware/pause_token.py
| @classmethod
def decode(cls, token: str, config: FrameworkConfig) -> PauseTokenClaims:
"""Decode and verify a pause token.
Requires ``python-jose`` (``symfonic-core[ask-user]``); raises
:class:`ImportError` with the install hint when absent.
"""
_require_jose()
# Re-bind ``JWTError`` from the now-imported jose module so the
# ``except`` clause catches the real type (the module-level
# symbol is ``None`` when jose was absent at import time, but
# ``_require_jose()`` above guarantees the import succeeded
# by the time we reach this line). Mirrors the engine.py:3400
# lazy-import precedent.
from jose import JWTError as _JWTError
secret = os.environ.get("JWT_SECRET", "framework-default-secret-change-me")
try:
payload = jwt.decode(token, secret, algorithms=["HS256"])
return PauseTokenClaims(**payload)
except _JWTError as exc:
raise SymfonicAgentError(f"Invalid pause token: {exc}", code="unauthorized") from exc
|
encode
classmethod
encode(
claims: PauseTokenClaims, config: FrameworkConfig
) -> str
Mint a HMAC-signed JWT token.
Requires python-jose (symfonic-core[ask-user]); raises
:class:ImportError with the install hint when absent.
Source code in src/symfonic/agent/middleware/pause_token.py
| @classmethod
def encode(cls, claims: PauseTokenClaims, config: FrameworkConfig) -> str:
"""Mint a HMAC-signed JWT token.
Requires ``python-jose`` (``symfonic-core[ask-user]``); raises
:class:`ImportError` with the install hint when absent.
"""
_require_jose()
secret = os.environ.get("JWT_SECRET", "framework-default-secret-change-me")
# Use HS256 for symmetric signing
return jwt.encode(claims.model_dump(), secret, algorithm="HS256")
|
hash_payload
staticmethod
hash_payload(payload: Any) -> str
Deterministic hash of an arbitrary Pydantic payload.
Used by Roadmap Item 9 generic interrupts: any registered
payload_schema (BaseModel subclass) can be hashed for the
request_hash claim. The hash protects against payload
tampering between mint and resume the same way
:meth:hash_request protects ask_user.
Source code in src/symfonic/agent/middleware/pause_token.py
| @staticmethod
def hash_payload(payload: Any) -> str:
"""Deterministic hash of an arbitrary Pydantic payload.
Used by Roadmap Item 9 generic interrupts: any registered
``payload_schema`` (BaseModel subclass) can be hashed for the
``request_hash`` claim. The hash protects against payload
tampering between mint and resume the same way
:meth:`hash_request` protects ``ask_user``.
"""
if hasattr(payload, "model_dump_json"):
raw = payload.model_dump_json()
else:
import json as _json
raw = _json.dumps(payload, sort_keys=True, default=str)
return hashlib.sha256(raw.encode()).hexdigest()
|
hash_request
staticmethod
hash_request(request: AskUserRequest) -> str
Deterministic hash of the server-stamped request.
Source code in src/symfonic/agent/middleware/pause_token.py
| @staticmethod
def hash_request(request: AskUserRequest) -> str:
"""Deterministic hash of the server-stamped request."""
return hashlib.sha256(request.model_dump_json().encode()).hexdigest()
|
hash_scope
staticmethod
hash_scope(scope: FrameworkTenantScope) -> str
Deterministic hash of the tenant scope for binding.
Source code in src/symfonic/agent/middleware/pause_token.py
| @staticmethod
def hash_scope(scope: FrameworkTenantScope) -> str:
"""Deterministic hash of the tenant scope for binding."""
raw = f"{scope.tenant_id}:{scope.sub_tenant_id or '_'}:{scope.namespace or '_'}"
return hashlib.sha256(raw.encode()).hexdigest()[:16]
|
jti_to_timestamp
staticmethod
jti_to_timestamp(jti: str) -> float
Extract issuance timestamp from JTI if encoded there (fallback to now).
Source code in src/symfonic/agent/middleware/pause_token.py
| @staticmethod
def jti_to_timestamp(jti: str) -> float:
"""Extract issuance timestamp from JTI if encoded there (fallback to now)."""
# Our JTI is uuid4.hex, so no timestamp. Fallback to now for time-to-answer.
return time.time()
|
load_original_request
async
staticmethod
load_original_request(
checkpointer: Any, claims: PauseTokenClaims
) -> AskUserRequest
Load the original stamped AskUserRequest from the checkpoint.
Tries two paths in order:
1. Checkpoint pending-writes metadata keyed by ask_user_request:{tool_call_id}
(written by _mint_pause_token via checkpointer.aput_writes).
2. AIMessage tool-call args in the checkpoint state — the args include
the full stamped request dict (id included) because the elicitation
node passed the stamped request into interrupt().
On either path, verifies the recovered request's hash matches
claims.request_hash to detect tampering.
Source code in src/symfonic/agent/middleware/pause_token.py
| @staticmethod
async def load_original_request(
checkpointer: Any, claims: PauseTokenClaims
) -> AskUserRequest:
"""Load the original stamped AskUserRequest from the checkpoint.
Tries two paths in order:
1. Checkpoint pending-writes metadata keyed by ``ask_user_request:{tool_call_id}``
(written by ``_mint_pause_token`` via ``checkpointer.aput_writes``).
2. AIMessage tool-call args in the checkpoint state — the args include
the full stamped request dict (id included) because the elicitation
node passed the stamped request into ``interrupt()``.
On either path, verifies the recovered request's hash matches
``claims.request_hash`` to detect tampering.
"""
import json as _json
if checkpointer is None:
raise SymfonicAgentError("No checkpointer available to resume", code="error")
cfg = {
"configurable": {
"thread_id": claims.thread_id,
"checkpoint_id": claims.checkpoint_id,
}
}
checkpoint_tuple = await checkpointer.aget_tuple(cfg)
if not checkpoint_tuple:
# When MemorySaver is the active checkpointer and the worker
# restarts between question-emit and resume, the checkpoint
# vanishes from process memory. The HMAC + scope binding still
# holds, but the original request cannot be reconstructed --
# surface a clear, actionable message so callers do not chase
# a perceived token problem.
if _is_non_persistent_checkpointer(checkpointer):
raise SymfonicAgentError(
"Checkpoint lost on restart; non-persistent "
"checkpointers (MemorySaver) are not supported with "
"ask_user_enabled=True. Configure AsyncPostgresSaver "
"or AsyncSqliteSaver for production. "
f"(thread={claims.thread_id}, "
f"checkpoint={claims.checkpoint_id})",
code="failed_dependency",
)
raise SymfonicAgentError(
f"Checkpoint {claims.checkpoint_id} not found for "
f"thread {claims.thread_id}",
code="not_found",
)
# -- Path 1: metadata written by _mint_pause_token -------------------
meta_key = f"ask_user_request:{claims.tool_call_id}"
pending_writes = getattr(checkpoint_tuple, "pending_writes", None) or []
for _task_id, key, value in (pending_writes or []):
if key == meta_key:
try:
raw = value if isinstance(value, str) else _json.dumps(value)
request = AskUserRequest.model_validate_json(raw)
if PauseToken.hash_request(request) == claims.request_hash:
return request
except Exception:
pass
# -- Path 2: AIMessage tool-call args in checkpoint state ------------
# The elicitation node passes stamped_request.model_dump() into
# interrupt(), so the interrupt payload carries the full request
# including server-stamped IDs. LangGraph stores interrupt payloads
# in ``checkpoint.pending_sends`` or in the channel state depending
# on the version. We also check the raw messages for the AIMessage
# whose tool_calls contain the matching id.
channel_values = checkpoint_tuple.checkpoint.get("channel_values", {})
messages = (
channel_values.get("messages", [])
or checkpoint_tuple.checkpoint.get("messages", [])
)
for msg in reversed(messages):
if not hasattr(msg, "tool_calls"):
continue
for tc in msg.tool_calls:
if tc.get("id") != claims.tool_call_id:
continue
raw_req = tc["args"].get("request", {})
# The args from the elicitation node's interrupt include id fields.
questions = [AskUserQuestion(**q_data) for q_data in raw_req.get("questions", [])]
try:
request = AskUserRequest(questions=questions)
if PauseToken.hash_request(request) == claims.request_hash:
return request
except Exception:
pass
raise SymfonicAgentError(
"Original request not found or hash mismatch — possible tampering",
code="bad_request",
)
|
reset_store_to_default
classmethod
reset_store_to_default() -> None
Restore the in-memory store on the process-wide default manager.
Legacy facade: test fixtures use this for isolation. New
tests should construct their own PauseTokenManager to avoid
cross-test global state.
Source code in src/symfonic/agent/middleware/pause_token.py
| @classmethod
def reset_store_to_default(cls) -> None:
"""Restore the in-memory store on the process-wide default manager.
**Legacy facade**: test fixtures use this for isolation. New
tests should construct their own ``PauseTokenManager`` to avoid
cross-test global state.
"""
_default_manager.reset_store_to_default()
|
set_store
classmethod
set_store(store: PauseTokenStore) -> None
Swap the consumption store on the process-wide default manager.
Legacy facade: mutates a global. Engine boot wiring at
engine.py now configures a per-agent manager
(self._pause_token_manager) instead of touching this global.
Kept for backward compatibility with existing test fixtures.
Source code in src/symfonic/agent/middleware/pause_token.py
| @classmethod
def set_store(cls, store: PauseTokenStore) -> None:
"""Swap the consumption store on the process-wide default manager.
**Legacy facade**: mutates a global. Engine boot wiring at
``engine.py`` now configures a per-agent manager
(``self._pause_token_manager``) instead of touching this global.
Kept for backward compatibility with existing test fixtures.
"""
_default_manager.set_store(store)
|
PauseTokenManager
PauseTokenManager(store: PauseTokenStore | None = None)
Per-agent owner of a :class:PauseTokenStore.
Held by SymfonicAgent at construction time so each agent in the
same process has its own consumption store. Replaces the old class-
level PauseToken._store ClassVar that two agents in the same
process would clobber.
The store can be swapped at any time via :meth:set_store (engine
boot wiring does this when a Postgres pool is available) or restored
to the in-memory default via :meth:reset_store_to_default (test
fixtures).
Source code in src/symfonic/agent/middleware/pause_token.py
| def __init__(self, store: PauseTokenStore | None = None) -> None:
self._store: PauseTokenStore = store or InMemoryPauseTokenStore()
|
store
property
Expose the active store (read-only) for diagnostics.
consume
async
consume(
jti: str,
scope: FrameworkTenantScope,
*,
name: str = "ask_user",
) -> bool
Mark jti consumed for scope (single-use enforcement).
Returns True for the first caller, False on replay. Concurrent
callers competing on the same jti see exactly one True.
Roadmap Item 9: name is forwarded to the store as a
telemetry field. Defaults to "ask_user" so existing callers
observe no behaviour change.
Source code in src/symfonic/agent/middleware/pause_token.py
| async def consume(
self,
jti: str,
scope: FrameworkTenantScope,
*,
name: str = "ask_user",
) -> bool:
"""Mark ``jti`` consumed for ``scope`` (single-use enforcement).
Returns True for the first caller, False on replay. Concurrent
callers competing on the same jti see exactly one True.
Roadmap Item 9: ``name`` is forwarded to the store as a
telemetry field. Defaults to ``"ask_user"`` so existing callers
observe no behaviour change.
"""
scope_hash = PauseToken.hash_scope(scope)
return await self._store.consume(jti, scope_hash, name=name)
|
reset_store_to_default
reset_store_to_default() -> None
Restore an :class:InMemoryPauseTokenStore.
Source code in src/symfonic/agent/middleware/pause_token.py
| def reset_store_to_default(self) -> None:
"""Restore an :class:`InMemoryPauseTokenStore`."""
self._store = InMemoryPauseTokenStore()
|
set_store
set_store(store: PauseTokenStore) -> None
Swap the consumption store.
Idempotent; safe to call again. Does not close/teardown the
previous store -- the caller owns store lifetime.
Source code in src/symfonic/agent/middleware/pause_token.py
| def set_store(self, store: PauseTokenStore) -> None:
"""Swap the consumption store.
Idempotent; safe to call again. Does not close/teardown the
previous store -- the caller owns store lifetime.
"""
self._store = store
|
get_default_pause_token_manager
get_default_pause_token_manager() -> PauseTokenManager
Return the process-wide default manager (legacy facade backing).
Source code in src/symfonic/agent/middleware/pause_token.py
| def get_default_pause_token_manager() -> PauseTokenManager:
"""Return the process-wide default manager (legacy facade backing)."""
return _default_manager
|