From d116edd6a70e54fdb9e64473d67815f3bb67ead0 Mon Sep 17 00:00:00 2001 From: yyjeqhc <1772413353@qq.com> Date: Wed, 21 Oct 2026 14:44:03 +0800 Subject: [PATCH 1/2] refactor(contracts): share read-tool policies and validation context checks --- .../webcodex-core/src/runner_protocol/job.rs | 25 ++++ .../src/runner_protocol/tests.rs | 41 ++++++ .../src/reconciliation.rs | 35 +---- .../webcodex_runner/job_manager/admission.rs | 45 ++---- .../webcodex_runner/job_manager/detached.rs | 46 ++---- .../src/registry/output_schemas/artifacts.rs | 93 +++++------- .../src/tool_definition.rs | 37 ++++- .../src/tool_definition/files.rs | 105 ++------------ .../src/tool_definition/git.rs | 102 ++----------- .../src/tool_definition/lsp.rs | 137 +++--------------- 10 files changed, 210 insertions(+), 456 deletions(-) diff --git a/crates/webcodex-core/src/runner_protocol/job.rs b/crates/webcodex-core/src/runner_protocol/job.rs index 28e48b436..e49ecf9da 100644 --- a/crates/webcodex-core/src/runner_protocol/job.rs +++ b/crates/webcodex-core/src/runner_protocol/job.rs @@ -1276,6 +1276,31 @@ pub struct ShellJobContext { } impl ShellJobContext { + /// Bounded ordered step names retained by admission and recovery. + pub fn validation_steps_are_valid(&self) -> bool { + self.validation_steps.len() <= 3 + && self + .validation_steps + .iter() + .enumerate() + .all(|(index, step)| { + matches!(step.as_str(), "format" | "check" | "lint" | "test") + && !self.validation_steps[..index].contains(step) + }) + } + + /// Optional executable metadata must describe the same ordered steps. + pub fn validation_metadata_is_valid(&self) -> bool { + self.validation.as_ref().is_none_or(|metadata| { + metadata.is_valid() + && metadata + .steps + .iter() + .map(|step| &step.name) + .eq(self.validation_steps.iter()) + }) + } + /// Semantic execution identities shared by Runner admission and Server /// reconciliation. Interpreter executable names are not shell identities. pub fn is_valid_shell(shell: &str) -> bool { diff --git a/crates/webcodex-core/src/runner_protocol/tests.rs b/crates/webcodex-core/src/runner_protocol/tests.rs index 9e522e0a7..87cc949b1 100644 --- a/crates/webcodex-core/src/runner_protocol/tests.rs +++ b/crates/webcodex-core/src/runner_protocol/tests.rs @@ -1,5 +1,46 @@ use super::*; +#[test] +fn validation_context_step_names_are_bounded_unique_and_closed() { + for (steps, expected) in [ + (vec![], true), + (vec!["format", "check", "test"], true), + (vec!["lint"], true), + (vec!["check", "check"], false), + (vec!["format", "check", "lint", "test"], false), + (vec!["unknown"], false), + ] { + let context: ShellJobContext = serde_json::from_value(serde_json::json!({ + "command_preview": "cargo check", + "validation_steps": steps, + })) + .unwrap(); + assert_eq!(context.validation_steps_are_valid(), expected, "{steps:?}"); + } +} + +#[test] +fn validation_context_metadata_must_match_retained_step_names() { + let mut context: ShellJobContext = serde_json::from_value(serde_json::json!({ + "command_preview": "cargo check", + "validation_steps": ["check"], + "validation": { + "tool": "cargo_check", "kind": "check", "adapter": "cargo_check", + "steps": [{"name": "check", "program": "cargo", "args": ["check"]}], + "effective_timeout_secs": 60, "sync_wait_secs": 5, + }, + })) + .unwrap(); + assert!(context.validation_metadata_is_valid()); + context.validation_steps = vec!["test".into()]; + assert!(!context.validation_metadata_is_valid()); + context.validation_steps = vec!["check".into()]; + context.validation.as_mut().unwrap().adapter = "go_test".into(); + assert!(!context.validation_metadata_is_valid()); + context.validation = None; + assert!(context.validation_metadata_is_valid()); +} + // Golden wire bytes captured against the explicit pre-catalog struct. These // freeze required false keys, omission and field order, not a capability inventory. const LEGACY_DEFAULT_JSON: &str = concat!( diff --git a/crates/webcodex-runner-registry/src/reconciliation.rs b/crates/webcodex-runner-registry/src/reconciliation.rs index 272aae3b6..b75bd6c66 100644 --- a/crates/webcodex-runner-registry/src/reconciliation.rs +++ b/crates/webcodex-runner-registry/src/reconciliation.rs @@ -79,17 +79,7 @@ fn validate_context( } } if context.purpose.as_deref().is_some_and(|purpose| { - !matches!( - purpose, - "validation" - | "test" - | "build" - | "format" - | "release" - | "diagnostic" - | "operation" - | "other" - ) + webcodex_core::workflow_session_contract::ExecutionPurpose::parse(purpose).is_none() }) { return Err("job inventory purpose is invalid".to_string()); } @@ -98,29 +88,10 @@ fn validate_context( }) { return Err("job inventory shell is invalid".to_string()); } - if context.validation_steps.len() > 3 - || context - .validation_steps - .iter() - .collect::>() - .len() - != context.validation_steps.len() - || context - .validation_steps - .iter() - .any(|step| !matches!(step.as_str(), "format" | "check" | "lint" | "test")) - { + if !context.validation_steps_are_valid() { return Err("job inventory validation_steps are invalid".to_string()); } - if context.validation.as_ref().is_some_and(|validation| { - !validation.is_valid() - || validation - .steps - .iter() - .map(|step| step.name.clone()) - .collect::>() - != context.validation_steps - }) { + if !context.validation_metadata_is_valid() { return Err("job inventory validation metadata is invalid".to_string()); } if context diff --git a/crates/webcodex-runner/src/webcodex_runner/job_manager/admission.rs b/crates/webcodex-runner/src/webcodex_runner/job_manager/admission.rs index bfe252f78..959b762e5 100644 --- a/crates/webcodex-runner/src/webcodex_runner/job_manager/admission.rs +++ b/crates/webcodex-runner/src/webcodex_runner/job_manager/admission.rs @@ -2,6 +2,7 @@ use super::lifecycle::{job_prestart_lifecycle, runner_job_is_active}; use super::*; +use webcodex_core::workflow_session_contract::ExecutionPurpose; pub(super) fn validate_runner_job_context_operation( context: &ShellJobContext, operation: &RunnerJobOperation, @@ -41,19 +42,11 @@ pub(super) fn validate_runner_job_context_operation( if context.ssh_resource.is_some() && context.workflow_session_id.is_none() { return Err("job recovery context SSH resource requires a Workflow Session".to_string()); } - if context.purpose.as_deref().is_some_and(|purpose| { - !matches!( - purpose, - "validation" - | "test" - | "build" - | "format" - | "release" - | "diagnostic" - | "operation" - | "other" - ) - }) { + if context + .purpose + .as_deref() + .is_some_and(|purpose| ExecutionPurpose::parse(purpose).is_none()) + { return Err("job recovery context purpose is invalid".to_string()); } if context @@ -100,31 +93,13 @@ pub(super) fn validate_runner_job_context_operation( } _ => Vec::new(), }; - if context.validation_steps != validation_steps - || context - .validation_steps - .iter() - .collect::>() - .len() - != context.validation_steps.len() - || context - .validation_steps - .iter() - .any(|step| !matches!(step.as_str(), "format" | "check" | "lint" | "test")) - { + if context.validation_steps != validation_steps || !context.validation_steps_are_valid() { return Err("job recovery context validation_steps are invalid".to_string()); } let validation_context = matches!(operation, RunnerJobOperation::StartValidation(_)); - if context.validation.as_ref().is_some_and(|metadata| { - !validation_context - || !metadata.is_valid() - || metadata - .steps - .iter() - .map(|step| step.name.clone()) - .collect::>() - != context.validation_steps - }) { + if (!validation_context && context.validation.is_some()) + || !context.validation_metadata_is_valid() + { return Err("job recovery context validation metadata is invalid".to_string()); } if context diff --git a/crates/webcodex-runner/src/webcodex_runner/job_manager/detached.rs b/crates/webcodex-runner/src/webcodex_runner/job_manager/detached.rs index 131dee0f7..935155b4f 100644 --- a/crates/webcodex-runner/src/webcodex_runner/job_manager/detached.rs +++ b/crates/webcodex-runner/src/webcodex_runner/job_manager/detached.rs @@ -3,6 +3,7 @@ use super::lifecycle::{runner_job_is_active, runner_job_is_terminal}; use super::retained::job_update_from_snapshot; use super::*; +use webcodex_core::workflow_session_contract::ExecutionPurpose; pub(super) fn validate_detached_recovery_context( context: &ShellJobContext, client_id: &str, @@ -32,19 +33,11 @@ pub(super) fn validate_detached_recovery_context( if context.ssh_resource.is_some() && context.workflow_session_id.is_none() { return Err("detached Job recovery SSH resource requires a Workflow Session".to_string()); } - if context.purpose.as_deref().is_some_and(|purpose| { - !matches!( - purpose, - "validation" - | "test" - | "build" - | "format" - | "release" - | "diagnostic" - | "operation" - | "other" - ) - }) { + if context + .purpose + .as_deref() + .is_some_and(|purpose| ExecutionPurpose::parse(purpose).is_none()) + { return Err("detached Job recovery purpose is invalid".to_string()); } if context.shell.as_deref().is_some_and(|shell| { @@ -55,31 +48,10 @@ pub(super) fn validate_detached_recovery_context( }) { return Err("detached Job recovery shell is invalid".to_string()); } - if !context.validation_steps.is_empty() { - if !(1..=3).contains(&context.validation_steps.len()) - || context - .validation_steps - .iter() - .collect::>() - .len() - != context.validation_steps.len() - || context - .validation_steps - .iter() - .any(|step| !matches!(step.as_str(), "format" | "check" | "lint" | "test")) - { - return Err("detached Job recovery validation_steps are invalid".to_string()); - } + if !context.validation_steps_are_valid() { + return Err("detached Job recovery validation_steps are invalid".to_string()); } - if context.validation.as_ref().is_some_and(|metadata| { - !metadata.is_valid() - || metadata - .steps - .iter() - .map(|step| step.name.clone()) - .collect::>() - != context.validation_steps - }) { + if !context.validation_metadata_is_valid() { return Err("detached Job recovery validation metadata is invalid".to_string()); } if context diff --git a/crates/webcodex-tool-contracts/src/registry/output_schemas/artifacts.rs b/crates/webcodex-tool-contracts/src/registry/output_schemas/artifacts.rs index 626122a7c..669286246 100644 --- a/crates/webcodex-tool-contracts/src/registry/output_schemas/artifacts.rs +++ b/crates/webcodex-tool-contracts/src/registry/output_schemas/artifacts.rs @@ -5,37 +5,45 @@ use super::common::{ wrapped_output_schema, }; +fn artifact_read_arguments_schema(selector: (&str, &str), description: &str) -> Value { + let mut schema = json!({ + "type": "object", + "description": description, + "additionalProperties": false, + "properties": { + "project": {"type": "string", "minLength": 1}, + "path": {"type": "string", "minLength": 1}, + "offset": {"type": "integer", "minimum": 0}, + "length": {"type": "integer", "minimum": 1, "maximum": 65536}, + "expected_sha256": { + "type": "string", + "minLength": 64, + "maxLength": 64, + "pattern": "^[0-9a-f]{64}$" + }, + "session_id": {"type": "string", "pattern": "^wc_sess_([A-Za-z0-9_-]{16}|[0-9a-f]{32})$"} + }, + "required": [ + "project", + "path", + selector.0, + "offset", + "length", + "expected_sha256" + ] + }); + schema["properties"][selector.0] = json!({"type": "string", "const": selector.1}); + schema +} + fn read_project_artifact_suggested_call_schema() -> Value { suggested_tool_call_schema( webcodex_core::runtime_contract::GeneratedFollowUpKind::MechanicallyFollowable, "read_project_artifact_chunk", - json!({ - "type": "object", - "description": "Parser-ready next ranged read of the same exact full-file artifact incarnation.", - "additionalProperties": false, - "properties": { - "project": {"type": "string", "minLength": 1}, - "path": {"type": "string", "minLength": 1}, - "encoding": {"type": "string", "const": "base64"}, - "offset": {"type": "integer", "minimum": 0}, - "length": {"type": "integer", "minimum": 1, "maximum": 65536}, - "expected_sha256": { - "type": "string", - "minLength": 64, - "maxLength": 64, - "pattern": "^[0-9a-f]{64}$" - }, - "session_id": {"type": "string", "pattern": "^wc_sess_([A-Za-z0-9_-]{16}|[0-9a-f]{32})$"} - }, - "required": [ - "project", - "path", - "encoding", - "offset", - "length", - "expected_sha256" - ] - }), + artifact_read_arguments_schema( + ("encoding", "base64"), + "Parser-ready next ranged read of the same exact full-file artifact incarnation.", + ), "Parser-ready advisory call for the next ranged read of the same exact artifact content snapshot. It grants no Project or Session authority.", ) } @@ -44,33 +52,10 @@ fn project_artifact_suggested_call_schema() -> Value { suggested_tool_call_schema( webcodex_core::runtime_contract::GeneratedFollowUpKind::MechanicallyFollowable, "inspect_project_artifact", - json!({ - "type": "object", - "description": "Parser-ready continuation for one more bounded inspect of the same exact full-file artifact incarnation.", - "additionalProperties": false, - "properties": { - "project": {"type": "string", "minLength": 1}, - "path": {"type": "string", "minLength": 1}, - "action": {"type": "string", "const": "inspect"}, - "offset": {"type": "integer", "minimum": 0}, - "length": {"type": "integer", "minimum": 1, "maximum": 65536}, - "expected_sha256": { - "type": "string", - "minLength": 64, - "maxLength": 64, - "pattern": "^[0-9a-f]{64}$" - }, - "session_id": {"type": "string", "pattern": "^wc_sess_([A-Za-z0-9_-]{16}|[0-9a-f]{32})$"} - }, - "required": [ - "project", - "path", - "action", - "offset", - "length", - "expected_sha256" - ] - }), + artifact_read_arguments_schema( + ("action", "inspect"), + "Parser-ready continuation for one more bounded inspect of the same exact full-file artifact incarnation.", + ), "Parser-ready advisory continuation for inspect_project_artifact(action=inspect). It carries the observed full-file SHA-256 fence and grants no Project or Session authority.", ) } diff --git a/crates/webcodex-tool-contracts/src/tool_definition.rs b/crates/webcodex-tool-contracts/src/tool_definition.rs index bee8103ad..88ac8662d 100644 --- a/crates/webcodex-tool-contracts/src/tool_definition.rs +++ b/crates/webcodex-tool-contracts/src/tool_definition.rs @@ -39,8 +39,8 @@ mod testing; use super::metadata::{ metadata as make_tool_metadata, ToolApprovalPolicy, ToolAuthorityPolicy, ToolEffect, - ToolIdempotency, ToolMetadata, ToolPathHint, ToolRisk, ToolSemanticContract, RUNTIME_READ, - TOOL_PROVIDER_CONTROL, + ToolIdempotency, ToolMetadata, ToolPathHint, ToolRisk, ToolSemanticContract, PROJECT_READ, + RUNTIME_READ, TOOL_PROVIDER_CONTROL, TOOL_PROVIDER_RUNNER, }; #[cfg(any(test, feature = "root-test-support"))] pub use super::tool_catalog::TOOL_MANIFEST_INTENTS; @@ -1216,6 +1216,39 @@ const fn def( } } +// Runner-backed Project reads share this authority/effect contract. Audit +// redaction, capability, path selection, and Session evidence stay explicit +// at each declaration; mutation and execution tools continue to use `def`. +const fn runner_project_read_def( + name: &'static str, + audit: ToolAuditPolicy, + category: &'static str, + runner_capability: RunnerCapabilityRequirement, + path_hint: ToolPathHint, + session_evidence: ToolSessionEvidencePolicy, +) -> ToolDefinition { + def( + name, + audit, + ToolVisibility::ModelVisible, + category, + Some(runner_capability), + TOOL_PROVIDER_RUNNER, + ToolSemanticContract { + effect: ToolEffect::Observe, + risk: ToolRisk::Read, + approval: ToolApprovalPolicy::None, + idempotency: ToolIdempotency::PureRead, + }, + Some(PROJECT_READ), + true, + path_hint, + false, + false, + session_evidence, + ) +} + // Server-owned Control operations without a Project, path, Runner dispatch, or // Session evidence. Authority, effect, idempotency, and destructive intent stay // explicit at each declaration. diff --git a/crates/webcodex-tool-contracts/src/tool_definition/files.rs b/crates/webcodex-tool-contracts/src/tool_definition/files.rs index fd53b5074..a7fb4cf75 100644 --- a/crates/webcodex-tool-contracts/src/tool_definition/files.rs +++ b/crates/webcodex-tool-contracts/src/tool_definition/files.rs @@ -1,106 +1,55 @@ use super::RunnerCapabilityRequirement::{FileRead, Shell}; -use super::ToolVisibility::ModelVisible; use super::{ - adaptive_runtime_direct, def, model_spec, ToolDefinition, TOOL_CATEGORY_FILE, - TOOL_CATEGORY_PROJECT, -}; -use crate::metadata::{ - ToolPathHint::None as NoPath, ToolRisk::Read, PROJECT_READ, TOOL_PROVIDER_RUNNER, + adaptive_runtime_direct, model_spec, runner_project_read_def, ToolDefinition, + TOOL_CATEGORY_FILE, TOOL_CATEGORY_PROJECT, }; +use crate::metadata::ToolPathHint::None as NoPath; pub(super) const SEARCH_DEFINITIONS: &[ToolDefinition] = &[ model_spec( - def( + runner_project_read_def( "read_project_overview", super::ToolAuditPolicy::TYPED_CANONICAL, - ModelVisible, TOOL_CATEGORY_PROJECT, - Some(FileRead), - TOOL_PROVIDER_RUNNER, - super::ToolSemanticContract { - effect: super::ToolEffect::Observe, - risk: Read, - approval: super::ToolApprovalPolicy::None, - idempotency: super::ToolIdempotency::PureRead, - }, - Some(PROJECT_READ), - true, + FileRead, NoPath, - false, - false, super::ToolSessionEvidencePolicy::NONE.review(super::ToolReviewEvidence::ReadOnlyInspection), ).with_composition_policy(super::ToolCompositionPolicy::Parallel), "Deterministic, bounded, metadata-only project overview. With the root/default scope it performs conservative project discovery from tracked state; with an explicit eligible path such as cache or build it can inspect ignored/generated directories through the bounded filesystem walk, while high-volume target/node_modules scopes remain excluded. Returns conventional project types, manifests, key files, roots, and direct children. Reads no file contents, uses no LLM, and is not semantic/LSP analysis; use read_files for exact file contents.", ).with_discovery_keywords(&["project", "overview", "structure", "manifests", "language", "framework", "项目", "概览", "结构", "清单", "框架"]), model_spec( - def( + runner_project_read_def( "list_project_files", super::ToolAuditPolicy::TYPED_CANONICAL, - ModelVisible, TOOL_CATEGORY_FILE, - Some(FileRead), - TOOL_PROVIDER_RUNNER, - super::ToolSemanticContract { - effect: super::ToolEffect::Observe, - risk: Read, - approval: super::ToolApprovalPolicy::None, - idempotency: super::ToolIdempotency::PureRead, - }, - Some(PROJECT_READ), - true, + FileRead, NoPath, - false, - false, super::ToolSessionEvidencePolicy::NONE.review(super::ToolReviewEvidence::ReadOnlyInspection), ), "List one live Project directory, including named generated/non-Git directories. Returns sorted project-relative paths and file/dir kinds. Follow next_call for another page; offsets are not snapshots, so concurrent directory changes can shift entries. Current Runners bound pages before transport; older Runners require a complete source or fail closed. Use list_project_tracked_files with query/globs/depth for repository discovery, search_project_texts for content, and read_files for a known file. Do not enumerate directories merely to confirm a known path.", ).with_discovery_keywords(&["list", "directory", "folder", "entries", "files", "列出", "目录", "文件夹", "文件"]), model_spec( - def( + runner_project_read_def( "list_project_tracked_files", super::ToolAuditPolicy::TYPED_CANONICAL, - ModelVisible, TOOL_CATEGORY_FILE, // Runs `git ls-files` on the Runner, so the shell capability is what // the Runner must actually hold — not FileRead's directory op. - Some(Shell), - TOOL_PROVIDER_RUNNER, - super::ToolSemanticContract { - effect: super::ToolEffect::Observe, - risk: Read, - approval: super::ToolApprovalPolicy::None, - idempotency: super::ToolIdempotency::PureRead, - }, - Some(PROJECT_READ), - true, + Shell, NoPath, - false, - false, super::ToolSessionEvidencePolicy::NONE, ).with_composition_policy(super::ToolCompositionPolicy::Parallel), "Default discovery tool: what files does this project contain? Lists Git-tracked paths from a bounded producer source, so ignored directories like .venv and target never appear. Supports globs, a project-relative path scope, rollup, and offset paging when source acquisition is complete. If list_truncated=true, the source itself is incomplete: next_offset is null and offset must not be treated as recovery for the full repository; narrow path and retry. Retained-tail source truncation fails closed rather than exposing a false continuation.", ).with_discovery_keywords(&["find", "filename", "glob", "tracked", "repository", "files", "文件名", "通配符", "跟踪", "仓库", "文件"]), adaptive_runtime_direct( model_spec( - def( + runner_project_read_def( "search_project_texts", super::ToolAuditPolicy::TYPED_CANONICAL .session_input(super::ToolAuditSessionInputPolicy::SearchProjectTexts), - ModelVisible, TOOL_CATEGORY_FILE, - Some(Shell), - TOOL_PROVIDER_RUNNER, - super::ToolSemanticContract { - effect: super::ToolEffect::Observe, - risk: Read, - approval: super::ToolApprovalPolicy::None, - idempotency: super::ToolIdempotency::PureRead, - }, - Some(PROJECT_READ), - true, + Shell, NoPath, - false, - false, super::ToolSessionEvidencePolicy::NONE.review(super::ToolReviewEvidence::Search).exploration(super::ToolExplorationEvidence::SearchBatch), ) .with_composition_policy(super::ToolCompositionPolicy::Parallel) @@ -115,25 +64,13 @@ pub(super) const SEARCH_DEFINITIONS: &[ToolDefinition] = &[ ), adaptive_runtime_direct( model_spec( - def( + runner_project_read_def( "search_file_context", super::ToolAuditPolicy::TYPED_CANONICAL .session_input(super::ToolAuditSessionInputPolicy::SearchAndRead), - ModelVisible, TOOL_CATEGORY_FILE, - Some(Shell), - TOOL_PROVIDER_RUNNER, - super::ToolSemanticContract { - effect: super::ToolEffect::Observe, - risk: Read, - approval: super::ToolApprovalPolicy::None, - idempotency: super::ToolIdempotency::PureRead, - }, - Some(PROJECT_READ), - true, + Shell, NoPath, - false, - false, super::ToolSessionEvidencePolicy::NONE .review(super::ToolReviewEvidence::ReadOnlyInspection) .exploration(super::ToolExplorationEvidence::SearchCompound), @@ -153,24 +90,12 @@ pub(super) const SEARCH_DEFINITIONS: &[ToolDefinition] = &[ pub(super) const READ_DEFINITIONS: &[ToolDefinition] = &[ adaptive_runtime_direct( model_spec( - def( + runner_project_read_def( "read_files", super::ToolAuditPolicy::TYPED_CANONICAL, - ModelVisible, TOOL_CATEGORY_FILE, - Some(FileRead), - TOOL_PROVIDER_RUNNER, - super::ToolSemanticContract { - effect: super::ToolEffect::Observe, - risk: Read, - approval: super::ToolApprovalPolicy::None, - idempotency: super::ToolIdempotency::PureRead, - }, - Some(PROJECT_READ), - true, + FileRead, NoPath, - false, - false, super::ToolSessionEvidencePolicy::NONE.review(super::ToolReviewEvidence::ReadOnlyInspection).exploration(super::ToolExplorationEvidence::ReadBatch), ) .with_composition_policy(super::ToolCompositionPolicy::Parallel) diff --git a/crates/webcodex-tool-contracts/src/tool_definition/git.rs b/crates/webcodex-tool-contracts/src/tool_definition/git.rs index 031de0813..6b4763d19 100644 --- a/crates/webcodex-tool-contracts/src/tool_definition/git.rs +++ b/crates/webcodex-tool-contracts/src/tool_definition/git.rs @@ -2,17 +2,17 @@ use super::RunnerCapabilityRequirement::GitOrShell; use super::ToolVisibility::ModelVisible; use super::{ adaptive_runtime_direct, change_summary_like, def, git_like, model_spec, require_all_scopes, - ToolDefinition, TOOL_CATEGORY_GIT, + runner_project_read_def, ToolDefinition, TOOL_CATEGORY_GIT, }; use crate::metadata::{ ToolPathHint::{None as NoPath, PathList}, - ToolRisk::{ProjectWrite, Read}, - JOB_RUN, PROJECT_READ, PROJECT_WRITE, TOOL_PROVIDER_RUNNER, + ToolRisk::ProjectWrite, + JOB_RUN, PROJECT_WRITE, TOOL_PROVIDER_RUNNER, }; pub(super) const SUMMARY_DEFINITIONS: &[ToolDefinition] = &[ change_summary_like(git_like(model_spec( - def( + runner_project_read_def( "read_git_review_summary", super::ToolAuditPolicy::typed_fields(&[ super::ToolAuditResultField::value("project"), @@ -28,21 +28,9 @@ pub(super) const SUMMARY_DEFINITIONS: &[ToolDefinition] = &[ super::ToolAuditResultField::array_len("file_count", "files"), ]) .drop_null_request_values(), - ModelVisible, TOOL_CATEGORY_GIT, - Some(GitOrShell), - TOOL_PROVIDER_RUNNER, - super::ToolSemanticContract { - effect: super::ToolEffect::Observe, - risk: Read, - approval: super::ToolApprovalPolicy::None, - idempotency: super::ToolIdempotency::PureRead, - }, - Some(PROJECT_READ), - true, + GitOrShell, NoPath, - false, - false, super::ToolSessionEvidencePolicy::NONE .review(super::ToolReviewEvidence::ReadOnlyInspection), ) @@ -52,7 +40,7 @@ pub(super) const SUMMARY_DEFINITIONS: &[ToolDefinition] = &[ ).with_discovery_keywords(&["committed", "statistics", "summary", "commit", "range", "提交", "统计", "汇总", "范围"]))), adaptive_runtime_direct( change_summary_like(git_like(model_spec( - def( + runner_project_read_def( "review_changes", super::ToolAuditPolicy::typed_fields(&[ super::ToolAuditResultField::value("project"), @@ -65,21 +53,9 @@ pub(super) const SUMMARY_DEFINITIONS: &[ToolDefinition] = &[ .session_input(super::ToolAuditSessionInputPolicy::OmitTopLevel(&[ "continuation", ])), - ModelVisible, TOOL_CATEGORY_GIT, - Some(GitOrShell), - TOOL_PROVIDER_RUNNER, - super::ToolSemanticContract { - effect: super::ToolEffect::Observe, - risk: Read, - approval: super::ToolApprovalPolicy::None, - idempotency: super::ToolIdempotency::PureRead, - }, - Some(PROJECT_READ), - true, + GitOrShell, NoPath, - false, - false, super::ToolSessionEvidencePolicy::NONE .review(super::ToolReviewEvidence::DiffReview) .diff_review(super::ToolDiffReviewEvidence::Always), @@ -92,7 +68,7 @@ pub(super) const SUMMARY_DEFINITIONS: &[ToolDefinition] = &[ ), change_summary_like(git_like( model_spec( - def( + runner_project_read_def( "read_workspace_changes", super::ToolAuditPolicy::TYPED_CANONICAL.context( super::ToolAuditContextPolicy::Fields(&[ @@ -106,21 +82,9 @@ pub(super) const SUMMARY_DEFINITIONS: &[ToolDefinition] = &[ super::ToolAuditResultField::value("changed_files"), ]), ), - ModelVisible, TOOL_CATEGORY_GIT, - Some(GitOrShell), - TOOL_PROVIDER_RUNNER, - super::ToolSemanticContract { - effect: super::ToolEffect::Observe, - risk: Read, - approval: super::ToolApprovalPolicy::None, - idempotency: super::ToolIdempotency::PureRead, - }, - Some(PROJECT_READ), - true, + GitOrShell, NoPath, - false, - false, super::ToolSessionEvidencePolicy::NONE .review(super::ToolReviewEvidence::WorkspaceReview) .diff_review(super::ToolDiffReviewEvidence::ArgumentBool("include_diff")), @@ -155,25 +119,13 @@ pub(super) const DETAIL_DEFINITIONS: &[ToolDefinition] = &[ "Commit exactly requested changed file paths with an atomic expected_head fence and isolated temporary index; normal Git clean filters may run under job:run authority, ordinary commit hooks are bypassed so they cannot add unrelated paths, and the tool never pushes.", )), &[PROJECT_WRITE, JOB_RUN]), git_like(model_spec( - def( + runner_project_read_def( "get_git_status", super::ToolAuditPolicy::TYPED_CANONICAL .context(super::ToolAuditContextPolicy::WorkingTreeStatus), - ModelVisible, TOOL_CATEGORY_GIT, - Some(GitOrShell), - TOOL_PROVIDER_RUNNER, - super::ToolSemanticContract { - effect: super::ToolEffect::Observe, - risk: Read, - approval: super::ToolApprovalPolicy::None, - idempotency: super::ToolIdempotency::PureRead, - }, - Some(PROJECT_READ), - true, + GitOrShell, NoPath, - false, - false, super::ToolSessionEvidencePolicy::NONE.review(super::ToolReviewEvidence::WorkspaceReview), ) .with_composition_policy(super::ToolCompositionPolicy::Parallel) @@ -184,7 +136,7 @@ pub(super) const DETAIL_DEFINITIONS: &[ToolDefinition] = &[ ).with_discovery_keywords(&["check", "clean", "dirty", "working", "tree", "staged", "unstaged", "untracked", "status", "changes", "工作区", "干净", "状态", "暂存", "未暂存", "变动", "修改"])), change_summary_like(git_like( model_spec( - def( + runner_project_read_def( "read_git_diff_hunks", super::ToolAuditPolicy::typed_fields(&[ super::ToolAuditResultField::value("project"), @@ -202,21 +154,9 @@ pub(super) const DETAIL_DEFINITIONS: &[ToolDefinition] = &[ .session_input(super::ToolAuditSessionInputPolicy::OmitTopLevel(&[ "continuation", ])), - ModelVisible, TOOL_CATEGORY_GIT, - Some(GitOrShell), - TOOL_PROVIDER_RUNNER, - super::ToolSemanticContract { - effect: super::ToolEffect::Observe, - risk: Read, - approval: super::ToolApprovalPolicy::None, - idempotency: super::ToolIdempotency::PureRead, - }, - Some(PROJECT_READ), - true, + GitOrShell, NoPath, - false, - false, super::ToolSessionEvidencePolicy::NONE .review(super::ToolReviewEvidence::DiffReview) .diff_review(super::ToolDiffReviewEvidence::Always), @@ -229,7 +169,7 @@ pub(super) const DETAIL_DEFINITIONS: &[ToolDefinition] = &[ ).with_discovery_keywords(&["staged", "unstaged", "diff", "patch", "hunks", "lines", "暂存", "未暂存", "差异", "补丁", "变动", "差异行"]), )), git_like(model_spec( - def( + runner_project_read_def( "read_git_log", super::ToolAuditPolicy::TYPED_CANONICAL.context( super::ToolAuditContextPolicy::Fields(&[ @@ -240,21 +180,9 @@ pub(super) const DETAIL_DEFINITIONS: &[ToolDefinition] = &[ super::ToolAuditResultField::value("truncated"), ]), ), - ModelVisible, TOOL_CATEGORY_GIT, - Some(GitOrShell), - TOOL_PROVIDER_RUNNER, - super::ToolSemanticContract { - effect: super::ToolEffect::Observe, - risk: Read, - approval: super::ToolApprovalPolicy::None, - idempotency: super::ToolIdempotency::PureRead, - }, - Some(PROJECT_READ), - true, + GitOrShell, NoPath, - false, - false, super::ToolSessionEvidencePolicy::NONE, ).with_composition_policy(super::ToolCompositionPolicy::Parallel), "Return bounded structured recent git commit history for a project. The first page resolves current HEAD to one exact 40-hex head_commit and reads that commit snapshot; later pages may supply the same head_commit so branch movement or rewrites cannot drift the traversal. When truncated and bounded forward progress is available, suggested_call is the sole parser-ready continuation and carries project, exact head_commit, effective limit, next skip, and Session identity when present. next_skip remains domain metadata only; null means the final page or the existing 10000 skip bound prevents a safe forward page. If the exact commit snapshot is no longer available, fail closed rather than falling back to current HEAD. Retained-tail or malformed source records also fail closed. Does not return commit bodies or modify the worktree.", diff --git a/crates/webcodex-tool-contracts/src/tool_definition/lsp.rs b/crates/webcodex-tool-contracts/src/tool_definition/lsp.rs index 586992cac..e9285fb3e 100644 --- a/crates/webcodex-tool-contracts/src/tool_definition/lsp.rs +++ b/crates/webcodex-tool-contracts/src/tool_definition/lsp.rs @@ -1,196 +1,95 @@ use super::RunnerCapabilityRequirement::{LspCallHierarchy, LspReadOnlyNavigation}; -use super::ToolVisibility::ModelVisible; -use super::{def, model_spec, ToolDefinition, TOOL_CATEGORY_LSP}; -use crate::metadata::{ - ToolPathHint::{None as NoPath, SinglePath}, - ToolRisk::Read, - PROJECT_READ, TOOL_PROVIDER_RUNNER, -}; +use super::{model_spec, runner_project_read_def, ToolDefinition, TOOL_CATEGORY_LSP}; +use crate::metadata::ToolPathHint::{None as NoPath, SinglePath}; pub(super) const DEFINITIONS: &[ToolDefinition] = &[ model_spec( - def( + runner_project_read_def( "get_lsp_status", super::ToolAuditPolicy::TYPED_CANONICAL, - ModelVisible, TOOL_CATEGORY_LSP, - Some(LspReadOnlyNavigation), - TOOL_PROVIDER_RUNNER, - super::ToolSemanticContract { - effect: super::ToolEffect::Observe, - risk: Read, - approval: super::ToolApprovalPolicy::None, - idempotency: super::ToolIdempotency::PureRead, - }, - Some(PROJECT_READ), - true, + LspReadOnlyNavigation, NoPath, - false, - false, super::ToolSessionEvidencePolicy::NONE, ) .with_activity_kind(super::ToolActivityKind::Navigate), "Read-only probe of configured Runner-side language-server availability for a Project. Does not start a language server, run checks, or execute Project code. Returns detected languages and availability/running status without absolute executable paths.", ), model_spec( - def( + runner_project_read_def( "list_document_symbols", super::ToolAuditPolicy::TYPED_CANONICAL, - ModelVisible, TOOL_CATEGORY_LSP, - Some(LspReadOnlyNavigation), - TOOL_PROVIDER_RUNNER, - super::ToolSemanticContract { - effect: super::ToolEffect::Observe, - risk: Read, - approval: super::ToolApprovalPolicy::None, - idempotency: super::ToolIdempotency::PureRead, - }, - Some(PROJECT_READ), - true, + LspReadOnlyNavigation, SinglePath, - false, - false, super::ToolSessionEvidencePolicy::NONE.exploration(super::ToolExplorationEvidence::Navigation(super::ToolNavigationEvidenceKind::DocumentSymbols)), ), "Read-only hierarchical document symbols for a Project-relative supported source file via its configured Runner-side language server. Returns Project-relative paths, 1-based Unicode scalar columns, and bounded pre-order results. External or invalid ranges are omitted.", ), model_spec( - def( + runner_project_read_def( "read_document_diagnostics", super::ToolAuditPolicy::TYPED_CANONICAL, - ModelVisible, TOOL_CATEGORY_LSP, - Some(LspReadOnlyNavigation), - TOOL_PROVIDER_RUNNER, - super::ToolSemanticContract { - effect: super::ToolEffect::Observe, - risk: Read, - approval: super::ToolApprovalPolicy::None, - idempotency: super::ToolIdempotency::PureRead, - }, - Some(PROJECT_READ), - true, + LspReadOnlyNavigation, SinglePath, - false, - false, super::ToolSessionEvidencePolicy::NONE.exploration(super::ToolExplorationEvidence::Navigation(super::ToolNavigationEvidenceKind::DocumentDiagnostics)), ), "Read-only bounded diagnostics for a Project-relative supported source file via Runner-side publishDiagnostics. Returns normalized 1-based Unicode scalar ranges and explicit freshness/timeout state; it does not run a Project check.", ), model_spec( - def( + runner_project_read_def( "read_symbol_hover", super::ToolAuditPolicy::TYPED_CANONICAL, - ModelVisible, TOOL_CATEGORY_LSP, - Some(LspReadOnlyNavigation), - TOOL_PROVIDER_RUNNER, - super::ToolSemanticContract { - effect: super::ToolEffect::Observe, - risk: Read, - approval: super::ToolApprovalPolicy::None, - idempotency: super::ToolIdempotency::PureRead, - }, - Some(PROJECT_READ), - true, + LspReadOnlyNavigation, SinglePath, - false, - false, super::ToolSessionEvidencePolicy::NONE.exploration(super::ToolExplorationEvidence::Navigation(super::ToolNavigationEvidenceKind::Hover)), ), "Read a symbol's type and documentation at a 1-based Unicode scalar position through the Project's language server; no source edit. MarkupContent and MarkedString forms are normalized to bounded markdown/plaintext; invalid optional ranges are omitted.", ).with_discovery_keywords(&["symbol", "documentation", "type", "signature", "hover", "符号", "文档", "类型", "签名", "悬浮"]), model_spec( - def( + runner_project_read_def( "list_workspace_symbols", super::ToolAuditPolicy::TYPED_CANONICAL.session_input( super::ToolAuditSessionInputPolicy::OmitTopLevel(&["query"]), ), - ModelVisible, TOOL_CATEGORY_LSP, - Some(LspReadOnlyNavigation), - TOOL_PROVIDER_RUNNER, - super::ToolSemanticContract { - effect: super::ToolEffect::Observe, - risk: Read, - approval: super::ToolApprovalPolicy::None, - idempotency: super::ToolIdempotency::PureRead, - }, - Some(PROJECT_READ), - true, + LspReadOnlyNavigation, NoPath, - false, - false, super::ToolSessionEvidencePolicy::NONE.exploration(super::ToolExplorationEvidence::Navigation(super::ToolNavigationEvidenceKind::WorkspaceSymbols)), ), "Read-only bounded workspace/symbol query via configured Runner-side language servers. Requires a non-empty 1..200 character query; results are workspace-filtered, sorted, deduplicated, and use Project-relative paths only.", ), model_spec( - def( + runner_project_read_def( "find_definition", super::ToolAuditPolicy::TYPED_CANONICAL, - ModelVisible, TOOL_CATEGORY_LSP, - Some(LspReadOnlyNavigation), - TOOL_PROVIDER_RUNNER, - super::ToolSemanticContract { - effect: super::ToolEffect::Observe, - risk: Read, - approval: super::ToolApprovalPolicy::None, - idempotency: super::ToolIdempotency::PureRead, - }, - Some(PROJECT_READ), - true, + LspReadOnlyNavigation, SinglePath, - false, - false, super::ToolSessionEvidencePolicy::NONE.exploration(super::ToolExplorationEvidence::Navigation(super::ToolNavigationEvidenceKind::Locations)), ), "Read-only goto-definition for a Project-relative supported source file at a 1-based Unicode scalar position via its configured Runner-side language server. Supports Location, Location[], and LocationLink[]; external dependency results are omitted.", ), model_spec( - def( + runner_project_read_def( "find_references", super::ToolAuditPolicy::TYPED_CANONICAL, - ModelVisible, TOOL_CATEGORY_LSP, - Some(LspReadOnlyNavigation), - TOOL_PROVIDER_RUNNER, - super::ToolSemanticContract { - effect: super::ToolEffect::Observe, - risk: Read, - approval: super::ToolApprovalPolicy::None, - idempotency: super::ToolIdempotency::PureRead, - }, - Some(PROJECT_READ), - true, + LspReadOnlyNavigation, SinglePath, - false, - false, super::ToolSessionEvidencePolicy::NONE.exploration(super::ToolExplorationEvidence::Navigation(super::ToolNavigationEvidenceKind::Locations)), ), "Read-only find-references for a Project-relative supported source file at a 1-based Unicode scalar position via its configured Runner-side language server. Results are deduplicated and truncated on the Runner; external/invalid locations are counted separately.", ), model_spec( - def( + runner_project_read_def( "read_call_hierarchy", super::ToolAuditPolicy::TYPED_CANONICAL, - ModelVisible, TOOL_CATEGORY_LSP, - Some(LspCallHierarchy), - TOOL_PROVIDER_RUNNER, - super::ToolSemanticContract { - effect: super::ToolEffect::Observe, - risk: Read, - approval: super::ToolApprovalPolicy::None, - idempotency: super::ToolIdempotency::PureRead, - }, - Some(PROJECT_READ), - true, + LspCallHierarchy, SinglePath, - false, - false, super::ToolSessionEvidencePolicy::NONE.exploration(super::ToolExplorationEvidence::Navigation(super::ToolNavigationEvidenceKind::CallHierarchy)), ), "Read-only bounded call hierarchy for a project-relative supported source position. The Runner performs prepare plus incoming/outgoing breadth-first traversal to depth 1 or 2, returning only normalized project-local symbols and globally bounded edges.", From c7c94b18eaeb2254f04865cd21a81b1ad7a08ec6 Mon Sep 17 00:00:00 2001 From: yyjeqhc <1772413353@qq.com> Date: Wed, 21 Oct 2026 15:03:38 +0800 Subject: [PATCH 2/2] fix(tooling): discover shared project-read tool definitions --- scripts/agent_tool_discovery.py | 2 +- .../tests/test_agent_tool_discovery_cases.py | 28 +++++++++++++++---- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/scripts/agent_tool_discovery.py b/scripts/agent_tool_discovery.py index 95176d2a6..e0e9a95ef 100644 --- a/scripts/agent_tool_discovery.py +++ b/scripts/agent_tool_discovery.py @@ -104,7 +104,7 @@ def _catalog(): sources[path] = raw.decode("utf-8") names = set() for path in paths: - names.update(re.findall(r'\b(?:def|control_def|resource_tool|docx_read_definition|document_read_definition)\(\s*"([a-z_]+)"', sources[path])) + names.update(re.findall(r'\b(?:def|control_def|runner_project_read_def|resource_tool|docx_read_definition|document_read_definition)\(\s*"([a-z_]+)"', sources[path])) scopes = set(re.findall(r'pub const SCOPE_[A-Z_]+: &str = "([a-z_:]+)";', sources[authority_path])) categories = set(re.findall(r'pub const TOOL_CATEGORY_[A-Z_]+: &str = "([a-z_]+)";', sources[paths[0]])) return names, scopes, categories diff --git a/scripts/tests/test_agent_tool_discovery_cases.py b/scripts/tests/test_agent_tool_discovery_cases.py index 7de7e1898..3f876822b 100644 --- a/scripts/tests/test_agent_tool_discovery_cases.py +++ b/scripts/tests/test_agent_tool_discovery_cases.py @@ -33,23 +33,37 @@ def canonical_definitions(): particular, additional scopes, capability checks, admission, and Host visibility remain the evaluator/runtime's responsibility. """ + constructor_source = (CONTRACTS / "tool_definition.rs").read_text(encoding="utf-8") + read_constructor = re.search( + r'^const fn runner_project_read_def\b.*?^}', constructor_source, re.MULTILINE | re.DOTALL, + ) + if read_constructor is None: + raise ValueError("runner_project_read_def constructor is missing") definitions = {} for path in sorted((CONTRACTS / "tool_definition").glob("*.rs")): source = path.read_text(encoding="utf-8") - matches = list(re.finditer(r'\b(?:def|control_def)\(\s*"([a-z_]+)"', source)) + matches = list(re.finditer( + r'\b(?Pdef|control_def|runner_project_read_def)\(\s*"(?P[a-z_]+)"', + source, + )) for index, match in enumerate(matches): end = matches[index + 1].start() if index + 1 < len(matches) else len(source) body = source[match.end():end] category = re.search(r'\bTOOL_CATEGORY_([A-Z_]+)\b', body) + policy_body = ( + read_constructor.group(0) + if match.group("constructor") == "runner_project_read_def" + else body + ) scope = re.search( r'ToolIdempotency::\w+\s*,?\s*},\s*Some\((?:crate::metadata::)?([A-Z_]+)\)', - body, + policy_body, ) if category and scope: - definitions[match.group(1)] = { + definitions[match.group("name")] = { "category": category.group(1).lower(), "scope_constant": scope.group(1), - "model_visible": bool(re.search(r'\bModelVisible\s*,', body)), + "model_visible": bool(re.search(r'\bModelVisible\s*,', policy_body)), } authority = (ROOT / "crates/webcodex-core/src/authority.rs").read_text(encoding="utf-8") scopes = dict(re.findall(r'pub const SCOPE_([A-Z_]+): &str = "([a-z_:]+)";', authority)) @@ -67,13 +81,17 @@ def setUpClass(cls): cls.cases = cls.corpus["cases"] cls.definitions, cls.scopes = canonical_definitions() - def test_control_definitions_preserve_category_scope_and_visibility(self): + def test_constructors_preserve_category_scope_and_visibility(self): for name, category, scope, visible in [ ("create_goal", "goal", "COMMUNICATION_MANAGE", True), ("list_agent_identities", "communication", "COMMUNICATION_READ", True), ("create_agent_task", "agent_task", "COMMUNICATION_MANAGE", True), ("wait_for_agent_events", "agent_wait", "COMMUNICATION_MANAGE", True), ("get_agent_wait_state", "agent_wait", "COMMUNICATION_READ", False), + ("read_files", "file", "PROJECT_READ", True), + ("get_git_status", "git", "PROJECT_READ", True), + ("list_document_symbols", "lsp", "PROJECT_READ", True), + ("read_project_overview", "project", "PROJECT_READ", True), ]: with self.subTest(tool=name): self.assertEqual(self.definitions.get(name), {