Skip to content

Commit f88129a

Browse files
nficanoclaude
andcommitted
feat(runtime): non-fatal budget exhaustion via tool_result.error (§9.6)
Spec §9.6 SHOULDs the `tool_result.error` form so the agent can decide whether to continue with non-cost-bearing operations. The runtime previously always threw `BudgetExhaustedException` from `Job.EmitMetricAsync`, which the run-loop converted into a terminal `job.error{BUDGET_EXHAUSTED}` — the agent never got a chance. - `Job.EmitMetricAsync` now returns the exhausted-currency string (or null) instead of throwing, leaving the policy decision to the caller. - `JobContext.MetricAsync` surfaces the exhaustion as `tool_result.error{BUDGET_EXHAUSTED, retryable:false}` and lets the agent continue. Opt back into terminal exhaustion with the new `ArcpServerOptions.FatalBudgetExhaustion = true`. - `BudgetEnforcementTests` is rewritten: one test for the new spec-preferred surface, one for the legacy fatal opt-in. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent dcefb24 commit f88129a

7 files changed

Lines changed: 111 additions & 18 deletions

File tree

src/Arcp.Runtime/ArcpServer.cs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,14 @@ public ArcpServer(ArcpServerOptions options, ILoggerFactory? loggerFactory = nul
6464
options.TimeProvider);
6565
AgentRegistry = new AgentRegistry();
6666
LeaseManager = new LeaseManager(options.TimeProvider);
67-
JobManager = new JobManager(AgentRegistry, LeaseManager, options.TimeProvider, _loggerFactory, CredentialManager, options.IdempotencyWindowSec);
67+
JobManager = new JobManager(
68+
AgentRegistry,
69+
LeaseManager,
70+
options.TimeProvider,
71+
_loggerFactory,
72+
CredentialManager,
73+
options.IdempotencyWindowSec,
74+
options.FatalBudgetExhaustion);
6875
if (CredentialManager is not null)
6976
{
7077
_ = Task.Run(() => RevokeOutstandingCredentialsAsync(CancellationToken.None));

src/Arcp.Runtime/ArcpServerOptions.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,12 @@ public sealed class ArcpServerOptions
3131
/// (spec §7.2). Submissions with the same key after this window create a fresh job.</summary>
3232
public int IdempotencyWindowSec { get; init; } = 3600;
3333

34+
/// <summary>Whether a <c>cost.budget</c> exhaustion terminates the job with
35+
/// <c>BUDGET_EXHAUSTED</c> (legacy v1.0 behavior) or surfaces a non-fatal
36+
/// <c>tool_result.error</c> so the agent may emit a partial result and return normally
37+
/// (spec §9.6 SHOULD-preferred form). Default: <see langword="false"/>.</summary>
38+
public bool FatalBudgetExhaustion { get; init; }
39+
3440
/// <summary>Gets the back pressure threshold.</summary>
3541
public int BackPressureThreshold { get; init; } = 1000;
3642

src/Arcp.Runtime/Job.cs

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -214,18 +214,19 @@ internal IReadOnlyList<Envelope> SnapshotEventHistory()
214214
}
215215
}
216216

217-
/// <summary>Apply the budget rule for <c>cost.*</c> metrics, then emit. If the metric exhausts
218-
/// the matching budget counter (spec §9.6), the metric event is still emitted but a
219-
/// <see cref="BudgetExhaustedException"/> is then thrown so the run-loop terminates the job
220-
/// with <c>BUDGET_EXHAUSTED</c> (spec §12).</summary>
221-
public async ValueTask EmitMetricAsync(MetricBody body, CancellationToken cancellationToken)
217+
/// <summary>Apply the budget rule for <c>cost.*</c> metrics, then emit. Returns the exhausted
218+
/// currency (or <see langword="null"/> if all counters remain positive). Callers decide whether
219+
/// to surface the exhaustion as a <c>tool_result.error</c> or a fatal exception per spec §9.6
220+
/// (which SHOULDs the former).</summary>
221+
public async ValueTask<string?> EmitMetricAsync(MetricBody body, CancellationToken cancellationToken)
222222
{
223223
var charged = BudgetLedger.ApplyMetric(body.Name, body.Value, body.Unit);
224224
await EmitEventAsync(EventKinds.Metric, body, cancellationToken).ConfigureAwait(false);
225-
if (charged)
225+
if (charged && !string.IsNullOrEmpty(body.Unit) && BudgetLedger.IsExhausted(body.Unit))
226226
{
227-
BudgetLedger.AssertNotExhausted();
227+
return body.Unit;
228228
}
229+
return null;
229230
}
230231

231232
/// <summary>Begin result stream.</summary>

src/Arcp.Runtime/JobContext.cs

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,15 @@ public sealed class JobContext
2323
{
2424
private readonly Job _job;
2525
private readonly CredentialManager? _credentials;
26+
private readonly bool _fatalBudgetExhaustion;
2627
private readonly Arcp.Runtime.Leases.LeaseManager? _leases;
2728

2829
internal JobContext(Job job, ILogger logger, CredentialManager? credentials = null,
29-
Arcp.Runtime.Leases.LeaseManager? leases = null)
30+
bool fatalBudgetExhaustion = false, Arcp.Runtime.Leases.LeaseManager? leases = null)
3031
{
3132
_job = job;
3233
_credentials = credentials;
34+
_fatalBudgetExhaustion = fatalBudgetExhaustion;
3335
_leases = leases;
3436
Logger = logger;
3537
}
@@ -137,9 +139,34 @@ public ValueTask ToolResultAsync(string callId, object? result, ToolError? error
137139
Error = error,
138140
}, cancellationToken);
139141

140-
/// <summary>Metric (asynchronous).</summary>
141-
public ValueTask MetricAsync(string name, double value, string? unit = null, IReadOnlyDictionary<string, string>? dimensions = null, CancellationToken cancellationToken = default) =>
142-
_job.EmitMetricAsync(new MetricBody { Name = name, Value = value, Unit = unit, Dimensions = dimensions }, cancellationToken);
142+
/// <summary>Emit a <c>metric</c> event. If <paramref name="name"/> begins with <c>cost.</c> and
143+
/// <paramref name="unit"/> matches a budgeted currency, the budget counter is decremented
144+
/// (spec §9.6). On exhaustion the runtime surfaces a non-fatal <c>tool_result.error</c> with
145+
/// code <c>BUDGET_EXHAUSTED</c> so the agent may continue with non-cost-bearing operations
146+
/// (spec §9.6 SHOULD); set <see cref="ArcpServerOptions.FatalBudgetExhaustion"/> to opt into
147+
/// legacy fatal termination.</summary>
148+
public async ValueTask MetricAsync(string name, double value, string? unit = null, IReadOnlyDictionary<string, string>? dimensions = null, CancellationToken cancellationToken = default)
149+
{
150+
var exhaustedCurrency = await _job
151+
.EmitMetricAsync(new MetricBody { Name = name, Value = value, Unit = unit, Dimensions = dimensions }, cancellationToken)
152+
.ConfigureAwait(false);
153+
if (exhaustedCurrency is null) return;
154+
155+
var message = $"{exhaustedCurrency} budget exhausted (remaining≤0)";
156+
if (_fatalBudgetExhaustion)
157+
throw new BudgetExhaustedException(message);
158+
159+
await ToolResultAsync(
160+
callId: $"budget_{exhaustedCurrency}",
161+
result: null,
162+
error: new ToolError
163+
{
164+
Code = ErrorCode.BudgetExhausted,
165+
Message = message,
166+
Retryable = false,
167+
},
168+
cancellationToken: cancellationToken).ConfigureAwait(false);
169+
}
143170

144171
/// <summary>Artifact ref (asynchronous).</summary>
145172
public ValueTask ArtifactRefAsync(string uri, string? contentType = null, long? byteSize = null, string? sha256 = null, CancellationToken cancellationToken = default) =>

src/Arcp.Runtime/JobManager.cs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ public sealed partial class JobManager
3535
private readonly ILoggerFactory _loggers;
3636
private readonly CredentialManager? _credentials;
3737
private readonly int _idempotencyWindowSec;
38+
private readonly bool _fatalBudgetExhaustion;
3839

3940
/// <summary>Stored record for an idempotency key: original submission fingerprint plus issue time.</summary>
4041
private sealed record IdempotencyRecord(JobId JobId, string Fingerprint, DateTimeOffset CreatedAt);
@@ -46,14 +47,16 @@ public JobManager(
4647
TimeProvider time,
4748
ILoggerFactory loggers,
4849
CredentialManager? credentials = null,
49-
int idempotencyWindowSec = 3600)
50+
int idempotencyWindowSec = 3600,
51+
bool fatalBudgetExhaustion = false)
5052
{
5153
_agents = agents;
5254
_leases = leases;
5355
_time = time;
5456
_loggers = loggers;
5557
_credentials = credentials;
5658
_idempotencyWindowSec = idempotencyWindowSec > 0 ? idempotencyWindowSec : 3600;
59+
_fatalBudgetExhaustion = fatalBudgetExhaustion;
5760
}
5861

5962
/// <summary>Initializes a new <see cref="JobManager"/> without credential provisioning.</summary>
@@ -177,7 +180,7 @@ private static JobAcceptedPayload BuildAccepted(Job job)
177180
public async Task RunAsync(Job job, IAgent agent, Func<Envelope, CancellationToken, ValueTask> emit, CancellationToken cancellationToken)
178181
{
179182
job.MarkRunning();
180-
var ctx = new JobContext(job, _loggers.CreateLogger($"Arcp.Job.{job.JobId.Value}"), _credentials, _leases);
183+
var ctx = new JobContext(job, _loggers.CreateLogger($"Arcp.Job.{job.JobId.Value}"), _credentials, _fatalBudgetExhaustion, _leases);
181184

182185
// Watchdog cancellation source — cancelled in `finally` so the watchdog never outlives
183186
// the job and never emits a late lease-expired event after the terminal result.

src/Arcp.Runtime/PublicAPI.Unshipped.txt

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ Arcp.Runtime.Job.CancellationSource.get -> System.Threading.CancellationTokenSou
7070
Arcp.Runtime.Job.CancellationToken.get -> System.Threading.CancellationToken
7171
Arcp.Runtime.Job.CreatedAt.get -> System.DateTimeOffset
7272
Arcp.Runtime.Job.EmitEventAsync(string! kind, object! body, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask
73-
Arcp.Runtime.Job.EmitMetricAsync(Arcp.Core.Messages.MetricBody! body, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask
73+
Arcp.Runtime.Job.EmitMetricAsync(Arcp.Core.Messages.MetricBody! body, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask<string?>
7474
Arcp.Runtime.Job.IdempotencyKey.get -> string?
7575
Arcp.Runtime.Job.InlineResultEmitted.get -> bool
7676
Arcp.Runtime.Job.Input.get -> System.Text.Json.JsonElement?
@@ -197,10 +197,12 @@ Arcp.Runtime.Credentials.InMemoryCredentialStore.RemoveAsync(Arcp.Core.Ids.JobId
197197
Arcp.Runtime.Job.Credentials.get -> System.Collections.Generic.IReadOnlyList<Arcp.Core.Messages.ProvisionedCredential!>!
198198
Arcp.Runtime.JobContext.Credentials.get -> System.Collections.Generic.IReadOnlyList<Arcp.Core.Messages.ProvisionedCredential!>!
199199
Arcp.Runtime.JobContext.RotateCredentialAsync(string! credentialId, Arcp.Core.Messages.ProvisionedCredential! next, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
200-
Arcp.Runtime.JobManager.JobManager(Arcp.Runtime.Agents.AgentRegistry! agents, Arcp.Runtime.Leases.LeaseManager! leases, System.TimeProvider! time, Microsoft.Extensions.Logging.ILoggerFactory! loggers, Arcp.Runtime.Credentials.CredentialManager? credentials = null, int idempotencyWindowSec = 3600) -> void
200+
Arcp.Runtime.JobManager.JobManager(Arcp.Runtime.Agents.AgentRegistry! agents, Arcp.Runtime.Leases.LeaseManager! leases, System.TimeProvider! time, Microsoft.Extensions.Logging.ILoggerFactory! loggers, Arcp.Runtime.Credentials.CredentialManager? credentials = null, int idempotencyWindowSec = 3600, bool fatalBudgetExhaustion = false) -> void
201201
Arcp.Runtime.Job.LeaseExpired.get -> bool
202202
Arcp.Runtime.Job.MaxRuntimeSec.get -> int?
203203
Arcp.Runtime.Job.RuntimeLimitExceeded.get -> bool
204+
Arcp.Runtime.ArcpServerOptions.FatalBudgetExhaustion.get -> bool
205+
Arcp.Runtime.ArcpServerOptions.FatalBudgetExhaustion.init -> void
204206
Arcp.Runtime.ArcpServerOptions.IdempotencyWindowSec.get -> int
205207
Arcp.Runtime.ArcpServerOptions.IdempotencyWindowSec.init -> void
206208
Arcp.Runtime.JobManager.SubmitAsync(Arcp.Core.Messages.JobSubmitPayload! submit, Arcp.Core.Ids.SessionId sessionId, string? submitterPrincipal, System.Func<Arcp.Core.Wire.Envelope!, System.Threading.CancellationToken, System.Threading.Tasks.ValueTask>! emit, Arcp.Core.Ids.TraceId? inboundTraceId, System.Threading.CancellationToken parentCancellation, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<(Arcp.Runtime.Job! Job, Arcp.Core.Messages.JobAcceptedPayload! Accepted)>!

tests/Arcp.IntegrationTests/BudgetEnforcementTests.cs

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,63 @@ namespace Arcp.IntegrationTests;
1717
public class BudgetEnforcementTests
1818
{
1919
[Fact]
20-
public async Task Job_emitting_metric_that_exhausts_cost_budget_terminates_with_BUDGET_EXHAUSTED()
20+
public async Task Budget_exhaustion_emits_non_fatal_tool_result_error_and_agent_finishes_normally()
2121
{
22+
// Spec §9.6 SHOULD: prefer surfacing exhaustion as a `tool_result.error` so the agent
23+
// may decide whether to continue with non-cost-bearing operations.
2224
var server = new ArcpServer(new ArcpServerOptions
2325
{
2426
Runtime = new RuntimeInfo { Name = "test-runtime", Version = "1.0.0" },
2527
});
2628
server.RegisterAgent("spender", async (ctx, ct) =>
2729
{
2830
await ctx.MetricAsync("cost.inference", 1.50, unit: "USD", cancellationToken: ct);
29-
// Should not reach here — the metric exhausts USD budget.
31+
// Agent may continue with non-cost-bearing work and emit a partial result.
32+
await ctx.LogAsync("info", "exhausted; returning partial", ct);
33+
return "partial";
34+
});
35+
var (client, srv) = MemoryTransport.Pair();
36+
_ = Task.Run(() => server.AcceptAsync(srv));
37+
38+
await using var c = await ArcpClient.ConnectAsync(client, new ArcpClientOptions
39+
{
40+
Client = new ClientInfo { Name = "test", Version = "1.0" },
41+
});
42+
43+
var lease = new Lease(new Dictionary<string, IReadOnlyList<string>>
44+
{
45+
[LeaseNamespaces.CostBudget] = new[] { "USD:1.00" },
46+
});
47+
var handle = await c.SubmitAsync("spender", leaseRequest: lease);
48+
49+
// Drain events on the foreground; the channel completes once the terminal envelope arrives.
50+
var sawBudgetExhausted = false;
51+
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
52+
await foreach (var ev in handle.Events(cts.Token))
53+
{
54+
if (ev.Kind != "tool_result") continue;
55+
var body = ev.BodyAs<ToolResultBody>();
56+
if (body?.Error?.Code == ErrorCode.BudgetExhausted) sawBudgetExhausted = true;
57+
}
58+
59+
var result = await handle.Result.WaitAsync(TimeSpan.FromSeconds(3));
60+
61+
result.Success.Should().BeTrue();
62+
sawBudgetExhausted.Should().BeTrue(
63+
because: "spec §9.6 SHOULD: exhaustion surfaces as a tool_result.error so the agent can continue");
64+
}
65+
66+
[Fact]
67+
public async Task FatalBudgetExhaustion_option_restores_legacy_terminal_BUDGET_EXHAUSTED_behavior()
68+
{
69+
var server = new ArcpServer(new ArcpServerOptions
70+
{
71+
Runtime = new RuntimeInfo { Name = "test-runtime", Version = "1.0.0" },
72+
FatalBudgetExhaustion = true,
73+
});
74+
server.RegisterAgent("spender", async (ctx, ct) =>
75+
{
76+
await ctx.MetricAsync("cost.inference", 1.50, unit: "USD", cancellationToken: ct);
3077
await Task.Delay(500, ct);
3178
return "should-not-complete";
3279
});

0 commit comments

Comments
 (0)