feat: add explicit Worker WIP mode
This commit is contained in:
@@ -3,6 +3,7 @@ name = "manifest"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
arc-swap = "1"
|
||||
|
||||
@@ -590,6 +590,8 @@ impl From<FeatureConfig> for FeatureConfigPartial {
|
||||
pub struct WorkerMetaConfig {
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub mode: Option<crate::WorkerMode>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
@@ -925,6 +927,7 @@ impl WorkerMetaConfig {
|
||||
fn merge(self, upper: Self) -> Self {
|
||||
Self {
|
||||
name: upper.name.or(self.name),
|
||||
mode: upper.mode.or(self.mode),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1356,7 +1359,10 @@ impl TryFrom<WorkerManifestConfig> for WorkerManifest {
|
||||
validate_mcp_config(&cfg.mcp)?;
|
||||
|
||||
Ok(WorkerManifest {
|
||||
worker: WorkerMeta { name },
|
||||
worker: WorkerMeta {
|
||||
name,
|
||||
mode: cfg.worker.mode.unwrap_or_default(),
|
||||
},
|
||||
model: cfg.model,
|
||||
engine,
|
||||
scope: cfg.scope,
|
||||
@@ -1391,6 +1397,7 @@ mod tests {
|
||||
WorkerManifestConfig {
|
||||
worker: WorkerMetaConfig {
|
||||
name: Some("test".into()),
|
||||
mode: None,
|
||||
},
|
||||
model: ModelManifest {
|
||||
scheme: Some(SchemeKind::Anthropic),
|
||||
@@ -1498,10 +1505,31 @@ mod tests {
|
||||
fn resolve_minimal_succeeds() {
|
||||
let manifest: WorkerManifest = minimal_valid().try_into().unwrap();
|
||||
assert_eq!(manifest.worker.name, "test");
|
||||
assert_eq!(manifest.worker.mode, crate::WorkerMode::Tools);
|
||||
assert_eq!(manifest.model.scheme, Some(SchemeKind::Anthropic));
|
||||
assert!(manifest.permissions.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_mode_is_explicit_opt_in_and_persists_in_snapshots() {
|
||||
let mut configured = minimal_valid();
|
||||
configured.worker.mode = Some(crate::WorkerMode::Wip);
|
||||
let manifest: WorkerManifest = configured.try_into().unwrap();
|
||||
assert_eq!(manifest.worker.mode, crate::WorkerMode::Wip);
|
||||
|
||||
let snapshot = crate::write_persisted_worker_manifest_snapshot(&manifest).unwrap();
|
||||
let restored = crate::read_persisted_worker_manifest_snapshot(snapshot.clone()).unwrap();
|
||||
assert_eq!(restored.worker.mode, crate::WorkerMode::Wip);
|
||||
|
||||
let mut legacy = snapshot;
|
||||
legacy["manifest"]["worker"]
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove("mode");
|
||||
let restored = crate::read_persisted_worker_manifest_snapshot(legacy).unwrap();
|
||||
assert_eq!(restored.worker.mode, crate::WorkerMode::Tools);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_mcp_stdio_config_preserves_explicit_policy() {
|
||||
let mut cfg = minimal_valid();
|
||||
@@ -1784,6 +1812,7 @@ mod tests {
|
||||
let lower = WorkerManifestConfig {
|
||||
worker: WorkerMetaConfig {
|
||||
name: Some("lower".into()),
|
||||
mode: None,
|
||||
},
|
||||
model: ModelManifest {
|
||||
model_id: Some("lower-model".into()),
|
||||
@@ -1794,6 +1823,7 @@ mod tests {
|
||||
let upper = WorkerManifestConfig {
|
||||
worker: WorkerMetaConfig {
|
||||
name: Some("upper".into()),
|
||||
mode: None,
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
@@ -2248,6 +2278,7 @@ enabled = false
|
||||
.merge(WorkerManifestConfig {
|
||||
worker: WorkerMetaConfig {
|
||||
name: Some("feature-test".into()),
|
||||
mode: None,
|
||||
},
|
||||
model: ModelManifest {
|
||||
scheme: Some(SchemeKind::Anthropic),
|
||||
@@ -2351,6 +2382,7 @@ enabled = true
|
||||
.merge(WorkerManifestConfig {
|
||||
worker: WorkerMetaConfig {
|
||||
name: Some("feature-merge-test".into()),
|
||||
mode: None,
|
||||
},
|
||||
model: ModelManifest {
|
||||
scheme: Some(SchemeKind::Anthropic),
|
||||
@@ -2428,6 +2460,7 @@ permission = "write"
|
||||
let overlay = WorkerManifestConfig {
|
||||
worker: WorkerMetaConfig {
|
||||
name: Some("x".into()),
|
||||
mode: None,
|
||||
},
|
||||
model: ModelManifest {
|
||||
scheme: Some(SchemeKind::Anthropic),
|
||||
|
||||
@@ -670,6 +670,21 @@ pub struct WorkspaceMemorySettingsSnapshot {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WorkerMeta {
|
||||
pub name: String,
|
||||
/// Model-facing capability surface. Existing manifests default to ordinary tools.
|
||||
#[serde(default)]
|
||||
pub mode: WorkerMode,
|
||||
}
|
||||
|
||||
/// Model-facing Worker capability surface.
|
||||
///
|
||||
/// WIP is explicit opt-in: it replaces individual model-visible tools with the
|
||||
/// discover/inspect/call Worldspace client while retaining the same host authority.
|
||||
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WorkerMode {
|
||||
#[default]
|
||||
Tools,
|
||||
Wip,
|
||||
}
|
||||
|
||||
/// When the engine may begin executing provider-confirmed tool calls.
|
||||
|
||||
@@ -626,6 +626,7 @@ fn resolve_profile_value(
|
||||
let config = WorkerManifestConfig {
|
||||
worker: WorkerMetaConfig {
|
||||
name: Some(worker_name),
|
||||
mode: profile.worker.and_then(|worker| worker.mode),
|
||||
},
|
||||
model: profile.model.unwrap_or_default(),
|
||||
engine: profile.engine.unwrap_or_default(),
|
||||
@@ -668,6 +669,8 @@ struct ProfileConfig {
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
#[serde(default)]
|
||||
worker: Option<ProfileWorkerConfig>,
|
||||
#[serde(default)]
|
||||
model: Option<ModelManifest>,
|
||||
#[serde(default)]
|
||||
engine: Option<EngineManifestConfig>,
|
||||
@@ -691,6 +694,13 @@ struct ProfileConfig {
|
||||
skills: Option<SkillsConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct ProfileWorkerConfig {
|
||||
#[serde(default)]
|
||||
mode: Option<crate::WorkerMode>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum ProfileScopeConfig {
|
||||
@@ -864,8 +874,15 @@ fn reject_manifest_shaped_profile(value: &serde_json::Value) -> Result<(), Profi
|
||||
)));
|
||||
}
|
||||
}
|
||||
if map.contains_key("worker") {
|
||||
return Err(ProfileError::InvalidProfile("field `worker` is runtime-bound and is not allowed in reusable Profiles; pass the Worker name via CLI/TUI runtime inputs".into()));
|
||||
if let Some(worker) = map.get("worker") {
|
||||
let Some(worker) = worker.as_object() else {
|
||||
return Err(ProfileError::InvalidProfile(
|
||||
"field `worker` must be an object containing only reusable `mode` policy".into(),
|
||||
));
|
||||
};
|
||||
if worker.contains_key("name") {
|
||||
return Err(ProfileError::InvalidProfile("field `worker.name` is runtime-bound and is not allowed in reusable Profiles; pass the Worker name via CLI/TUI runtime inputs".into()));
|
||||
}
|
||||
}
|
||||
if let Some(scope) = map.get("scope").and_then(|v| v.as_object()) {
|
||||
for key in ["allow", "deny"] {
|
||||
@@ -1691,6 +1708,7 @@ mod tests {
|
||||
r#"
|
||||
slug = "coder"
|
||||
scope = "workspace_read"
|
||||
worker = { mode = "wip" }
|
||||
|
||||
[model]
|
||||
scheme = "anthropic"
|
||||
@@ -1710,6 +1728,7 @@ reasoning = "high"
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(resolved.manifest.worker.name, "runtime-worker");
|
||||
assert_eq!(resolved.manifest.worker.mode, crate::WorkerMode::Wip);
|
||||
assert_eq!(resolved.manifest.model.scheme, Some(SchemeKind::Anthropic));
|
||||
assert_eq!(
|
||||
resolved.manifest.engine.reasoning,
|
||||
|
||||
@@ -3,6 +3,7 @@ name = "worker"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
autobins = false
|
||||
|
||||
[features]
|
||||
@@ -34,6 +35,7 @@ chrono = "0.4"
|
||||
config-source = { path = "../config-source" }
|
||||
include_dir = "0.7.4"
|
||||
fs4 = { workspace = true, features = ["sync"] }
|
||||
http = { workspace = true }
|
||||
flow = { path = "../flow" }
|
||||
fs-operation = { workspace = true }
|
||||
libc = { workspace = true }
|
||||
@@ -46,6 +48,10 @@ arc-swap = "1.9.1"
|
||||
tungstenite = { version = "0.28.0", default-features = false, features = ["handshake", "native-tls", "url"] }
|
||||
tokio-tungstenite = { version = "0.28.0", default-features = false, features = ["native-tls", "connect"] }
|
||||
futures-util = { version = "0.3", features = ["sink"] }
|
||||
jsonschema = { version = "0.33.0", default-features = false }
|
||||
wip-client = { workspace = true }
|
||||
wip-http = { workspace = true }
|
||||
wip-protocol = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
dotenv = "0.15.0"
|
||||
|
||||
@@ -1631,6 +1631,23 @@ where
|
||||
if let Some((resident_summary, system_prompt_override)) = feature_prompt_contribution {
|
||||
worker.install_system_prompt_contribution(resident_summary, system_prompt_override);
|
||||
}
|
||||
if worker.manifest().worker.mode == manifest::WorkerMode::Wip {
|
||||
let permissions = worker.manifest().permissions.clone();
|
||||
let security_context = format!(
|
||||
"workspace={};worker={};session={}",
|
||||
worker.workspace_id().map_or("standalone", |id| id.as_str()),
|
||||
worker.manifest().worker.name,
|
||||
worker.session_id()
|
||||
);
|
||||
crate::wip::install_wip_mode(worker.engine_mut(), permissions, security_context).map_err(
|
||||
|error| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("install WIP Worker surface: {error}"),
|
||||
)
|
||||
},
|
||||
)?;
|
||||
}
|
||||
worker.attach_tracker(tracker);
|
||||
Ok(workdir_for_view)
|
||||
}
|
||||
|
||||
@@ -123,6 +123,12 @@ where
|
||||
authority,
|
||||
} = spec;
|
||||
|
||||
// Feature-owned private Internal Workers use their explicitly supplied tool
|
||||
// composition. They are not model-facing direct SubWorkers and must not
|
||||
// inherit a parent's WIP surface (which is finalized only by the normal
|
||||
// controller path); keeping Tool mode also preserves manifest permission
|
||||
// hooks for these private jobs.
|
||||
manifest.worker.mode = manifest::WorkerMode::Tools;
|
||||
// Internal identities are run-scoped and never enter the public Runtime Worker catalog.
|
||||
manifest.worker.name = format!("internal-{}-{}", identity.kind, identity.run_id);
|
||||
// Internal jobs only receive the explicitly supplied Feature set below. A
|
||||
|
||||
@@ -20,6 +20,7 @@ pub mod shared_state;
|
||||
mod shutdown_after_idle;
|
||||
pub mod skill;
|
||||
pub mod spawn;
|
||||
pub mod wip;
|
||||
|
||||
mod internal_worker;
|
||||
mod interrupt_prep;
|
||||
|
||||
@@ -21,16 +21,7 @@ impl PermissionHook {
|
||||
}
|
||||
|
||||
fn action_for(&self, input: &ToolCallSummary) -> ToolPermissionAction {
|
||||
let target = permission_target(&input.arguments);
|
||||
self.config
|
||||
.rules
|
||||
.iter()
|
||||
.find(|rule| {
|
||||
rule.tool.eq_ignore_ascii_case(&input.tool_name)
|
||||
&& wildcard_match(&rule.pattern, &target)
|
||||
})
|
||||
.map(|rule| rule.action)
|
||||
.unwrap_or(self.config.default_action)
|
||||
permission_action_for(&self.config, &input.tool_name, &input.arguments)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +30,9 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
||||
let Some(permissions) = self.manifest().permissions.clone() else {
|
||||
return;
|
||||
};
|
||||
self.add_pre_tool_call_hook(PermissionHook::new(permissions));
|
||||
if self.manifest().worker.mode == manifest::WorkerMode::Tools {
|
||||
self.add_pre_tool_call_hook(PermissionHook::new(permissions));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +52,22 @@ impl Hook<PreToolCall> for PermissionHook {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn permission_action_for(
|
||||
config: &ToolPermissionConfig,
|
||||
tool_name: &str,
|
||||
arguments: &Value,
|
||||
) -> ToolPermissionAction {
|
||||
let target = permission_target(arguments);
|
||||
config
|
||||
.rules
|
||||
.iter()
|
||||
.find(|rule| {
|
||||
rule.tool.eq_ignore_ascii_case(tool_name) && wildcard_match(&rule.pattern, &target)
|
||||
})
|
||||
.map(|rule| rule.action)
|
||||
.unwrap_or(config.default_action)
|
||||
}
|
||||
|
||||
fn permission_denied_message(input: &ToolCallSummary) -> String {
|
||||
format!(
|
||||
"permission denied: tool `{}` arguments matched the manifest permission policy",
|
||||
|
||||
@@ -883,6 +883,7 @@ fn build_spawn_config_json(
|
||||
let config = WorkerManifestConfig {
|
||||
worker: WorkerMetaConfig {
|
||||
name: Some(name.to_string()),
|
||||
mode: None,
|
||||
},
|
||||
model: model.clone(),
|
||||
engine: EngineManifestConfig {
|
||||
@@ -957,6 +958,7 @@ fn manifest_to_reusable_config(manifest: &WorkerManifest) -> WorkerManifestConfi
|
||||
WorkerManifestConfig {
|
||||
worker: WorkerMetaConfig {
|
||||
name: Some(manifest.worker.name.clone()),
|
||||
mode: Some(manifest.worker.mode),
|
||||
},
|
||||
model: manifest.model.clone(),
|
||||
engine: EngineManifestConfig {
|
||||
@@ -1871,6 +1873,7 @@ enabled = false
|
||||
WorkerManifestConfig {
|
||||
worker: WorkerMetaConfig {
|
||||
name: Some("parent".into()),
|
||||
mode: None,
|
||||
},
|
||||
model: ModelManifest {
|
||||
scheme: Some(SchemeKind::Anthropic),
|
||||
@@ -2188,7 +2191,8 @@ max_tokens = 3333
|
||||
let delegated = tmp.path().join("delegated");
|
||||
std::fs::create_dir_all(&parent_deny).unwrap();
|
||||
std::fs::create_dir_all(&delegated).unwrap();
|
||||
let parent = parent_manifest(&parent_root, Some(&parent_deny));
|
||||
let mut parent = parent_manifest(&parent_root, Some(&parent_deny));
|
||||
parent.worker.mode = manifest::WorkerMode::Wip;
|
||||
let scope = vec![abs_rule(&delegated, Permission::Read)];
|
||||
let available = AvailableProfiles {
|
||||
registry: None,
|
||||
@@ -2206,6 +2210,7 @@ max_tokens = 3333
|
||||
);
|
||||
|
||||
assert_eq!(config.worker.name.as_deref(), Some("inherited-child"));
|
||||
assert_eq!(config.worker.mode, Some(manifest::WorkerMode::Wip));
|
||||
assert_eq!(config.model.model_id.as_deref(), Some("parent-model"));
|
||||
assert_eq!(config.engine.instruction.as_deref(), Some("default"));
|
||||
assert_eq!(config.engine.language.as_deref(), Some("Parentish"));
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user