diff --git a/src/Modules/TeamUp.Modules.Assembler/Runtime/AgentRunExecutor.cs b/src/Modules/TeamUp.Modules.Assembler/Runtime/AgentRunExecutor.cs index 4d8f3cd..defabe2 100644 --- a/src/Modules/TeamUp.Modules.Assembler/Runtime/AgentRunExecutor.cs +++ b/src/Modules/TeamUp.Modules.Assembler/Runtime/AgentRunExecutor.cs @@ -3,6 +3,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using TeamUp.Modules.Assembler.Domain; using TeamUp.Modules.Assembler.Persistence; +using TeamUp.SharedKernel.Access; using TeamUp.SharedKernel.Ai; namespace TeamUp.Modules.Assembler.Runtime; @@ -61,9 +62,7 @@ internal sealed class AgentRunExecutor( : null) ?? throw new InvalidOperationException("No usable model config for the agent."); - var completion = await modelClient.CompleteAsync( - new ModelRequest(config.Provider, config.Model, config.ApiKey, config.Endpoint, assembled.Prompt, MaxTokens: 512), - cancellationToken); + var (completion, output, toolCalls) = await RunModelAsync(context, assembled, config, tools, cancellationToken); if (!completion.Success) { @@ -79,9 +78,9 @@ internal sealed class AgentRunExecutor( action = assembled.PrimaryAction, risk = assembled.PrimaryActionRisk, 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()); await db.SaveChangesAsync(cancellationToken); @@ -107,4 +106,59 @@ internal sealed class AgentRunExecutor( logger.LogError(ex, "Agent-run job {JobId} failed.", job.Id); } } + + /// + /// 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. + /// + private async Task<(ModelCompletion Completion, string Output, IReadOnlyList ToolCalls)> RunModelAsync( + AgentRunContext context, AssembledPrompt assembled, ResolvedApiConfig config, + IReadOnlyList tools, CancellationToken cancellationToken) + { + ModelRequest Request(IReadOnlyList? toolDefs, IReadOnlyList? 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 { new("user", assembled.Prompt) }; + var trace = new List(); + 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); + } } diff --git a/src/Modules/TeamUp.Modules.Integrations/Ai/ModelClients.cs b/src/Modules/TeamUp.Modules.Integrations/Ai/ModelClients.cs index a1eaea8..c312474 100644 --- a/src/Modules/TeamUp.Modules.Integrations/Ai/ModelClients.cs +++ b/src/Modules/TeamUp.Modules.Integrations/Ai/ModelClients.cs @@ -16,9 +16,32 @@ internal sealed class StubModelClient : IModelClient LatencyMs: 0)); } +/// +/// 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. +/// +internal sealed class ToolUseStubModelClient : IModelClient +{ + public Task 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)); + } +} + /// /// 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 () +/// 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. /// 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.Content = JsonContent.Create(new + var body = new Dictionary { - model = request.Model, - max_tokens = request.MaxTokens, - messages = new[] { new { role = "user", content = request.Prompt } }, - }); + ["model"] = request.Model, + ["max_tokens"] = request.MaxTokens, + ["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); stopwatch.Stop(); @@ -49,8 +80,28 @@ internal sealed class OpenAiCompatibleModelClient(HttpClient http) : IModelClien } var doc = await response.Content.ReadFromJsonAsync(cancellationToken); - var text = doc.GetProperty("choices")[0].GetProperty("message").GetProperty("content").GetString(); - return new ModelCompletion(true, text, null, stopwatch.ElapsedMilliseconds); + var msg = doc.GetProperty("choices")[0].GetProperty("message"); + var text = msg.TryGetProperty("content", out var content) && content.ValueKind == JsonValueKind.String + ? content.GetString() + : null; + + List? 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) { @@ -58,15 +109,57 @@ internal sealed class OpenAiCompatibleModelClient(HttpClient http) : IModelClien 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(); + } } /// Routes a request to the adapter for its provider. -internal sealed class ModelClientRouter(StubModelClient stub, OpenAiCompatibleModelClient openAi) : IModelClient +internal sealed class ModelClientRouter(StubModelClient stub, ToolUseStubModelClient toolUse, OpenAiCompatibleModelClient openAi) : IModelClient { public Task CompleteAsync(ModelRequest request, CancellationToken cancellationToken = default) => request.Provider.ToLowerInvariant() switch { "stub" or "echo" or "test" => stub.CompleteAsync(request, cancellationToken), + "tooluse" => toolUse.CompleteAsync(request, cancellationToken), _ => openAi.CompleteAsync(request, cancellationToken), }; } diff --git a/src/Modules/TeamUp.Modules.Integrations/IntegrationsModule.cs b/src/Modules/TeamUp.Modules.Integrations/IntegrationsModule.cs index 07f3ada..87199eb 100644 --- a/src/Modules/TeamUp.Modules.Integrations/IntegrationsModule.cs +++ b/src/Modules/TeamUp.Modules.Integrations/IntegrationsModule.cs @@ -38,6 +38,7 @@ public sealed class IntegrationsModule : IModule // Model clients: a router over per-provider adapters. services.AddSingleton(); + services.AddSingleton(); services.AddHttpClient(); services.AddScoped(); diff --git a/src/Shared/TeamUp.SharedKernel/Ai/IModelClient.cs b/src/Shared/TeamUp.SharedKernel/Ai/IModelClient.cs index 964c965..820a935 100644 --- a/src/Shared/TeamUp.SharedKernel/Ai/IModelClient.cs +++ b/src/Shared/TeamUp.SharedKernel/Ai/IModelClient.cs @@ -1,15 +1,42 @@ namespace TeamUp.SharedKernel.Ai; -/// One model invocation. The key is passed explicitly (BYOK, server-side only). +/// A tool the model may call (OpenAI "function" tool). Parameters is a JSON-Schema string. +public sealed record ModelTool(string Name, string? Description, string ParametersJson); + +/// A tool call the model asked for, to be executed and fed back. +public sealed record ModelToolCall(string Id, string Name, string ArgumentsJson); + +/// +/// One message in a tool-use conversation. Role is user|assistant|tool. An assistant turn may carry +/// ; a tool turn carries the result for . +/// +public sealed record ModelMessage( + string Role, + string? Content, + IReadOnlyList? ToolCalls = null, + string? ToolCallId = null); + +/// +/// One model invocation. The key is passed explicitly (BYOK, server-side only). When +/// is set it is the full conversation (for the tool-use loop) and overrides +/// ; offers callable tools. +/// public sealed record ModelRequest( string Provider, string Model, string ApiKey, string? Endpoint, string Prompt, - int MaxTokens = 256); + int MaxTokens = 256, + IReadOnlyList? Tools = null, + IReadOnlyList? 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? ToolCalls = null); /// /// Provider-agnostic model client. Implemented in Integrations (a router over per-provider HTTP diff --git a/tests/TeamUp.IntegrationTests/ModelClientToolTests.cs b/tests/TeamUp.IntegrationTests/ModelClientToolTests.cs new file mode 100644 index 0000000..1537df3 --- /dev/null +++ b/tests/TeamUp.IntegrationTests/ModelClientToolTests.cs @@ -0,0 +1,94 @@ +using System.Net; +using System.Text; +using TeamUp.Modules.Integrations.Ai; +using TeamUp.SharedKernel.Ai; +using Xunit; + +namespace TeamUp.IntegrationTests; + +/// +/// 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). +/// +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 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 SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + LastBody = await request.Content!.ReadAsStringAsync(cancellationToken); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(responseJson, Encoding.UTF8, "application/json"), + }; + } + } +}