Skip to content

Commit abff059

Browse files
committed
Defend from replication lag and duplicate elicitation requests
1 parent f4d86fe commit abff059

9 files changed

Lines changed: 838 additions & 159 deletions

File tree

lib/phantom/elicit.ex

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,75 @@ defmodule Phantom.Elicit do
247247
}) :: t
248248
def build(attrs), do: form(attrs)
249249

250+
@doc """
251+
Generate a deterministic JSON-RPC request id for an elicitation.
252+
253+
Two dispatches of the same tool call (e.g. when a proxy retries
254+
across nodes) produce the same elicitation id, so the client
255+
sees identical duplicates rather than two logically distinct
256+
requests.
257+
258+
When `tool_call_id` is `nil` (elicit called outside a request
259+
context), falls back to `UUIDv7.generate/0` for uniqueness.
260+
"""
261+
@spec deterministic_id(String.t(), term() | nil, t()) :: String.t()
262+
def deterministic_id(_session_id, nil, _elicitation), do: UUIDv7.generate()
263+
264+
def deterministic_id(session_id, tool_call_id, %__MODULE__{} = elicitation) do
265+
seq = next_elicit_sequence(session_id, tool_call_id)
266+
267+
payload =
268+
"#{session_id}|#{inspect(tool_call_id)}|#{seq}|#{:erlang.phash2(elicitation)}"
269+
270+
<<a::32, b::16, c::16, d::16, e::48, _rest::binary>> = :crypto.hash(:sha256, payload)
271+
272+
:io_lib.format("~8.16.0b-~4.16.0b-~4.16.0b-~4.16.0b-~12.16.0b", [a, b, c, d, e])
273+
|> IO.iodata_to_binary()
274+
end
275+
276+
defp next_elicit_sequence(session_id, tool_call_id) do
277+
key = {:elicit_seq, session_id, tool_call_id}
278+
seq = Process.get(key, 0)
279+
Process.put(key, seq + 1)
280+
seq
281+
end
282+
283+
@doc """
284+
Build the `elicitation/create` JSON-RPC request, assign it a
285+
deterministic id, and register it with `Phantom.Tracker` so
286+
the client's eventual response can route back.
287+
288+
Returns `{request, ref}`. The caller should write the request
289+
to its transport and `receive do {:phantom_elicitation_response, ^ref, response}`.
290+
Calling this function does not block.
291+
"""
292+
@spec prepare_request(String.t(), term() | nil, t()) :: {struct(), reference()}
293+
def prepare_request(session_id, tool_call_id, %__MODULE__{} = elicitation) do
294+
request_id = deterministic_id(session_id, tool_call_id, elicitation)
295+
296+
{:ok, request} =
297+
Phantom.Request.build(%{
298+
"id" => request_id,
299+
"jsonrpc" => "2.0",
300+
"method" => "elicitation/create",
301+
"params" => to_json(elicitation)
302+
})
303+
304+
ref = make_ref()
305+
306+
Phantom.Tracker.track_request(self(), request.id, %{
307+
type: :elicitation,
308+
reply_ref: ref,
309+
reply_pid: self()
310+
})
311+
312+
if elicitation.mode == :url do
313+
Phantom.Tracker.track_request(self(), elicitation.elicitation_id, %{type: :elicitation})
314+
end
315+
316+
{request, ref}
317+
end
318+
250319
@doc "Build a form mode elicitation"
251320
def form(attrs) do
252321
%{

lib/phantom/plug.ex

Lines changed: 144 additions & 113 deletions
Original file line numberDiff line numberDiff line change
@@ -374,95 +374,118 @@ defmodule Phantom.Plug do
374374
end
375375

376376
defp continue(state) do
377-
stream_fun = state.stream_fun
378-
379-
params =
380-
cond do
381-
state.conn.method == "GET" -> []
382-
is_map_key(state.conn.body_params, "_json") -> state.conn.body_params["_json"]
383-
true -> List.wrap(state.conn.body_params)
384-
end
385-
386377
{state, exceptions} =
387-
Enum.reduce(
388-
params,
389-
{state, []},
390-
fn
391-
_request, {%{conn: %{halted: true}} = state_acc, exceptions_acc} ->
392-
{state_acc, exceptions_acc}
393-
394-
request, {state_acc, exceptions_acc} ->
395-
case Request.build(request) do
396-
{:ok, request} ->
397-
state_acc = maybe_track_response(state_acc, request)
398-
state_acc = put_in(state_acc.conn, maybe_track_session_stream(state_acc.conn))
399-
400-
state_acc =
401-
put_in(state_acc.session, %{
402-
state_acc.conn.private.phantom.session
403-
| elicit: elicit_fun(state_acc.conn, stream_fun)
404-
})
405-
406-
try do
407-
case state_acc.session.router.dispatch_method([
408-
request.method,
409-
request.params,
410-
request,
411-
state_acc.session
412-
]) do
413-
{:noreply, %Session{} = session_acc} ->
414-
requests = Map.put(session_acc.requests, request.id, request.response)
415-
state_acc = put_in(state_acc.session, %{session_acc | requests: requests})
416-
{state_acc, exceptions_acc}
417-
418-
{:reply, result, %Session{} = session_acc} ->
419-
request = Request.result(request, "message", result)
420-
state_acc = put_in(state_acc.session, session_acc)
421-
422-
state_acc =
423-
stream_fun.(state_acc, request.id, request.type, request.response)
424-
425-
{state_acc, exceptions_acc}
426-
427-
{:error, error, %Session{} = session_acc} ->
428-
error = Request.error(request.id, error)
429-
state_acc = put_in(state_acc.session, session_acc)
430-
state_acc = stream_fun.(state_acc, error[:id], "message", error)
431-
{state_acc, exceptions_acc}
432-
433-
{:error, error} ->
434-
error = Request.error(request.id, error)
435-
state_acc = stream_fun.(state_acc, error[:id], "message", error)
436-
{state_acc, exceptions_acc}
437-
438-
_response ->
439-
error = Request.error(request.id, Request.internal_error())
440-
state_acc = stream_fun.(state_acc, error[:id], "message", error)
441-
{state_acc, exceptions_acc}
442-
end
443-
rescue
444-
exception ->
445-
error =
446-
Request.error(
447-
request.id,
448-
Request.internal_error(Exception.message(exception))
449-
)
450-
451-
exceptions_acc = [{request, exception, __STACKTRACE__} | exceptions_acc]
452-
state_acc = stream_fun.(state_acc, request.id, "message", error)
453-
{state_acc, exceptions_acc}
454-
end
455-
456-
{:error, error} ->
457-
state_acc = stream_fun.(state_acc, error.id, "message", error.response)
458-
{state_acc, exceptions_acc}
459-
end
460-
end
461-
)
378+
state
379+
|> incoming_requests()
380+
|> Enum.reduce({state, []}, &process_request/2)
462381

463382
maybe_reraise(state, exceptions)
464383
end
465384

385+
defp incoming_requests(%{conn: %{method: "GET"}}), do: []
386+
387+
defp incoming_requests(%{conn: %{body_params: %{"_json" => batch}}}), do: batch
388+
389+
defp incoming_requests(%{conn: %{body_params: body}}), do: List.wrap(body)
390+
391+
# Skip subsequent requests in a batch after the conn is halted.
392+
defp process_request(_request, {%{conn: %{halted: true}} = state, exceptions}),
393+
do: {state, exceptions}
394+
395+
defp process_request(raw_request, {state, exceptions}) do
396+
case Request.build(raw_request) do
397+
{:ok, request} ->
398+
state
399+
|> prepare_for_dispatch(request)
400+
|> dispatch_or_reject(request, exceptions)
401+
402+
{:error, error} ->
403+
state = state.stream_fun.(state, error.id, "message", error.response)
404+
{state, exceptions}
405+
end
406+
end
407+
408+
defp prepare_for_dispatch(state, request) do
409+
state = maybe_track_response(state, request)
410+
state = put_in(state.conn, maybe_track_session_stream(state.conn))
411+
412+
put_in(state.session, %{
413+
state.conn.private.phantom.session
414+
| elicit: elicit_fun(state.conn, state.stream_fun, request)
415+
})
416+
end
417+
418+
defp dispatch_or_reject(state, request, exceptions) do
419+
session_id = state.session.id
420+
421+
case claim_in_flight(session_id, request) do
422+
:duplicate ->
423+
error = Request.error(request.id, Request.duplicate_request())
424+
state = state.stream_fun.(state, error[:id], "message", error)
425+
{state, exceptions}
426+
427+
:ok ->
428+
run_dispatch(state, request, exceptions)
429+
end
430+
end
431+
432+
defp run_dispatch(state, request, exceptions) do
433+
result =
434+
state.session.router.dispatch_method([
435+
request.method,
436+
request.params,
437+
request,
438+
state.session
439+
])
440+
441+
handle_dispatch_result(result, state, request, exceptions)
442+
rescue
443+
exception ->
444+
error =
445+
Request.error(request.id, Request.internal_error(Exception.message(exception)))
446+
447+
state = state.stream_fun.(state, request.id, "message", error)
448+
release_in_flight(state.session.id, request)
449+
{state, [{request, exception, __STACKTRACE__} | exceptions]}
450+
end
451+
452+
defp handle_dispatch_result({:noreply, %Session{} = session}, state, request, exceptions) do
453+
# Async tool — in-flight claim stays held until `Session.respond/2`
454+
# eventually casts to the session GenServer and untracks.
455+
requests = Map.put(session.requests, request.id, request.response)
456+
{put_in(state.session, %{session | requests: requests}), exceptions}
457+
end
458+
459+
defp handle_dispatch_result({:reply, result, %Session{} = session}, state, request, exceptions) do
460+
request = Request.result(request, "message", result)
461+
state = put_in(state.session, session)
462+
state = state.stream_fun.(state, request.id, request.type, request.response)
463+
release_in_flight(state.session.id, request)
464+
{state, exceptions}
465+
end
466+
467+
defp handle_dispatch_result({:error, error, %Session{} = session}, state, request, exceptions) do
468+
error = Request.error(request.id, error)
469+
state = put_in(state.session, session)
470+
state = state.stream_fun.(state, error[:id], "message", error)
471+
release_in_flight(state.session.id, request)
472+
{state, exceptions}
473+
end
474+
475+
defp handle_dispatch_result({:error, error}, state, request, exceptions) do
476+
error = Request.error(request.id, error)
477+
state = state.stream_fun.(state, error[:id], "message", error)
478+
release_in_flight(state.session.id, request)
479+
{state, exceptions}
480+
end
481+
482+
defp handle_dispatch_result(_response, state, request, exceptions) do
483+
error = Request.error(request.id, Request.internal_error())
484+
state = state.stream_fun.(state, error[:id], "message", error)
485+
release_in_flight(state.session.id, request)
486+
{state, exceptions}
487+
end
488+
466489
defp cors_preflight(%Plug.Conn{halted: true} = conn, _opts), do: conn
467490

468491
defp cors_preflight(%Plug.Conn{method: "OPTIONS"} = conn, opts) do
@@ -715,43 +738,51 @@ defmodule Phantom.Plug do
715738
"#{method} #{info}"
716739
end
717740

718-
defp elicit_fun(%Plug.Conn{} = conn, stream_fun) do
741+
defp elicit_fun(%Plug.Conn{} = conn, stream_fun, tool_call_request) do
742+
session_id = conn.private.phantom.session.id
743+
tool_call_id = tool_call_request.id
744+
719745
fn elicitation, timeout ->
720-
{:ok, request} =
721-
Request.build(%{
722-
"id" => UUIDv7.generate(),
723-
"jsonrpc" => "2.0",
724-
"method" => "elicitation/create",
725-
"params" => Phantom.Elicit.to_json(elicitation)
726-
})
727-
728-
ref = make_ref()
729-
730-
Phantom.Tracker.track_request(self(), request.id, %{
731-
type: :elicitation,
732-
reply_ref: ref,
733-
reply_pid: self()
734-
})
735-
736-
if elicitation.mode == :url do
737-
Phantom.Tracker.track_request(self(), elicitation.elicitation_id, %{type: :elicitation})
738-
end
746+
{request, ref} = Phantom.Elicit.prepare_request(session_id, tool_call_id, elicitation)
739747

740748
state = %{conn: conn}
741749
stream_fun.(state, request.id, "message", Request.to_json(request))
742750

743-
receive do
744-
{:phantom_elicitation_response, ^ref, response} ->
745-
Phantom.Tracker.untrack_request(request.id)
746-
{:ok, response}
747-
after
748-
timeout ->
749-
Phantom.Tracker.untrack_request(request.id)
750-
:timeout
751-
end
751+
await_elicitation_response(request.id, ref, timeout)
752+
end
753+
end
754+
755+
defp await_elicitation_response(request_id, ref, timeout) do
756+
receive do
757+
{:phantom_elicitation_response, ^ref, response} ->
758+
Phantom.Tracker.untrack_request(request_id)
759+
{:ok, response}
760+
after
761+
timeout ->
762+
Phantom.Tracker.untrack_request(request_id)
763+
:timeout
752764
end
753765
end
754766

767+
# Methods that dispatch to user-defined handlers and may have
768+
# side effects (including elicitation). Other methods — tools/list,
769+
# prompts/list, initialize, ping — are idempotent and safe to
770+
# re-dispatch, so we don't dedupe them (retries on reconnect stay
771+
# working).
772+
@dedupable_methods ~w[tools/call prompts/get]
773+
774+
defp claim_in_flight(session_id, %Request{id: id, method: method})
775+
when method in @dedupable_methods and not is_nil(id),
776+
do: Phantom.Tracker.track_in_flight(session_id, id)
777+
778+
defp claim_in_flight(_session_id, _request), do: :ok
779+
780+
defp release_in_flight(session_id, %Request{id: id, method: method})
781+
when method in @dedupable_methods and not is_nil(id),
782+
do: Phantom.Tracker.untrack_in_flight(session_id, id)
783+
784+
defp release_in_flight(_session_id, _request), do: :ok
785+
755786
defp inherit_session_meta(%Session{} = session) do
756787
case Phantom.Tracker.get_session_meta(session.id) do
757788
%{client_capabilities: caps} when is_map(caps) ->

lib/phantom/request.ex

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ defmodule Phantom.Request do
2828
%{code: @invalid_request, message: message || "Invalid request"}
2929
end
3030

31+
@doc "Duplicate JSON-RPC request id for a session"
32+
def duplicate_request,
33+
do: %{code: @invalid_request, message: "Duplicate request id for session"}
34+
3135
@doc "Invalid request due to bad parameters"
3236
def invalid_params(data), do: %{code: @invalid_params, message: "Invalid Params", data: data}
3337
def invalid_params, do: %{code: @invalid_params, message: "Invalid Params"}

0 commit comments

Comments
 (0)