We’re exploring a deployment pattern where the agent is intentionally
restricted from making outbound calls — treating it like an ML model
(text in, text out, no credentials, no network access). The serving
layer owns all I/O and s2s calls; the agent declares what data it needs,
and the serving layer fulfills it.
To implement this with LangGraph Server, we’re using interrupt() for
data fetches — but the contract feels wrong for this use case:
interrupt()is indeterminate — a human may never respond- A data fetch always resumes, has a strict SLA, is automated
Why this pattern matters
Treating the agent as a pure model unlocks two things that are hard today:
-
Clean A/B testing — swap agent versions behind the serving layer the
same way you’d swap model checkpoints. If the agent makes outbound calls
itself, you can’t isolate its behavior from the data layer during
evaluation. Afetch()primitive makes that boundary explicit and testable. -
Replay and offline evaluation — log the fetch requests and responses
in production, then replay real conversations against a new agent version
without hitting live services. Withinterrupt()today this is possible
but requires inspecting payloads to know which interrupts to replay vs.
which to surface to a human.
interrupt() can mechanically implement this today, but the semantic mismatch
leaks into every layer: monitoring, API clients, and serving layer code all
need to know “this interrupt is actually a data fetch, not a human decision.”
Proposed: fetch() primitive
# Instead of:
raw = interrupt({"resource": "transactions", "start_date": start_date})
# A semantically distinct call:
raw = fetch("transactions", start_date=start_date)
At minimum a thin wrapper over interrupt() with a reserved marker, but the
framework could expose tasks[i].fetches vs tasks[i].interrupts separately,
and LangGraph Server could surface /fetch as a distinct endpoint — so
serving layer code becomes unambiguous:
snapshot = graph.get_state(config)
for task in snapshot.tasks:
for req in task.fetches: # always automated — fulfill and resume
result = data_layer.get(req.resource, **req.params)
graph.invoke(Command(resume=result), config=config)
for val in task.interrupts: # always human — surface to UI
ui.show(val)
break
Question for maintainers: Is this distinction intentional — i.e., are
users expected to use interrupt() for s2s data dependencies? Or is there a
cleaner pattern we’re missing?