c8d9af6191
Autonomous agents with MCP tools now run a bounded tool-use loop: the model may call tools (executed via the gateway, results fed back) until it returns a final answer. Gated/DraftOnly agents get the tool catalog as data but never auto-call — a human-in-the-loop agent never autonomously reaches an external tool. Extends IModelClient with tool definitions and a tool-use conversation, adds the OpenAI-compatible tool serialization/parsing plus a deterministic "tooluse" stub client, and records every tool call in the run trace. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
95 lines
3.9 KiB
C#
95 lines
3.9 KiB
C#
using System.Net;
|
|
using System.Text;
|
|
using TeamUp.Modules.Integrations.Ai;
|
|
using TeamUp.SharedKernel.Ai;
|
|
using Xunit;
|
|
|
|
namespace TeamUp.IntegrationTests;
|
|
|
|
/// <summary>
|
|
/// The model-client tool-use plumbing: the OpenAI-compatible adapter serializes tools + a tool-use
|
|
/// conversation and parses tool calls out of the reply; the deterministic "tooluse" stub drives the
|
|
/// loop (ask for a tool, then answer once a result is present).
|
|
/// </summary>
|
|
public sealed class ModelClientToolTests
|
|
{
|
|
[Fact]
|
|
public async Task OpenAi_adapter_sends_tools_and_parses_tool_calls()
|
|
{
|
|
const string reply =
|
|
"""{"choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"call_abc","type":"function","function":{"name":"search_issues","arguments":"{\"q\":\"bug\"}"}}]}}]}""";
|
|
var handler = new CapturingHandler(reply);
|
|
var client = new OpenAiCompatibleModelClient(new HttpClient(handler));
|
|
|
|
var request = new ModelRequest(
|
|
"openai", "gpt-4o", "sk-test", null, "find bugs", MaxTokens: 512,
|
|
Tools: [new ModelTool("search_issues", "Search the tracker.", """{"type":"object"}""")],
|
|
Messages: [new ModelMessage("user", "find bugs")]);
|
|
|
|
var completion = await client.CompleteAsync(request);
|
|
|
|
Assert.True(completion.Success);
|
|
var call = Assert.Single(completion.ToolCalls!);
|
|
Assert.Equal("call_abc", call.Id);
|
|
Assert.Equal("search_issues", call.Name);
|
|
Assert.Contains("bug", call.ArgumentsJson);
|
|
|
|
// The outgoing body carries the tool definition and the conversation.
|
|
Assert.Contains("\"tools\"", handler.LastBody);
|
|
Assert.Contains("search_issues", handler.LastBody);
|
|
Assert.Contains("find bugs", handler.LastBody);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task OpenAi_adapter_returns_plain_text_when_no_tool_calls()
|
|
{
|
|
const string reply = """{"choices":[{"message":{"role":"assistant","content":"Here are the bugs."}}]}""";
|
|
var client = new OpenAiCompatibleModelClient(new HttpClient(new CapturingHandler(reply)));
|
|
|
|
var completion = await client.CompleteAsync(new ModelRequest("openai", "gpt-4o", "sk", null, "hi"));
|
|
|
|
Assert.True(completion.Success);
|
|
Assert.Equal("Here are the bugs.", completion.Text);
|
|
Assert.Null(completion.ToolCalls);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ToolUse_stub_asks_for_a_tool_then_answers_once_a_result_is_present()
|
|
{
|
|
var stub = new ToolUseStubModelClient();
|
|
List<ModelTool> tools = [new ModelTool("lookup", null, "{}")];
|
|
|
|
var first = await stub.CompleteAsync(new ModelRequest(
|
|
"tooluse", "m", "", null, "do it", Tools: tools, Messages: [new ModelMessage("user", "do it")]));
|
|
var toolCall = Assert.Single(first.ToolCalls!);
|
|
Assert.Equal("lookup", toolCall.Name);
|
|
Assert.Null(first.Text);
|
|
|
|
var second = await stub.CompleteAsync(new ModelRequest(
|
|
"tooluse", "m", "", null, "do it", Tools: tools,
|
|
Messages:
|
|
[
|
|
new ModelMessage("user", "do it"),
|
|
new ModelMessage("assistant", null, first.ToolCalls),
|
|
new ModelMessage("tool", "the result", ToolCallId: toolCall.Id),
|
|
]));
|
|
|
|
Assert.Null(second.ToolCalls);
|
|
Assert.Contains("do it", second.Text!);
|
|
}
|
|
|
|
private sealed class CapturingHandler(string responseJson) : HttpMessageHandler
|
|
{
|
|
public string LastBody { get; private set; } = string.Empty;
|
|
|
|
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
|
{
|
|
LastBody = await request.Content!.ReadAsStringAsync(cancellationToken);
|
|
return new HttpResponseMessage(HttpStatusCode.OK)
|
|
{
|
|
Content = new StringContent(responseJson, Encoding.UTF8, "application/json"),
|
|
};
|
|
}
|
|
}
|
|
}
|