Skip to content

Commit 4fb8ec7

Browse files
authored
feat: tool call iteration count parameter (#134)
Allow user to set call LLM/MCP iteration limit to override the hardcoded value. Example: ```c# await AIHub.Chat() .WithModel(Models.OpenAi.Gpt4o) .WithMessage("Research the weather in Warsaw, then check forecast for next 3 days") .WithTools(new ToolsConfigurationBuilder() .AddTool("get_weather", "Get current weather", parameters: new { type = "object", properties = new { city = new { type = "string" } } }, execute: (args) => $"Weather in {args}: sunny, 22°C") .WithMaxIterations(3) .Build()) .CompleteAsync(); ``` Do TODO: try to share logic of adding tool to the list across methods * feat: add an optional MaxIterations parameter The MaxIterations parameter allows user to configure the call iterations limit instead of relying on the hardcoded value. Some cleanups (blank lines and == null changed to is null) * refactor: Apply TODO suggestion about sharing the tool addition logic * fix(McpService): send final synthesis request on iteration cap instead of returning error string * fix(McpService): Setting MaxToolIteration for Gemini or Vertex backedn throws an exception * fix: ToolMaxIteration does not affect MCP * fix: minor refactors - OpenAiService uses virtual property instead of hardcoded max iterations. - Extracted MaxIteration validation * chore: bump version to 0.10.10 and add release notes
1 parent 4d2ba70 commit 4fb8ec7

15 files changed

Lines changed: 425 additions & 282 deletions

File tree

Releases/0.10.10.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# 0.10.10 release
2+
3+
- Adds `WithMaxIterations()` to `ToolsConfigurationBuilder` and `McpContext` to override the default tool-call iteration limit.
4+
- Fix: MCP loop now sends a final synthesis request instead of returning an error string when the iteration cap is reached.
5+
- Fix: Gemini/Vertex backends now throw `NotSupportedException` when `WithMaxIterations` is used instead of silently ignoring the value.

src/MaIN.Core/.nuspec

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
<package>
33
<metadata>
44
<id>MaIN.NET</id>
5-
<version>0.10.9</version>
5+
<version>0.10.10</version>
66
<authors>Wisedev</authors>
77
<owners>Wisedev</owners>
88
<icon>favicon.png</icon>
@@ -34,4 +34,4 @@
3434
<file src="..\MaIN.Domain\bin\Release\net8.0\MaIN.Domain.dll" target="lib\net8.0" />
3535
<file src="..\MaIN.Infrastructure\bin\Release\net8.0\MaIN.Infrastructure.dll" target="lib\net8.0" />
3636
</files>
37-
</package>
37+
</package>

src/MaIN.Core/Hub/Contexts/Interfaces/McpContext/IMcpContext.cs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
using MaIN.Domain.Configuration;
1+
using MaIN.Domain.Configuration;
22
using MaIN.Domain.Entities;
33
using MaIN.Services.Services.Models;
44

@@ -22,10 +22,20 @@ public interface IMcpContext
2222
/// <returns>The context instance implementing <see cref="IMcpContext"/> for method chaining.</returns>
2323
IMcpContext WithBackend(BackendType backendType);
2424

25+
/// <summary>
26+
/// Sets the maximum number of tool-call iterations allowed in a single MCP prompt.
27+
/// Overrides the default limit of 10. Must be at least 1.
28+
/// </summary>
29+
/// <remarks>
30+
/// Not supported for <see cref="BackendType.Gemini"/> and <see cref="BackendType.Vertex"/> backends -
31+
/// a <see cref="NotSupportedException"/> will be thrown at runtime when <see cref="PromptAsync"/> is called.
32+
/// </remarks>
33+
IMcpContext WithMaxIterations(int maxIterations);
34+
2535
/// <summary>
2636
/// Asynchronously processes a prompt through the configured MCP service, sending the prompt to the MCP server and returning the processed result.
2737
/// </summary>
2838
/// <param name="prompt">The text prompt to be processed by the MCP service</param>
2939
/// <returns>A <see cref="McpResult"/> object containing the processed response from the MCP server.</returns>
3040
Task<McpResult> PromptAsync(string prompt);
31-
}
41+
}

src/MaIN.Core/Hub/Contexts/McpContext.cs

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
using MaIN.Domain.Configuration;
33
using MaIN.Domain.Entities;
44
using MaIN.Domain.Exceptions.MPC;
5+
using MaIN.Domain.Exceptions.Tools;
56
using MaIN.Services.Constants;
67
using MaIN.Services.Services.Abstract;
78
using MaIN.Services.Services.Models;
@@ -13,6 +14,7 @@ public sealed class McpContext : IMcpContext
1314
private readonly IMcpService _mcpService;
1415
private Mcp? _mcpConfig;
1516
private BackendType? _explicitBackend;
17+
private int? _maxIterations;
1618

1719
internal McpContext(IMcpService mcpService)
1820
{
@@ -24,7 +26,10 @@ public IMcpContext WithConfig(Mcp mcpConfig)
2426
{
2527
_mcpConfig = mcpConfig;
2628
if (_explicitBackend.HasValue)
29+
{
2730
_mcpConfig.Backend = _explicitBackend;
31+
}
32+
2833
return this;
2934
}
3035

@@ -35,18 +40,25 @@ public IMcpContext WithBackend(BackendType backendType)
3540
return this;
3641
}
3742

43+
public IMcpContext WithMaxIterations(int maxIterations)
44+
{
45+
InvalidToolIterationsException.ThrowIfInvalid(maxIterations);
46+
_maxIterations = maxIterations;
47+
return this;
48+
}
49+
3850
public async Task<McpResult> PromptAsync(string prompt)
3951
{
4052
if (_mcpConfig == null)
4153
{
4254
throw new MPCConfigNotFoundException();
4355
}
44-
45-
return await _mcpService.Prompt(_mcpConfig!, [new Message()
56+
57+
return await _mcpService.Prompt(_mcpConfig, [new Message()
4658
{
4759
Content = prompt,
4860
Role = ServiceConstants.Roles.User,
4961
Type = MessageType.CloudLLM
50-
}]);
62+
}], _maxIterations);
5163
}
52-
}
64+
}
Lines changed: 53 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -1,150 +1,96 @@
11
using System.Text.Json;
22
using MaIN.Domain.Entities.Tools;
3+
using MaIN.Domain.Exceptions.Tools;
34

45
namespace MaIN.Core.Hub.Utils;
5-
//TODO try to share logic of adding tool to the list across methods https://github.com/wisedev-code/MaIN.NET/pull/98#discussion_r2454997846
6+
67
public sealed class ToolsConfigurationBuilder
78
{
9+
private static readonly JsonSerializerOptions s_deserializeOptions = new() { PropertyNameCaseInsensitive = true };
810
private readonly ToolsConfiguration _config = new() { Tools = [] };
9-
10-
public ToolsConfigurationBuilder AddDefaultTool(
11-
string type)
11+
12+
public ToolsConfigurationBuilder AddDefaultTool(string type)
1213
{
13-
_config.Tools.Add(new ToolDefinition
14-
{
15-
Type = type
16-
});
14+
_config.Tools.Add(new ToolDefinition { Type = type });
1715
return this;
1816
}
19-
17+
2018
public ToolsConfigurationBuilder AddTool(
21-
string name,
22-
string description,
19+
string name,
20+
string description,
2321
object parameters,
2422
Func<string, Task<string>> execute)
2523
{
26-
_config.Tools.Add(new ToolDefinition
27-
{
28-
Function = new FunctionDefinition
29-
{
30-
Name = name,
31-
Description = description,
32-
Parameters = parameters
33-
},
34-
Execute = execute
35-
});
36-
return this;
24+
return AddToolCore(name, description, parameters, execute);
3725
}
3826

3927
public ToolsConfigurationBuilder AddTool(
40-
string name,
41-
string description,
28+
string name,
29+
string description,
4230
object parameters,
4331
Func<string, string> execute)
4432
{
45-
_config.Tools!.Add(new ToolDefinition
46-
{
47-
Function = new FunctionDefinition
48-
{
49-
Name = name,
50-
Description = description,
51-
Parameters = parameters
52-
},
53-
Execute = args => Task.FromResult(execute(args))
54-
});
55-
return this;
33+
return AddToolCore(name, description, parameters, args => Task.FromResult(execute(args)));
5634
}
5735

5836
public ToolsConfigurationBuilder AddTool<TArgs>(
59-
string name,
60-
string description,
37+
string name,
38+
string description,
6139
object parameters,
6240
Func<TArgs, Task<object>> execute) where TArgs : class
6341
{
64-
_config.Tools.Add(new ToolDefinition
65-
{
66-
Function = new FunctionDefinition
42+
return AddToolCore(name, description, parameters, async argsJson =>
6743
{
68-
Name = name,
69-
Description = description,
70-
Parameters = parameters
71-
},
72-
Execute = async (argsJson) =>
73-
{
74-
var args = JsonSerializer.Deserialize<TArgs>(argsJson,
75-
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
76-
var result = await execute(args);
77-
return JsonSerializer.Serialize(result);
78-
}
79-
});
80-
return this;
44+
var args = JsonSerializer.Deserialize<TArgs>(argsJson, s_deserializeOptions)!;
45+
return JsonSerializer.Serialize(await execute(args));
46+
});
8147
}
8248

8349
public ToolsConfigurationBuilder AddTool<TArgs>(
84-
string name,
85-
string description,
50+
string name,
51+
string description,
8652
object parameters,
8753
Func<TArgs, object> execute) where TArgs : class
8854
{
89-
_config.Tools!.Add(new ToolDefinition
90-
{
91-
Function = new FunctionDefinition
55+
return AddToolCore(name, description, parameters, argsJson =>
9256
{
93-
Name = name,
94-
Description = description,
95-
Parameters = parameters
96-
},
97-
Execute = (argsJson) =>
98-
{
99-
var args = JsonSerializer.Deserialize<TArgs>(argsJson,
100-
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
101-
var result = execute(args);
102-
return Task.FromResult(JsonSerializer.Serialize(result));
103-
}
104-
});
105-
return this;
57+
var args = JsonSerializer.Deserialize<TArgs>(argsJson, s_deserializeOptions)!;
58+
return Task.FromResult(JsonSerializer.Serialize(execute(args)));
59+
});
10660
}
10761

10862
public ToolsConfigurationBuilder AddTool(
109-
string name,
63+
string name,
11064
string description,
11165
Func<Task<object>> execute)
11266
{
113-
_config.Tools.Add(new ToolDefinition
114-
{
115-
Function = new FunctionDefinition
116-
{
117-
Name = name,
118-
Description = description,
119-
Parameters = new { type = "object", properties = new { } }
120-
},
121-
Execute = async (args) =>
122-
{
123-
var result = await execute();
124-
return JsonSerializer.Serialize(result);
125-
}
126-
});
127-
return this;
67+
return AddToolCore(
68+
name,
69+
description,
70+
new { type = "object", properties = new { } },
71+
async _ => JsonSerializer.Serialize(await execute()));
12872
}
12973

13074
public ToolsConfigurationBuilder AddTool(
131-
string name,
75+
string name,
13276
string description,
13377
Func<object> execute)
78+
=> AddToolCore(
79+
name,
80+
description,
81+
new { type = "object", properties = new { } },
82+
_ => Task.FromResult(JsonSerializer.Serialize(execute())));
83+
84+
private ToolsConfigurationBuilder AddToolCore(
85+
string name,
86+
string description,
87+
object parameters,
88+
Func<string, Task<string>> execute)
13489
{
13590
_config.Tools.Add(new ToolDefinition
13691
{
137-
Function = new FunctionDefinition
138-
{
139-
Name = name,
140-
Description = description,
141-
Parameters = new { type = "object", properties = new { } }
142-
},
143-
Execute = (args) =>
144-
{
145-
var result = execute();
146-
return Task.FromResult(JsonSerializer.Serialize(result));
147-
}
92+
Function = new FunctionDefinition { Name = name, Description = description, Parameters = parameters },
93+
Execute = execute
14894
});
14995
return this;
15096
}
@@ -155,5 +101,12 @@ public ToolsConfigurationBuilder WithToolChoice(string choice)
155101
return this;
156102
}
157103

104+
public ToolsConfigurationBuilder WithMaxIterations(int maxIterations)
105+
{
106+
InvalidToolIterationsException.ThrowIfInvalid(maxIterations);
107+
_config.MaxIterations = maxIterations;
108+
return this;
109+
}
110+
158111
public ToolsConfiguration Build() => _config;
159-
}
112+
}

src/MaIN.Domain/Entities/Tools/ToolsConfiguration.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@ public class ToolsConfiguration
44
{
55
public required List<ToolDefinition> Tools { get; set; }
66
public string? ToolChoice { get; set; }
7-
7+
public int? MaxIterations { get; set; }
8+
89
public Func<string, Task<string>>? GetExecutor(string functionName)
910
{
1011
return Tools.FirstOrDefault(t => t.Function!.Name == functionName)?.Execute;
1112
}
12-
}
13+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
using System.Net;
2+
3+
namespace MaIN.Domain.Exceptions.Tools;
4+
5+
public class InvalidToolIterationsException(int value)
6+
: MaINCustomException($"MaxIterations must be at least 1, but received {value}.")
7+
{
8+
public override string PublicErrorMessage => Message;
9+
public override HttpStatusCode HttpStatusCode => HttpStatusCode.BadRequest;
10+
11+
public static void ThrowIfInvalid(int value)
12+
{
13+
if (value < 1)
14+
{
15+
throw new InvalidToolIterationsException(value);
16+
}
17+
}
18+
}
Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
using MaIN.Domain.Entities;
1+
using MaIN.Domain.Entities;
22
using MaIN.Services.Services.Models;
33

44
namespace MaIN.Services.Services.Abstract;
55

66
public interface IMcpService
77
{
8-
Task<McpResult> Prompt(Mcp config, List<Message> messageHistory);
9-
}
8+
Task<McpResult> Prompt(Mcp config, List<Message> messageHistory, int? maxIterations = null);
9+
}

0 commit comments

Comments
 (0)