From 471113fbcffcbdf29bda013d56943737e91eaaf5 Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 9 Oct 2026 19:17:05 +0900 Subject: [PATCH] fix: inspect only the requested WIP entity --- crates/worker/src/checkout_http_tests.rs | 5 +- crates/worker/src/checkout_wip_tests.rs | 14 +- .../workspace_config/production_tests.rs | 16 +- crates/worker/src/wip.rs | 137 ++++++-- crates/worker/src/wip/ancestor_tests.rs | 28 +- crates/worker/src/wip/binding.rs | 320 +++++++++++++----- .../worker/src/wip/binding/inspect_target.rs | 151 +++++++++ crates/worker/src/wip/provider_tests.rs | 8 +- .../src/server_workspace_config_wip_tests.rs | 39 ++- docs/design/worker-wip-mode.md | 15 +- 10 files changed, 588 insertions(+), 145 deletions(-) create mode 100644 crates/worker/src/wip/binding/inspect_target.rs diff --git a/crates/worker/src/checkout_http_tests.rs b/crates/worker/src/checkout_http_tests.rs index b69c99d5b..e74e054f6 100644 --- a/crates/worker/src/checkout_http_tests.rs +++ b/crates/worker/src/checkout_http_tests.rs @@ -337,7 +337,10 @@ async fn observe_interface(runtime: &WipRuntime, path: &str) -> wip_protocol::In .interfaces[0] .clone() }; - runtime.inspect(path.into(), true).await.unwrap(); + runtime + .prepare_call_observations(path.into(), true) + .await + .unwrap(); interface } diff --git a/crates/worker/src/checkout_wip_tests.rs b/crates/worker/src/checkout_wip_tests.rs index 89972eefe..c4ca3698f 100644 --- a/crates/worker/src/checkout_wip_tests.rs +++ b/crates/worker/src/checkout_wip_tests.rs @@ -36,7 +36,9 @@ fn runtime( async fn interface(r: &WipRuntime, path: &str) -> wip_protocol::InterfaceReference { r.tree(path.into(), 0, true).await.unwrap(); let p = r.host.projection_live(path).await.unwrap().unwrap(); - r.inspect(path.into(), true).await.unwrap(); + r.prepare_call_observations(path.into(), true) + .await + .unwrap(); p.interface } async fn call( @@ -764,8 +766,14 @@ async fn checkout_model_list_glob_grep_entries_are_lazy_and_never_become_tree_ed entrance["object_signature"], wip_text_view::render_object(&projection.object).unwrap() ); + assert!(entrance["interfaces"][0].get("signature").is_none()); + let definition = model_inspect( + r.clone(), + entrance["interfaces"][0]["path"].as_str().unwrap(), + ) + .await; assert_eq!( - entrance["interfaces"][0]["signature"], + definition["interface_signature"], wip_text_view::render_interface(&wip_text_view::Interface { reference: &projection.interface, descriptor: &projection.descriptor @@ -773,7 +781,7 @@ async fn checkout_model_list_glob_grep_entries_are_lazy_and_never_become_tree_ed .unwrap() ); assert!( - entrance["interfaces"][0]["signature"] + definition["interface_signature"] .as_str() .unwrap() .contains("entry") diff --git a/crates/worker/src/feature/builtin/workspace_config/production_tests.rs b/crates/worker/src/feature/builtin/workspace_config/production_tests.rs index 103db15c7..3473d176d 100644 --- a/crates/worker/src/feature/builtin/workspace_config/production_tests.rs +++ b/crates/worker/src/feature/builtin/workspace_config/production_tests.rs @@ -440,7 +440,10 @@ fn interface(path: &str, attached: bool) -> wip_protocol::InterfaceReference { } async fn observe_interface(runtime: &WipRuntime, path: &str, _attached: bool) { runtime.tree(path.into(), 0, true).await.unwrap(); - runtime.inspect(path.into(), true).await.unwrap(); + runtime + .prepare_call_observations(path.into(), true) + .await + .unwrap(); } async fn call( runtime: &WipRuntime, @@ -630,13 +633,20 @@ async fn workspace_config_production_revocation_ro_cross_workspace_and_cache_iso ); observe_interface(&runtime, path, true).await; let descriptor = runtime - .inspect(path.into(), true) + .inspect( + format!( + "{}::{}", + serde_json::to_string(path).unwrap(), + serde_json::to_string(&interface(path, true).name).unwrap() + ), + true, + ) .await .unwrap() .content .unwrap(); let descriptor: Json = serde_json::from_str(&descriptor).unwrap(); - let signature = descriptor["interfaces"][0]["signature"].as_str().unwrap(); + let signature = descriptor["interface_signature"].as_str().unwrap(); assert!(signature.contains("operation read(")); assert!(!signature.contains("operation write(")); assert!( diff --git a/crates/worker/src/wip.rs b/crates/worker/src/wip.rs index 3c7145ad6..af33f29bc 100644 --- a/crates/worker/src/wip.rs +++ b/crates/worker/src/wip.rs @@ -2884,6 +2884,25 @@ pub(crate) fn wip_to_json(value: &Value) -> Result { } } +// Low-level call() tests explicitly acquire both observation kinds. Unlike +// Invoke, call() deliberately does not perform automatic retrieval. +#[cfg(test)] +impl WipRuntime { + pub(crate) async fn prepare_call_observations( + &self, + path: String, + refresh: bool, + ) -> Result { + let output = self.inspect(path, refresh).await?; + let value: Json = serde_json::from_str(output.content.as_deref().unwrap()).unwrap(); + for interface in value["interfaces"].as_array().unwrap() { + self.inspect(interface["path"].as_str().unwrap().into(), refresh) + .await?; + } + Ok(output) + } +} + #[cfg(test)] mod tests { use super::*; @@ -3092,8 +3111,19 @@ mod tests { assert_eq!(inspected["path"], "/tools/Echo"); let interface = &inspected["interfaces"][0]["reference"]; assert_eq!(interface, &json!({"scope":"/","name":"yoi.tool/Echo/v1"})); + assert!(inspected["interfaces"][0].get("signature").is_none()); + let definition = transport_output_json( + tools + .call_tool( + "Inspect", + &json!({"path": inspected["interfaces"][0]["path"]}).to_string(), + ToolExecutionContext::direct(), + ) + .await + .unwrap(), + ); assert!( - inspected["interfaces"][0]["signature"] + definition["interface_signature"] .as_str() .unwrap() .contains("operation call(") @@ -3197,7 +3227,10 @@ mod tests { ) .unwrap(); runtime.tree("/assets/A-1".into(), 0, true).await.unwrap(); - runtime.inspect("/assets/A-1".into(), true).await.unwrap(); + runtime + .prepare_call_observations("/assets/A-1".into(), true) + .await + .unwrap(); let error = runtime .call( "/assets/A-1".into(), @@ -3569,7 +3602,7 @@ mod tests { .await .unwrap(); old_runtime - .inspect(old_projection.route.clone(), false) + .prepare_call_observations(old_projection.route.clone(), false) .await .unwrap(); let restored_host = WipHost::new(asset_registry( @@ -3609,7 +3642,7 @@ mod tests { assert_eq!(calls.load(Ordering::SeqCst), 0); restored.tree("/assets/A-1".into(), 0, false).await.unwrap(); restored - .inspect(current.route.clone(), false) + .prepare_call_observations(current.route.clone(), false) .await .unwrap(); restored @@ -4165,7 +4198,10 @@ mod tests { .unwrap(); runtime.tree("/assets/A-1".into(), 0, false).await.unwrap(); let interface = runtime.host.projection("/assets/A-1").unwrap().interface; - runtime.inspect("/assets/A-1".into(), false).await.unwrap(); + runtime + .prepare_call_observations("/assets/A-1".into(), false) + .await + .unwrap(); contributor.permitted.store(false, Ordering::SeqCst); // The Client still knows the old descriptor; the Host must independently // check its current filtered descriptor rather than trust Known Space. @@ -4194,7 +4230,10 @@ mod tests { ); assert_eq!(owner.calls.load(Ordering::SeqCst), 0); assert_eq!(contributor.calls.load(Ordering::SeqCst), 0); - runtime.inspect("/assets/A-1".into(), true).await.unwrap(); + runtime + .prepare_call_observations("/assets/A-1".into(), true) + .await + .unwrap(); runtime .call( "/assets/A-1".into(), @@ -4208,7 +4247,7 @@ mod tests { owner.visible.store(false, Ordering::SeqCst); assert!(runtime.host.fetch_interface(&interface).is_err()); let error = runtime - .inspect("/assets/A-1".into(), true) + .prepare_call_observations("/assets/A-1".into(), true) .await .unwrap_err(); assert!(error.to_string().contains("NotFound")); @@ -4370,7 +4409,10 @@ mod tests { } async fn observed_contextual_interface(runtime: &WipRuntime, path: &str) -> InterfaceReference { - runtime.inspect(path.into(), true).await.unwrap(); + runtime + .prepare_call_observations(path.into(), true) + .await + .unwrap(); let state = runtime.state.lock().unwrap(); state .client @@ -5418,11 +5460,20 @@ mod tests { WipRuntime::new(WipHost::new(registry), SecurityContext::new("worker-a"), 0).unwrap(); runtime.tree("/".into(), 2, false).await.unwrap(); let inspected = runtime - .inspect("/native/typed".into(), false) + .prepare_call_observations("/native/typed".into(), false) .await .unwrap(); let inspected: Json = serde_json::from_str(inspected.content.as_deref().unwrap()).unwrap(); - let signature = inspected["interfaces"][0]["signature"].as_str().unwrap(); + let definition = runtime + .inspect( + inspected["interfaces"][0]["path"].as_str().unwrap().into(), + false, + ) + .await + .unwrap(); + let definition: Json = + serde_json::from_str(definition.content.as_deref().unwrap()).unwrap(); + let signature = definition["interface_signature"].as_str().unwrap(); assert!(signature.contains("type Payload")); assert!(signature.contains("red tag")); assert!(signature.contains("tags?: [enum")); @@ -5476,7 +5527,10 @@ mod tests { let runtime = WipRuntime::new(host, SecurityContext::new("worker-a"), 4096).unwrap(); runtime.tree("/".into(), 2, false).await.unwrap(); - runtime.inspect("/tools/Echo".into(), false).await.unwrap(); + runtime + .prepare_call_observations("/tools/Echo".into(), false) + .await + .unwrap(); let output = runtime .call( "/tools/Echo".into(), @@ -5514,7 +5568,10 @@ mod tests { let host = WipHost::new(registry_with_tool("Echo", Arc::clone(&calls), None)); let runtime = WipRuntime::new(host, SecurityContext::new("worker-a"), 0).unwrap(); runtime.tree("/".into(), 2, false).await.unwrap(); - runtime.inspect("/tools/Echo".into(), false).await.unwrap(); + runtime + .prepare_call_observations("/tools/Echo".into(), false) + .await + .unwrap(); let projection = runtime.host.projection("/tools/Echo").unwrap(); let mut request = CallOperationRequest { @@ -5963,7 +6020,10 @@ mod tests { assert!(!discovered.contains("/features")); assert!(!discovered.contains("/tools/QueryTicket")); assert!(!discovered.contains("/tools/TicketCreate")); - authoring.inspect("/tickets".into(), false).await.unwrap(); + authoring + .prepare_call_observations("/tickets".into(), false) + .await + .unwrap(); let collection = "/tickets"; authoring .call( @@ -5988,7 +6048,10 @@ mod tests { assert!(created.content.unwrap().contains("/tickets/T-9")); let item = "/tickets/T-9"; authoring.tree(item.into(), 0, false).await.unwrap(); - authoring.inspect(item.into(), false).await.unwrap(); + authoring + .prepare_call_observations(item.into(), false) + .await + .unwrap(); authoring .call( item.into(), @@ -6059,7 +6122,10 @@ mod tests { ) .unwrap(); workflow.tree(item.into(), 0, false).await.unwrap(); - workflow.inspect(item.into(), false).await.unwrap(); + workflow + .prepare_call_observations(item.into(), false) + .await + .unwrap(); let result = workflow .call( item.into(), @@ -6163,7 +6229,7 @@ mod tests { assert!(!discovered.contains("/tools/OpenMergeRequest")); assert!(!discovered.contains("/tools/ShowMergeRequest")); coder - .inspect("/merge-requests".into(), false) + .prepare_call_observations("/merge-requests".into(), false) .await .unwrap(); let collection = "/merge-requests"; @@ -6190,7 +6256,10 @@ mod tests { ); let item = format!("{collection}/MR-1"); coder.tree(item.clone(), 0, false).await.unwrap(); - coder.inspect(item.clone(), false).await.unwrap(); + coder + .prepare_call_observations(item.clone(), false) + .await + .unwrap(); coder .call( item.clone(), @@ -6277,7 +6346,10 @@ mod tests { ) .unwrap(); reviewer.tree(item.clone(), 0, false).await.unwrap(); - reviewer.inspect(item.clone(), false).await.unwrap(); + reviewer + .prepare_call_observations(item.clone(), false) + .await + .unwrap(); reviewer .call( item.clone(), @@ -6352,7 +6424,10 @@ mod tests { ) .unwrap(); orchestrator.tree(item.clone(), 0, false).await.unwrap(); - orchestrator.inspect(item.clone(), false).await.unwrap(); + orchestrator + .prepare_call_observations(item.clone(), false) + .await + .unwrap(); orchestrator .call( item.clone(), @@ -6386,7 +6461,7 @@ mod tests { .await .unwrap(); orchestrator - .inspect("/merge-requests".into(), false) + .prepare_call_observations("/merge-requests".into(), false) .await .unwrap(); let rejected = orchestrator @@ -6491,7 +6566,10 @@ mod tests { let discovered = discovered.content.unwrap(); assert!(discovered.contains("/objectives")); assert!(!discovered.contains("/tools/QueryObjective")); - runtime.inspect("/objectives".into(), false).await.unwrap(); + runtime + .prepare_call_observations("/objectives".into(), false) + .await + .unwrap(); let collection_path = "/objectives"; runtime .call( @@ -6529,7 +6607,10 @@ mod tests { let item_path = "/objectives/O-3"; runtime.tree(item_path.into(), 0, false).await.unwrap(); - runtime.inspect(item_path.into(), false).await.unwrap(); + runtime + .prepare_call_observations(item_path.into(), false) + .await + .unwrap(); for (operation, arguments) in [ ("read", json!({})), ("edit", json!({"title": "Changed"})), @@ -6590,7 +6671,7 @@ mod tests { WipRuntime::new(WipHost::new(registry), SecurityContext::new("worker-a"), 0).unwrap(); runtime.tree("/".into(), 2, false).await.unwrap(); runtime - .inspect(format!("/tools/{name}"), false) + .prepare_call_observations(format!("/tools/{name}"), false) .await .unwrap(); runtime @@ -6754,7 +6835,10 @@ mod tests { let runtime = WipRuntime::new(WipHost::new(registry), SecurityContext::new("worker-a"), 0).unwrap(); runtime.tree("/assets/A-1".into(), 0, false).await.unwrap(); - runtime.inspect("/assets/A-1".into(), false).await.unwrap(); + runtime + .prepare_call_observations("/assets/A-1".into(), false) + .await + .unwrap(); let execution = ToolExecutionContext::direct(); let call = runtime.call( "/assets/A-1".into(), @@ -6893,7 +6977,10 @@ mod tests { )); let runtime = WipRuntime::new(host, SecurityContext::new("worker-a"), 0).unwrap(); runtime.tree("/".into(), 2, false).await.unwrap(); - runtime.inspect("/tools/Echo".into(), false).await.unwrap(); + runtime + .prepare_call_observations("/tools/Echo".into(), false) + .await + .unwrap(); let error = runtime .call( "/tools/Echo".into(), diff --git a/crates/worker/src/wip/ancestor_tests.rs b/crates/worker/src/wip/ancestor_tests.rs index d645a3436..a61d53301 100644 --- a/crates/worker/src/wip/ancestor_tests.rs +++ b/crates/worker/src/wip/ancestor_tests.rs @@ -160,7 +160,7 @@ async fn live_root_and_intermediate_ancestors_use_one_current_publication_not_pl let fetched = runtime.host.fetch_interface_live(&selected).await.unwrap(); assert_eq!(fetched.scope_ref, with_ref.then(|| "live-scope".into())); runtime - .inspect(provider.target_path.clone(), false) + .prepare_call_observations(provider.target_path.clone(), false) .await .unwrap(); provider.state.lock().unwrap().lookups.clear(); @@ -217,7 +217,7 @@ async fn deletion_without_prior_scope_inspect_rejects_cached_invoke_and_orphan_o let (runtime, provider) = fixture(scope, with_ref, false); let selected = reference(scope, "read"); runtime - .inspect(provider.target_path.clone(), false) + .prepare_call_observations(provider.target_path.clone(), false) .await .unwrap(); provider.state.lock().unwrap().visible = false; @@ -235,7 +235,7 @@ async fn deletion_without_prior_scope_inspect_rejects_cached_invoke_and_orphan_o assert_eq!(provider.calls.load(Ordering::SeqCst), 0); assert!( runtime - .inspect(provider.target_path.clone(), true) + .prepare_call_observations(provider.target_path.clone(), true) .await .is_err() ); @@ -267,7 +267,7 @@ async fn republishing_scope_never_restores_old_registration_even_without_refs() let (runtime, provider) = fixture("/github", with_ref, false); let old = reference("/github", "read"); runtime - .inspect(provider.target_path.clone(), false) + .prepare_call_observations(provider.target_path.clone(), false) .await .unwrap(); provider.state.lock().unwrap().visible = false; @@ -311,7 +311,7 @@ async fn republishing_scope_never_restores_old_registration_even_without_refs() ); assert_eq!(provider.calls.load(Ordering::SeqCst), 0); runtime - .inspect(provider.target_path.clone(), true) + .prepare_call_observations(provider.target_path.clone(), true) .await .unwrap(); let new = reference("/github", "new"); @@ -401,7 +401,7 @@ async fn ancestor_replacement_during_in_flight_call_keeps_the_original_descripto let (runtime, provider) = fixture("/github/team", true, true); let selected = reference("/github/team", "read"); runtime - .inspect(provider.target_path.clone(), false) + .prepare_call_observations(provider.target_path.clone(), false) .await .unwrap(); let pending = runtime.invoke( @@ -419,10 +419,18 @@ async fn ancestor_replacement_during_in_flight_call_keeps_the_original_descripto state.generation = 1; } let updated = runtime - .inspect(provider.target_path.clone(), true) + .prepare_call_observations(provider.target_path.clone(), true) .await .unwrap(); - assert!(updated.content.unwrap().contains("integer")); + let updated: Json = serde_json::from_str(updated.content.as_deref().unwrap()).unwrap(); + let definition = runtime + .inspect( + updated["interfaces"][0]["path"].as_str().unwrap().into(), + false, + ) + .await + .unwrap(); + assert!(definition.content.unwrap().contains("integer")); provider.release.notify_one(); }; let (old, ()) = tokio::time::timeout(std::time::Duration::from_secs(5), async { @@ -464,7 +472,7 @@ async fn same_name_scope_replacement_or_loss_of_ref_rejects_old_metadata_without let (runtime, provider) = fixture("/github/team", true, false); let selected = reference("/github/team", "read"); runtime - .inspect(provider.target_path.clone(), false) + .prepare_call_observations(provider.target_path.clone(), false) .await .unwrap(); { @@ -510,7 +518,7 @@ async fn same_name_scope_replacement_or_loss_of_ref_rejects_old_metadata_without } ); runtime - .inspect(provider.target_path.clone(), true) + .prepare_call_observations(provider.target_path.clone(), true) .await .unwrap(); runtime diff --git a/crates/worker/src/wip/binding.rs b/crates/worker/src/wip/binding.rs index 9f20bca3e..917238fa9 100644 --- a/crates/worker/src/wip/binding.rs +++ b/crates/worker/src/wip/binding.rs @@ -1,4 +1,4 @@ -//! Object-centered AI binding. Retrieval, display and invocation are separate: +//! Target-specific AI binding. Retrieval, display and invocation are separate: //! signatures are untrusted display data, never parsed back into call targets. use super::*; @@ -6,6 +6,8 @@ use wip_client::{InterfaceObservation, PreparedRequest}; use wip_protocol::InterfaceReference; use wip_text_view::{Interface, render_interface, render_object}; +mod inspect_target; + const MAX_TREE_DEPTH: u32 = 8; const MAX_TREE_NODES: usize = 2048; @@ -293,79 +295,66 @@ impl WipRuntime { ) } - /// Direct Object acquisition plus every published Interface in Host order. - /// Path context is a JSON string outside the upstream path-free signatures. + /// Inspect exactly one Object or explicitly addressed Interface. + /// Compact references are decoded only at this input boundary, never from signatures. pub async fn inspect(&self, path: String, refresh: bool) -> Result { - self.binding_observe(&path, 0, refresh, false).await?; - let object_observation = { - let state = self.state.lock().unwrap_or_else(|error| error.into_inner()); - fresh_object(&state, &path).map_err(|issue| issue.error("inspect object"))? - }; - let object = object_observation - .object - .as_ref() - .expect("fresh_object requires an Object"); - let object_signature = render_object(object) - .map_err(|error| binding_render_error(json!({"path": path}), error))?; - let mut bytes = object_signature.len(); - let mut observations = Vec::new(); - let mut interfaces = Vec::new(); - for reference in &object.interfaces { - let observation = self.binding_interface(reference, refresh, false).await?; - let signature = render_interface(&Interface { - reference, - descriptor: observation - .descriptor - .as_ref() - .expect("fresh_interface requires a descriptor"), - }) - .map_err(|error| { - binding_render_error( - json!({"path": path, "reference": reference_json(reference)}), - error, + match inspect_target::parse(&path)? { + inspect_target::Target::Object(object_path) => { + self.binding_observe(&object_path, 0, refresh, false) + .await?; + let observation = { + let state = self.state.lock().unwrap_or_else(|error| error.into_inner()); + fresh_object(&state, &object_path) + .map_err(|issue| issue.error("inspect object"))? + }; + let object = observation.object.as_ref().expect("fresh Object"); + let signature = render_object(object) + .map_err(|error| binding_render_error(json!({"path": path}), error))?; + let interfaces: Vec<_> = object + .interfaces + .iter() + .map(|reference| { + json!({ + "reference": reference_json(reference), + "path": inspect_target::interface_path(reference), + }) + }) + .collect(); + self.binding_output( + "Inspected WIP Object".into(), + json!({ + "path": object_path, + "object_signature": signature, + "interfaces": interfaces, + "metrics": metrics_json(self.metrics.snapshot()), + }), ) - })?; - bytes = bytes.saturating_add(signature.len()); - if bytes > self.wire_limits.max_response_bytes() { - return Err(ToolError::ExecutionFailed( - "complete Inspect signatures exceed the binding response limit".into(), - )); } - interfaces - .push(json!({"reference": reference_json(reference), "signature": signature})); - observations.push(observation); - } - // Later interface completions may invalidate earlier scope bindings or - // evict data. Never return those earlier snapshots as a coherent Inspect. - { - let state = self.state.lock().unwrap_or_else(|error| error.into_inner()); - if fresh_object(&state, &path).map_err(|issue| issue.error("inspect final object"))? - != object_observation - { - return Err(ToolError::ExecutionFailed( - "WIP Inspect target changed during acquisition; refresh".into(), - )); - } - for observation in &observations { - if fresh_interface(&state, &observation.reference) - .map_err(|issue| issue.error("inspect final interface"))? - != *observation - { - return Err(ToolError::ExecutionFailed( - "WIP Inspect interface changed during acquisition; refresh".into(), - )); - } + inspect_target::Target::Interface(reference) => { + let observation = self.binding_interface(&reference, refresh, false).await?; + let signature = render_interface(&Interface { + reference: &reference, + descriptor: observation.descriptor.as_ref().expect("fresh Interface"), + }) + .map_err(|error| { + binding_render_error( + json!({ + "path": path, "reference": reference_json(&reference), + }), + error, + ) + })?; + self.binding_output( + "Inspected WIP Interface".into(), + json!({ + "path": inspect_target::interface_path(&reference), + "reference": reference_json(&reference), + "interface_signature": signature, + "metrics": metrics_json(self.metrics.snapshot()), + }), + ) } } - self.binding_output( - "Inspected WIP Object and all published Interfaces".into(), - json!({ - "path": path, - "object_signature": object_signature, - "interfaces": interfaces, - "metrics": metrics_json(self.metrics.snapshot()), - }), - ) } /// Recover only observations before dispatch. The parent call owns argument @@ -468,9 +457,11 @@ fn binding_one() -> u32 { #[derive(Debug, Deserialize, schemars::JsonSchema)] #[serde(deny_unknown_fields)] struct WipInspectInput { - /// Canonical Object path; may be supplied directly without a prior Tree. + /// Object path, or absolute Interface address scope::name (e.g. /::example). + /// JSON-quote special reference components; quote an entire Object path containing ::. + /// No prior Tree or Inspect is required; only the selected entity is displayed. path: String, - /// Explicitly refresh the Object and every published Interface. + /// Explicitly refresh only the selected Object or Interface. #[serde(default)] refresh: bool, } @@ -844,7 +835,7 @@ mod tests { } #[tokio::test] - async fn direct_inspect_acquires_object_and_full_signatures_from_empty_observations() { + async fn direct_inspect_only_acquires_and_displays_the_selected_entity() { let fixture = fixture("/tools/item", reference("/", "fixture")); let output = content( fixture @@ -860,7 +851,26 @@ mod tests { .unwrap() .contains("Object description") ); - let signature = output["interfaces"][0]["signature"].as_str().unwrap(); + assert!(output["interfaces"][0].get("signature").is_none()); + assert_eq!(fixture.runtime.metrics().inspect_round_trips, 0); + assert_eq!(output["interfaces"][0]["path"], "/::fixture"); + let definition = content( + fixture + .runtime + .inspect( + output["interfaces"][0]["path"].as_str().unwrap().into(), + false, + ) + .await + .unwrap(), + ); + assert!(definition.get("object_signature").is_none()); + assert!(definition.get("interfaces").is_none()); + assert_eq!( + definition["reference"], + reference_json(&reference("/", "fixture")) + ); + let signature = definition["interface_signature"].as_str().unwrap(); let expected = render_interface(&Interface { reference: &reference("/", "fixture"), descriptor: &descriptor(), @@ -879,9 +889,109 @@ mod tests { .unwrap(); assert_eq!(fixture.runtime.metrics().discover_round_trips, 1); assert_eq!(fixture.runtime.metrics().inspect_round_trips, 1); + fixture + .runtime + .inspect("/::fixture".into(), false) + .await + .unwrap(); + assert_eq!(fixture.runtime.metrics().inspect_round_trips, 1); + fixture + .runtime + .inspect("/tools/item".into(), true) + .await + .unwrap(); + assert_eq!(fixture.runtime.metrics().inspect_round_trips, 1); assert_eq!(fixture.calls.load(Ordering::SeqCst), 0); } + #[tokio::test] + async fn interface_inspect_from_empty_cache_never_observes_objects_and_refresh_is_scoped() { + let fixture = fixture("/tools/item", reference("/", "fixture")); + let tools = model_tools(Arc::clone(&fixture.runtime)); + let definition = content( + model_execute(&tools, "Inspect", json!({"path": "/::fixture"})) + .await + .unwrap(), + ); + assert!( + definition["interface_signature"] + .as_str() + .unwrap() + .contains("read(") + ); + assert!(definition.get("object_signature").is_none()); + assert!(definition.get("interfaces").is_none()); + assert_eq!(fixture.runtime.metrics().discover_round_trips, 0); + assert_eq!(fixture.runtime.metrics().inspect_round_trips, 1); + model_execute( + &tools, + "Inspect", + json!({"path": "/::fixture", "refresh": true}), + ) + .await + .unwrap(); + assert_eq!(fixture.runtime.metrics().discover_round_trips, 0); + assert_eq!(fixture.runtime.metrics().inspect_round_trips, 2); + assert!( + model_execute(&tools, "Inspect", json!({"path": "/::missing"})) + .await + .is_err() + ); + assert_eq!(fixture.runtime.metrics().discover_round_trips, 0); + assert_eq!(fixture.calls.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn failed_interface_does_not_block_or_get_refreshed_by_object_inspect() { + let selected = reference("/", "fixture"); + let fixture = fixture("/tools/item", selected.clone()); + fail_interface(&fixture.runtime, selected); + for refresh in [false, true] { + let object = content( + fixture + .runtime + .inspect("/tools/item".into(), refresh) + .await + .unwrap(), + ); + assert!(object.get("object_signature").is_some()); + assert!(object["interfaces"][0].get("signature").is_none()); + } + assert_eq!(fixture.runtime.metrics().inspect_round_trips, 0); + assert!( + fixture + .runtime + .inspect("/::fixture".into(), false) + .await + .is_err() + ); + fixture + .runtime + .inspect("/::fixture".into(), true) + .await + .unwrap(); + assert_eq!(fixture.runtime.metrics().inspect_round_trips, 1); + } + + #[tokio::test] + async fn invalid_inspect_addresses_fail_before_any_host_retrieval() { + let fixture = fixture("/tools/item", reference("/", "fixture")); + for path in [ + "relative::fixture", + "/::", + "/::fixture#read", + "/::\"broken", + "/::same::name", + ] { + assert!(matches!( + fixture.runtime.inspect(path.into(), false).await, + Err(ToolError::InvalidArgument(_)) + )); + } + assert_eq!(fixture.runtime.metrics().discover_round_trips, 0); + assert_eq!(fixture.runtime.metrics().inspect_round_trips, 0); + } + #[tokio::test] async fn inspect_path_context_is_json_escaped_and_not_inserted_into_signatures() { let path = "/tools/item\"\n} injected"; @@ -951,8 +1061,14 @@ mod tests { assert_eq!(interfaces[0]["reference"], reference_json(&global)); assert_eq!(interfaces[1]["reference"], reference_json(&local)); for (shown, reference) in interfaces.iter().zip([global, local]) { + assert!(shown.get("signature").is_none()); + let definition = content( + model_execute(&tools, "Inspect", json!({"path": shown["path"]})) + .await + .unwrap(), + ); assert_eq!( - shown["signature"], + definition["interface_signature"], render_interface(&Interface { reference: &reference, descriptor: &descriptor() @@ -1144,14 +1260,14 @@ mod tests { assert!( fixture .runtime - .inspect("/tools/item".into(), false) + .inspect("/::fixture".into(), false) .await .is_err() ); assert_eq!(fixture.runtime.metrics().inspect_round_trips, 0); fixture .runtime - .inspect("/tools/item".into(), true) + .inspect("/::fixture".into(), true) .await .unwrap(); assert_eq!(fixture.runtime.metrics().inspect_round_trips, 1); @@ -1201,14 +1317,14 @@ mod tests { assert!( fixture .runtime - .inspect("/tools/item".into(), false) + .inspect("/::fixture".into(), false) .await .is_err() ); assert_eq!(fixture.runtime.metrics().inspect_round_trips, 0); fixture .runtime - .inspect("/tools/item".into(), true) + .inspect("/::fixture".into(), true) .await .unwrap(); assert_eq!(fixture.runtime.metrics().inspect_round_trips, 1); @@ -1240,7 +1356,7 @@ mod tests { let fixture = fixture("/tools/item", selected.clone()); fixture .runtime - .inspect("/tools/item".into(), false) + .inspect("/tools/item::fixture".into(), false) .await .unwrap(); // A new scope identity retires the previously acquired descriptor. @@ -1256,13 +1372,13 @@ mod tests { assert!( fixture .runtime - .inspect("/tools/item".into(), false) + .inspect("/tools/item::fixture".into(), false) .await .is_err() ); fixture .runtime - .inspect("/tools/item".into(), true) + .inspect("/tools/item::fixture".into(), true) .await .unwrap(); } @@ -1411,11 +1527,20 @@ mod tests { children: None, }, ); - assert!( + let object = content( fixture .runtime .inspect("/tools/item".into(), false) .await + .unwrap(), + ); + assert_eq!(fixture.runtime.metrics().inspect_round_trips, 0); + assert_eq!(object["interfaces"][0]["path"], "/::missing"); + assert!( + fixture + .runtime + .inspect("/::missing".into(), false) + .await .is_err() ); assert_eq!(fixture.runtime.metrics().inspect_round_trips, 1); @@ -1494,7 +1619,7 @@ mod tests { } #[tokio::test] - async fn inspect_fetches_all_missing_interfaces_without_flattening_operations() { + async fn object_inspect_lists_references_without_fetching_any_interface() { let global = reference("/", "same"); let local = reference("/tools/item", "same"); let mut registry = WipMountRegistry::new(); @@ -1518,14 +1643,26 @@ mod tests { let runtime = WipRuntime::from_mounts(registry, "binding-test".into()).unwrap(); let output = content(runtime.inspect("/tools/item".into(), false).await.unwrap()); assert_eq!(runtime.metrics().discover_round_trips, 1); - assert_eq!(runtime.metrics().inspect_round_trips, 2); + assert_eq!(runtime.metrics().inspect_round_trips, 0); assert_eq!( output["interfaces"][0]["reference"], reference_json(&global) ); assert_eq!(output["interfaces"][1]["reference"], reference_json(&local)); for shown in output["interfaces"].as_array().unwrap() { - assert!(shown["signature"].as_str().unwrap().contains("read(")); + assert!(shown.get("signature").is_none()); + let definition = content( + runtime + .inspect(shown["path"].as_str().unwrap().into(), false) + .await + .unwrap(), + ); + assert!( + definition["interface_signature"] + .as_str() + .unwrap() + .contains("read(") + ); } } @@ -1724,8 +1861,17 @@ mod tests { catalog["interfaces"][0]["reference"], reference_json(&collection_reference) ); + let definition = content( + model_execute( + &tools, + "Inspect", + json!({"path": catalog["interfaces"][0]["path"]}), + ) + .await + .unwrap(), + ); assert_eq!( - catalog["interfaces"][0]["signature"], + definition["interface_signature"], render_interface(&Interface { reference: &collection_reference, descriptor: &entry_descriptor( @@ -1910,7 +2056,7 @@ pub(super) fn wip_tool_definitions(runtime: Arc) -> Vec, ) diff --git a/crates/worker/src/wip/binding/inspect_target.rs b/crates/worker/src/wip/binding/inspect_target.rs new file mode 100644 index 000000000..6ebd6b867 --- /dev/null +++ b/crates/worker/src/wip/binding/inspect_target.rs @@ -0,0 +1,151 @@ +//! Input-only compact Interface addresses. Never parse rendered signatures. +use super::{InterfaceReference, ToolError}; + +#[derive(Debug, PartialEq, Eq)] +pub(super) enum Target { + Object(String), + Interface(InterfaceReference), +} + +fn invalid() -> ToolError { + ToolError::InvalidArgument( + "Inspect requires an absolute Object path or scope::name Interface address; JSON-quote special components, and quote an entire Object path containing ::. Operation suffixes are not Inspect targets.".into(), + ) +} + +fn quoted(input: &str) -> Result<(String, &str), ToolError> { + let mut stream = serde_json::Deserializer::from_str(input).into_iter::(); + let value = stream.next().ok_or_else(invalid)?.map_err(|_| invalid())?; + Ok((value, &input[stream.byte_offset()..])) +} + +fn bare(value: &str, scope: bool) -> bool { + !value.is_empty() + && value + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"_.-".contains(&b) || (scope && b == b'/')) +} + +pub(super) fn parse(input: &str) -> Result { + let (scope, remainder) = if input.starts_with('"') { + quoted(input)? + } else if let Some(boundary) = input.find("::") { + let (scope, remainder) = input.split_at(boundary); + if !bare(scope, true) { + return Err(invalid()); + } + (scope.to_owned(), remainder) + } else { + // Ordinary Object paths are Protocol paths, not restricted Text View tokens. + wip_protocol::validate_path(input).map_err(|_| invalid())?; + return Ok(Target::Object(input.to_owned())); + }; + wip_protocol::validate_path(&scope).map_err(|_| invalid())?; + if remainder.is_empty() { + return Ok(Target::Object(scope)); + } + let name = remainder.strip_prefix("::").ok_or_else(invalid)?; + let name = if name.starts_with('"') { + let (value, rest) = quoted(name)?; + if !rest.is_empty() { + return Err(invalid()); + } + value + } else { + if !bare(name, false) { + return Err(invalid()); + } + name.to_owned() + }; + let reference = InterfaceReference { scope, name }; + reference.validate().map_err(|_| invalid())?; + Ok(Target::Interface(reference)) +} + +pub(super) fn interface_path(reference: &InterfaceReference) -> String { + fn component(value: &str, scope: bool) -> String { + if bare(value, scope) { + value.to_owned() + } else { + serde_json::to_string(value).expect("string serializes") + } + } + format!( + "{}::{}", + component(&reference.scope, true), + component(&reference.name, false) + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn compact_addresses_roundtrip_exact_components_without_normalization() { + for scope in ["/", "/github", "/github::archive", "/開発", "/x\"\n"] { + for name in [ + "issue.read", + "same::name", + "yoi.tool/Read/v1", + "課題#read", + "x\"\\\n", + ] { + let reference = InterfaceReference { + scope: scope.into(), + name: name.into(), + }; + assert_eq!( + parse(&interface_path(&reference)).unwrap(), + Target::Interface(reference) + ); + } + } + assert_eq!( + parse("\"/github\"::\"issue.read\"").unwrap(), + Target::Interface(InterfaceReference { + scope: "/github".into(), + name: "issue.read".into() + }) + ); + } + + #[test] + fn object_paths_are_not_interfaces_and_quoted_delimiters_remain_literal() { + for path in ["/", "/tools/Read", "/開発", "/x\"\n", "/item#read"] { + assert_eq!(parse(path).unwrap(), Target::Object(path.into())); + assert_eq!( + parse(&serde_json::to_string(path).unwrap()).unwrap(), + Target::Object(path.into()) + ); + } + assert_eq!( + parse("\"/literal::name\"").unwrap(), + Target::Object("/literal::name".into()) + ); + } + + #[test] + fn invalid_or_ambiguous_addresses_do_not_infer_scope_or_operation() { + for input in [ + "", + "relative", + "relative::name", + "::name", + "/::", + "/::\"\"", + "/::name#read", + "/::name::other", + "/::unquoted/name", + "/::\"name\"#read", + "\"/\"junk", + "\"/unterminated", + "/::\"bad\\q\"", + "/bad/../scope::name", + "/:: name", + "/::\"name\" ", + ] { + assert!(parse(input).is_err(), "accepted {input:?}"); + } + } +} diff --git a/crates/worker/src/wip/provider_tests.rs b/crates/worker/src/wip/provider_tests.rs index b6e334dc5..a0e3bb106 100644 --- a/crates/worker/src/wip/provider_tests.rs +++ b/crates/worker/src/wip/provider_tests.rs @@ -311,7 +311,10 @@ async fn current_self_scope_fetch_does_not_attach_a_new_ref_to_an_old_target_sna .unwrap(); let runtime = WipRuntime::from_mounts(registry, "scope-test".into()).unwrap(); let reference = contextual_reference("read", "/github/item"); - runtime.inspect("/github/item".into(), false).await.unwrap(); + runtime + .prepare_call_observations("/github/item".into(), false) + .await + .unwrap(); assert_eq!( runtime .host @@ -524,7 +527,8 @@ async fn in_flight_result_uses_frozen_host_and_client_descriptors_after_refresh( let update = async { started.notified().await; generation.store(1, Ordering::SeqCst); - let output = runtime.inspect("/github".into(), true).await.unwrap(); + runtime.inspect("/github".into(), true).await.unwrap(); + let output = runtime.inspect("/github::read".into(), true).await.unwrap(); assert!(output.content.unwrap().contains("integer")); release.notify_one(); }; diff --git a/crates/workspace-server/src/server_workspace_config_wip_tests.rs b/crates/workspace-server/src/server_workspace_config_wip_tests.rs index b14affe7c..2948e3bf8 100644 --- a/crates/workspace-server/src/server_workspace_config_wip_tests.rs +++ b/crates/workspace-server/src/server_workspace_config_wip_tests.rs @@ -152,18 +152,25 @@ fn config_interface(path: &str) -> wip_protocol::InterfaceReference { async fn inspect_interface(runtime: &worker::wip::WipRuntime, path: &str, base: &str) -> Value { let tree = runtime.tree(path.into(), 0, true).await.unwrap(); let tree: Value = serde_json::from_str(tree.content.as_deref().unwrap()).unwrap(); - assert_eq!(tree["path"], path, "Object must come from live Tree: {tree}"); + assert_eq!( + tree["path"], path, + "Object must come from live Tree: {tree}" + ); let output = runtime.inspect(path.into(), true).await.unwrap(); let observed: Value = serde_json::from_str(output.content.as_deref().unwrap()).unwrap(); assert_eq!(observed["path"], path); + for interface in observed["interfaces"].as_array().unwrap() { + runtime + .inspect(interface["path"].as_str().unwrap().into(), true) + .await + .unwrap(); + } assert!( observed["interfaces"] .as_array() .unwrap() .iter() - .any(|interface| { - interface["reference"] == json!({"scope": path, "name": base}) - }), + .any(|interface| { interface["reference"] == json!({"scope": path, "name": base}) }), "interface must come from live inspection: {observed}" ); observed @@ -628,14 +635,22 @@ async fn assert_config_production_http_adapter( .unwrap(); assert_config_catalog(&wip, &attached).await; let main_interface = config_inspect(&wip, "/workspace-config/main.dcdl").await; - assert!( - main_interface["interfaces"] - .as_array() - .unwrap() - .iter() - .all(|interface| !interface["signature"].as_str().unwrap().contains("delete(")), - "required entrypoint cannot advertise delete: {main_interface}" - ); + for interface in main_interface["interfaces"].as_array().unwrap() { + assert!(interface.get("signature").is_none()); + let definition = wip + .inspect(interface["path"].as_str().unwrap().into(), false) + .await + .unwrap(); + let definition: Value = + serde_json::from_str(definition.content.as_deref().unwrap()).unwrap(); + assert!( + !definition["interface_signature"] + .as_str() + .unwrap() + .contains("delete("), + "required entrypoint cannot advertise delete: {definition}" + ); + } let main = config_call(&wip, "/workspace-config/main.dcdl", "read", json!({})) .await .unwrap(); diff --git a/docs/design/worker-wip-mode.md b/docs/design/worker-wip-mode.md index 40feeeb9d..107bf14ea 100644 --- a/docs/design/worker-wip-mode.md +++ b/docs/design/worker-wip-mode.md @@ -31,10 +31,21 @@ The implementation uses the published **WIP 0.2.0** Client, HTTP, Protocol and o A WIP Worker exposes exactly these generic LLM tools instead of exposing every enabled ordinary tool twice: - `Tree(path, depth)`: retrieve the full indexable range within depth 0..8, or report incomplete/loading/failed/limited coverage as an error. Children at the requested boundary remain unobserved (null), not empty. Cached nonindexable paths are never included by prefix scanning Known Space. -- `Inspect(path)`: directly acquire an Object, then every published Interface, and return complete official Object/Interface signatures in declaration order. It requires no prior Tree or manual descriptor fetching. The path is explicit JSON envelope context outside the path-free signature. Summaries are complete; runtime metadata and documentation details are not appended to signatures. +- `Inspect(path)`: acquire and display exactly the selected entity. An Object path returns only its official Object signature and ordered Interface references (each with an explicit inspection path); it does not fetch Interface descriptors. An absolute `scope::name` Interface address returns only that Interface's complete official signature. Neither form requires a prior Tree or Inspect. The path is explicit JSON envelope context outside the path-free signature. Summaries are complete; runtime metadata and documentation details are not appended to signatures. - `Invoke(path, interface, operation, arguments)`: use a structured `{scope, name}` reference and named argument record. The Client owns validators and observed scope_ref. Missing/stale/failed observations are recovered with at most one pre-dispatch retrieval per subject. A dispatched operation is never automatically retried. -Tree and Inspect accept explicit `refresh: true` for blocked observations. An ensure API returning None is never itself readiness evidence: freshness, complete edge coverage and retained data are checked. Loading and capacity pressure are reported rather than superseded or busy-looped. +Tree and Inspect accept explicit `refresh: true` for blocked observations. Inspect refreshes only the selected Object or Interface, not referenced entities. An ensure API returning None is never itself readiness evidence: freshness, complete edge coverage and retained data are checked. Loading and capacity pressure are reported rather than superseded or busy-looped. + +The Yoi binding uses the Text View compact reference notation as an **input address**, not a parser for rendered signatures. For example: + +```json +{"path":"/tickets"} +{"path":"/::\"yoi.tool/Read/v1\""} +``` + +The first inspects an Object; the second inspects one root-scoped Interface. Object results contain `object_signature` and `interfaces: [{reference: {scope, name}, path}]`, without Interface signatures. Pass an entry's `path` directly to Inspect to obtain `{path, reference, interface_signature}`. This explicit per-entity display selection is a Yoi binding policy; it replaces the earlier automatic expansion of every Interface. Invoke continues to acquire missing observations automatically and accepts structured references, never a compact string. + +Reference components follow Text View quoting: scope allows unquoted `[A-Za-z0-9_./-]+`, name allows `[A-Za-z0-9_.-]+`; other components use JSON string quoting/escapes. There is no scope inference, normalization, relative resolution, or operation suffix. A literal Object path containing `::` must be JSON-quoted inside the `path` value (for example `{"path":"\"/literal::name\""}`) to distinguish it from an Interface address. Ordinary Object paths retain Protocol path syntax, including Unicode. Invalid or unavailable Interface addresses fail rather than falling back to Object lookup. The compatibility projection mounts enabled ordinary tools at: