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:
@@ -3,6 +3,7 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using TeamUp.Modules.Assembler.Domain;
|
using TeamUp.Modules.Assembler.Domain;
|
||||||
using TeamUp.Modules.Assembler.Persistence;
|
using TeamUp.Modules.Assembler.Persistence;
|
||||||
|
using TeamUp.SharedKernel.Access;
|
||||||
using TeamUp.SharedKernel.Ai;
|
using TeamUp.SharedKernel.Ai;
|
||||||
|
|
||||||
namespace TeamUp.Modules.Assembler.Runtime;
|
namespace TeamUp.Modules.Assembler.Runtime;
|
||||||
@@ -61,9 +62,7 @@ internal sealed class AgentRunExecutor(
|
|||||||
: null)
|
: null)
|
||||||
?? throw new InvalidOperationException("No usable model config for the agent.");
|
?? throw new InvalidOperationException("No usable model config for the agent.");
|
||||||
|
|
||||||
var completion = await modelClient.CompleteAsync(
|
var (completion, output, toolCalls) = await RunModelAsync(context, assembled, config, tools, cancellationToken);
|
||||||
new ModelRequest(config.Provider, config.Model, config.ApiKey, config.Endpoint, assembled.Prompt, MaxTokens: 512),
|
|
||||||
cancellationToken);
|
|
||||||
|
|
||||||
if (!completion.Success)
|
if (!completion.Success)
|
||||||
{
|
{
|
||||||
@@ -79,9 +78,9 @@ internal sealed class AgentRunExecutor(
|
|||||||
action = assembled.PrimaryAction,
|
action = assembled.PrimaryAction,
|
||||||
risk = assembled.PrimaryActionRisk,
|
risk = assembled.PrimaryActionRisk,
|
||||||
skill = context.SkillKeys.Count > 0 ? context.SkillKeys[0] : null,
|
skill = context.SkillKeys.Count > 0 ? context.SkillKeys[0] : null,
|
||||||
|
toolCalls,
|
||||||
});
|
});
|
||||||
|
|
||||||
var output = completion.Text ?? string.Empty;
|
|
||||||
run.Complete(output, assembled.PrimaryAction, assembled.PrimaryActionRisk, result, completion.LatencyMs, clock.GetUtcNow());
|
run.Complete(output, assembled.PrimaryAction, assembled.PrimaryActionRisk, result, completion.LatencyMs, clock.GetUtcNow());
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
@@ -107,4 +106,59 @@ internal sealed class AgentRunExecutor(
|
|||||||
logger.LogError(ex, "Agent-run job {JobId} failed.", job.Id);
|
logger.LogError(ex, "Agent-run job {JobId} failed.", job.Id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One model call by default. For an Autonomous agent with MCP tools available, runs 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. The final artifact
|
||||||
|
/// still goes through the action gate; every tool call is recorded in the run trace.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<(ModelCompletion Completion, string Output, IReadOnlyList<object> ToolCalls)> RunModelAsync(
|
||||||
|
AgentRunContext context, AssembledPrompt assembled, ResolvedApiConfig config,
|
||||||
|
IReadOnlyList<McpToolDescriptor> tools, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ModelRequest Request(IReadOnlyList<ModelTool>? toolDefs, IReadOnlyList<ModelMessage>? messages) =>
|
||||||
|
new(config.Provider, config.Model, config.ApiKey, config.Endpoint, assembled.Prompt, MaxTokens: 512, toolDefs, messages);
|
||||||
|
|
||||||
|
if (context.Autonomy != Autonomy.Autonomous || tools.Count == 0)
|
||||||
|
{
|
||||||
|
var single = await modelClient.CompleteAsync(Request(null, null), cancellationToken);
|
||||||
|
return (single, single.Text ?? string.Empty, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
var byName = tools
|
||||||
|
.GroupBy(t => t.Name, StringComparer.Ordinal)
|
||||||
|
.ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal);
|
||||||
|
var toolDefs = tools.Select(t => new ModelTool(t.Name, t.Description, t.InputSchemaJson)).ToList();
|
||||||
|
var messages = new List<ModelMessage> { new("user", assembled.Prompt) };
|
||||||
|
var trace = new List<object>();
|
||||||
|
ModelCompletion completion = new(false, null, "No model response.", 0);
|
||||||
|
|
||||||
|
const int maxIterations = 4;
|
||||||
|
for (var iteration = 0; iteration < maxIterations; iteration++)
|
||||||
|
{
|
||||||
|
completion = await modelClient.CompleteAsync(Request(toolDefs, messages), cancellationToken);
|
||||||
|
if (!completion.Success || completion.ToolCalls is not { Count: > 0 })
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
messages.Add(new ModelMessage("assistant", completion.Text, completion.ToolCalls));
|
||||||
|
foreach (var call in completion.ToolCalls)
|
||||||
|
{
|
||||||
|
byName.TryGetValue(call.Name, out var descriptor);
|
||||||
|
var toolResult = descriptor is null
|
||||||
|
? new McpToolResult(false, null, $"Unknown tool '{call.Name}'.")
|
||||||
|
: await mcpGateway.CallToolAsync(context.OrganizationId, descriptor.ServerId, call.Name, call.ArgumentsJson, cancellationToken);
|
||||||
|
|
||||||
|
var content = toolResult.Success ? toolResult.Content ?? string.Empty : $"ERROR: {toolResult.Error}";
|
||||||
|
messages.Add(new ModelMessage("tool", content, ToolCallId: call.Id));
|
||||||
|
trace.Add(new { tool = call.Name, server = descriptor?.ServerName, ok = toolResult.Success });
|
||||||
|
logger.LogInformation("Run {RunId} tool call {Tool} → {Ok}.", context.AgentId, call.Name, toolResult.Success);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (completion, completion.Text ?? string.Empty, trace);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,9 +16,32 @@ internal sealed class StubModelClient : IModelClient
|
|||||||
LatencyMs: 0));
|
LatencyMs: 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deterministic adapter for the "tooluse" provider, used to exercise the MCP tool-use loop without a
|
||||||
|
/// real model: when tools are offered and no tool result is in the conversation yet, it asks to call
|
||||||
|
/// the first tool; once a tool result is present, it produces a final answer.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class ToolUseStubModelClient : IModelClient
|
||||||
|
{
|
||||||
|
public Task<ModelCompletion> CompleteAsync(ModelRequest request, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var hasToolResult = request.Messages?.Any(m => m.Role == "tool") ?? false;
|
||||||
|
if (!hasToolResult && request.Tools is { Count: > 0 })
|
||||||
|
{
|
||||||
|
var tool = request.Tools[0];
|
||||||
|
return Task.FromResult(new ModelCompletion(true, null, null, 0, [new ModelToolCall("call_1", tool.Name, "{}")]));
|
||||||
|
}
|
||||||
|
|
||||||
|
var prompt = request.Messages?.FirstOrDefault(m => m.Role == "user")?.Content ?? request.Prompt;
|
||||||
|
return Task.FromResult(new ModelCompletion(true, $"[tooluse {request.Model}] {prompt}", null, 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// OpenAI-compatible /v1/chat/completions adapter (OpenAI, Ollama, vLLM, and OpenAI-compatible
|
/// OpenAI-compatible /v1/chat/completions adapter (OpenAI, Ollama, vLLM, and OpenAI-compatible
|
||||||
/// gateways). Returns a failed completion rather than throwing, so the connection test can report it.
|
/// gateways). Supports the tool-use loop: it forwards a conversation (<see cref="ModelRequest.Messages"/>)
|
||||||
|
/// and tool definitions, and parses any tool calls out of the response. Returns a failed completion
|
||||||
|
/// rather than throwing, so the connection test can report it.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed class OpenAiCompatibleModelClient(HttpClient http) : IModelClient
|
internal sealed class OpenAiCompatibleModelClient(HttpClient http) : IModelClient
|
||||||
{
|
{
|
||||||
@@ -34,12 +57,20 @@ internal sealed class OpenAiCompatibleModelClient(HttpClient http) : IModelClien
|
|||||||
message.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", request.ApiKey);
|
message.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", request.ApiKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
message.Content = JsonContent.Create(new
|
var body = new Dictionary<string, object?>
|
||||||
{
|
{
|
||||||
model = request.Model,
|
["model"] = request.Model,
|
||||||
max_tokens = request.MaxTokens,
|
["max_tokens"] = request.MaxTokens,
|
||||||
messages = new[] { new { role = "user", content = request.Prompt } },
|
["messages"] = BuildMessages(request),
|
||||||
});
|
};
|
||||||
|
if (request.Tools is { Count: > 0 })
|
||||||
|
{
|
||||||
|
body["tools"] = request.Tools
|
||||||
|
.Select(t => new { type = "function", function = new { name = t.Name, description = t.Description, parameters = ParseSchema(t.ParametersJson) } })
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
message.Content = JsonContent.Create(body);
|
||||||
|
|
||||||
using var response = await http.SendAsync(message, cancellationToken);
|
using var response = await http.SendAsync(message, cancellationToken);
|
||||||
stopwatch.Stop();
|
stopwatch.Stop();
|
||||||
@@ -49,8 +80,28 @@ internal sealed class OpenAiCompatibleModelClient(HttpClient http) : IModelClien
|
|||||||
}
|
}
|
||||||
|
|
||||||
var doc = await response.Content.ReadFromJsonAsync<JsonElement>(cancellationToken);
|
var doc = await response.Content.ReadFromJsonAsync<JsonElement>(cancellationToken);
|
||||||
var text = doc.GetProperty("choices")[0].GetProperty("message").GetProperty("content").GetString();
|
var msg = doc.GetProperty("choices")[0].GetProperty("message");
|
||||||
return new ModelCompletion(true, text, null, stopwatch.ElapsedMilliseconds);
|
var text = msg.TryGetProperty("content", out var content) && content.ValueKind == JsonValueKind.String
|
||||||
|
? content.GetString()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
List<ModelToolCall>? toolCalls = null;
|
||||||
|
if (msg.TryGetProperty("tool_calls", out var calls) && calls.ValueKind == JsonValueKind.Array && calls.GetArrayLength() > 0)
|
||||||
|
{
|
||||||
|
toolCalls = [];
|
||||||
|
foreach (var call in calls.EnumerateArray())
|
||||||
|
{
|
||||||
|
var id = call.TryGetProperty("id", out var idEl) ? idEl.GetString() ?? string.Empty : string.Empty;
|
||||||
|
var fn = call.GetProperty("function");
|
||||||
|
var name = fn.GetProperty("name").GetString() ?? string.Empty;
|
||||||
|
var args = fn.TryGetProperty("arguments", out var a)
|
||||||
|
? (a.ValueKind == JsonValueKind.String ? a.GetString() ?? "{}" : a.GetRawText())
|
||||||
|
: "{}";
|
||||||
|
toolCalls.Add(new ModelToolCall(id, name, args));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ModelCompletion(true, text, null, stopwatch.ElapsedMilliseconds, toolCalls);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -58,15 +109,57 @@ internal sealed class OpenAiCompatibleModelClient(HttpClient http) : IModelClien
|
|||||||
return new ModelCompletion(false, null, ex.Message, stopwatch.ElapsedMilliseconds);
|
return new ModelCompletion(false, null, ex.Message, stopwatch.ElapsedMilliseconds);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static object[] BuildMessages(ModelRequest request)
|
||||||
|
{
|
||||||
|
if (request.Messages is not { Count: > 0 })
|
||||||
|
{
|
||||||
|
return [new { role = "user", content = request.Prompt }];
|
||||||
|
}
|
||||||
|
|
||||||
|
return request.Messages.Select(object (m) => m.Role switch
|
||||||
|
{
|
||||||
|
"tool" => new { role = "tool", tool_call_id = m.ToolCallId, content = m.Content ?? string.Empty },
|
||||||
|
_ when m.ToolCalls is { Count: > 0 } => new
|
||||||
|
{
|
||||||
|
role = m.Role,
|
||||||
|
content = m.Content,
|
||||||
|
tool_calls = m.ToolCalls
|
||||||
|
.Select(tc => new { id = tc.Id, type = "function", function = new { name = tc.Name, arguments = tc.ArgumentsJson } })
|
||||||
|
.ToArray(),
|
||||||
|
},
|
||||||
|
_ => new { role = m.Role, content = m.Content ?? string.Empty },
|
||||||
|
}).ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonElement ParseSchema(string json)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(json))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var parsed = JsonDocument.Parse(json);
|
||||||
|
return parsed.RootElement.Clone();
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
// Fall through to a permissive default.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
using var fallback = JsonDocument.Parse("""{"type":"object"}""");
|
||||||
|
return fallback.RootElement.Clone();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Routes a request to the adapter for its provider.</summary>
|
/// <summary>Routes a request to the adapter for its provider.</summary>
|
||||||
internal sealed class ModelClientRouter(StubModelClient stub, OpenAiCompatibleModelClient openAi) : IModelClient
|
internal sealed class ModelClientRouter(StubModelClient stub, ToolUseStubModelClient toolUse, OpenAiCompatibleModelClient openAi) : IModelClient
|
||||||
{
|
{
|
||||||
public Task<ModelCompletion> CompleteAsync(ModelRequest request, CancellationToken cancellationToken = default) =>
|
public Task<ModelCompletion> CompleteAsync(ModelRequest request, CancellationToken cancellationToken = default) =>
|
||||||
request.Provider.ToLowerInvariant() switch
|
request.Provider.ToLowerInvariant() switch
|
||||||
{
|
{
|
||||||
"stub" or "echo" or "test" => stub.CompleteAsync(request, cancellationToken),
|
"stub" or "echo" or "test" => stub.CompleteAsync(request, cancellationToken),
|
||||||
|
"tooluse" => toolUse.CompleteAsync(request, cancellationToken),
|
||||||
_ => openAi.CompleteAsync(request, cancellationToken),
|
_ => openAi.CompleteAsync(request, cancellationToken),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ public sealed class IntegrationsModule : IModule
|
|||||||
|
|
||||||
// Model clients: a router over per-provider adapters.
|
// Model clients: a router over per-provider adapters.
|
||||||
services.AddSingleton<StubModelClient>();
|
services.AddSingleton<StubModelClient>();
|
||||||
|
services.AddSingleton<ToolUseStubModelClient>();
|
||||||
services.AddHttpClient<OpenAiCompatibleModelClient>();
|
services.AddHttpClient<OpenAiCompatibleModelClient>();
|
||||||
services.AddScoped<IModelClient, ModelClientRouter>();
|
services.AddScoped<IModelClient, ModelClientRouter>();
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,42 @@
|
|||||||
namespace TeamUp.SharedKernel.Ai;
|
namespace TeamUp.SharedKernel.Ai;
|
||||||
|
|
||||||
/// <summary>One model invocation. The key is passed explicitly (BYOK, server-side only).</summary>
|
/// <summary>A tool the model may call (OpenAI "function" tool). Parameters is a JSON-Schema string.</summary>
|
||||||
|
public sealed record ModelTool(string Name, string? Description, string ParametersJson);
|
||||||
|
|
||||||
|
/// <summary>A tool call the model asked for, to be executed and fed back.</summary>
|
||||||
|
public sealed record ModelToolCall(string Id, string Name, string ArgumentsJson);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One message in a tool-use conversation. Role is user|assistant|tool. An assistant turn may carry
|
||||||
|
/// <see cref="ToolCalls"/>; a tool turn carries the result <see cref="Content"/> for <see cref="ToolCallId"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record ModelMessage(
|
||||||
|
string Role,
|
||||||
|
string? Content,
|
||||||
|
IReadOnlyList<ModelToolCall>? ToolCalls = null,
|
||||||
|
string? ToolCallId = null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One model invocation. The key is passed explicitly (BYOK, server-side only). When
|
||||||
|
/// <see cref="Messages"/> is set it is the full conversation (for the tool-use loop) and overrides
|
||||||
|
/// <see cref="Prompt"/>; <see cref="Tools"/> offers callable tools.
|
||||||
|
/// </summary>
|
||||||
public sealed record ModelRequest(
|
public sealed record ModelRequest(
|
||||||
string Provider,
|
string Provider,
|
||||||
string Model,
|
string Model,
|
||||||
string ApiKey,
|
string ApiKey,
|
||||||
string? Endpoint,
|
string? Endpoint,
|
||||||
string Prompt,
|
string Prompt,
|
||||||
int MaxTokens = 256);
|
int MaxTokens = 256,
|
||||||
|
IReadOnlyList<ModelTool>? Tools = null,
|
||||||
|
IReadOnlyList<ModelMessage>? Messages = null);
|
||||||
|
|
||||||
public sealed record ModelCompletion(bool Success, string? Text, string? Error, long LatencyMs);
|
public sealed record ModelCompletion(
|
||||||
|
bool Success,
|
||||||
|
string? Text,
|
||||||
|
string? Error,
|
||||||
|
long LatencyMs,
|
||||||
|
IReadOnlyList<ModelToolCall>? ToolCalls = null);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Provider-agnostic model client. Implemented in Integrations (a router over per-provider HTTP
|
/// Provider-agnostic model client. Implemented in Integrations (a router over per-provider HTTP
|
||||||
|
|||||||
@@ -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