Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion dotnet/server/src/Interactions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -286,15 +286,28 @@ public RequestInteractionTool(

protected override async ValueTask<object?> InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken)
{
var args = Interactions.ArgsToObject(arguments);
// The stream loop DEFERRED this tool's toolCall chunk (see TurnRunner.IsInteractionRaise) so
// the park path can emit it AFTER interaction_required — the canonical (Rust) order, and the
// same order the write-confirmation park already uses. Every non-park exit emits it here.
void EmitCall() => _sink(ProtocolEvents.StreamChunk(_requestId, Name, new JsonObject
{
["rawResponse"] = new JsonObject
{
["toolCall"] = new JsonObject { ["name"] = Name, ["arguments"] = args.DeepClone() },
},
}));

InteractionRequest request;
try
{
request = _kind.ParseRequest(Interactions.ArgsToObject(arguments));
request = _kind.ParseRequest(args);
}
catch (InteractionParseException ex)
{
// Contract violation (count/length/uniqueness) — hand the reason back so the model can fix
// and re-call, exactly like Rust returning the parse error as the tool result.
EmitCall();
return ex.Message;
}

Expand All @@ -303,6 +316,7 @@ public RequestInteractionTool(
// Text-only channel: no card. Stash the spec for a format-only submit, and return the
// conversational directive telling the model to ask + submit via submit_interaction.
_raised[request.Kind] = request.Spec;
EmitCall();
return new JsonObject
{
["mode"] = "conversational",
Expand All @@ -318,6 +332,7 @@ public RequestInteractionTool(
var interactionId = Guid.NewGuid().ToString();
var parked = _park.Register(_sessionId, interactionId, request.Kind, request.Spec);
_sink(ProtocolEvents.InteractionRequired(_requestId, interactionId, request.Kind, request.Spec.DeepClone(), request.Reason));
EmitCall();

var outcome = await AwaitOutcome(parked, cancellationToken).ConfigureAwait(false);
return outcome.Status switch
Expand Down
14 changes: 10 additions & 4 deletions dotnet/server/src/TurnRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,12 @@ private string BuildSystemPrompt(string? currentStepId, bool isFirstTurn, string
private bool IsGated(string toolName) =>
_confirmations is not null && _confirmTools.Any(pattern => toolName.Contains(pattern, StringComparison.Ordinal));

/// <summary>True when <paramref name="toolName"/> is one of this turn's <c>request_&lt;kind&gt;</c>
/// raise tools. Their toolCall chunk is deferred out of the stream loop and re-emitted by the tool
/// itself — after <c>interaction_required</c> on the park path.</summary>
private bool IsInteractionRaise(string toolName) =>
_interactions is not null && _interactions.Kinds.Any(kind => kind.ToolName == toolName);

public Task<TurnResult> RunAsync(string conversationId, string requestId, string userMessage, Action<JsonObject> sink, CancellationToken cancellationToken = default) =>
RunAsync(conversationId, requestId, userMessage, sink, sessionId: conversationId, cancellationToken);

Expand Down Expand Up @@ -496,10 +502,10 @@ public async Task<TurnResult> RunAsync(string conversationId, string requestId,
// `gen_ai.tool` child span (nests under the turn span), mirroring the Rust
// runner emitting one gen_ai.tool span per tool call with redacted args.
EmitToolSpan(call, conversationId);
// DEFER a confirmation-gated tool's toolCall chunk: it is emitted from the
// gate AFTER write_confirmation_required, so the wire order matches the
// canonical (Rust) server. Non-gated tools emit their chunk inline as before.
if (IsGated(call.Name))
// DEFER a parking tool's toolCall chunk: it is emitted from the park path
// AFTER write_confirmation_required / interaction_required, so the wire
// order matches the canonical (Rust) server. Ungated tools emit inline.
if (IsGated(call.Name) || IsInteractionRaise(call.Name))
{
break;
}
Expand Down
11 changes: 6 additions & 5 deletions go/server/identity_intake_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,15 +91,16 @@ func TestIdentityIntakeRichPathStampsSessionIdentity(t *testing.T) {
if ack := expectType(t, transport, "immediate_response"); mustStatus(t, ack) != 202 {
t.Fatalf("expected 202 ack, got %v", ack["status"])
}
call := expectType(t, transport, "stream_chunk")
if name, _ := dot(t, call, "data.state.rawResponse.toolCall.name"); name != "request_identity_intake" {
t.Fatalf("expected request_identity_intake toolCall, got %v", name)
}

// The park event precedes the raise tool's toolCall chunk (the reference order).
req := expectType(t, transport, "interaction_required")
if kind, _ := dot(t, req, "data.data.kind"); kind != "identity_intake" {
t.Fatalf("interaction_required kind = %v, want identity_intake (event=%s)", kind, mustJSON(req))
}

call := expectType(t, transport, "stream_chunk")
if name, _ := dot(t, call, "data.state.rawResponse.toolCall.name"); name != "request_identity_intake" {
t.Fatalf("expected request_identity_intake toolCall, got %v", name)
}
iid, _ := mustDotString(t, req, "data.data.interactionId")
if key, _ := dot(t, req, "data.data.spec.fields.1.key"); key != "email" {
t.Fatalf("spec fields[1].key = %v, want email (event=%s)", key, mustJSON(req))
Expand Down
16 changes: 9 additions & 7 deletions go/server/interaction_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,13 +87,9 @@ func TestSubmitInteractionRichPathResumes(t *testing.T) {
t.Fatalf("expected 202 ack, got %v", ack["status"])
}

// The raise tool's toolCall chunk is emitted (deterministically) before the park.
call := expectType(t, transport, "stream_chunk")
if name, _ := dot(t, call, "data.state.rawResponse.toolCall.name"); name != "request_choices" {
t.Fatalf("expected request_choices toolCall chunk, got %v (event=%s)", name, mustJSON(call))
}

// The turn PARKS: interaction_required carries the kind, the spec, and an interactionId.
// It precedes the raise tool's toolCall chunk (the reference order, and the same order
// this server already uses for the write-confirmation park).
req := expectType(t, transport, "interaction_required")
if rid, _ := req["requestId"].(string); rid != "r-msg" {
t.Fatalf("interaction_required requestId = %q, want r-msg", rid)
Expand All @@ -111,6 +107,12 @@ func TestSubmitInteractionRichPathResumes(t *testing.T) {
t.Fatalf("interaction_required spec question header = %v, want Plan (event=%s)", header, mustJSON(req))
}

// Only now does the raise tool's toolCall chunk land.
call := expectType(t, transport, "stream_chunk")
if name, _ := dot(t, call, "data.state.rawResponse.toolCall.name"); name != "request_choices" {
t.Fatalf("expected request_choices toolCall chunk, got %v (event=%s)", name, mustJSON(call))
}

// Submit a valid pick → the server acks and the parked raise resumes. The ack and the
// resumed tool-result chunk come from different goroutines, so collect the tail and
// assert on its contents rather than a strict interleaving.
Expand Down Expand Up @@ -150,8 +152,8 @@ func TestSubmitInteractionInvalidStaysParked(t *testing.T) {
"action": "send_message", "requestId": "r-msg", "sessionId": sessionID, "message": "sign me up",
})
expectType(t, transport, "immediate_response") // 202
expectType(t, transport, "stream_chunk") // request_choices toolCall
req := expectType(t, transport, "interaction_required")
expectType(t, transport, "stream_chunk") // request_choices toolCall, after the park
iid, _ := mustDotString(t, req, "data.data.interactionId")

// Invalid pick (Platinum isn't offered) → interaction_invalid, turn STAYS parked.
Expand Down
16 changes: 12 additions & 4 deletions go/server/turn_runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -784,14 +784,20 @@ func (r *TurnRunner) raiseTool(kind InteractionKind, rich bool, sessionID, reque
Desc: schema.Description,
Params: schema.Parameters,
Fn: func(ctx context.Context, args map[string]any) (string, error) {
// Emit the deferred toolCall chunk here (the stream loop skipped it), so it
// deterministically precedes interaction_required / the fallback result.
if argsJSON, err := json.Marshal(args); err == nil {
sink(streamChunk(requestID, schema.Name, toolCallState(schema.Name, string(argsJSON))))
// The stream loop skipped this tool's toolCall chunk; emit it here. On the
// rich path it must follow interaction_required (the reference order — a
// client that renders tool calls would otherwise show "calling
// request_identity_intake…" before the card). On every non-park path there
// is no park event, so it goes out immediately.
emitCall := func() {
if argsJSON, err := json.Marshal(args); err == nil {
sink(streamChunk(requestID, schema.Name, toolCallState(schema.Name, string(argsJSON))))
}
}

req, err := kind.ParseRequest(args)
if err != nil {
emitCall()
return "", err
}

Expand All @@ -800,6 +806,7 @@ func (r *TurnRunner) raiseTool(kind InteractionKind, rich bool, sessionID, reque
// model collects the answer turn by turn and submits via submit_interaction.
// Stash the raised spec so that same-turn submit validates required-ness.
r.stashRaisedSpec(req.Kind, req.Spec)
emitCall()
return marshalInteractionResult(map[string]any{
"mode": "conversational",
"kind": req.Kind,
Expand All @@ -814,6 +821,7 @@ func (r *TurnRunner) raiseTool(kind InteractionKind, rich bool, sessionID, reque
interactionID := uuid.NewString()
outcome := r.interactions.Register(sessionID, interactionID, req.Kind, req.Spec)
sink(interactionRequired(requestID, interactionID, req.Kind, req.Spec, req.Reason))
emitCall()

select {
case oc := <-outcome:
Expand Down
18 changes: 17 additions & 1 deletion python/server/src/smooth_operator_server/interaction_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,26 @@ def _request_tool(
schema = kind.tool_schema()

async def _run(args: dict[str, Any]) -> str:
request = kind.parse_request(args) # ValueError → surfaced to the model
# The stream loop DEFERRED this tool's toolCall chunk (see
# ``TurnRunner._is_interaction_raise``) so the park path can emit it AFTER
# ``interaction_required`` — the reference order, and the same order the
# write-confirmation park already uses. Every non-park exit emits it here.
# (the state shape is inlined rather than imported from `turn_runner`, which
# imports this module — `_tool_call_state_from`'s one-line dict, not a cycle.)
def emit_call() -> None:
state = {"rawResponse": {"toolCall": {"name": schema["name"], "arguments": args}}}
sink(protocol.stream_chunk(request_id, schema["name"], state))

try:
request = kind.parse_request(args) # ValueError → surfaced to the model
except Exception:
emit_call()
raise
if not rich:
# Fallback: no card can render — hand the model the conversational directive
# and stash the spec so the submit tool validates with full required-ness.
raised_specs[request.kind] = request.spec
emit_call()
return json.dumps(
{
"mode": "conversational",
Expand All @@ -77,6 +92,7 @@ async def _run(args: dict[str, Any]) -> str:
interaction_id = str(uuid.uuid4())
future = pending.register(session_id, interaction_id, request.kind, request.spec)
sink(protocol.interaction_required(request_id, interaction_id, request.kind, request.spec, request.reason))
emit_call()
try:
outcome = await asyncio.wait_for(future, INTERACTION_TIMEOUT)
except (asyncio.TimeoutError, asyncio.CancelledError):
Expand Down
18 changes: 13 additions & 5 deletions python/server/src/smooth_operator_server/turn_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,14 @@ def _is_gated(self, tool_name: str) -> bool:
return False
return any(pattern in tool_name for pattern in self._confirm_tools)

def _is_interaction_raise(self, tool_name: str) -> bool:
"""True when ``tool_name`` is one of this turn's ``request_<kind>`` raise tools.
Their toolCall chunk is deferred out of the stream loop and re-emitted by the
tool itself — after ``interaction_required`` on the park path."""
if self._interactions is None:
return False
return any(kind.tool_schema()["name"] == tool_name for kind in self._interactions.kinds())

async def run(
self,
conversation_id: str,
Expand Down Expand Up @@ -530,11 +538,11 @@ async def _gate(req: HumanApprovalRequest) -> HumanApprovalResponse:
# child span). Emitted for gated tools too — the span is
# independent of the deferred wire chunk below.
_emit_tool_span(event, conversation_id, self._org_id)
# DEFER a confirmation-gated tool's toolCall chunk: it is emitted
# from the gate AFTER `write_confirmation_required`, so the wire
# order matches the reference (Rust) server. Non-gated tools emit
# their chunk inline as before.
if self._is_gated(event.name):
# DEFER a parking tool's toolCall chunk: it is emitted from the
# park path AFTER `write_confirmation_required` /
# `interaction_required`, so the wire order matches the reference
# (Rust) server. Ungated tools emit their chunk inline as before.
if self._is_gated(event.name) or self._is_interaction_raise(event.name):
continue
sink(protocol.stream_chunk(request_id, event.name, _tool_call_state(event)))
elif isinstance(event, ToolResultEvent):
Expand Down
25 changes: 16 additions & 9 deletions python/server/tests/test_identity_intake_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,19 @@ async def _recv(ws):
return event


async def _recv_park(ws):
"""The park event plus the raise tool's deferred toolCall chunk that follows it.

The reference order is ``interaction_required`` FIRST, then the raise tool's
``stream_chunk`` — same as this server's write-confirmation park.
"""
event = await _recv(ws)
assert event["type"] == "interaction_required", event
chunk = await _recv(ws)
assert chunk["type"] == "stream_chunk", chunk
return event


async def _send_message(ws) -> None:
await ws.send(
json.dumps(
Expand All @@ -87,11 +100,8 @@ async def test_rich_path_parks_resumes_and_stamps_contacts() -> None:
ack = await _recv(ws)
assert ack["type"] == "immediate_response" and ack["status"] == 202

# Park: an interaction_required arrives (a toolCall chunk may precede it).
event = await _recv(ws)
if event["type"] == "stream_chunk":
event = await _recv(ws)
assert event["type"] == "interaction_required"
# Park: interaction_required, then the raise tool's deferred toolCall chunk.
event = await _recv_park(ws)
inner = event["data"]["data"]
assert inner["kind"] == "identity_intake"
assert inner["spec"]["fields"][1]["key"] == "email"
Expand Down Expand Up @@ -160,10 +170,7 @@ async def test_invalid_submit_stays_parked_and_does_not_stamp() -> None:
_SID = await _create_session(ws, supports=["identity_form"])
await _send_message(ws)
assert (await _recv(ws))["status"] == 202
event = await _recv(ws)
if event["type"] == "stream_chunk":
event = await _recv(ws)
assert event["type"] == "interaction_required"
event = await _recv_park(ws)
interaction_id = event["data"]["data"]["interactionId"]

# A bad email → interaction_invalid, turn stays parked, nothing stamped.
Expand Down
25 changes: 16 additions & 9 deletions python/server/tests/test_submit_interaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,19 @@ async def _recv(ws):
return event


async def _recv_park(ws):
"""The park event plus the raise tool's deferred toolCall chunk that follows it.

The reference order is ``interaction_required`` FIRST, then the raise tool's
``stream_chunk`` — same as this server's write-confirmation park.
"""
event = await _recv(ws)
assert event["type"] == "interaction_required", event
chunk = await _recv(ws)
assert chunk["type"] == "stream_chunk", chunk
return event


async def _send_message(ws) -> None:
await ws.send(
json.dumps({"action": "send_message", "requestId": "r-msg", "sessionId": _SID, "message": "help me pick"})
Expand All @@ -89,11 +102,8 @@ async def test_rich_path_parks_emits_interaction_required_and_resumes() -> None:
ack = await _recv(ws)
assert ack["type"] == "immediate_response" and ack["status"] == 202

# Park: an interaction_required arrives (a toolCall chunk may precede it).
event = await _recv(ws)
if event["type"] == "stream_chunk":
event = await _recv(ws)
assert event["type"] == "interaction_required"
# Park: interaction_required, then the raise tool's deferred toolCall chunk.
event = await _recv_park(ws)
assert event["requestId"] == "r-msg"
inner = event["data"]["data"]
assert inner["kind"] == "choices"
Expand Down Expand Up @@ -155,10 +165,7 @@ async def test_invalid_submit_stays_parked_then_resubmit_resumes() -> None:
_SID = await _create_session(ws, supports=["choice_chips"])
await _send_message(ws)
assert (await _recv(ws))["status"] == 202
event = await _recv(ws)
if event["type"] == "stream_chunk":
event = await _recv(ws)
assert event["type"] == "interaction_required"
event = await _recv_park(ws)
interaction_id = event["data"]["data"]["interactionId"]

# A bad pick (not an offered option) → interaction_invalid, turn stays parked.
Expand Down
2 changes: 1 addition & 1 deletion python/server/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading