diff --git a/iii-directory/README.md b/iii-directory/README.md index 8f11c41e3..b70125992 100644 --- a/iii-directory/README.md +++ b/iii-directory/README.md @@ -95,6 +95,8 @@ or use the console Workers tab — all three propagate without a redeploy. skills_folder: ~/.iii/skills # read/write root for skills + prompts local_skills_folder: ./.iii/skills # project-scoped overrides (whole-namespace local-wins) auto_download: true # subscribe to worker-add + run the boot reconcile +inject_guidance: false # bind a pre-generate hook that points agent prompts + # at directory::skills::index (opt-in) # TUNABLE — hot-reload live on `configuration:updated`. registry_url: https://api.workers.iii.dev # workers registry base URL @@ -105,6 +107,16 @@ filter_unregistered: true # hide skills whose namespace isn't The `skills_folder` is created on first download if it doesn't exist. +### Prompt guidance hook + +With `inject_guidance: true`, the worker binds a `harness::hook::pre-generate` +trigger (`on_error: fail_open`) at boot and appends a short pointer to +`directory::skills::index` to the agent system prompt while it is connected. +Pointer only: the index body costs context solely when an agent calls the +function. The hook is a no-op when the base prompt is empty or already +mentions the index function. Off by default so a stock deployment leaves +prompts untouched. + ### Zero-config default + seed With no seed and no stored value the worker uses built-in defaults @@ -120,8 +132,8 @@ On `configuration::set` (or an external edit to the persisted file), the worker re-fetches the authoritative value. Tunable changes apply in place and the registry caches are cleared so a repointed `registry_url` takes effect immediately. Topology changes (`skills_folder` / `local_skills_folder` / -`auto_download`) are refused with a "restart required" log; the previous -configuration is kept until the worker restarts. +`auto_download` / `inject_guidance`) are refused with a "restart required" +log; the previous configuration is kept until the worker restarts. --- diff --git a/iii-directory/src/config.rs b/iii-directory/src/config.rs index 42abedd4d..be6cdad1f 100644 --- a/iii-directory/src/config.rs +++ b/iii-directory/src/config.rs @@ -61,6 +61,10 @@ fn default_auto_download() -> bool { true } +fn default_inject_guidance() -> bool { + false +} + #[derive(Deserialize, Serialize, Debug, Clone, JsonSchema)] pub struct SkillsConfig { /// Folder that backs every read (`directory::skills::list`, @@ -113,6 +117,15 @@ pub struct SkillsConfig { /// folder. #[serde(default = "default_auto_download")] pub auto_download: bool, + + /// When `true`, bind a `harness::hook::pre-generate` trigger at boot + /// that appends a short pointer to `directory::skills::index` to the + /// agent system prompt while this worker is connected. Off by default + /// so a stock deployment (including the harness integration stack) + /// leaves prompts untouched; enabling it is an operator decision. + /// Boot-time wiring: changing it requires a worker restart. + #[serde(default = "default_inject_guidance")] + pub inject_guidance: bool, } impl Default for SkillsConfig { @@ -125,6 +138,7 @@ impl Default for SkillsConfig { registry_cache_ttl_ms: default_registry_cache_ttl_ms(), filter_unregistered: default_filter_unregistered(), auto_download: default_auto_download(), + inject_guidance: default_inject_guidance(), } } } @@ -195,6 +209,7 @@ impl SkillsConfig { skills_folder: self.skills_folder.clone(), local_skills_folder: self.local_skills_folder.clone(), auto_download: self.auto_download, + inject_guidance: self.inject_guidance, } } @@ -251,6 +266,7 @@ pub struct Topology { pub skills_folder: String, pub local_skills_folder: String, pub auto_download: bool, + pub inject_guidance: bool, } pub fn load_config(path: &str) -> Result { diff --git a/iii-directory/src/configuration.rs b/iii-directory/src/configuration.rs index 302947284..0134364f5 100644 --- a/iii-directory/src/configuration.rs +++ b/iii-directory/src/configuration.rs @@ -198,8 +198,9 @@ async fn on_config_change(iii: &IIIClient, state: &SharedState) { }; if cfg.topology() != state.boot_topology { tracing::warn!( - "configuration change alters topology (skills_folder, local_skills_folder, or \ - auto_download); a restart is required to apply it — keeping previous configuration" + "configuration change alters topology (skills_folder, local_skills_folder, \ + auto_download, or inject_guidance); a restart is required to apply it — keeping \ + previous configuration" ); return; } diff --git a/iii-directory/src/guidance.rs b/iii-directory/src/guidance.rs new file mode 100644 index 000000000..bdd0c6280 --- /dev/null +++ b/iii-directory/src/guidance.rs @@ -0,0 +1,180 @@ +//! `directory::inject-guidance` — a `pre_generate` hook that points the +//! agent's system prompt at the skills index, ONLY while this worker is +//! connected. The hook is bound at worker startup; the engine parks a +//! binding whose trigger type is not registered yet as a pending intent +//! and activates it when the type appears (recoverable triggers), which +//! also covers a harness restart. Presence-gated: without iii-directory +//! the hook function is gone and the fail_open binding contributes +//! nothing. Mirrors fp/src/guidance.rs. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use serde_json::json; + +use iii_sdk::errors::Error; +use iii_sdk::protocol::RegisterTriggerInput; +use iii_sdk::{IIIClient, RegisterFunction}; + +const GUIDANCE_HOOK_ID: &str = "directory::inject-guidance"; +const GUIDANCE_HOOK_DESC: &str = + "Internal pre_generate hook: appends a pointer to the worker skills index to the agent \ + system prompt. Bound to harness::hook::pre-generate at worker startup; not called directly."; + +/// The harness-PROVIDED trigger type this worker binds to (NOT an engine +/// built-in). +const PRE_GENERATE_TRIGGER_TYPE: &str = "harness::hook::pre-generate"; + +/// The pointer appended to the system prompt. Deliberately a pointer, not +/// content: the index body stays out of every turn's context and is paid +/// for only when the agent actually calls the function. +const GUIDANCE: &str = "## Worker knowledge index\n\n\ +A skills index covering every connected worker exists. Call directory::skills::index for a \ +one-paragraph map of what each worker does, then directory::skills::get { \"id\": \"\" } \ +for the full guide before first use of an unfamiliar worker."; + +/// The slice of the `pre_generate` hook envelope we read (lenient: +/// ignores every other field the harness sends). +#[derive(Debug, Default, Deserialize, JsonSchema)] +struct PreGenerateEvent { + #[serde(default)] + generate: GenerateContext, +} + +#[derive(Debug, Default, Deserialize, JsonSchema)] +struct GenerateContext { + /// The system prompt assembled so far (base + any prior hook's mutation). + #[serde(default)] + system_prompt: String, +} + +/// Hook envelope returned to the harness: the mutations to apply. +#[derive(Debug, Serialize, JsonSchema)] +struct PreGenerateResponse { + mutations: PreGenerateMutations, +} + +/// The harness applies `system_prompt` only when the key is present, so +/// `None` serializes to an empty object: the safe no-op that preserves +/// the harness's assembled prompt. +#[derive(Debug, Default, Serialize, JsonSchema)] +struct PreGenerateMutations { + /// Full replacement system prompt (base + appended pointer). The + /// harness overwrites, it does not merge. + #[serde(skip_serializing_if = "Option::is_none")] + system_prompt: Option, +} + +/// Build the `pre_generate` mutations for a given base prompt. Pure, so +/// it's unit-testable. +/// +/// Returns NO `system_prompt` when `base` is empty (a missing or renamed +/// `generate.system_prompt` field deserializes to `""`, and a fail-open +/// hook must preserve the harness's assembled prompt, never replace it +/// with the pointer alone) or when the base already mentions the index +/// function (avoids duplicating a prompt variant that covers it). +fn mutations_for(base: &str) -> PreGenerateMutations { + if base.is_empty() || base.contains("directory::skills::index") { + PreGenerateMutations::default() + } else { + PreGenerateMutations { + system_prompt: Some(format!("{base}\n\n{GUIDANCE}")), + } + } +} + +async fn handle(event: PreGenerateEvent) -> Result { + Ok(PreGenerateResponse { + mutations: mutations_for(&event.generate.system_prompt), + }) +} + +/// Register the guidance hook function and bind it to the harness +/// pre-generate trigger type. One shot: if the harness is not up yet, +/// the engine parks the binding and activates it when the type registers. +/// `on_error: fail_open` is MANDATORY: pre_generate defaults fail-CLOSED, +/// which would abort generation if this hook ever errored or timed out; a +/// missing pointer must never block a turn. +pub fn setup(iii: &IIIClient) { + iii.register_function( + GUIDANCE_HOOK_ID, + RegisterFunction::new_async( + move |event: PreGenerateEvent| async move { handle(event).await }, + ) + .description(GUIDANCE_HOOK_DESC) + .metadata(json!({ "internal": true })), + ); + + match iii.register_trigger(RegisterTriggerInput { + trigger_type: PRE_GENERATE_TRIGGER_TYPE.to_string(), + function_id: GUIDANCE_HOOK_ID.to_string(), + config: json!({ "on_error": "fail_open" }), + metadata: None, + }) { + Ok(_) => tracing::info!( + trigger_type = PRE_GENERATE_TRIGGER_TYPE, + function_id = GUIDANCE_HOOK_ID, + "guidance hook binding requested" + ), + Err(e) => tracing::warn!( + trigger_type = PRE_GENERATE_TRIGGER_TYPE, + function_id = GUIDANCE_HOOK_ID, + error = %e, + "guidance hook binding failed" + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_base_is_a_no_op() { + let m = mutations_for(""); + assert!(m.system_prompt.is_none(), "empty base must not be replaced"); + } + + #[test] + fn base_mentioning_index_is_a_no_op() { + let m = mutations_for("Use directory::skills::index for discovery."); + assert!( + m.system_prompt.is_none(), + "a base that already covers the index must not be duplicated" + ); + } + + #[test] + fn pointer_is_appended_to_base() { + let m = mutations_for("You are an agent."); + let prompt = m.system_prompt.expect("pointer appended"); + assert!(prompt.starts_with("You are an agent.")); + assert!(prompt.contains("Worker knowledge index")); + } + + #[test] + fn no_op_serializes_to_empty_mutations() { + let wire = serde_json::to_value(PreGenerateResponse { + mutations: mutations_for(""), + }) + .expect("response serializes"); + assert_eq!(wire, serde_json::json!({ "mutations": {} })); + } + + /// Mirrors the registry publish gate (`collect_worker_interface.py`): the + /// derived response schema must carry a schema-defining keyword, not the + /// AnyValue schema. + #[test] + fn response_schema_passes_the_publish_typed_gate() { + let schema = schemars::r#gen::SchemaSettings::draft07() + .into_generator() + .into_root_schema_for::(); + let value = serde_json::to_value(schema).expect("schema serializes"); + let obj = value.as_object().expect("schema is an object"); + assert!( + ["type", "properties", "$ref"] + .iter() + .any(|k| obj.contains_key(*k)), + "PreGenerateResponse schema is untyped: {value}" + ); + } +} diff --git a/iii-directory/src/lib.rs b/iii-directory/src/lib.rs index a32f40421..60e7f5fad 100644 --- a/iii-directory/src/lib.rs +++ b/iii-directory/src/lib.rs @@ -41,6 +41,7 @@ pub mod config; pub mod configuration; pub mod fs_source; pub mod functions; +pub mod guidance; pub mod manifest; pub mod sources; pub mod trigger_types; diff --git a/iii-directory/src/main.rs b/iii-directory/src/main.rs index cc9c8561d..27a172238 100644 --- a/iii-directory/src/main.rs +++ b/iii-directory/src/main.rs @@ -42,7 +42,7 @@ use iii_directory::functions::skills::{ make_registered_cache, RegisteredWorkersCache, ENGINE_NAMESPACE, }; use iii_directory::sources::registry::VersionSpec; -use iii_directory::{configuration, functions, manifest, trigger_types}; +use iii_directory::{configuration, functions, guidance, manifest, trigger_types}; #[derive(Parser, Debug)] #[command( @@ -159,6 +159,9 @@ async fn main() -> Result<()> { registry_cache.clone(), ); functions::log_fs_health(&cfg_handle.load_full()); + if cfg_handle.load_full().inject_guidance { + guidance::setup(&iii); + } // Auto-download: subscribe to worker add events + boot reconcile. Wired // from the boot value of `auto_download` (a topology field — changing it