RealtimeSession: execute server-side tools at response.done
This commit is contained in:
@@ -0,0 +1,67 @@
|
|||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using backend.Conversations;
|
||||||
|
using backend.Data;
|
||||||
|
using backend.Realtime;
|
||||||
|
using backend.Tools;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace backend.tests;
|
||||||
|
|
||||||
|
public class RealtimeSessionToolsTests(ApiFactory factory) : IClassFixture<ApiFactory>
|
||||||
|
{
|
||||||
|
private readonly ApiFactory _factory = factory;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Get_current_time_call_returns_function_call_output_and_persists_tool_turn()
|
||||||
|
{
|
||||||
|
using var scope = _factory.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
var log = scope.ServiceProvider.GetRequiredService<ConversationLog>();
|
||||||
|
var reg = new ToolRegistry(new ITool[] { new GetCurrentTimeTool() });
|
||||||
|
var upstream = new ScriptedRealtimeUpstream();
|
||||||
|
var sink = new RecordingSink();
|
||||||
|
var cfg = new RealtimeSettings("m", "v", "p", 30, new HashSet<string> { "get_current_time" });
|
||||||
|
var session = new RealtimeSession(Guid.NewGuid(), upstream, sink, log, reg, cfg,
|
||||||
|
new FakeDeviceChannel(), NullLogger.Instance);
|
||||||
|
|
||||||
|
upstream.Push(new JsonObject { ["type"] = "session.updated" });
|
||||||
|
var cts = new CancellationTokenSource();
|
||||||
|
var run = session.RunAsync(cts.Token);
|
||||||
|
await sink.WaitForAsync("session_started", TimeSpan.FromSeconds(2));
|
||||||
|
|
||||||
|
upstream.Push(new JsonObject
|
||||||
|
{
|
||||||
|
["type"] = "response.function_call_arguments.done",
|
||||||
|
["call_id"] = "call_abc",
|
||||||
|
["name"] = "get_current_time",
|
||||||
|
["arguments"] = "{}",
|
||||||
|
});
|
||||||
|
upstream.Push(new JsonObject { ["type"] = "response.done" });
|
||||||
|
|
||||||
|
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(2);
|
||||||
|
while (DateTime.UtcNow < deadline && !upstream.Sent.Any(e =>
|
||||||
|
(string?)e["type"] == "conversation.item.create"))
|
||||||
|
{
|
||||||
|
await Task.Delay(20);
|
||||||
|
}
|
||||||
|
|
||||||
|
var item = upstream.Sent.First(e => (string?)e["type"] == "conversation.item.create");
|
||||||
|
((string?)item["item"]!["type"]).Should().Be("function_call_output");
|
||||||
|
((string?)item["item"]!["call_id"]).Should().Be("call_abc");
|
||||||
|
((string?)item["item"]!["output"]).Should().Contain("\"now\"");
|
||||||
|
|
||||||
|
upstream.Sent.Should().Contain(e => (string?)e["type"] == "response.create");
|
||||||
|
|
||||||
|
var convoId = session.ConversationId!.Value;
|
||||||
|
var toolTurn = await db.Turns.FirstAsync(t =>
|
||||||
|
t.ConversationId == convoId && t.Role == TurnRole.Tool);
|
||||||
|
toolTurn.ToolName.Should().Be("get_current_time");
|
||||||
|
toolTurn.ToolResultJson.Should().Contain("\"now\"");
|
||||||
|
|
||||||
|
cts.Cancel();
|
||||||
|
await run;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ public class RealtimeSession
|
|||||||
private Conversation? _conversation;
|
private Conversation? _conversation;
|
||||||
private EndReason _endReason = EndReason.Unknown;
|
private EndReason _endReason = EndReason.Unknown;
|
||||||
private bool _endRequested;
|
private bool _endRequested;
|
||||||
|
private readonly List<(string CallId, string Name, string ArgsJson)> _pendingCalls = new();
|
||||||
|
|
||||||
private readonly System.Threading.Channels.Channel<byte[]> _uplink =
|
private readonly System.Threading.Channels.Channel<byte[]> _uplink =
|
||||||
System.Threading.Channels.Channel.CreateBounded<byte[]>(
|
System.Threading.Channels.Channel.CreateBounded<byte[]>(
|
||||||
@@ -135,13 +136,76 @@ public class RealtimeSession
|
|||||||
await _log.AppendAssistantAsync(_conversation.Id, text!, ct);
|
await _log.AppendAssistantAsync(_conversation.Id, text!, ct);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case "response.function_call_arguments.done":
|
||||||
|
{
|
||||||
|
var callId = (string?)evt["call_id"] ?? "";
|
||||||
|
var name = (string?)evt["name"] ?? "";
|
||||||
|
var args = (string?)evt["arguments"] ?? "{}";
|
||||||
|
_pendingCalls.Add((callId, name, args));
|
||||||
|
break;
|
||||||
|
}
|
||||||
case "response.done":
|
case "response.done":
|
||||||
|
{
|
||||||
await _output.WriteEnvelopeAsync(
|
await _output.WriteEnvelopeAsync(
|
||||||
new JsonObject { ["type"] = "assistant_done" }, ct);
|
new JsonObject { ["type"] = "assistant_done" }, ct);
|
||||||
|
|
||||||
|
if (_pendingCalls.Count == 0) break;
|
||||||
|
var calls = _pendingCalls.ToList();
|
||||||
|
_pendingCalls.Clear();
|
||||||
|
|
||||||
|
if (calls.Any(c => c.Name == "end_session"))
|
||||||
|
{
|
||||||
|
_endReason = EndReason.Tool;
|
||||||
|
_endRequested = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var c in calls)
|
||||||
|
{
|
||||||
|
await ExecuteToolAsync(c.CallId, c.Name, c.ArgsJson, ct);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ExecuteToolAsync(string callId, string name, string argsJson, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var tool = _registry.Get(name);
|
||||||
|
string outputJson;
|
||||||
|
if (tool is null)
|
||||||
|
{
|
||||||
|
outputJson = $$"""{"ok":false,"error":"unknown tool: {{name}}"}""";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var argsDoc = System.Text.Json.JsonDocument.Parse(argsJson);
|
||||||
|
var ctx = new DeviceContext(_deviceId, _conversation!.Id, _channel);
|
||||||
|
var res = await tool.ExecuteAsync(
|
||||||
|
new ToolInvocation(callId, argsDoc.RootElement.Clone()), ctx, ct);
|
||||||
|
outputJson = res.Output.GetRawText();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
outputJson = System.Text.Json.JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
ok = false,
|
||||||
|
error = ex.Message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_conversation is not null)
|
||||||
|
{
|
||||||
|
await _log.AppendToolAsync(_conversation.Id, name, argsJson, outputJson, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
await _upstream.SendJsonAsync(RealtimeEvents.FunctionCallOutput(callId, outputJson), ct);
|
||||||
|
await _upstream.SendJsonAsync(RealtimeEvents.ResponseCreate(), ct);
|
||||||
|
}
|
||||||
|
|
||||||
private async Task ForwardAudioDeltaAsync(JsonObject evt, CancellationToken ct)
|
private async Task ForwardAudioDeltaAsync(JsonObject evt, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user