Skip to content
Merged
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,7 @@ Agents depend only on `ILlmClient`; `LlmClientFactory` resolves a keyed implemen
| `OpenAICompatible` (aliases `Ollama`, `vLLM`, `Local`) | Any `/v1/chat/completions` server via the OpenAI SDK — self-hosted / air-gapped, your own prices | `Modules.Llm` |
| `MAF` | Microsoft Agent Framework over Azure OpenAI | `Modules.Llm` |
| `RemoteAgent` | SignalR-dispatched dev-machine agent | `Modules.RemoteAgent` |
| `Offline` | Keyless canned output — only at the end of a failover chain when offline fallback is on | `Modules.Llm` |
| `Offline` | Keyless canned output — only at the end of a failover chain when offline fallback is on, and never for Board agent runs | `Modules.Llm` |

Routing per call: `Llm:ForceProvider` (if set) → the agent profile's provider → `Llm:Provider`.
`Llm:Fallbacks` adds inter-provider failover (e.g. Claude → AzureOpenAI) above each provider's own
Expand Down
2 changes: 1 addition & 1 deletion docs/CONFIG.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ Defaults below are the code defaults (tier 5). **Scope**: *Tenant* = each tenant
|---|---|---|---|
| `Llm:Provider` | `Claude` | Tenant | Used by any agent whose profile names no provider. `Anthropic` is an alias of `Claude`. |
| `Llm:ForceProvider` | empty | Tenant | Route every agent to one provider. |
| `Llm:OfflineFallback` | **`true` in Development, else `false`** | Platform | Keyless provider → canned, schema-valid output, labelled Offline everywhere (runs, Workflow chip, PR refusal). Lets a fresh checkout run with no secrets. Locked off in Production. |
| `Llm:OfflineFallback` | **`true` in Development, else `false`** | Platform | Keyless provider → canned, schema-valid output, labelled Offline everywhere (runs, Workflow chip, PR refusal). Lets a fresh checkout run with no secrets. Locked off in Production. Never applies to Board agent runs (New idea, Coding, Reviewer, Testing, the Autopilot lead, Retrospective): those act on a real project, so they fail with the real reason (e.g. a plan limit) instead of getting canned text. |
| `Llm:Claude:ApiKey` / `ApiKeys` | empty | Tenant | Secret, write-only in the UI. Pool = round-robin with 429 failover. |
| `Llm:AzureOpenAi:Endpoint` / `ApiKey` / `ApiKeys` | empty | Tenant | Endpoint must be absolute `https://`. |
| `Llm:AzureOpenAi:Model` | `gpt-4.1` | Tenant | Default deployment. |
Expand Down
4 changes: 3 additions & 1 deletion src/AgentOs.Domain/Llm/LlmCallScope.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ public sealed record LlmCallRecord(
string? Error,
DateTimeOffset At);

/// <summary>Collects the LLM calls made in the current async flow while it is open (innermost scope wins).</summary>
/// <summary>Collects the LLM calls made in the current async flow while it is open (innermost scope wins). An open
/// scope also marks the flow as a Board agent run on a real project: the gateway never lets the keyless offline demo
/// answer in place of the real model there (see LlmClientFactory).</summary>
public sealed class LlmCallScope : IDisposable
{
private static readonly AsyncLocal<LlmCallScope?> CurrentScope = new();
Expand Down
8 changes: 6 additions & 2 deletions src/AgentOs.Modules.Llm/LlmClientFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,13 @@ public ILlmClient Create(string providerName)
// to the END of the chain so a no-key LlmException falls through to canned schema-valid output instead
// of failing the run. Skipped when the effective provider already IS Offline, or it's already a
// configured fallback, so it never appears twice.
var offlineFallback = _settings is null
// Never for a Board agent run (an open LlmCallScope: Discovery, Coding, Reviewer, Testing, the Autopilot lead,
// Retrospective): those write tickets, reviews and merges on a real project, so canned demo text standing in
// for a model that hit its plan limit or went offline would become junk tickets or a false verdict. They fail
// with the real reason instead ("session limit · resets 23:50") and the Board says so.
var offlineFallback = AgentOs.Domain.Llm.LlmCallScope.Current is null && (_settings is null
? _options.OfflineFallback
: LiveSettings.ReadBool(_settings, LlmSettings.OfflineFallback, tenant, _options.OfflineFallback);
: LiveSettings.ReadBool(_settings, LlmSettings.OfflineFallback, tenant, _options.OfflineFallback));
if (offlineFallback
&& !string.Equals(NormalizeKey(effective), OfflineLlmClient.ProviderName, StringComparison.Ordinal))
{
Expand Down
2 changes: 1 addition & 1 deletion src/AgentOs.Web/Components/Pages/BoardApp.razor
Original file line number Diff line number Diff line change
Expand Up @@ -1599,7 +1599,7 @@ else if (_tab == Tab.Sessions)
}
_ideaStep = IdeaStep.Review;
},
ex => _ideaError = $"Generation failed: {ex.Message}");
ex => _ideaError = $"The AI couldn't write the spec, so nothing was created — {ex.Message} Try again once it is back.");
_ideaBusy = false;
}

Expand Down
32 changes: 32 additions & 0 deletions tests/AgentOs.Tests/Llm/LlmClientFactoryLiveSettingsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
using AgentOs.SharedKernel.Modularity;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using Shouldly;
using Xunit;

Expand Down Expand Up @@ -91,6 +92,37 @@ public async Task Create_PlatformOfflineFallback_TogglesTheCannedSafetyNetLive()
(await settings.SetAsync(LlmSettings.OfflineFallback, "false", SettingScope.Tenant, "acme", "admin")).Ok.ShouldBeFalse();
}

[Fact]
public async Task Create_InsideBoardAgentRun_NeverFallsOverToTheOfflineDemo()
{
// 2026-10-23 on the lab: the member's Claude plan hit its session limit, the Offline client answered New idea
// with its canned spec, and three junk tickets landed on a real repo. A Board agent run must fail instead.
var services = new ServiceCollection();
services.AddLogging();
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?> { ["Llm:Provider"] = "Claude", ["Llm:OfflineFallback"] = "true" })
.Build();
services.AddModulesFromAssemblies(config, typeof(LlmModule).Assembly, typeof(AgentOs.Modules.AppConfig.AppConfigModule).Assembly);
var limited = Substitute.For<ILlmClient>();
limited.Provider.Returns("Limited");
limited.SendAsync(Arg.Any<LlmRequest>(), Arg.Any<System.Threading.CancellationToken>())
.Returns<Task<LlmResponse>>(_ => throw new LlmException("You've hit your session limit · resets 11:50pm"));
services.AddKeyedSingleton("Limited", limited);
var factory = services.BuildServiceProvider().GetRequiredService<ILlmClientFactory>();
var request = new LlmRequest("You are the Requirement Agent of an SDLC pipeline.", "An idea", "claude-sonnet-5", MaxTokens: 1000);
using var _ = AmbientIdentity.Push("acme", "u");

// Outside a Board run (Workflow demo, standalone) the canned safety net still answers.
(await factory.Create("Limited").SendAsync(request)).Provider.ShouldBe(OfflineLlmClient.ProviderName);

using (var run = LlmCallScope.Begin())
{
var ex = await Should.ThrowAsync<LlmException>(() => factory.Create("Limited").SendAsync(request));
ex.Message.ShouldContain("session limit");
run.Calls.ShouldHaveSingleItem().Success.ShouldBeFalse(); // Insights still sees the failed call
}
}

// FailoverLlmClient is internal; a chain reports the primary's provider, so detect it by type name.
private static bool IsChain(ILlmClient client) => client.GetType().Name == "FailoverLlmClient";

Expand Down
Loading