Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,10 @@ private static FrameDispatcher BuildDispatcher(HttpContext context)
// Tool-name patterns gated behind write-confirmation HITL (default none — the DI analog of
// Python's ServerState.confirm_tools). Each connection gets its own ConfirmationRegistry
// (a confirm_tool_action frame and the parked turn it resumes are always on the same one).
confirmTools: services.GetService<ConfirmTools>()?.Patterns);
confirmTools: services.GetService<ConfirmTools>()?.Patterns,
// Per-turn token/iteration limits + the resolved model's output ceiling (EPIC th-1cc9fa).
// Absent ⇒ the raised server defaults (max_tokens 8192, iterations 20) with no ceiling.
limits: services.GetService<TurnLimits>());
}

private static async Task PumpAsync(WebSocket socket, FrameDispatcher dispatcher, CancellationToken cancellationToken)
Expand Down
14 changes: 14 additions & 0 deletions dotnet/server/host/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,20 @@ string Get(string key, string? fallback = null) =>
builder.Services.AddSingleton(new ConfirmTools(confirmTools));
}

// ── Per-turn limits: raised output-token budget + agentic-iteration cap (EPIC th-1cc9fa), plus the
// resolved model's HARD output ceiling clamped from the gateway's /model/info so a budget can
// never exceed what the model can physically emit (a reasoning model would otherwise burn the
// budget and return empty, or the upstream 400s). The ceiling fetch is best-effort at boot — this
// single-model server resolves it once rather than the Rust server's per-turn lazy cache; any
// gateway problem ⇒ null ⇒ unclamped. SMOOTH_MAX_TOKENS / SMOOTH_MAX_ITERATIONS override the
// defaults. ──
var maxTokens = int.TryParse(Get("SMOOTH_MAX_TOKENS"), out var mt) && mt > 0 ? mt : TurnLimits.DefaultMaxTokens;
var maxIterations = int.TryParse(Get("SMOOTH_MAX_ITERATIONS"), out var mi) && mi > 0 ? mi : TurnLimits.DefaultMaxIterations;
int? modelCeiling = string.IsNullOrEmpty(gatewayKey)
? null
: await ModelInfo.FetchCeilingAsync(EmbeddingHttpClient(gatewayUrl, gatewayKey), model);
builder.Services.AddSingleton(new TurnLimits(maxTokens, maxIterations, modelCeiling));

builder.Services.AddSmoothOperatorServer();

var app = builder.Build();
Expand Down
9 changes: 7 additions & 2 deletions dotnet/server/src/FrameDispatcher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ public sealed class FrameDispatcher
private readonly IReadOnlyList<AITool> _tools;
private readonly IReadOnlyList<string> _confirmTools;
private readonly ConfirmationRegistry _confirmations;
private readonly TurnLimits _limits;

// In-flight spawned send_message turns. A turn that calls a confirmation-gated tool parks
// awaiting a later confirm_tool_action frame, so the turn runs as a background Task (not awaited
Expand All @@ -41,7 +42,8 @@ public FrameDispatcher(
IReranker? reranker = null,
IReadOnlyList<AITool>? tools = null,
IReadOnlyList<string>? confirmTools = null,
ConfirmationRegistry? confirmations = null)
ConfirmationRegistry? confirmations = null,
TurnLimits? limits = null)
{
_store = store ?? throw new ArgumentNullException(nameof(store));
_chatClient = chatClient ?? throw new ArgumentNullException(nameof(chatClient));
Expand All @@ -57,6 +59,9 @@ public FrameDispatcher(
// Session-keyed pending-confirmation registry shared with each spawned turn so a
// confirm_tool_action frame resolves the verdict a parked turn awaits. One per connection.
_confirmations = confirmations ?? new ConfirmationRegistry();
// Per-turn token/iteration limits + the resolved model's output ceiling (EPIC th-1cc9fa).
// Absent ⇒ the raised server defaults (max_tokens 8192, iterations 20) with no ceiling.
_limits = limits ?? TurnLimits.Default;
}

/// <summary>
Expand Down Expand Up @@ -208,7 +213,7 @@ private async Task HandleSendMessageAsync(JsonObject frame, string? requestId, A
// 2. Stream the turn, retrieving through knowledge SCOPED to this connection's access — so a
// user only ever sees documents their groups grant (ACL enforced on the chat path).
var scopedKnowledge = _knowledge?.ForAccess(_access);
var runner = new TurnRunner(_chatClient, _store, scopedKnowledge, _systemPrompt, _reranker, _tools, _confirmTools, _confirmations);
var runner = new TurnRunner(_chatClient, _store, scopedKnowledge, _systemPrompt, _reranker, _tools, _confirmTools, _confirmations, _limits);

// Run the turn as a background task, NOT awaited inline. A turn that calls a
// confirmation-gated tool PARKS awaiting a later confirm_tool_action frame; the connection's
Expand Down
84 changes: 84 additions & 0 deletions dotnet/server/src/ModelInfo.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
using System.Text.Json.Nodes;

namespace SmooAI.SmoothOperator.Server;

/// <summary>
/// Reads a model's HARD output ceiling (<c>max_output_tokens</c>) from the LiteLLM gateway's
/// <c>/model/info</c>, so the chat path can clamp <c>max_tokens</c> to what the model can physically
/// emit (EPIC th-1cc9fa). The C# analog of the Rust server's <c>admin::model_output_ceiling</c> +
/// <c>map_model_info</c> — kept out of the engine so the published engine takes no LiteLLM-specific
/// HTTP dependency. Best-effort: any gateway error, an unknown model, or a model with no positive
/// ceiling ⇒ <c>null</c> ⇒ the engine leaves <c>max_tokens</c> unclamped (graceful, no behavior change).
/// </summary>
public static class ModelInfo
{
/// <summary>
/// Parse the gateway's <c>/model/info</c> payload
/// (<c>{ data: [{ model_name, model_info: { max_output_tokens } }] }</c>) into a
/// <c>model_name → ceiling</c> map. Entries without a <c>model_name</c> or with a missing /
/// non-positive ceiling are dropped. Pure + network-free, so it's unit-testable on a sample payload.
/// </summary>
public static IReadOnlyDictionary<string, int> ParseCeilings(JsonNode? payload)
{
var map = new Dictionary<string, int>(StringComparer.Ordinal);
if (payload?["data"] is not JsonArray entries)
{
return map;
}
foreach (var entry in entries)
{
var name = (entry?["model_name"] as JsonValue)?.GetValue<string>();
if (string.IsNullOrEmpty(name))
{
continue;
}
var ceiling = TryGetPositiveInt(entry?["model_info"]?["max_output_tokens"]);
if (ceiling is not null)
{
map[name] = ceiling.Value;
}
}
return map;
}

/// <summary>
/// Fetch the output ceiling for <paramref name="model"/> from <c>{gateway}/model/info</c> via
/// <paramref name="http"/> (its <see cref="HttpClient.BaseAddress"/> is the gateway root — with a
/// trailing slash — and the auth header is already set). Returns <c>null</c> on ANY error, an
/// unknown model, or a model with no positive ceiling; the caller then leaves <c>max_tokens</c>
/// unclamped. Never throws.
/// </summary>
public static async Task<int?> FetchCeilingAsync(HttpClient http, string model, CancellationToken cancellationToken = default)
{
try
{
using var response = await http.GetAsync("model/info", cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
return null;
}
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
var ceilings = ParseCeilings(JsonNode.Parse(body));
return ceilings.TryGetValue(model, out var ceiling) ? ceiling : null;
}
catch
{
// Best-effort: a gateway blip must never fail a boot or a turn — just skip the clamp.
return null;
}
}

/// <summary>The node as a positive <see cref="int"/> (accepting int/long/double JSON numbers), or
/// <c>null</c> when it's absent, non-numeric, or ≤ 0 (a bogus ceiling must not clamp to nothing).</summary>
private static int? TryGetPositiveInt(JsonNode? node)
{
if (node is not JsonValue value)
{
return null;
}
long? number = value.TryGetValue<long>(out var l) ? l
: value.TryGetValue<double>(out var d) && d is >= long.MinValue and <= long.MaxValue ? (long)d
: null;
return number is > 0 and <= int.MaxValue ? (int)number.Value : null;
}
}
2 changes: 1 addition & 1 deletion dotnet/server/src/SmooAI.SmoothOperator.Server.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
<!-- The service is built ON the engine: it drives SmoothAgent per turn and adds the
protocol/session/storage surface around it. The engine now lives in (and publishes
from) the smooth-operator-core repo; consume it as the published NuGet package. -->
<PackageReference Include="SmooAI.SmoothOperator.Core" Version="1.3.0" />
<PackageReference Include="SmooAI.SmoothOperator.Core" Version="1.5.0" />
</ItemGroup>

</Project>
26 changes: 26 additions & 0 deletions dotnet/server/src/TurnLimits.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
namespace SmooAI.SmoothOperator.Server;

/// <summary>
/// Per-turn token/iteration limits threaded into the agent config. Raised from the starvation-prone
/// legacy defaults (<c>max_tokens</c> 512, iterations 6) that made reasoning models return EMPTY — a
/// reasoning model needs headroom to think AND answer, and more than a handful of iterations to
/// actually use its tools (EPIC th-1cc9fa, matching the Rust server's <c>DEFAULT_MAX_TOKENS</c> /
/// <c>DEFAULT_MAX_ITERATIONS</c> raise). <see cref="ModelMaxOutputTokens"/> is the resolved model's
/// HARD output ceiling (from the gateway's <c>/model/info</c>); <c>null</c> ⇒ the engine leaves
/// <c>max_tokens</c> unclamped. A distinct carrier type (like <c>ConfirmTools</c>) so it can't collide
/// with other values in the DI container.
/// </summary>
public sealed record TurnLimits(
int MaxTokens = TurnLimits.DefaultMaxTokens,
int MaxIterations = TurnLimits.DefaultMaxIterations,
int? ModelMaxOutputTokens = null)
{
/// <summary>Default per-turn output-token budget. Raised 512 → 8192 (EPIC th-1cc9fa).</summary>
public const int DefaultMaxTokens = 8_192;

/// <summary>Default per-turn agentic-iteration cap. Raised 6 → 20 (EPIC th-1cc9fa).</summary>
public const int DefaultMaxIterations = 20;

/// <summary>The "raised server defaults, no model ceiling resolved" instance.</summary>
public static readonly TurnLimits Default = new();
}
18 changes: 16 additions & 2 deletions dotnet/server/src/TurnRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,9 @@ public sealed class TurnRunner
private readonly IReadOnlyList<AITool> _tools;
private readonly IReadOnlyList<string> _confirmTools;
private readonly ConfirmationRegistry? _confirmations;
private readonly TurnLimits _limits;

public TurnRunner(IChatClient chatClient, ISessionStore store, IKnowledgeBase? knowledge = null, string? systemPrompt = null, IReranker? reranker = null, IReadOnlyList<AITool>? tools = null, IReadOnlyList<string>? confirmTools = null, ConfirmationRegistry? confirmations = null)
public TurnRunner(IChatClient chatClient, ISessionStore store, IKnowledgeBase? knowledge = null, string? systemPrompt = null, IReranker? reranker = null, IReadOnlyList<AITool>? tools = null, IReadOnlyList<string>? confirmTools = null, ConfirmationRegistry? confirmations = null, TurnLimits? limits = null)
{
_chatClient = chatClient ?? throw new ArgumentNullException(nameof(chatClient));
_store = store ?? throw new ArgumentNullException(nameof(store));
Expand All @@ -45,6 +46,9 @@ public TurnRunner(IChatClient chatClient, ISessionStore store, IKnowledgeBase? k
_confirmTools = confirmTools ?? Array.Empty<string>();
// The session-keyed pending-confirmation registry the gate parks on (null → HITL off).
_confirmations = confirmations;
// Per-turn output-token budget + agentic-iteration cap, plus the resolved model's hard output
// ceiling. Absent ⇒ the raised server defaults (max_tokens 8192, iterations 20; EPIC th-1cc9fa).
_limits = limits ?? TurnLimits.Default;
}

/// <summary>True when <paramref name="toolName"/> matches a confirmation-gated pattern (substring,
Expand Down Expand Up @@ -89,7 +93,17 @@ public async Task<TurnResult> RunAsync(string conversationId, string requestId,
// Registered tools (default none) are passed straight to the engine's agentic loop; the
// streaming block below already translates the resulting tool-call/result events into
// stream_chunks, so enabling tools is purely a matter of supplying them here.
var options = new AgentOptions { Instructions = _systemPrompt, Knowledge = _knowledge };
// MaxOutputTokens is clamped to the model's ModelMaxOutputTokens ceiling by the engine so a
// budget never exceeds what the model can physically emit (EPIC th-1cc9fa). The raised defaults
// (8192 / 20) give reasoning models room to think AND answer, and iterations to actually use tools.
var options = new AgentOptions
{
Instructions = _systemPrompt,
Knowledge = _knowledge,
MaxIterations = _limits.MaxIterations,
MaxOutputTokens = _limits.MaxTokens,
ModelMaxOutputTokens = _limits.ModelMaxOutputTokens,
};
foreach (var tool in _tools)
{
options.Tools.Add(tool);
Expand Down
Loading
Loading