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
1 change: 1 addition & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ dotnet_diagnostic.IDE0004.severity = warning # unnecessary cast -> cs/u
dotnet_diagnostic.IDE0035.severity = warning # unreachable code -> cs/unreachable-code
dotnet_diagnostic.IDE0059.severity = warning # unused value assignment -> cs/useless-assignment-to-local
dotnet_diagnostic.IDE0060.severity = warning # unused parameter -> cs/unused-parameter
dotnet_diagnostic.IDE0044.severity = warning # field can be readonly -> cs/missed-readonly-modifier

# EF Core migrations — auto-generated, exempt from style/analyzer enforcement (block-scoped namespace, etc.).
[**/Migrations/*.cs]
Expand Down
91 changes: 91 additions & 0 deletions .github/workflows/perf.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
name: Perf

# Plan 1.9 (owner directive 2 — "measure, then fix"): run the BenchmarkDotNet suite (tests/AgentOs.Benchmarks) and
# compare it with the committed baseline (tests/AgentOs.Benchmarks/baseline/*.json). The job fails when a benchmark
# crashes, when a full ('*') run is missing a baseline benchmark, or when allocations grow (0 B → anything, or more
# than the threshold + 64 B). Not part of CI: benchmarks are slow and need a quiet machine.
#
# Timing: the baseline was recorded on a different machine than a GitHub-hosted runner, and a ShortRun (N=3) on a
# shared VM is noisy, so on the nightly a Mean regression is a warning annotation, not a failure — allocations are
# machine-independent and are the gate. A manual run gates on Mean too unless 'timing' is set to advisory.
# When the hardware changes, re-record the baseline from this workflow's own artifact (docs/architecture.md §8).

on:
workflow_dispatch:
inputs:
filter:
description: "BenchmarkDotNet --filter glob (e.g. '*CostCalculator*')"
required: false
default: "*"
threshold:
description: "Allowed Mean / allocation growth: a fraction in (0, 1] (0.20) or a percentage (20%)"
required: false
default: "0.20"
timing:
description: "Mean regressions: 'gate' fails the job, 'advisory' only warns"
required: false
type: choice
options: [gate, advisory]
default: gate
schedule:
- cron: "17 3 * * *" # nightly, 03:17 UTC

permissions:
contents: read

# Separate groups per trigger so a manual run never cancels the nightly (and vice versa); only a newer manual run
# cancels an older manual one — a scheduled run is never cancelled mid-way.
concurrency:
group: perf-${{ github.event_name }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'workflow_dispatch' }}

jobs:
benchmarks:
name: Benchmarks vs baseline
runs-on: ubuntu-latest
timeout-minutes: 60

steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Setup .NET 10
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: '10.0.x'

- name: Build benchmarks (Release)
run: dotnet build tests/AgentOs.Benchmarks --configuration Release

# ShortRun job (3 warm-ups, 3 iterations, 1 launch) keeps the nightly under ~20 minutes; the memory diagnoser
# is always on (BenchmarkConfig). Inputs reach the script through env, never interpolated into it.
- name: Run benchmarks
env:
FILTER: ${{ inputs.filter || '*' }}
run: |
dotnet run --configuration Release --no-build --project tests/AgentOs.Benchmarks -- \
--filter "$FILTER" --job short --artifacts "$GITHUB_WORKSPACE/BenchmarkDotNet.Artifacts"

- name: Upload BenchmarkDotNet artifacts
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: benchmark-results
path: BenchmarkDotNet.Artifacts/
if-no-files-found: warn
retention-days: 30

# Runs even when a benchmark crashed (the run step exits 1) so the summary still shows every row.
# Scheduled runs: timing is advisory. A full ('*') run must cover the whole baseline (--require-all).
- name: Compare with baseline
if: ${{ !cancelled() }}
env:
FILTER: ${{ inputs.filter || '*' }}
THRESHOLD: ${{ inputs.threshold || '0.20' }}
TIMING: ${{ github.event_name == 'schedule' && 'advisory' || inputs.timing || 'gate' }}
run: |
extra=()
if [ "$FILTER" = "*" ]; then extra+=(--require-all); fi
dotnet run --configuration Release --no-build --project tests/AgentOs.Benchmarks -- \
compare tests/AgentOs.Benchmarks/baseline "$GITHUB_WORKSPACE/BenchmarkDotNet.Artifacts/results" \
--threshold "$THRESHOLD" --timing "$TIMING" "${extra[@]}" --summary "$GITHUB_STEP_SUMMARY"
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ src/AgentOs.Web/runner-dist/
# Runtime plugin drop folders (built by scripts/build-plugins.ps1; plugin SOURCE lives in samples/)
src/AgentOs.Api/plugins/
src/AgentOs.Web/plugins/
# BenchmarkDotNet output (tests/AgentOs.Benchmarks); the committed baseline lives in tests/AgentOs.Benchmarks/baseline/
**/BenchmarkDotNet.Artifacts/
**/*.binlog
**/*.tlog

Expand Down
1 change: 1 addition & 0 deletions AgentOs.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
<Project Path="samples/AgentOs.Plugins.Sample/AgentOs.Plugins.Sample.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/AgentOs.Benchmarks/AgentOs.Benchmarks.csproj" />
<Project Path="tests/AgentOs.E2E.Tests/AgentOs.E2E.Tests.csproj" />
<Project Path="tests/AgentOs.Lab.Tests/AgentOs.Lab.Tests.csproj" />
<Project Path="tests/AgentOs.Tests/AgentOs.Tests.csproj" />
Expand Down
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,8 @@ The owner's standing instructions. They apply to every change; the execution pla
- `cs/local-not-disposed`: CodeQL doesn't see `await using (x.ConfigureAwait(false))` → write `await using var x = …`.
- `cs/catch-of-all-exceptions`: a "never throw" catch needs an explicit `when (ex is not OperationCanceledException)` filter.
- `cs/dispose-not-called-on-throw`: disposables in tests go under `using`.
- `cs/missed-readonly-modifier`: enforced at build time via `IDE0044` (`.editorconfig`).
- `cs/loss-of-precision`: `97 * i` passed as a `double` is an int multiplication first — write `97.0 * i`.
- **Row-level security (ADR-0012):** the dev/lab stack connects as the Postgres SUPERUSER, which bypasses RLS even
when forced — every connection therefore `SET ROLE agentos_rls`. A new tenant table gets the policy by being
added to `RowLevelSecurity.Tables`; its module context needs `.AddInterceptors(RowLevelSecurityInterceptor.Instance)`
Expand Down
4 changes: 4 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,10 @@
<PackageVersion Include="ModelContextProtocol" Version="2.2.0" />
<PackageVersion Include="ModelContextProtocol.AspNetCore" Version="2.2.0" />

<!-- Benchmarks (tests/AgentOs.Benchmarks — not run by `dotnet test`; see .github/workflows/perf.yml).
0.15.8 is the latest stable as of 2026-11-01 (0.16 is preview-only). -->
<PackageVersion Include="BenchmarkDotNet" Version="0.15.8" />

<!-- Test stack -->
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.11.0" />
<!-- xunit.v3 4.0.0 is Microsoft.Testing.Platform (MTP)-native — it drops the legacy VSTest
Expand Down
55 changes: 55 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,3 +230,58 @@ flowchart LR
registers the product apps in `AppCatalog` (Insights, Workflow, Board, Settings, Terminal, Files, and
the Settings-hub screens) and renders them as windows. Design rules: [design-system.md](design/design-system.md).
Plugins (`IAgentOsPlugin`, loaded from `plugins/`) can add tools, providers and windows.

## 8. Performance benchmarks

Hot paths are measured, not guessed (owner directive 2; plan §2.7 / PR 1.9). `tests/AgentOs.Benchmarks` is a
[BenchmarkDotNet](https://benchmarkdotnet.org/) console app in `AgentOs.slnx`; it is **not** a test project, so
`dotnet test` never runs it. Every benchmark runs with the memory diagnoser, uses realistic inputs, and touches no
network or database (the EF settings store runs on the EF in-memory provider).

| Area | Benchmarks | Why it is hot |
|---|---|---|
| LLM gateway | `CostCalculatorBenchmarks` (exact id, dated/Vertex snapshots, unknown model, long-context tier), `ModelCapabilitiesBenchmarks` | priced and shaped on every LLM call; the budget gate reads the price |
| Settings (ADR-0011) | `SettingsServiceBenchmarks` (tenant → platform → seed → default chain), `AppConfigStoreBenchmarks` (EF store cache hits) | read on every LLM call, policy check and agent profile lookup |
| Prompt text | `UntrustedTextBenchmarks`, `RepoInstructionsBenchmarks`, `PullRequestTextBenchmarks`, `JsonExtractorBenchmarks` (~100 KB replies: bare, fenced, prose-wrapped) | every agent prompt / reply / PR |
| Tool output | `ToolOutputShaperBenchmarks` (a ~1 MB failing `dotnet build` log, as text and inside build_verifier JSON) | every tool result headed back to the model (not in the baseline yet: reported as new until the next re-record) |
| Queues | `SessionRunFeedBenchmarks` (publish fan-out, subscribe round trip), `ToolEvidenceBenchmarks` (buffered evidence enqueue + drain) | on the agent's critical path |
| Desktop folds | `InsightsFoldBenchmarks` (by-role folds, `AgentRoles.Canonical`), `SettingsSearchBenchmarks` (per keystroke), `KnowledgeLayoutBenchmarks` | run per render / keystroke on the Blazor circuit |

```bash
# Everything (ShortRun, ~20 min) — artifacts land in ./BenchmarkDotNet.Artifacts (git-ignored)
dotnet run -c Release --project tests/AgentOs.Benchmarks -- --filter '*' --job short
# One class, full statistical run
dotnet run -c Release --project tests/AgentOs.Benchmarks -- --filter '*CostCalculator*'
# The perf gate: compare a run with the committed baseline (exit 1 on regression, 2 on bad arguments / no data)
dotnet run -c Release --project tests/AgentOs.Benchmarks -- compare tests/AgentOs.Benchmarks/baseline BenchmarkDotNet.Artifacts/results
# [--threshold 0.20|20%] [--timing gate|advisory] [--require-all] [--alloc-floor-bytes 64] [--noise-floor-ns 2]
```

**Regression gate.** `.github/workflows/perf.yml` (manual + nightly) runs the suite with `--job short`, uploads the
BenchmarkDotNet artifacts, and runs the comparer against `tests/AgentOs.Benchmarks/baseline/*.json` (BenchmarkDotNet's
brief JSON reports). The job fails when a benchmark:

- **allocates more** — its baseline allocates **0 B** and it now allocates anything, or its allocated bytes per
operation grow by more than the threshold (20 %) **plus a 64 B floor** (one extra small object is not a regression);
- **crashes** — the report lists it without statistics, or the run itself exits non-zero (a failed build/run or a
critical validation error makes the benchmark run exit 1);
- **is missing on a full run** — with `--filter '*'` the workflow passes `--require-all`, so a baseline benchmark that
never produced a report fails instead of passing as "not run" (a filtered run reports missing rows, never fails);
- **got slower** — its Mean grows by more than 20 % (and by more than a 2 ns noise floor). This is a **failure only on
manual runs** (`timing: gate`, the default for `workflow_dispatch`); on the **nightly it is advisory**: a `::warning`
annotation and a ⚠️ row in the summary, not a red job (see the machine caveat below).

`--threshold` takes a fraction in (0, 1] (`0.20`) or a percentage with `%` (`20%`); a bare `20` is rejected rather than
read as 2000 %. New benchmarks are reported, never failed. The nightly's concurrency group is separate from manual runs
and never cancels in progress. The comparer (`Comparison/BaselineComparer.cs`, `Comparison/ComparerArguments.cs`) is
pure BCL and unit-tested in `AgentOs.Tests` (`BaselineComparerTests`).

**Machine caveat.** Absolute timings belong to the machine that recorded them. The committed baseline was recorded
on a 4-vCPU cloud Linux VM (see `HostEnvironmentInfo` inside each baseline file), not on a GitHub-hosted runner, and
shared runners are noisy; the nightly's ShortRun job (3 iterations) on that hardware against a fixed 2 ns floor would
fail on noise alone. That is why the nightly gates on allocations only — they are machine-independent and the reliable
signal — and reports timing as advisory. The simpler alternative (benchmark base and head in the same job) was rejected
for now: it doubles the run time and still compares two noisy ShortRuns. Treat an advisory timing warning as "re-run on a
quiet machine (or dispatch with `timing: gate`) before you believe it". To re-record: download the `benchmark-results`
artifact of a `perf.yml` run on `main`, copy its `results/*-report-brief.json` over `tests/AgentOs.Benchmarks/baseline/`,
and commit that with a note of the runner it came from.
2 changes: 1 addition & 1 deletion docs/plans/agentic-sdlc-v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,7 @@ Every UI change round-trips through Claude Design (CLAUDE.md design workflow).
| **1.7** `feat(web)` | **Insights v2**: north-star tiles (cost per accepted change, first-pass acceptance), funnel (runs → green → PR → merged), efficiency (tokens/success, cache hit, $ by stage/model), quality, human, failure mix; CSV/JSON export; Claude Design round-trip | Screenshot light+dark; numbers match SQL spot checks |
| **1.8** `test(evals)` | `tests/AgentOs.Evals` + `evals.yml` workflow (manual + nightly, `$` cap); suite `greenfield-csharp` with hidden tests; **record the baseline** | Baseline report committed to `docs/evals/baseline-2026-xx.md` |

| **1.9** `perf(bench)` | `tests/AgentOs.Benchmarks` (BenchmarkDotNet) for the sink, ledger KPI queries, pricing/settings lookups; `perf.yml` (manual + nightly) compares against the stored baseline and fails on > 15% regression of a §2.7 budget | Baseline committed; a deliberate O(n²) regression fails the job |
| **1.9** `perf(bench)` | ✅ **Shipped #301** — 52 BenchmarkDotNet benchmarks over the LLM-call, settings, prompt-text, queue and desktop-fold hot paths; `perf.yml` gates allocation growth (0 B → any, or > 20 % + 64 B), crashed/missing benchmarks, and > 20 % Mean (advisory on the nightly) (the sink + ledger KPI benchmarks follow the Metrics module, 1.2). Original scope: `tests/AgentOs.Benchmarks` (BenchmarkDotNet) for the sink, ledger KPI queries, pricing/settings lookups; `perf.yml` (manual + nightly) compares against the stored baseline and fails on > 15% regression of a §2.7 budget | Baseline committed; a deliberate O(n²) regression fails the job |

**Exit:** a single dashboard answers "what does an accepted change cost, how often is it accepted
first time, and where do tokens go" — with a recorded baseline that every later phase is compared to.
Expand Down
89 changes: 61 additions & 28 deletions src/AgentOs.Domain/Llm/UntrustedText.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
// and the length is capped (token thrift + a bound on how much an attacker can inject).

using System;
using System.Buffers;
using System.Text;

namespace AgentOs.Domain.Llm;
Expand All @@ -29,57 +30,86 @@ public static class UntrustedText

/// <summary>
/// Fence <paramref name="text"/> as untrusted data of the given <paramref name="kind"/> (for example
/// <c>ticket-title</c>). Single pass, O(n) in the input length.
/// <c>ticket-title</c>). Single pass, O(n) in the input length, one builder for the whole block.
/// </summary>
public static string Wrap(string kind, string? text, int maxChars = DefaultMaxChars)
{
ArgumentException.ThrowIfNullOrWhiteSpace(kind);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxChars);

var body = Sanitize(text, maxChars);
return $"<{Tag} kind=\"{SafeKind(kind)}\">\n{body}\n</{Tag}>";
var sb = new StringBuilder(Math.Min(text?.Length ?? 0, maxChars) + 96);
sb.Append('<').Append(Tag).Append(" kind=\"");
AppendSafeKind(sb, kind);
sb.Append("\">\n");
AppendSanitized(sb, text, maxChars);
sb.Append("\n</").Append(Tag).Append('>');
return sb.ToString();
}

/// <summary>Drop control characters (keeping newline/tab), neutralise any embedded
/// <c>&lt;untrusted</c> / <c>&lt;/untrusted</c> so the text cannot close or forge the fence, and cap the length.</summary>
internal static string Sanitize(string? text, int maxChars)
{
var sb = new StringBuilder(Math.Min(text?.Length ?? 0, maxChars) + 32);
AppendSanitized(sb, text, maxChars);
return sb.ToString();
}

// The characters that need handling: '<' (a possible fence tag) and every control character except '\n' and '\t'
// (C0, DEL, C1 — exactly char.IsControl). Everything between two of them is copied as one run.
private static readonly SearchValues<char> Special = SearchValues.Create(BuildSpecial());

private static string BuildSpecial()
{
var chars = new StringBuilder("<");
for (var c = '\0'; c <= '\u009F'; c++)
{
if (char.IsControl(c) && c is not ('\n' or '\t')) { chars.Append(c); }
}
return chars.ToString();
}

private static void AppendSanitized(StringBuilder sb, string? text, int maxChars)
{
if (string.IsNullOrWhiteSpace(text))
{
return "(empty)";
sb.Append("(empty)");
return;
}

var source = text.AsSpan().Trim();
var take = Math.Min(source.Length, maxChars);
var sb = new StringBuilder(take + 32);
for (var i = 0; i < take; i++)
var window = source[..take];
var i = 0;
while (i < take)
{
var c = source[i];
if (c == '<' && StartsFenceTag(source, i))
var next = window[i..].IndexOfAny(Special);
if (next < 0)
{
sb.Append("&lt;"); // "<untrusted" / "</untrusted" can no longer be parsed as the fence
continue;
sb.Append(window[i..]);
break;
}
if (char.IsControl(c) && c is not ('\n' or '\t'))
sb.Append(window.Slice(i, next));
i += next;

var c = window[i];
if (c == '<')
{
if (c == '\r' && i + 1 < take && source[i + 1] == '\n')
{
continue; // CRLF → LF
}
if (c == '\r')
{
sb.Append('\n');
}
continue; // drop other control chars (NUL, ESC, bidi-free C0/C1 controls)
// "<untrusted" / "</untrusted" can no longer be parsed as the fence
if (StartsFenceTag(source, i)) { sb.Append("&lt;"); } else { sb.Append('<'); }
}
sb.Append(c);
else if (c == '\r' && !(i + 1 < take && window[i + 1] == '\n'))
{
sb.Append('\n'); // a lone CR → LF (the CR of a CRLF is simply dropped)
}
// other control chars (NUL, ESC, C0/C1 controls) are dropped
i++;
}

if (source.Length > take)
{
sb.Append("\n[… truncated ").Append(source.Length - take).Append(" chars]");
}
return sb.ToString();
}

// True when source[i] == '<' begins "<untrusted" or "</untrusted" (case-insensitive).
Expand All @@ -93,22 +123,25 @@ private static bool StartsFenceTag(ReadOnlySpan<char> source, int i)
return rest.StartsWith(Tag, StringComparison.OrdinalIgnoreCase);
}

// The kind lands inside an attribute; keep it to a safe identifier alphabet.
private static string SafeKind(string kind)
// The kind lands inside an attribute; keep it to a safe identifier alphabet (at most 40 characters).
private static void AppendSafeKind(StringBuilder sb, string kind)
{
Span<char> buf = stackalloc char[Math.Min(kind.Length, 40)];
var n = 0;
foreach (var c in kind)
{
if (n == buf.Length)
if (n == 40)
{
break;
}
if (char.IsAsciiLetterOrDigit(c) || c is '-' or '_' or '.' or ':' or '/' or '#')
{
buf[n++] = c;
sb.Append(c);
n++;
}
}
return n == 0 ? "text" : new string(buf[..n]);
if (n == 0)
{
sb.Append("text");
}
}
}
Loading
Loading