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
70 changes: 70 additions & 0 deletions src/AgentOs.Domain/Llm/LlmCallScope.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// Metrics first: every LLM call an agent makes inside a scope is recorded — tokens (fresh / cache read / cache write /
// output / reasoning), cost, latency, provider, outcome. The Board's agents (Coding, Reviewer, Testing, Discovery, the
// Autopilot Orchestrator) run outside the 5-agent pipeline, so their calls never reached Insights; the caller opens a
// scope around one agent run and stores what it collected as one run with its calls.

using System;
using System.Collections.Generic;
using System.Threading;

namespace AgentOs.Domain.Llm;

/// <summary>One LLM call as recorded for metrics.</summary>
public sealed record LlmCallRecord(
string? AgentRole,
string Model,
string Provider,
int InputTokens,
int OutputTokens,
int CacheReadTokens,
int CacheWriteTokens,
int ReasoningTokens,
decimal CostUsd,
TimeSpan Latency,
bool Success,
string? Error,
DateTimeOffset At);

/// <summary>Collects the LLM calls made in the current async flow while it is open (innermost scope wins).</summary>
public sealed class LlmCallScope : IDisposable
{
private static readonly AsyncLocal<LlmCallScope?> CurrentScope = new();

private readonly LlmCallScope? _previous;
private readonly List<LlmCallRecord> _calls = [];
private bool _disposed;

private LlmCallScope(LlmCallScope? previous) => _previous = previous;

/// <summary>The open scope of this flow, or null.</summary>
public static LlmCallScope? Current => CurrentScope.Value;

/// <summary>Open a scope for this async flow; dispose to close it.</summary>
public static LlmCallScope Begin()
{
var scope = new LlmCallScope(CurrentScope.Value);
CurrentScope.Value = scope;
return scope;
}

/// <summary>The calls recorded so far.</summary>
public IReadOnlyList<LlmCallRecord> Calls
{
get { lock (_calls) { return [.. _calls]; } }
}

/// <summary>Record one call.</summary>
public void Add(LlmCallRecord call)
{
ArgumentNullException.ThrowIfNull(call);
lock (_calls) { _calls.Add(call); }
}

/// <inheritdoc />
public void Dispose()
{
if (_disposed) { return; }
_disposed = true;
if (ReferenceEquals(CurrentScope.Value, this)) { CurrentScope.Value = _previous; }
}
}
8 changes: 6 additions & 2 deletions src/AgentOs.Modules.Llm/LlmClientFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -77,14 +77,18 @@ public ILlmClient Create(string providerName)
// No fallbacks declared → return the bare client, identical to the pre-failover behavior.
if (fallbacks.Count == 0)
{
return primary;
return Observed(primary);
}

var chain = new List<ILlmClient>(1 + fallbacks.Count) { primary };
chain.AddRange(fallbacks);
return new FailoverLlmClient(chain, _services.GetRequiredService<ILogger<FailoverLlmClient>>());
return Observed(new FailoverLlmClient(chain, _services.GetRequiredService<ILogger<FailoverLlmClient>>()));
}

// Metrics first: inside an open LlmCallScope (a Board agent run) every call is recorded for Insights.
private static ILlmClient Observed(ILlmClient client) =>
AgentOs.Domain.Llm.LlmCallScope.Current is { } scope ? new ObservedLlmClient(client, scope) : client;

// The primary must resolve — a missing primary is a hard misconfiguration.
private ILlmClient ResolveRequired(string effective, string requested)
{
Expand Down
36 changes: 36 additions & 0 deletions src/AgentOs.Modules.Llm/ObservedLlmClient.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// Reports each call to the open LlmCallScope (see AgentOs.Domain.Llm.LlmCallScope). The factory only wraps a client
// when a scope is open, so the pipeline's own metrics path and every unscoped call are untouched.

using System;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using AgentOs.Domain.Llm;

namespace AgentOs.Modules.Llm;

/// <summary>An <see cref="ILlmClient"/> that records its calls into the scope that was open when it was created.</summary>
internal sealed class ObservedLlmClient(ILlmClient inner, LlmCallScope scope) : ILlmClient
{
public string Provider => inner.Provider;

public async Task<LlmResponse> SendAsync(LlmRequest request, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var watch = Stopwatch.StartNew();
try
{
var r = await inner.SendAsync(request, cancellationToken).ConfigureAwait(false);
scope.Add(new LlmCallRecord(request.AgentRole, r.Model, r.Provider, r.InputTokens, r.OutputTokens,
r.CacheReadTokens, r.CacheWriteTokens, r.ReasoningTokens, r.CostUsd,
r.Latency > TimeSpan.Zero ? r.Latency : watch.Elapsed, true, null, DateTimeOffset.UtcNow));
return r;
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
scope.Add(new LlmCallRecord(request.AgentRole, request.Model, inner.Provider, 0, 0, 0, 0, 0, 0m, watch.Elapsed,
false, ex.Message.Length > 500 ? ex.Message[..500] : ex.Message, DateTimeOffset.UtcNow));
throw;
}
}
}
16 changes: 16 additions & 0 deletions src/AgentOs.Modules.Pipeline/Persistence/IPipelineRunRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ public interface IPipelineRunRepository
/// tenant's workflow spend.</summary>
Task SaveAsync(PipelineRunRecord record, string tenantId, CancellationToken ct = default);

/// <summary>Stores one Board agent run (Coding, Reviewer, Testing, Discovery, Autopilot Orchestrator) with the
/// LLM calls it made, so Insights counts it. It has no pipeline artifacts: its run detail reads as not found.</summary>
Task SaveAgentRunAsync(AgentRunRecord record, string tenantId, CancellationToken ct = default) => Task.CompletedTask;

/// <summary>Gets a single full run by Id (null if not found).</summary>
Task<PipelineRunRecord?> GetAsync(Guid id, CancellationToken ct = default);

Expand Down Expand Up @@ -64,6 +68,18 @@ public sealed record PipelineRunRecord(
DateTimeOffset CreatedAtUtc,
DateTimeOffset CompletedAtUtc);

/// <summary>One Board agent run and its LLM calls (see <see cref="IPipelineRunRepository.SaveAgentRunAsync"/>).</summary>
/// <param name="Label">What ran, shown in the run history (e.g. "Coding · #15 Build SVG game viewer").</param>
/// <param name="Agent">The agent name its calls are filed under when a call carries no role.</param>
public sealed record AgentRunRecord(
Guid Id,
string Label,
string Agent,
bool Success,
DateTimeOffset StartedAtUtc,
DateTimeOffset CompletedAtUtc,
IReadOnlyList<AgentOs.Domain.Llm.LlmCallRecord> Calls);

/// <summary>Summary of a single run for the history list.</summary>
public sealed record PipelineRunSummary(
Guid Id,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,52 @@ public async Task SaveAsync(PipelineRunRecord record, string tenantId, Cancellat
await db.SaveChangesAsync(ct);
}

public async Task SaveAgentRunAsync(AgentRunRecord record, string tenantId, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(record);
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
var entity = new PipelineRunEntity
{
Id = record.Id,
TenantId = tenantId,
UserStoryText = record.Label,
Status = record.Success ? "Completed" : "Failed",
TotalCostUsd = record.Calls.Sum(c => c.CostUsd),
TotalTokensIn = record.Calls.Sum(c => c.InputTokens),
TotalTokensOut = record.Calls.Sum(c => c.OutputTokens),
IterationCount = 0,
CreatedAtUtc = record.StartedAtUtc,
CompletedAtUtc = record.CompletedAtUtc,
// No pipeline artifacts: the run detail deserializes this to null and reads as not found.
ResultJson = "null",
};
foreach (var c in record.Calls)
{
entity.Metrics.Add(new RunMetricEntity
{
RunId = record.Id,
TenantId = tenantId,
KcId = "board",
Iteration = 0,
AgentName = string.IsNullOrWhiteSpace(c.AgentRole) ? record.Agent : c.AgentRole,
Model = c.Model,
Provider = c.Provider,
TokensIn = c.InputTokens,
TokensOut = c.OutputTokens,
CacheReadTokens = c.CacheReadTokens,
CacheWriteTokens = c.CacheWriteTokens,
ReasoningTokens = c.ReasoningTokens,
LatencyMs = c.Latency.TotalMilliseconds,
CostUsd = c.CostUsd,
Success = c.Success,
ErrorMessage = c.Error,
TimestampUtc = c.At,
});
}
db.PipelineRuns.Add(entity);
await db.SaveChangesAsync(ct);
}

public async Task<PipelineRunRecord?> GetAsync(Guid id, CancellationToken ct = default)
{
var entity = await db.PipelineRuns
Expand Down
13 changes: 12 additions & 1 deletion src/AgentOs.Web/Components/Pages/BoardApp.razor
Original file line number Diff line number Diff line change
Expand Up @@ -1569,7 +1569,18 @@ else if (_tab == Tab.Sessions)
// on its members' computers sends Discovery there too — it used to fall back to the keyless offline demo).
using var _identity = AgentOs.SharedKernel.Identity.AmbientIdentity.Push(_tenantId, _userId);
var svc = scope.ServiceProvider.GetRequiredService<IIdeaBootstrapService>();
_preview = await svc.GenerateAsync(_ideaText.Trim());
// Metrics first: the Requirement + Decomposition calls are stored as one Discovery run for Insights.
var started = DateTimeOffset.UtcNow;
using (var calls = AgentOs.Domain.Llm.LlmCallScope.Begin())
{
try { _preview = await svc.GenerateAsync(_ideaText.Trim()); }
finally
{
var idea = _ideaText.Trim().ReplaceLineEndings(" ");
await TaskService.SaveAgentRunAsync(_tenantId, _userId, "Discovery",
"Discovery · " + (idea.Length > 120 ? idea[..120] + "…" : idea), started, calls.Calls);
}
}

_draftRows.Clear();
for (var k = 0; k < _preview.Tickets.Count; k++)
Expand Down
54 changes: 51 additions & 3 deletions src/AgentOs.Web/Services/BoardTasks/BoardTaskService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,11 @@
}
var stopLease = stops.Begin(tenantId, session.Id);
// Fire-and-forget in a fresh scope, independent of whoever asked (a circuit, or the orchestrator).
_ = Task.Run(() => RunAsync(tenantId, session, board, pat, children, cliProfile, change, stopLease), CancellationToken.None);
// The Thorough engine stores its own pipeline run + metrics; every other run is recorded here for Insights.
_ = Task.Run(() => EngineSelection.IsQuality(session.Brain)
? RunAsync(tenantId, session, board, pat, children, cliProfile, change, stopLease)
: RecordedAsync(tenantId, session, "Coding", () => RunAsync(tenantId, session, board, pat, children, cliProfile, change, stopLease)),
CancellationToken.None);
return TaskStart.Started;
}

Expand Down Expand Up @@ -170,7 +174,8 @@
await sessions.UpdateRepoRunResultAsync(tenantId, c.Id, "Testing", c.BranchName, c.PrUrl, null, ct).ConfigureAwait(false);
}
var stopLease = stops.Begin(tenantId, session.Id);
_ = Task.Run(() => TestChangesAsync(tenantId, session, board, pat, changes, cliProfile, stopLease), CancellationToken.None);
_ = Task.Run(() => RecordedAsync(tenantId, session, "Testing", () => TestChangesAsync(tenantId, session, board, pat, changes, cliProfile, stopLease)),
CancellationToken.None);
return TaskStart.Started;
}

Expand Down Expand Up @@ -203,7 +208,8 @@
await sessions.UpdateRepoRunResultAsync(tenantId, c.Id, "Reviewing", c.BranchName, c.PrUrl, null, ct).ConfigureAwait(false);
}
var stopLease = stops.Begin(tenantId, session.Id);
_ = Task.Run(() => ReviewChangesAsync(tenantId, session, board, pat, changes, cliProfile, stopLease), CancellationToken.None);
_ = Task.Run(() => RecordedAsync(tenantId, session, "Reviewer", () => ReviewChangesAsync(tenantId, session, board, pat, changes, cliProfile, stopLease)),
CancellationToken.None);
return TaskStart.Started;
}

Expand Down Expand Up @@ -371,6 +377,48 @@
return $"{name}-{DateTimeOffset.UtcNow:yyyyMMddHHmm}";
}

/// <summary>
/// Metrics first: run one agent step of a task inside an LLM call scope, then store it as a run with its calls
/// (tokens, latency, provider, outcome) — Insights reads those. Best-effort: metrics never fail the task.
/// </summary>
internal async Task RecordedAsync(string tenantId, RemoteSessionEntity session, string agent, Func<Task> run)
{
ArgumentNullException.ThrowIfNull(session);
ArgumentNullException.ThrowIfNull(run);
var started = DateTimeOffset.UtcNow;
using var calls = AgentOs.Domain.Llm.LlmCallScope.Begin();
try
{
await run().ConfigureAwait(false);
}
finally
{
await SaveAgentRunAsync(tenantId, session.MemberUserId, agent, $"{agent} · #{session.IssueNumber} {session.Title}", started, calls.Calls)
.ConfigureAwait(false);
}
}

/// <summary>Store an agent run and its LLM calls (nothing when it made none). Never throws.</summary>
public async Task SaveAgentRunAsync(string tenantId, string memberId, string agent, string label, DateTimeOffset started,
IReadOnlyList<AgentOs.Domain.Llm.LlmCallRecord> calls)
{
ArgumentNullException.ThrowIfNull(calls);
if (calls.Count == 0) { return; }
try
{
using var _identity = AgentOs.SharedKernel.Identity.AmbientIdentity.Push(tenantId, memberId);
using var scope = scopes.CreateScope();
await scope.ServiceProvider.GetRequiredService<AgentOs.Modules.Pipeline.Persistence.IPipelineRunRepository>()
.SaveAgentRunAsync(new AgentOs.Modules.Pipeline.Persistence.AgentRunRecord(Guid.NewGuid(),
label.Length > 200 ? label[..200] : label, agent, calls.All(c => c.Success), started, DateTimeOffset.UtcNow, calls), tenantId)
.ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_ = ex; // metrics are best-effort: a lost row never fails the task
}
}

/// <summary>The number of a pull request from its URL (…/pull/42).</summary>
public static int? PrNumber(string? url)
{
Expand Down Expand Up @@ -553,7 +601,7 @@
if (cls != TicketClass.Greenfield)
{
var reason = TicketClassifier.RefusalMessage(cls);
await sessRepo.UpdateRunResultAsync(tenantId, session.Id, "Failed", null, reason).ConfigureAwait(false);

Check warning on line 604 in src/AgentOs.Web/Services/BoardTasks/BoardTaskService.cs

View workflow job for this annotation

GitHub Actions / Build & Test

Forward the 'stop' parameter to the 'UpdateRunResultAsync' method or pass in 'CancellationToken.None' explicitly to indicate intentionally not propagating the token (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2016)
Publish(SessionRunEventKind.Failed, reason);
return;
}
Expand All @@ -562,7 +610,7 @@
if (primary is null)
{
const string noRepo = "Quality run has no target repo — recreate the session from a board ticket.";
await sessRepo.UpdateRunResultAsync(tenantId, session.Id, "Failed", null, noRepo).ConfigureAwait(false);

Check warning on line 613 in src/AgentOs.Web/Services/BoardTasks/BoardTaskService.cs

View workflow job for this annotation

GitHub Actions / Build & Test

Forward the 'stop' parameter to the 'UpdateRunResultAsync' method or pass in 'CancellationToken.None' explicitly to indicate intentionally not propagating the token (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2016)
Publish(SessionRunEventKind.Failed, noRepo);
return;
}
Expand Down Expand Up @@ -605,8 +653,8 @@
if (result is null || result.Status == PipelineStatus.Failed)
{
var msg = error ?? "Quality pipeline failed — see the server log.";
await sessRepo.UpdateRepoRunResultAsync(tenantId, primary.Id, "Failed", null, null, msg).ConfigureAwait(false);

Check warning on line 656 in src/AgentOs.Web/Services/BoardTasks/BoardTaskService.cs

View workflow job for this annotation

GitHub Actions / Build & Test

Forward the 'stop' parameter to the 'UpdateRepoRunResultAsync' method or pass in 'CancellationToken.None' explicitly to indicate intentionally not propagating the token (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2016)
await sessRepo.UpdateRunResultAsync(tenantId, session.Id, "Failed", null, msg).ConfigureAwait(false);

Check warning on line 657 in src/AgentOs.Web/Services/BoardTasks/BoardTaskService.cs

View workflow job for this annotation

GitHub Actions / Build & Test

Forward the 'stop' parameter to the 'UpdateRunResultAsync' method or pass in 'CancellationToken.None' explicitly to indicate intentionally not propagating the token (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2016)
Publish(SessionRunEventKind.Failed, $"{repoLabel} — {msg}", repoLabel, "Failed");
return;
}
Expand All @@ -615,8 +663,8 @@
var gate = PrGate.Evaluate(result);
if (!gate.Allowed)
{
await sessRepo.UpdateRepoRunResultAsync(tenantId, primary.Id, "Blocked", null, null, gate.Reason).ConfigureAwait(false);

Check warning on line 666 in src/AgentOs.Web/Services/BoardTasks/BoardTaskService.cs

View workflow job for this annotation

GitHub Actions / Build & Test

Forward the 'stop' parameter to the 'UpdateRepoRunResultAsync' method or pass in 'CancellationToken.None' explicitly to indicate intentionally not propagating the token (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2016)
await sessRepo.RecomputeSessionStatusForTenantAsync(tenantId, session.Id).ConfigureAwait(false);

Check warning on line 667 in src/AgentOs.Web/Services/BoardTasks/BoardTaskService.cs

View workflow job for this annotation

GitHub Actions / Build & Test

Forward the 'stop' parameter to the 'RecomputeSessionStatusForTenantAsync' method or pass in 'CancellationToken.None' explicitly to indicate intentionally not propagating the token (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2016)
Publish(SessionRunEventKind.Failed, $"{repoLabel} — {gate.Reason}", repoLabel, "Blocked");
return;
}
Expand All @@ -628,8 +676,8 @@
try
{
var pr = await prService.OpenPrAsync(result, descriptor, title, body, CancellationToken.None).ConfigureAwait(false);
await sessRepo.UpdateRepoRunResultAsync(tenantId, primary.Id, "Done", pr.Branch, pr.HtmlUrl, null).ConfigureAwait(false);

Check warning on line 679 in src/AgentOs.Web/Services/BoardTasks/BoardTaskService.cs

View workflow job for this annotation

GitHub Actions / Build & Test

Forward the 'stop' parameter to the 'UpdateRepoRunResultAsync' method or pass in 'CancellationToken.None' explicitly to indicate intentionally not propagating the token (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2016)
await sessRepo.RecomputeSessionStatusForTenantAsync(tenantId, session.Id).ConfigureAwait(false);

Check warning on line 680 in src/AgentOs.Web/Services/BoardTasks/BoardTaskService.cs

View workflow job for this annotation

GitHub Actions / Build & Test

Forward the 'stop' parameter to the 'RecomputeSessionStatusForTenantAsync' method or pass in 'CancellationToken.None' explicitly to indicate intentionally not propagating the token (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2016)
Publish(SessionRunEventKind.Done, $"{repoLabel} — PR opened.", repoLabel, "Done", pr.HtmlUrl);
}
catch (Exception ex) when (ex is System.Net.Http.HttpRequestException or System.Text.Json.JsonException
Expand Down
Loading
Loading