Skip to content

Commit 3921c45

Browse files
nficanoclaude
andcommitted
feat(leases): add cost.budget capability + BUDGET_EXHAUSTED enforcement per ARCP v1.1 §9.6
Adds runtime-enforced cost budgeting on `tool.invoke` jobs. A new `cost_budget` field on `ToolInvokePayload` carries a `CostBudget` lease capability — a list of `currency:amount` strings on the wire, parsed into typed `CostBudgetAmount` entries. The runtime threads a per-job `BudgetTracker` through `ToolContext` so tool handlers can report cost via `ctx.charge(name, value, currency)`, which: - decrements the matching currency counter (rejects negative amounts), - emits a `metric` event with `name=cost.*`, `unit=currency` per §9.6, - emits a follow-up `cost.budget.remaining` metric so clients can render budget gauges without re-summing, - surfaces `ARCPError::BudgetExhausted` once the counter falls to or below zero — the runtime then emits a terminal `job.failed` with `code: BUDGET_EXHAUSTED` and `retryable: false`, matching §9.6 and §13.5's enforcement flow. Subset enforcement (§9.4) is provided by `CostBudget::subset_violation`, which takes a per-currency remaining-budget snapshot and returns the first child currency that exceeds the parent's envelope. This is the hook delegated-lease code will plug into when delegation grows a typed-lease path; the rust-sdk does not yet emit lease envelopes on `tool.invoke`, so the runtime carries the budget on the invoke payload itself rather than via a separate `lease_request`. This is a pragmatic deviation from the wire shape sketched in spec §13.5 — the in-process semantics match §9.6 exactly. The `ToolInvokePayload` change is additive; a new `ToolInvokePayload::new(tool, arguments)` constructor preserves the v1.0 ergonomics for callers that don't need budgeting. Adds tests covering: budget enforcement, BUDGET_EXHAUSTED → job.failed flow, cost.budget.remaining metric emission, unbudgeted-job pass-through, and the §9.4 subset check. Closes #36 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f2ef11e commit 3921c45

18 files changed

Lines changed: 943 additions & 66 deletions

File tree

Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,10 @@ path = "examples/job_subscribe/main.rs"
146146
name = "result_chunk"
147147
path = "examples/result_chunk/main.rs"
148148

149+
[[example]]
150+
name = "cost_budget"
151+
path = "examples/cost_budget/main.rs"
152+
149153
[lints.rust]
150154
unsafe_code = "deny"
151155
missing_docs = "deny"

examples/agent_versions/main.rs

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -93,10 +93,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
9393

9494
// 1. Pin to an existing version → should complete.
9595
{
96-
let mut invoke = Envelope::new(MessageType::ToolInvoke(ToolInvokePayload {
97-
tool: "echo@1.0.0".into(),
98-
arguments: serde_json::json!({"msg": "hello"}),
99-
}));
96+
let mut invoke = Envelope::new(MessageType::ToolInvoke(ToolInvokePayload::new(
97+
"echo@1.0.0",
98+
serde_json::json!({"msg": "hello"}),
99+
)));
100100
invoke.session_id = Some(session_id.clone());
101101
client_t.send(invoke).await?;
102102
loop {
@@ -118,10 +118,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
118118

119119
// 2. Pin to a missing version → should surface AGENT_VERSION_NOT_AVAILABLE.
120120
{
121-
let mut invoke = Envelope::new(MessageType::ToolInvoke(ToolInvokePayload {
122-
tool: "echo@9.9.9".into(),
123-
arguments: serde_json::json!({"msg": "hello"}),
124-
}));
121+
let mut invoke = Envelope::new(MessageType::ToolInvoke(ToolInvokePayload::new(
122+
"echo@9.9.9",
123+
serde_json::json!({"msg": "hello"}),
124+
)));
125125
invoke.session_id = Some(session_id);
126126
client_t.send(invoke).await?;
127127
loop {

examples/cost_budget/main.rs

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
//! ARCP v1.1 §9.6 — `cost.budget` capability + `BUDGET_EXHAUSTED`.
2+
//!
3+
//! Hosts a `web-research` agent that charges $0.30 per iteration. The
4+
//! client submits with a `cost.budget: ["USD:1.00"]` lease, so the
5+
//! fourth iteration's pre-call charge fails with `BUDGET_EXHAUSTED`
6+
//! and the runtime emits a terminal `job.failed`. Along the way the
7+
//! runtime emits `cost.search` (the agent's cost report) and
8+
//! `cost.budget.remaining` (the running counter) metric events.
9+
//!
10+
//! Run with:
11+
//! `cargo run --example cost_budget`
12+
13+
#![allow(clippy::similar_names, clippy::expect_used, clippy::print_stdout)]
14+
15+
use std::sync::Arc;
16+
use std::time::Duration;
17+
18+
use arcp::auth::BearerAuthenticator;
19+
use arcp::envelope::Envelope;
20+
use arcp::error::ARCPError;
21+
use arcp::messages::{
22+
AuthScheme, Capabilities, ClientIdentity, CostBudget, CostBudgetAmount, Credentials,
23+
MessageType, SessionOpenPayload, ToolInvokePayload,
24+
};
25+
use arcp::runtime::context::ToolContext;
26+
use arcp::runtime::tools::{ToolHandler, ToolRegistryBuilder};
27+
use arcp::runtime::ARCPRuntime;
28+
use arcp::transport::{paired, Transport};
29+
use async_trait::async_trait;
30+
31+
struct WebResearchTool;
32+
33+
#[async_trait]
34+
impl ToolHandler for WebResearchTool {
35+
fn name(&self) -> &'static str {
36+
"web-research"
37+
}
38+
39+
async fn invoke(
40+
&self,
41+
arguments: serde_json::Value,
42+
ctx: ToolContext,
43+
) -> Result<serde_json::Value, ARCPError> {
44+
let iterations = arguments
45+
.get("iterations")
46+
.and_then(serde_json::Value::as_u64)
47+
.unwrap_or(8);
48+
let per = arguments
49+
.get("perCallUSD")
50+
.and_then(serde_json::Value::as_f64)
51+
.unwrap_or(0.3);
52+
for i in 1..=iterations {
53+
println!(
54+
"[agent] iteration {i}: charging {per:.2} USD (remaining={})",
55+
ctx.budget().remaining("USD").unwrap_or(f64::INFINITY)
56+
);
57+
ctx.charge("cost.search", per, "USD").await?;
58+
}
59+
Ok(serde_json::json!({"iterations": iterations}))
60+
}
61+
}
62+
63+
#[tokio::main]
64+
async fn main() -> Result<(), Box<dyn std::error::Error>> {
65+
let runtime = ARCPRuntime::builder()
66+
.with_authenticator(Box::new(
67+
BearerAuthenticator::new().with_token("demo-token", "demo"),
68+
))
69+
.with_tools(
70+
ToolRegistryBuilder::new()
71+
.with(Arc::new(WebResearchTool))
72+
.build(),
73+
)
74+
.build()
75+
.await?;
76+
77+
let (server_t, client_t) = paired();
78+
let _h = runtime.serve_connection(server_t);
79+
80+
let mut open = Envelope::new(MessageType::SessionOpen(SessionOpenPayload {
81+
auth: Credentials {
82+
scheme: AuthScheme::Bearer,
83+
token: Some("demo-token".into()),
84+
},
85+
client: ClientIdentity {
86+
kind: "cost-budget-demo".into(),
87+
version: env!("CARGO_PKG_VERSION").into(),
88+
fingerprint: None,
89+
principal: None,
90+
},
91+
capabilities: Capabilities::default(),
92+
}));
93+
open.id = arcp::ids::MessageId::new();
94+
client_t.send(open).await?;
95+
let accepted = client_t.recv().await?.ok_or("no session.accepted")?;
96+
let MessageType::SessionAccepted(payload) = accepted.payload else {
97+
return Err("expected session.accepted".into());
98+
};
99+
let session_id = payload.session_id;
100+
println!("connected; session_id={session_id}");
101+
102+
let mut invoke = Envelope::new(MessageType::ToolInvoke(ToolInvokePayload {
103+
tool: "web-research".into(),
104+
arguments: serde_json::json!({"iterations": 8, "perCallUSD": 0.3}),
105+
cost_budget: Some(CostBudget {
106+
amounts: vec![CostBudgetAmount {
107+
currency: "USD".into(),
108+
amount: 1.0,
109+
}],
110+
}),
111+
}));
112+
invoke.session_id = Some(session_id);
113+
client_t.send(invoke).await?;
114+
115+
let deadline = std::time::Instant::now() + Duration::from_secs(5);
116+
while std::time::Instant::now() < deadline {
117+
let env = tokio::time::timeout(Duration::from_millis(500), client_t.recv())
118+
.await??
119+
.ok_or("transport closed")?;
120+
match env.payload {
121+
MessageType::JobAccepted(p) => println!("job_id={}", p.job_id),
122+
MessageType::Metric(m) => {
123+
println!("metric[{}]={:.2} {}", m.name, m.value, m.unit);
124+
}
125+
MessageType::JobFailed(p) => {
126+
println!(
127+
"job.failed code={} retryable={:?} message={:?}",
128+
p.code, p.retryable, p.message
129+
);
130+
break;
131+
}
132+
MessageType::JobCompleted(p) => {
133+
println!("job.completed value={:?}", p.value);
134+
break;
135+
}
136+
_ => {}
137+
}
138+
}
139+
140+
Ok(())
141+
}

examples/job_subscribe/main.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -92,10 +92,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
9292
println!("[A] connected as submitter");
9393

9494
// Submitter invokes a slow job.
95-
let mut invoke = Envelope::new(MessageType::ToolInvoke(ToolInvokePayload {
96-
tool: "timer".into(),
97-
arguments: serde_json::json!({}),
98-
}));
95+
let mut invoke = Envelope::new(MessageType::ToolInvoke(ToolInvokePayload::new(
96+
"timer",
97+
serde_json::json!({}),
98+
)));
9999
invoke.session_id = Some(a_session.clone());
100100
a.send(invoke).await?;
101101
let accepted_env = a.recv().await?.ok_or("no job.accepted")?;

examples/result_chunk/main.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -104,10 +104,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
104104
let session_id = payload.session_id;
105105
println!("connected; session_id={session_id}");
106106

107-
let mut invoke = Envelope::new(MessageType::ToolInvoke(ToolInvokePayload {
108-
tool: "report-builder".into(),
109-
arguments: serde_json::json!({"chunks": 5}),
110-
}));
107+
let mut invoke = Envelope::new(MessageType::ToolInvoke(ToolInvokePayload::new(
108+
"report-builder",
109+
serde_json::json!({"chunks": 5}),
110+
)));
111111
invoke.session_id = Some(session_id);
112112
client_t.send(invoke).await?;
113113

examples/session_ack/main.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -80,10 +80,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
8080

8181
// Submit one echo job. The runtime will try to emit
8282
// job.accepted -> job.started -> job.completed, all countable.
83-
let mut invoke = Envelope::new(MessageType::ToolInvoke(ToolInvokePayload {
84-
tool: "echo".into(),
85-
arguments: serde_json::json!({"hello": "world"}),
86-
}));
83+
let mut invoke = Envelope::new(MessageType::ToolInvoke(ToolInvokePayload::new(
84+
"echo",
85+
serde_json::json!({"hello": "world"}),
86+
)));
8787
invoke.session_id = Some(session_id.clone());
8888
client_t.send(invoke).await?;
8989

examples/session_list_jobs/main.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -80,10 +80,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
8080

8181
// Submit two sleep jobs.
8282
for _ in 0..2 {
83-
let mut invoke = Envelope::new(MessageType::ToolInvoke(ToolInvokePayload {
84-
tool: "sleep".into(),
85-
arguments: serde_json::json!({}),
86-
}));
83+
let mut invoke = Envelope::new(MessageType::ToolInvoke(ToolInvokePayload::new(
84+
"sleep",
85+
serde_json::json!({}),
86+
)));
8787
invoke.session_id = Some(session_id.clone());
8888
client_t.send(invoke).await?;
8989
// Drain job.accepted so the listing observes a running job

src/client/api.rs

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -354,10 +354,9 @@ impl<T: Transport + 'static> Session<Authenticated, T> {
354354
arguments: serde_json::Value,
355355
) -> Result<JobHandle, ARCPError> {
356356
let session_id = self.id().await?;
357-
let mut env = Envelope::new(MessageType::ToolInvoke(ToolInvokePayload {
358-
tool: tool.into(),
359-
arguments,
360-
}));
357+
let mut env = Envelope::new(MessageType::ToolInvoke(ToolInvokePayload::new(
358+
tool, arguments,
359+
)));
361360
env.session_id = Some(session_id);
362361
let correlation_id = env.id.clone();
363362

src/messages/execution.rs

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,12 +193,30 @@ mod agent_ref_tests {
193193
}
194194

195195
/// Payload for `tool.invoke`.
196-
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
196+
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
197197
pub struct ToolInvokePayload {
198198
/// Tool identifier.
199199
pub tool: String,
200200
/// Tool-specific arguments.
201201
pub arguments: serde_json::Value,
202+
/// `cost.budget` lease capability for this job (ARCP v1.1 §9.6).
203+
/// When present, the runtime tracks per-currency counters and
204+
/// surfaces `BUDGET_EXHAUSTED` to the agent once any counter
205+
/// reaches zero.
206+
#[serde(default, skip_serializing_if = "Option::is_none")]
207+
pub cost_budget: Option<crate::messages::permissions::CostBudget>,
208+
}
209+
210+
impl ToolInvokePayload {
211+
/// New `tool.invoke` payload with no budget.
212+
#[must_use]
213+
pub fn new(tool: impl Into<String>, arguments: serde_json::Value) -> Self {
214+
Self {
215+
tool: tool.into(),
216+
arguments,
217+
cost_budget: None,
218+
}
219+
}
202220
}
203221

204222
/// Payload for `tool.result`.

src/messages/mod.rs

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,9 @@ pub use human::{
4646
HumanInputCancelledPayload, HumanInputRequestPayload, HumanInputResponsePayload,
4747
};
4848
pub use permissions::{
49-
LeaseExtendedPayload, LeaseGrantedPayload, LeaseRefreshPayload, LeaseRevokedPayload,
50-
PermissionDenyPayload, PermissionGrantPayload, PermissionRequestPayload, TrustLevel,
49+
CostBudget, CostBudgetAmount, CostBudgetParseError, LeaseExtendedPayload, LeaseGrantedPayload,
50+
LeaseRefreshPayload, LeaseRevokedPayload, PermissionDenyPayload, PermissionGrantPayload,
51+
PermissionRequestPayload, TrustLevel,
5152
};
5253
pub use session::{
5354
AuthScheme, ClientIdentity, Credentials, JobListEntry, RuntimeIdentity, SessionAcceptedPayload,
@@ -1142,10 +1143,7 @@ mod tests {
11421143
"backpressure",
11431144
),
11441145
(
1145-
MessageType::ToolInvoke(ToolInvokePayload {
1146-
tool: "x".into(),
1147-
arguments: serde_json::json!({}),
1148-
}),
1146+
MessageType::ToolInvoke(ToolInvokePayload::new("x", serde_json::json!({}))),
11491147
"tool.invoke",
11501148
),
11511149
(

0 commit comments

Comments
 (0)