Skip to content

Commit cd6f454

Browse files
committed
tool-provider 0.7.3: send content/v1 only to routes whose consumer declares it
A tool could opt into content/v1 result bodies, but a provider had no way to tell whether the consumer reads them, so opting in would break every consumer that still reads the legacy body. A consumer that decodes content/v1 now declares it in route.open consumer_capabilities, which the daemon relays on the bind. A provider sends a content/v1 body only on such a route and the legacy body everywhere else. consumer_reads_content_v1() makes that check. The conformance case makes the same call on both kinds of route, and a fake provider that ignores the declaration fails it by name.
1 parent 8ebc492 commit cd6f454

13 files changed

Lines changed: 198 additions & 30 deletions

File tree

‎Cargo.lock‎

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎crates/cortexkit-role-tool-provider-conformance/CHANGELOG.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
# Changelog
22

3+
## 0.7.2
4+
5+
- Require cortexkit-role-tool-provider 0.7.3 and verify content/v1 results are
6+
sent only on routes whose consumer declares that format.
7+
- Exercise the same content/v1 tool call over legacy and content/v1 routes;
8+
reject content/v1 bodies sent to consumers that did not declare support.
9+
310
## 0.7.1
411

512
- Require cortexkit-role-tool-provider 0.7.1 and add the catalog-gated

‎crates/cortexkit-role-tool-provider-conformance/Cargo.toml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
# flags.
99
[package]
1010
name = "cortexkit-role-tool-provider-conformance"
11-
version = "0.7.1"
11+
version = "0.7.2"
1212
edition.workspace = true
1313
license.workspace = true
1414
repository.workspace = true
@@ -19,7 +19,7 @@ publish = true
1919
[dependencies]
2020
async-trait = "0.1"
2121
cortexkit-role-harness = { version = "0.1.0", path = "../cortexkit-role-harness" }
22-
cortexkit-role-tool-provider = { version = "0.7.1", path = "../cortexkit-role-tool-provider" }
22+
cortexkit-role-tool-provider = { version = "0.7.3", path = "../cortexkit-role-tool-provider" }
2323
futures-util = { version = "0.3", default-features = false }
2424
serde_json = "1"
2525
subc-protocol = "0.31"

‎crates/cortexkit-role-tool-provider-conformance/README.md‎

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,20 @@ unknown format strings do not opt in.
2121
`content_v1_calls()` supplies promptly completing `(tool name, arguments)`
2222
probes for every declared tool. The default is `[quick_call()]`; override it
2323
when other tools opt in, and supply multiple probes to check success and
24-
tool-level error bodies. A missing probe fails by tool name. The case uses
25-
the real route, decodes each reply as `ToolResult`, and runs `validate`;
26-
it also checks the entire serialized JSON body's size before decoding so
27-
unmodeled extensions cannot evade the 3 MiB bound. Malformed replies fail
28-
by case, tool and member, not as an opaque success.
24+
tool-level error bodies. A missing probe fails by tool name. For every probe,
25+
the case makes the same call over a route with no consumer declaration and
26+
over a route whose bind declares `content/v1`. The first reply must not decode
27+
as `ToolResult`; the second must decode and pass `validate`. The opted-in route
28+
must still list `result_format: "content/v1"` in its catalog. The case also
29+
checks the entire serialized JSON body's size before decoding so unmodeled
30+
extensions cannot evade the 3 MiB bound. Malformed replies fail by case, tool
31+
and member, not as an opaque success.
32+
33+
Subjects with a catalog tool that opts into `content/v1` must implement
34+
`route_with_consumer_capabilities()` to open the declared route through the
35+
real route-opening path. The method receives the exact consumer capability
36+
list to send when opening the route; it must not add that declaration to a
37+
call body.
2938

3039
## Per-tool call modes
3140

‎crates/cortexkit-role-tool-provider-conformance/src/report.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ pub const CASES: &[CaseSpec] = &[
109109
CaseSpec {
110110
name: "tool_result_content_v1_valid",
111111
requires: &[ContentV1],
112-
checks: "every tool declaring result_format content/v1 has probes whose replies decode and validate, including the serialized size bound",
112+
checks: "each declared content/v1 tool returns an opaque legacy body to an undeclared route and a valid content/v1 body to a route whose consumer declares support",
113113
},
114114
CaseSpec {
115115
name: "terminal_frame_on_refusal",

‎crates/cortexkit-role-tool-provider-conformance/src/runner.rs‎

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ use cortexkit_role_tool_provider::{
2222
ops, points,
2323
result::{validate, ResultFormat, ToolResult, MAX_RESULT_BYTES},
2424
withdraw::{WithdrawAnswer, TARGET_CALL_KEY_FIELD},
25-
CallMode, PROVIDES,
25+
CallMode, CONTENT_V1_CAPABILITY, PROVIDES,
2626
};
2727
use futures_util::future::join;
2828
use serde_json::{json, Value};
@@ -326,6 +326,14 @@ where
326326
self.route(&self.subject.plain_stamp()).await
327327
}
328328

329+
async fn consumer_route(&self, consumer_capabilities: &[String]) -> Result<S::Route, String> {
330+
let stamp = self.subject.plain_stamp();
331+
self.subject
332+
.route_with_consumer_capabilities(self.handle.inner(), &stamp, consumer_capabilities)
333+
.await
334+
.map_err(|e| format!("opening a route with consumer capabilities: {e}"))
335+
}
336+
329337
fn principals(&self) -> Result<ScopedPrincipals, String> {
330338
principals(self.subject)
331339
}
@@ -359,8 +367,8 @@ where
359367
}
360368

361369
async fn tool_result_content_v1_valid(&self) -> CaseResult {
362-
let route = self.plain_route().await?;
363-
let catalog = self.catalog(&route).await?;
370+
let legacy_route = self.plain_route().await?;
371+
let catalog = self.catalog(&legacy_route).await?;
364372
let calls = self.subject.content_v1_calls();
365373
let tools: Vec<_> = catalog
366374
.tools
@@ -370,7 +378,18 @@ where
370378
if tools.is_empty() {
371379
return Err("content/v1 catalog declaration disappeared during the run".into());
372380
}
381+
let capabilities = vec![CONTENT_V1_CAPABILITY.to_owned()];
382+
let content_v1_route = self.consumer_route(&capabilities).await?;
383+
let opted_in_catalog = self.catalog(&content_v1_route).await?;
373384
for tool in tools {
385+
if !opted_in_catalog.tools.iter().any(|listed| {
386+
listed.name == tool.name && listed.result_format == Some(ResultFormat::ContentV1)
387+
}) {
388+
return Err(format!(
389+
"{}: catalog did not list result_format content/v1 on the declared route",
390+
tool.name
391+
));
392+
}
374393
let probes: Vec<_> = calls
375394
.iter()
376395
.filter(|(name, _)| name == &tool.name)
@@ -382,10 +401,16 @@ where
382401
));
383402
}
384403
for (name, arguments) in probes {
385-
let body = expect_response(
386-
name,
387-
request(&route, call_body(name, arguments.clone(), None)).await?,
388-
)?;
404+
let call = call_body(name, arguments.clone(), None);
405+
let legacy_body =
406+
expect_response(name, request(&legacy_route, call.clone()).await?)?;
407+
if serde_json::from_value::<ToolResult>(legacy_body).is_ok() {
408+
return Err(format!(
409+
"{name}: route without consumer declaration returned a body decodable as content/v1"
410+
));
411+
}
412+
413+
let body = expect_response(name, request(&content_v1_route, call).await?)?;
389414
let bytes = serde_json::to_vec(&body)
390415
.map_err(|e| format!("{name}: reply serialization: {e}"))?;
391416
if bytes.len() >= MAX_RESULT_BYTES {

‎crates/cortexkit-role-tool-provider-conformance/src/subject.rs‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,21 @@ pub trait ToolProviderSubject: Harness {
135135
/// `tool.catalog` and plain calls.
136136
fn plain_stamp(&self) -> RouteStamp;
137137

138+
/// Open a real route whose bind declares the supplied consumer
139+
/// capabilities. The adapter must put this list on `route.open` exactly
140+
/// as provided; it must not infer the declaration from a call body.
141+
/// Required when the catalog declares a `content/v1` tool.
142+
async fn route_with_consumer_capabilities(
143+
&self,
144+
_handle: &Self::Handle,
145+
_stamp: &RouteStamp,
146+
_consumer_capabilities: &[String],
147+
) -> Result<Self::Route, HarnessError> {
148+
Err(HarnessError::new(
149+
"no consumer-capability route adapter supplied",
150+
))
151+
}
152+
138153
/// The identities for the scoped cases. Required by
139154
/// [`Capability::ScopeStamp`].
140155
fn scoped_principals(&self) -> Option<ScopedPrincipals>;

‎crates/cortexkit-role-tool-provider-conformance/tests/fake/mod.rs‎

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ use cortexkit_role_tool_provider_conformance::{
3939
composition_digest, schema_digest, serves_call_mode, system_text_digest,
4040
CatalogRequest, CatalogTool,
4141
},
42-
errors,
42+
consumer_reads_content_v1, errors,
4343
late_results::{
4444
check_since, kinds, reasons, AckRequest, Cursor, LateEntry, LateResultsReply,
4545
LateResultsRequest,
@@ -128,6 +128,8 @@ pub struct Defects {
128128
pub opaque_result: bool,
129129
/// The content/v1 tool replies with structured data but no content parts.
130130
pub malformed_result: bool,
131+
/// Send content/v1 even when the route's bind does not declare support.
132+
pub ignore_consumer_capabilities: bool,
131133
}
132134

133135
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -508,6 +510,7 @@ impl Module {
508510
&self,
509511
stamp: &RouteStamp,
510512
mode: &CallMode,
513+
consumer_capabilities: Option<&[String]>,
511514
body: Value,
512515
) -> Result<Vec<ObservedFrame>, RouteFailure> {
513516
if !self.alive.load(Ordering::SeqCst) {
@@ -565,7 +568,11 @@ impl Module {
565568
Ok(match name {
566569
ops::ROLE_DESCRIBE => vec![ObservedFrame::Response(Self::describe())],
567570
ops::TOOL_CATALOG => reply(self.catalog(&request.arguments)),
568-
QUICK if !self.defects.opaque_result => {
571+
QUICK
572+
if !self.defects.opaque_result
573+
&& (self.defects.ignore_consumer_capabilities
574+
|| consumer_reads_content_v1(consumer_capabilities)) =>
575+
{
569576
vec![ObservedFrame::Response(if self.defects.malformed_result {
570577
json!({"content": [], "structuredContent": {"only": "program data"}})
571578
} else {
@@ -738,13 +745,19 @@ pub struct FakeRoute {
738745
module: Arc<Module>,
739746
stamp: RouteStamp,
740747
mode: CallMode,
748+
consumer_capabilities: Option<Vec<String>>,
741749
}
742750

743751
#[async_trait]
744752
impl ToolRoute for FakeRoute {
745753
async fn request(&self, body: Value) -> Result<Exchange, RouteFailure> {
746754
Ok(Exchange {
747-
frames: self.module.handle(&self.stamp, &self.mode, body)?,
755+
frames: self.module.handle(
756+
&self.stamp,
757+
&self.mode,
758+
self.consumer_capabilities.as_deref(),
759+
body,
760+
)?,
748761
})
749762
}
750763

@@ -846,6 +859,7 @@ impl Harness for FakeSubject {
846859
module: handle.0.clone(),
847860
stamp: stamp.clone(),
848861
mode: CallMode::Classic,
862+
consumer_capabilities: None,
849863
})
850864
}
851865

@@ -932,6 +946,21 @@ impl ToolProviderSubject for FakeSubject {
932946
module: handle.0.clone(),
933947
stamp: stamp.clone(),
934948
mode: mode.clone(),
949+
consumer_capabilities: None,
950+
})
951+
}
952+
953+
async fn route_with_consumer_capabilities(
954+
&self,
955+
handle: &FakeHandle,
956+
stamp: &RouteStamp,
957+
consumer_capabilities: &[String],
958+
) -> Result<FakeRoute, HarnessError> {
959+
Ok(FakeRoute {
960+
module: handle.0.clone(),
961+
stamp: stamp.clone(),
962+
mode: CallMode::Classic,
963+
consumer_capabilities: Some(consumer_capabilities.to_vec()),
935964
})
936965
}
937966

‎crates/cortexkit-role-tool-provider-conformance/tests/runner.rs‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,18 @@ async fn malformed_content_v1_reply_fails_by_case_tool_and_member() {
134134
assert_passed(&report, "terminal_frame_on_success");
135135
}
136136

137+
#[tokio::test]
138+
async fn content_v1_is_sent_only_to_a_route_that_declares_support() {
139+
let report = run(&FakeSubject::new(Defects {
140+
ignore_consumer_capabilities: true,
141+
..Defects::default()
142+
}))
143+
.await;
144+
let reason = failed(&report, "tool_result_content_v1_valid");
145+
assert!(reason.contains("without consumer declaration"), "{reason}");
146+
assert_passed(&report, "terminal_frame_on_success");
147+
}
148+
137149
#[tokio::test]
138150
async fn opaque_tools_skip_content_v1_by_name_never_pass() {
139151
let mut subject = FakeSubject::new(Defects {

‎crates/cortexkit-role-tool-provider/CHANGELOG.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
# Changelog
22

3+
## 0.7.3
4+
5+
- Add a route-bind capability constant and helper for detecting consumers that
6+
declare support for `content/v1` result bodies.
7+
- Require providers to choose a tool's `content/v1` or legacy result body from
8+
the route bind, and document the consumer's matching declaration.
9+
310
## 0.7.2
411

512
- Define three more unprefixed capability tags, so a consumer can ask for

0 commit comments

Comments
 (0)