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:
soroush.asadi
2026-06-15 15:20:48 +03:30
parent a9d4d691f0
commit c8d9af6191
5 changed files with 285 additions and 16 deletions
@@ -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);
}
}
/// <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);
}
}