diff --git a/crates/worker/src/feature/builtin/workspace_config.rs b/crates/worker/src/feature/builtin/workspace_config.rs index 16a5db133..1549c1792 100644 --- a/crates/worker/src/feature/builtin/workspace_config.rs +++ b/crates/worker/src/feature/builtin/workspace_config.rs @@ -318,7 +318,7 @@ impl FeatureInvocationHandler for WorkspaceConfigFeature { result.access.as_str() ), context: Some(format!( - "Workspace configuration is available as logical attachment `{ATTACHMENT_ALIAS}` at WIP `{CONTENT_ROOT}` with {} access. Use Tree for indexable structure, Inspect with a known Object path for its complete signatures, and Invoke for published Operations. This attachment grants no OS paths or command execution. Backend validation, concurrency checks and activation rules apply; saving does not imply running Workers adopted new configuration.", + "Workspace configuration is available as logical attachment `{ATTACHMENT_ALIAS}` at WIP `{CONTENT_ROOT}` with {} access. Use a shallow Tree only when paths are unknown. Inspect a needed Object to learn its Interface references, then inspect only unknown Interface contracts via their returned paths. Reuse known exact references/contracts and Invoke directly; do not repeat Inspect before each operation. Invoke handles observation freshness. Use published read/list Operations for configuration content, not Inspect. This attachment grants no OS paths or command execution. Backend validation, concurrency checks and activation rules apply; saving does not imply running Workers adopted new configuration.", result.access.as_str() )), }) diff --git a/crates/worker/src/wip.rs b/crates/worker/src/wip.rs index af33f29bc..69f3eb453 100644 --- a/crates/worker/src/wip.rs +++ b/crates/worker/src/wip.rs @@ -45,6 +45,7 @@ use wip_protocol::{ use crate::permission::permission_action_for; mod binding; +mod compatibility_schema; #[cfg(test)] mod provider_tests; use binding::wip_tool_definitions; @@ -2025,10 +2026,12 @@ fn compatibility_projection( name: "input".into(), required: true, documentation: Some(Documentation { - summary: "Original JSON tool arguments".into(), + summary: format!( + "Pass the original tool arguments unchanged as input. The WIP type is a structural guide; JSON-only shapes remain json, not tagged unions. Authoritative input JSON Schema (including all constraints and local definitions): {schema}" + ), details: Some(schema), }), - r#type: TypeExpr::Json, + r#type: compatibility_schema::input_type(&meta.input_schema), }], returns: ReturnDeclaration { documentation: Some(Documentation { @@ -3087,7 +3090,12 @@ mod tests { ["Inspect", "Invoke", "Tree"] ); let metrics = runtime.metrics(); - assert!(metrics.ordinary_schema_bytes > metrics.wip_schema_bytes); + assert!( + metrics.ordinary_schema_bytes > metrics.wip_schema_bytes, + "ordinary={} WIP={}", + metrics.ordinary_schema_bytes, + metrics.wip_schema_bytes + ); } #[tokio::test] diff --git a/crates/worker/src/wip/binding.rs b/crates/worker/src/wip/binding.rs index 917238fa9..37b8017c5 100644 --- a/crates/worker/src/wip/binding.rs +++ b/crates/worker/src/wip/binding.rs @@ -459,7 +459,7 @@ fn binding_one() -> u32 { struct WipInspectInput { /// 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. + /// No prior discovery required. path: String, /// Explicitly refresh only the selected Object or Interface. #[serde(default)] @@ -2003,6 +2003,26 @@ mod tests { assert!(detail.contains("ResourceLimit")); } + #[test] + fn tool_instructions_reuse_known_contracts_without_inspect_rituals() { + let fixture = fixture("/tools/item", reference("/", "fixture")); + let descriptions: BTreeMap<_, _> = wip_tool_definitions(fixture.runtime) + .into_iter() + .map(|definition| { + let meta = definition().0; + (meta.name, meta.description) + }) + .collect(); + assert!(descriptions["Tree"].contains("Start shallow")); + assert!(descriptions["Tree"].contains("list/search/query")); + assert!(descriptions["Inspect"].contains("Reuse known exact scope/name")); + assert!(descriptions["Inspect"].contains("Do not repeat Inspect before Invoke")); + assert!(descriptions["Inspect"].contains("new/changed/forgotten")); + assert!(descriptions["Inspect"].contains("authoritative JSON Schema")); + assert!(descriptions["Invoke"].contains("no preparatory Tree or Inspect")); + assert!(descriptions["Invoke"].contains("Never automatically retry dispatched operations")); + } + #[test] fn tool_schemas_expose_only_object_centered_names_and_structured_reference_input() { let fixture = fixture("/tools/item", reference("/", "fixture")); @@ -2048,7 +2068,7 @@ pub(super) fn wip_tool_definitions(runtime: Arc) -> Vec, ) @@ -2056,7 +2076,7 @@ pub(super) fn wip_tool_definitions(runtime: Arc) -> Vec, ) @@ -2064,7 +2084,7 @@ pub(super) fn wip_tool_definitions(runtime: Arc) -> Vec, ) diff --git a/crates/worker/src/wip/compatibility_schema.rs b/crates/worker/src/wip/compatibility_schema.rs new file mode 100644 index 000000000..90d56649b --- /dev/null +++ b/crates/worker/src/wip/compatibility_schema.rs @@ -0,0 +1,409 @@ +//! Conservative JSON-shape-preserving types for the compatibility `input` value. +//! The original JSON Schema remains authoritative and visible in Inspect. +//! In particular, open records and JSON unions must not become closed records +//! or WIP's tagged unions: that would reject/change valid original tool inputs. +use super::{Documentation, Json, TypeExpr}; +use wip_protocol::{EnumCase, FieldDeclaration}; + +pub(super) fn input_type(schema: &Json) -> TypeExpr { + project(schema, schema, &mut Vec::new(), 0, &mut 2048) +} + +fn project( + schema: &Json, + root: &Json, + visiting: &mut Vec, + depth: usize, + budget: &mut usize, +) -> TypeExpr { + if depth >= 16 || *budget == 0 { + return TypeExpr::Json; + } + *budget -= 1; + let Some(object) = schema.as_object() else { + return TypeExpr::Json; + }; + // Composition and resource-relative reference semantics are not equivalent + // to WIP's wire types. Keep their exact schema visible instead of guessing. + if [ + "anyOf", + "oneOf", + "allOf", + "$dynamicRef", + "$recursiveRef", + "$id", + "id", + ] + .iter() + .any(|key| object.contains_key(*key)) + { + return TypeExpr::Json; + } + if let Some(reference) = schema.get("$ref").and_then(Json::as_str) { + let Some(pointer) = reference.strip_prefix('#') else { + return TypeExpr::Json; + }; + if (!pointer.is_empty() && !pointer.starts_with('/')) + || visiting.iter().any(|value| value == reference) + { + return TypeExpr::Json; + } + let Some(target) = root.pointer(pointer) else { + return TypeExpr::Json; + }; + visiting.push(reference.into()); + let result = project(target, root, visiting, depth + 1, budget); + visiting.pop(); + return result; + } + match schema.get("type").and_then(Json::as_str) { + Some("null") => TypeExpr::Unit, + Some("boolean") => TypeExpr::Boolean, + Some("integer") => TypeExpr::Integer, + Some("number") => TypeExpr::Number, + Some("string") => { + if let Some(values) = schema.get("enum").and_then(Json::as_array) { + if !values.is_empty() + && values.len() <= *budget + && values.iter().enumerate().all(|(index, value)| { + value.as_str().is_some_and(|s| !s.is_empty()) + && !values[..index].contains(value) + }) + { + *budget -= values.len(); + return TypeExpr::Enum { + cases: values + .iter() + .map(|value| EnumCase { + name: value.as_str().unwrap().into(), + documentation: None, + }) + .collect(), + }; + } + } + TypeExpr::String + } + Some("array") => TypeExpr::List { + items: Box::new( + // Neither legacy tuples nor prefixItems describe one uniform element type. + schema + .get("items") + .filter(|items| items.is_object() && !object.contains_key("prefixItems")) + .map(|items| project(items, root, visiting, depth + 1, budget)) + .unwrap_or(TypeExpr::Json), + ), + }, + Some("object") + if schema.get("additionalProperties") == Some(&Json::Bool(false)) + && !object.contains_key("patternProperties") => + { + let properties = schema.get("properties").and_then(Json::as_object); + if properties.is_some_and(|properties| { + properties.len() > *budget || properties.keys().any(String::is_empty) + }) { + return TypeExpr::Json; + } + // Reserve sibling fields before expanding descendants: a wide early + // child must not spend the allocation already promised to siblings. + *budget -= properties.map_or(0, |properties| properties.len()); + let required = schema.get("required").and_then(Json::as_array); + TypeExpr::Record { + fields: properties + .into_iter() + .flat_map(|properties| properties.iter()) + .map(|(name, value)| FieldDeclaration { + name: name.clone(), + required: required.is_some_and(|required| { + required.iter().any(|field| field.as_str() == Some(name)) + }), + documentation: value.get("description").and_then(Json::as_str).map( + |summary| Documentation { + summary: summary.into(), + details: None, + }, + ), + r#type: project(value, root, visiting, depth + 1, budget), + }) + .collect(), + } + } + _ => TypeExpr::Json, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn closed_records_lists_required_fields_and_enum_are_not_erased() { + let schema = json!({"type":"object","additionalProperties":false,"required":["items"],"properties":{ + "items":{"type":"array","items":{"$ref":"#/$defs/Item"}}, + "limit":{"type":"integer","minimum":1}, + "enabled":{"type":"boolean"} + },"$defs":{"Item":{"type":"object","additionalProperties":false,"required":["kind"],"properties":{"kind":{"type":"string","enum":["file","folder"]},"weight":{"type":"number"}}}}}); + let TypeExpr::Record { fields } = input_type(&schema) else { + panic!("record lost") + }; + let items = fields.iter().find(|field| field.name == "items").unwrap(); + assert!(items.required); + let TypeExpr::List { items } = &items.r#type else { + panic!("list lost") + }; + let TypeExpr::Record { fields: nested } = items.as_ref() else { + panic!("local ref lost") + }; + assert!(matches!(nested[0].r#type, TypeExpr::Enum { .. })); + assert_eq!( + fields + .iter() + .find(|field| field.name == "limit") + .unwrap() + .r#type, + TypeExpr::Integer + ); + assert!( + !fields + .iter() + .find(|field| field.name == "limit") + .unwrap() + .required + ); + } + + #[test] + fn unrepresentable_json_shapes_remain_json_not_invented_tagged_values() { + for schema in [ + json!({"type":"object","properties":{"known":{"type":"string"}}}), + json!({"type":"object","additionalProperties":{"type":"integer"}}), + json!({"type":["string","null"]}), + json!({"anyOf":[{"type":"string"},{"type":"null"}]}), + json!({"oneOf":[{"type":"string"},{"type":"integer"}]}), + json!({"allOf":[{"type":"object"}]}), + json!({"$ref":"#"}), + json!({"$ref":"https://example.invalid/schema"}), + json!({"type":"object","additionalProperties":false,"patternProperties":{"^x":{"type":"string"}}}), + ] { + assert_eq!(input_type(&schema), TypeExpr::Json, "{schema}"); + } + assert_eq!( + input_type(&json!({"type":"array","items":[{"type":"string"},{"type":"integer"}]})), + TypeExpr::List { + items: Box::new(TypeExpr::Json) + } + ); + } + + #[test] + fn acyclic_reference_expansion_is_bounded_before_allocating_siblings() { + let mut definitions = serde_json::Map::new(); + for depth in 0..12 { + let properties: serde_json::Map = (0..40) + .map(|index| { + ( + format!("field{index}"), + if depth == 11 { + json!({"type":"string"}) + } else { + json!({"$ref":format!("#/$defs/Level{}", depth + 1)}) + }, + ) + }) + .collect(); + definitions.insert( + format!("Level{depth}"), + json!({"type":"object","additionalProperties":false,"properties":properties}), + ); + } + let projected = input_type(&json!({"$ref":"#/$defs/Level0","$defs":definitions})); + fn nodes(ty: &TypeExpr) -> usize { + match ty { + TypeExpr::Record { fields } => { + 1 + fields + .iter() + .map(|field| 1 + nodes(&field.r#type)) + .sum::() + } + TypeExpr::List { items } => 1 + nodes(items), + _ => 1, + } + } + assert!(nodes(&projected) < 4096); + assert!(matches!(projected, TypeExpr::Record { .. })); + } + + #[test] + fn recursive_refs_terminate_without_losing_outer_fields() { + let schema = json!({"$ref":"#/$defs/Node","$defs":{"Node":{"type":"object","additionalProperties":false,"properties":{"next":{"$ref":"#/$defs/Node"}}}}}); + let TypeExpr::Record { fields } = input_type(&schema) else { + panic!("outer record lost") + }; + assert_eq!(fields[0].r#type, TypeExpr::Json); + } +} + +#[cfg(test)] +mod integration_tests { + use super::super::*; + use serde_json::json; + use std::sync::atomic::{AtomicUsize, Ordering}; + + struct EchoInput(Arc); + #[async_trait] + impl Tool for EchoInput { + async fn execute( + &self, + input: &str, + _: ToolExecutionContext, + ) -> Result { + self.0.fetch_add(1, Ordering::SeqCst); + Ok(ToolOutput { + summary: "Echo input".into(), + content: Some(input.into()), + attachments: Vec::new(), + }) + } + } + fn runtime(schema: Json) -> (WipRuntime, Arc) { + let calls = Arc::new(AtomicUsize::new(0)); + let projection = compatibility_projection( + ToolMeta::new("Fixture") + .description("Typed compatibility fixture") + .input_schema(schema), + Arc::new(EchoInput(calls.clone())), + None, + ) + .unwrap(); + let mut registry = WipMountRegistry::new(); + registry.mount(projection).unwrap(); + ( + WipRuntime::from_mounts(registry, "schema-test".into()).unwrap(), + calls, + ) + } + async fn invoke(runtime: &WipRuntime, input: Json) -> Result { + runtime + .invoke( + "/tools/Fixture".into(), + root_reference("yoi.tool/Fixture/v1"), + "call".into(), + json!({"input":input}), + ToolExecutionContext::direct(), + ) + .await + } + + #[tokio::test] + async fn inspect_exposes_nested_types_and_exact_constraints_and_invoke_preserves_json() { + let schema = json!({ + "type":"object", "additionalProperties":false, "required":["items","mode","nullable","attributes"], + "properties":{ + "items":{"type":"array","minItems":1,"items":{"$ref":"#/$defs/Item"}}, + "mode":{"type":"string","enum":["fast","safe"]}, + "limit":{"type":"integer","minimum":1,"maximum":10,"description":"Bounded count"}, + "nullable":{"anyOf":[{"type":"string"},{"type":"null"}]}, + "attributes":{"type":"object","additionalProperties":{"type":"integer"}} + }, + "$defs":{"Item":{"type":"object","additionalProperties":false,"required":["name"],"properties":{"name":{"type":"string","minLength":2,"pattern":"^[a-z]+$"}}}} + }); + let (runtime, calls) = runtime(schema.clone()); + let output = runtime + .inspect("/::\"yoi.tool/Fixture/v1\"".into(), false) + .await + .unwrap(); + let inspected: Json = serde_json::from_str(output.content.as_deref().unwrap()).unwrap(); + let signature = inspected["interface_signature"].as_str().unwrap(); + for text in [ + "items", + "mode", + "enum", + "limit?: integer", + "Bounded count", + "nullable", + "anyOf", + "attributes", + "additionalProperties", + "minItems", + "minimum", + "maximum", + "minLength", + "pattern", + "$defs", + ] { + assert!(signature.contains(text), "missing {text}: {signature}"); + } + // Full schema is in the rendered summary, not exclusively hidden details. + let escaped_schema = serde_json::to_string(&schema.to_string()).unwrap(); + assert!(signature.contains(&escaped_schema[1..escaped_schema.len() - 1])); + assert_eq!(runtime.metrics().discover_round_trips, 0); + let good = json!({"items":[{"name":"entry"}],"mode":"safe","nullable":null,"attributes":{"arbitrary-key":4},"limit":3}); + let output = invoke(&runtime, good.clone()).await.unwrap(); + assert_eq!( + serde_json::from_str::(output.content.as_deref().unwrap()).unwrap(), + good + ); + assert_eq!(calls.load(Ordering::SeqCst), 1); + for invalid in [ + json!({"items":[{"name":"x"}],"mode":"safe","nullable":null,"attributes":{}}), + json!({"items":[{"name":"Entry"}],"mode":"safe","nullable":null,"attributes":{}}), + json!({"items":[],"mode":"safe","nullable":null,"attributes":{}}), + json!({"items":[{"name":"entry"}],"mode":"fast","nullable":null,"attributes":{},"limit":11}), + json!({"items":[{"name":"entry"}],"mode":"other","nullable":null,"attributes":{}}), + json!({"items":[{"name":"entry"}],"mode":"safe","attributes":{}}), + json!({"items":[{"name":"entry"}],"mode":"safe","nullable":false,"attributes":{}}), + json!({"items":[{"name":"entry"}],"mode":"safe","nullable":null,"attributes":{"x":"wrong"}}), + ] { + assert!(invoke(&runtime, invalid).await.is_err()); + } + assert_eq!( + calls.load(Ordering::SeqCst), + 1, + "invalid inputs must not reach the original tool" + ); + } + + #[tokio::test] + async fn open_records_nullable_unions_and_recursive_inputs_keep_their_original_shape() { + for (schema, input) in [ + ( + json!({"type":"object","properties":{"known":{"type":"string"}}}), + json!({"known":"yes","extra":[1,null,{"free":true}]}), + ), + (json!({"type":["string","null"]}), Json::Null), + (json!({"type":"null"}), Json::Null), + (json!({"type":"boolean"}), json!(true)), + (json!({"type":"number"}), json!(1.5)), + (json!({"type":"string","enum":["", "value"]}), json!("")), + ( + json!({"type":"array","prefixItems":[{"type":"string"}],"items":{"type":"integer"}}), + json!(["first", 2]), + ), + ( + json!({"type":"object","additionalProperties":false,"properties":{"defaulted":{"type":"string","default":"do not insert"}}}), + json!({}), + ), + ( + json!({"$ref":"#/$defs/A~1B","$defs":{"A/B":{"type":"string"}}}), + json!("escaped pointer"), + ), + ( + json!({"oneOf":[{"type":"integer"},{"type":"string"}]}), + json!(7), + ), + ( + json!({"$ref":"#/$defs/Node","$defs":{"Node":{"type":"object","additionalProperties":false,"properties":{"next":{"$ref":"#/$defs/Node"}}}}}), + json!({"next":{"next":{}}}), + ), + ] { + let (runtime, calls) = runtime(schema); + let output = invoke(&runtime, input.clone()).await.unwrap(); + assert_eq!( + serde_json::from_str::(output.content.as_deref().unwrap()).unwrap(), + input + ); + assert_eq!(calls.load(Ordering::SeqCst), 1); + } + } +} diff --git a/docs/design/worker-wip-mode.md b/docs/design/worker-wip-mode.md index 107bf14ea..e1d957568 100644 --- a/docs/design/worker-wip-mode.md +++ b/docs/design/worker-wip-mode.md @@ -47,6 +47,10 @@ The first inspects an Object; the second inspects one root-scoped Interface. Obj 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. +### Model usage guidance + +The three tool descriptions are the recurring model-facing usage instructions, including after compaction. Use a shallow Tree only when needed to locate entrances, inspect only relevant Objects and unknown Interface contracts, and then work through Invoke. Reuse known paths and understood contracts for the **same exact scope/name reference**; do not inspect every node or repeat Inspect as a prerequisite for every operation. Same local names under different scopes are not evidence of the same contract. Reinspect only when a reference/contract is new, changed, or no longer available in the conversation context. Cache maintenance is the Client's responsibility, not a reason for extra model calls. Use domain read/list/search/query Operations for content and large/nonindexable collections. Current authorization checks and the prohibition on blindly retrying dispatched operations remain unchanged. + The compatibility projection mounts enabled ordinary tools at: ```text @@ -59,9 +63,11 @@ Each compatibility Object publishes an explicit root-scoped reference: {"scope":"/", "name":"yoi.tool//v1"} ``` -Its single operation is `call`. For this compatibility route, `Invoke.arguments` is `{ "input": }`, matching the descriptor's named Json parameter. Native projections instead receive a JSON object keyed by the descriptor's declared parameter names. Native input is decoded with the published `wip-http` descriptor-bound codec, so named and composite types are resolved, an integral JSON number remains a WIP `Number` when declared as such, and WIP `Bytes` use the codec's canonical RFC 4648 base64 JSON representation. Tool argument IDs remain JSON values; the adapter never infers a domain object or Worldspace route from them. +Its single operation is `call`. For this compatibility route, `Invoke.arguments` is `{ "input": }`, matching the descriptor's named `input` parameter. Native projections instead receive a JSON object keyed by the descriptor's declared parameter names. Native input is decoded with the published `wip-http` descriptor-bound codec, so named and composite types are resolved, an integral JSON number remains a WIP `Number` when declared as such, and WIP `Bytes` use the codec's canonical RFC 4648 base64 JSON representation. Tool argument IDs remain JSON values; the adapter never infers a domain object or Worldspace route from them. -The interface descriptor uses WIP `Json` for compatibility input and includes the exact original JSON Schema in descriptor documentation. Before execution, the Host validates the JSON value with that original schema and then delegates to the original async `Tool`. Constraints are therefore neither approximated nor silently dropped. A schema that cannot be compiled prevents WIP startup instead of creating a weaker projection. +Compatibility input is projected conservatively into JSON-shape-preserving WIP types: primitives, closed records with required/optional fields, homogeneous lists, and string enums. Local JSON Pointer references are expanded within bounded depth/node budgets; cycles and unsupported constructs stay `Json`. Open records, nullable/mixed unions, composition, and tuple elements are not falsely modeled as closed records or tagged WIP unions. The original input JSON Schema, including constraints, descriptions, defaults, and local definitions, is included in the **visible parameter summary** as well as retained documentation details. Thus a single Interface Inspect reveals the usable input contract even where WIP's type system cannot express it or an expansion budget is reached; it is never just an opaque `input: json` with hidden instructions. Full Inspect output still obeys response limits and is not silently truncated. + +The call envelope remains `{ "input": }`; no tagged union wrapper, field renaming, or default insertion is introduced. Before execution, the Host validates the reconstructed JSON value with the exact original schema and then delegates to the original async `Tool`. Structural projection never replaces original-schema constraints or permission checks. A schema that cannot be compiled prevents WIP startup instead of creating a weaker projection. ## Native projection extension and collisions