Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions iii-directory/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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.

---

Expand Down
16 changes: 16 additions & 0 deletions iii-directory/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down Expand Up @@ -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 {
Expand All @@ -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(),
}
}
}
Expand Down Expand Up @@ -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,
}
}

Expand Down Expand Up @@ -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<SkillsConfig> {
Expand Down
5 changes: 3 additions & 2 deletions iii-directory/src/configuration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
180 changes: 180 additions & 0 deletions iii-directory/src/guidance.rs
Original file line number Diff line number Diff line change
@@ -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\": \"<worker>\" } \
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<String>,
}

/// 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<PreGenerateResponse, Error> {
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::<PreGenerateResponse>();
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}"
);
}
}
1 change: 1 addition & 0 deletions iii-directory/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
5 changes: 4 additions & 1 deletion iii-directory/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down
Loading