MCP tool-use execution loop for autonomous agent runs
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>
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
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"),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user