Skip to content
Merged
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: 15 additions & 1 deletion .github/scripts/tests/test_validate_worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,13 +116,27 @@ def test_missing_name_field_fails(self, tmp_path):
assert r.returncode != 0, r.stdout + r.stderr
assert "name" in r.stdout + r.stderr

def test_version_must_be_strictly_greater_than_base(self, tmp_path):
def test_version_equal_to_base_passes(self, tmp_path):
repo = make_worker(tmp_path, "smoke", version="1.0.0")
init_git(repo)
# Touch source so worker is source_changed, but don't bump version.
(repo / "smoke" / "src.rs").write_text("// touch\n")
subprocess.run(["git", "add", "."], cwd=repo, check=True, env=GIT_HERMETIC_ENV)
subprocess.run(["git", "commit", "-q", "-m", "touch"], cwd=repo, check=True, env=GIT_HERMETIC_ENV)
r = run_script(repo, "smoke", base_ref="main~1", source_changed=["smoke"])
assert r.returncode == 0, r.stdout + r.stderr

def test_version_less_than_base_fails(self, tmp_path):
repo = make_worker(tmp_path, "smoke", version="1.0.1")
init_git(repo)
cargo = repo / "smoke" / "Cargo.toml"
cargo.write_text(
cargo.read_text().replace('version = "1.0.1"', 'version = "1.0.0"'),
)
(repo / "smoke" / "src.rs").write_text("// touch\n")
subprocess.run(["git", "add", "."], cwd=repo, check=True, env=GIT_HERMETIC_ENV)
subprocess.run(["git", "commit", "-q", "-m", "downgrade"], cwd=repo, check=True, env=GIT_HERMETIC_ENV)
r = run_script(repo, "smoke", base_ref="main~1", source_changed=["smoke"])
assert r.returncode != 0
assert "version" in r.stdout + r.stderr
assert "less" in r.stdout + r.stderr
10 changes: 5 additions & 5 deletions .github/scripts/validate_worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
Enforces:
1. README.md exists and is non-empty.
2. iii.worker.yaml parses and has required fields + valid enum values.
3. The manifest version on this ref is strictly greater than on --base-ref.
3. The manifest version on this ref is greater than or equal to on --base-ref.
4. tests/ exists and is non-empty.

If `--worker` is not in `--source-changed`, requirements 1, 3, and 4 are
Expand Down Expand Up @@ -80,7 +80,7 @@ def soft(msg: str) -> None:
f"{worker}/iii.worker.yaml language must be 'rust' | 'node' | 'python'"
)

# 3. Manifest version > base
# 3. Manifest version >= base
if m is not None and m.manifest:
manifest_path = root / m.manifest
if not manifest_path.exists():
Expand All @@ -102,7 +102,7 @@ def soft(msg: str) -> None:
base_blob = None
# Only enforce when base resolves to a commit distinct from
# HEAD. With a single-commit repo (e.g. brand-new branch on
# this PR), base == HEAD and "strictly greater" is impossible.
# this PR), base == HEAD and comparing to base is meaningless.
try:
base_sha = subprocess.check_output(
["git", "rev-parse", args.base_ref],
Expand All @@ -124,9 +124,9 @@ def soft(msg: str) -> None:
base_ver = _lib.read_version(tmp)
except (ValueError, FileNotFoundError):
base_ver = None
if base_ver is not None and _lib.parse_semver(pr_ver) <= _lib.parse_semver(base_ver):
if base_ver is not None and _lib.parse_semver(pr_ver) < _lib.parse_semver(base_ver):
soft(
f"{worker}/{m.manifest} version {pr_ver} is not greater "
f"{worker}/{m.manifest} version {pr_ver} is less "
f"than base {base_ver}"
)

Expand Down
151 changes: 49 additions & 102 deletions AGENTS-NEW-WORKER.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,12 +194,15 @@ Every worker should register a markdown skill on the [`skills` platform
worker](https://workers.iii.dev/workers/skills) at startup so MCP clients
(Claude Desktop, Cursor, MCP Inspector) can discover and orient to its
functions. The skill body lives at `<worker>/skill.md` and is served at
`iii://<worker>`; the auto-rendered `iii://skills` index links every worker.
`iii://<worker>`; the auto-rendered `iii://directory/skills` index
links every worker.

> The `skills` worker version pinned by this convention is **v0.2.0+** —
> needed for multi-segment ids and `skills::unregister`.
> The iii-directory worker version pinned by this convention is
> **v0.4.x** — the `directory::*` namespace (skills, prompts, engine
> introspection, registry HTTP proxy) is the source of truth for every
> reader-side surface this guide refers to.

### 10.1 Skill ID validation rules (skills v0.2.0+)
### 10.1 Skill ID validation rules (iii-directory v0.4.x)

- 1+ segments separated by `/`.
- Each segment: lowercase ASCII letters, digits, `-`, `_`; max 64 chars per segment.
Expand All @@ -215,14 +218,16 @@ The skill registry expects two kinds of bodies:

- **Router** (`<worker>/skill.md`) — small. Lists the per-function or
per-group sub-skills under `iii://<worker>/...`. The agent loads this
first; it then fetches deeper bodies on demand via `skill::fetch`.
first; it then fetches deeper bodies on demand via
`directory::skills::fetch-skill`.
- **Leaf** (`<worker>/skills/<sub>.md`) — describes one function (or one
logical group of functions). Loaded only when the agent decides to drill
in.

The platform contract is minimal: H1 first (used as the link title in
`iii://skills`), then a non-heading paragraph (used as the description,
truncated at 140 chars). Everything else is up to the worker.
`iii://directory/skills`), then a non-heading paragraph (used as the
description, truncated at 140 chars). Everything else is up to the
worker.

**Router template** (`<worker>/skill.md`):

Expand All @@ -235,7 +240,7 @@ this).
```markdown
# <worker-name>

<One-sentence summary used as the description in the iii://skills index. Imperative tone.>
<One-sentence summary used as the description in the iii://directory/skills index. Imperative tone.>

- [`<worker>`](iii://<worker>)
- [`<namespace>::<fn>`](iii://<worker>/<sub>) — one-line purpose
Expand All @@ -246,17 +251,18 @@ this).

Leaf link text is the **actual function id** (e.g. `auth::set_token`) — what
the agent calls via `iii.trigger`. The link target is the **skill URI**
(`iii://<worker>/<sub>`) — what `skill::fetch` resolves. The two strings
diverge: a worker named `auth-credentials` registers functions under the
`auth::*` namespace, so the function id `auth::set_token` lives at the
skill URI `iii://auth-credentials/set_token`.
(`iii://<worker>/<sub>`) — what `directory::skills::fetch-skill`
resolves. The two strings diverge: a worker named `auth-credentials`
registers functions under the `auth::*` namespace, so the function id
`auth::set_token` lives at the skill URI
`iii://auth-credentials/set_token`.

**Leaf template** (`<worker>/skills/<sub>.md`):

```markdown
# <namespace>::<fn>

<One-sentence summary used as the description in the iii://skills index.>
<One-sentence summary used as the description in the iii://directory/skills index.>

`(input) → output` — argument/return shape and any nuance the caller needs
(idempotency, side effects, bus failures).
Expand All @@ -270,10 +276,11 @@ skill URI `iii://auth-credentials/set_token`.
<Optional: required config, dependencies on other workers, operational caveats.>
```

The leaf H1 is the function id with `::` so the auto-rendered `iii://skills`
index shows the calling shape directly. The skill URI in the registry
(`iii://<worker>/<sub>`) stays path-form — that's what `skill::fetch`
resolves and what `SUB_SKILLS` registers (see §10.4).
The leaf H1 is the function id with `::` so the auto-rendered
`iii://directory/skills` index shows the calling shape directly. The
skill URI (`iii://<worker>/<sub>`) stays path-form — that's what
`directory::skills::fetch-skill` resolves and what `SUB_SKILLS`
registers (see §10.4).

If a worker exposes only one function (e.g. `policy-denylist`), skip the
leaves layer and put the leaf content directly in `<worker>/skill.md`. The
Expand Down Expand Up @@ -313,64 +320,27 @@ folder name.

### 10.5 Wire-up: main.rs

Add four small helpers to `main.rs` and call them from `main`:
> **Migration note (iii-directory v0.4.x).** The state-backed
> `skills::register` / `skills::unregister` calls this section
> previously documented are gone. Skills now live on disk under the
> iii-directory worker's `skills_folder` and are populated by
> `directory::skills::download` (from the public registry or a GitHub
> repo). New workers should publish their bundled skills as part of
> their release pipeline rather than re-registering at boot. See the
> [iii-directory README](iii-directory/README.md) for the full flow.
>
> The `SUB_SKILLS` table from §10.4 still lives in `lib.rs` because
> integration tests reference the bundled markdown directly; what
> changes is just where the bus exposes those bodies (the
> iii-directory worker reads them off disk after a download instead of
> a worker pushing them up at boot).

Catches BOTH SIGINT (Ctrl-C in dev) and SIGTERM (container kill) so the
worker shuts down cleanly in production container restarts:

```rust
use std::sync::Arc;
use std::time::{Duration, Instant};

use anyhow::{Context, Result};
use iii_sdk::TriggerRequest;
use serde_json::json;

// Registers ONE skill with capped exponential backoff. Background-friendly:
// caller wraps a series of these in a single tokio::spawn.
async fn register_skill_with_retry(iii: &iii_sdk::III, id: &str, body: &str) {
let mut backoff = Duration::from_secs(5);
let started = Instant::now();
loop {
let res = iii
.trigger(TriggerRequest {
function_id: "skills::register".into(),
payload: json!({ "id": id, "skill": body }),
action: None,
timeout_ms: Some(5_000),
})
.await;
match res {
Ok(_) => {
log::info!("registered skill: {id}");
return;
}
Err(e) => {
if started.elapsed() > Duration::from_secs(180) {
log::warn!(
"skills handshake gave up for {id}; install/start the skills worker and restart (last error: {e})"
);
return;
}
log::debug!("skills::register failed for {id}: {e}; retrying in {backoff:?}");
}
}
tokio::time::sleep(backoff).await;
backoff = (backoff * 2).min(Duration::from_secs(60));
}
}

// Registers the worker's router skill plus every leaf. Fires AFTER the
// worker's register_with_iii() returns so leaves never advertise functions
// that aren't registered yet.
fn spawn_skill_register(iii: Arc<iii_sdk::III>) {
tokio::spawn(async move {
register_skill_with_retry(&iii, <crate>::SKILL_ID, <crate>::SKILL_MD).await;
for (id, body) in <crate>::SUB_SKILLS {
register_skill_with_retry(&iii, id, body).await;
}
});
}

// Catches BOTH SIGINT (Ctrl-C in dev) and SIGTERM (container kill) so the
// unregister below runs in production container shutdown, not just dev.
async fn wait_for_shutdown() -> Result<()> {
#[cfg(unix)]
{
Expand All @@ -390,29 +360,6 @@ async fn wait_for_shutdown() -> Result<()> {
.context("failed to await SIGINT")
}
}

// Best-effort: a missed unregister is self-healing on next boot's re-register.
// Leaves go first so the router is the last entry to disappear from iii://skills.
async fn unregister_skill(iii: &Arc<iii_sdk::III>) {
for (id, _) in <crate>::SUB_SKILLS {
let _ = iii
.trigger(TriggerRequest {
function_id: "skills::unregister".into(),
payload: json!({ "id": id }),
action: None,
timeout_ms: Some(2_000),
})
.await;
}
let _ = iii
.trigger(TriggerRequest {
function_id: "skills::unregister".into(),
payload: json!({ "id": <crate>::SKILL_ID }),
action: None,
timeout_ms: Some(2_000),
})
.await;
}
```

Replace `<crate>` with the worker's library crate name (e.g.
Expand Down Expand Up @@ -517,14 +464,14 @@ fn sub_skills_well_formed() {
```
boot:
register_worker() → configure_store/cfg → register_with_iii() → serve traffic
▼ (spawn here, async)
skills::register router
skills::register leaf 1
skills::register leaf 2
... (each with capped retry)

skills are populated separately (iii-directory v0.4.x):
operator → directory::skills::download (registry or git repo)
→ markdown lands at <skills_folder>/<worker>/...
→ directory::skills::on-change fires for subscribers (mcp, etc.)

shutdown (SIGINT or SIGTERM):
wait_for_shutdown() → skills::unregister leaves → skills::unregister router → exit
(best-effort, 2s timeout each, errors swallowed)
wait_for_shutdown() → exit
(no per-worker skill unregister anymore — skills
live on disk under the iii-directory worker)
```
9 changes: 5 additions & 4 deletions harness/docs/iii-skill.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,10 @@ Three differences from the SDK examples below:
Every call is synchronous with the bus default timeout. Putting these
fields in `payload` does nothing.

`skill::fetch` is a real, callable function for loading skill bodies by
`iii://` URI — the blacklist below is about *function-listing* calls
only.
`directory::skills::fetch-skill` is a real, callable function for
loading skill bodies by `iii://` URI (or by bare skill path, the
`id` returned from `directory::skills::list`) — the blacklist below
is about *function-listing* calls only.

Everything else in this document — discovery, schemas, listings —
applies as written.
Expand All @@ -40,7 +41,7 @@ The response is `{ "functions": [ { function_id, description, request_format, re
**Do NOT guess any of these — none of them exist:**

- ~~`skill::list`~~ → use `engine::functions::list`
- ~~`skills::list`~~ → that is a skills-registry CRUD call (lists skill bodies, not functions)
- ~~`skills::list`~~ / ~~`directory::skills::list`~~ → those list skill *bodies* (markdown), not functions; for functions use `engine::functions::list` or `directory::engine::functions::list`
- ~~`iii::list`~~ → not a thing
- ~~`bus::list`~~ → not a thing
- ~~`function::list`~~ → wrong scope; the scope is `engine`, the noun is plural `functions`
Expand Down
2 changes: 1 addition & 1 deletion harness/web/src/components/Composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -353,7 +353,7 @@ export function Composer({
if (item.kind === "skill") {
// Skills get inserted as a /skill-id mention so the user can add
// context after it. The agent picks it up via the system-prompt
// skills index and skill::fetch.
// skills index and directory::skills::fetch-skill.
setText(`${item.id} `);
dispatch({ kind: "close" });
return;
Expand Down
23 changes: 13 additions & 10 deletions harness/web/src/menuItems.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,16 +104,19 @@ export function filterCommands(items: MenuItem[], query: string): MenuItem[] {
return scored.map((x) => x.item);
}

// Each line of the rendered iii://skills index looks like:
// - [name](iii://skills/<id>) — <description>
// or with a hyphen-minus instead of em-dash. We keep the regex permissive
// but anchor it on the `iii://skills/` URI so non-skill lines are skipped.
const SKILL_LINE = /^-\s+\[([^\]]+)\]\((iii:\/\/skills\/[^)]+)\)\s*[—\-]\s*(.+)$/;
// Each line of the rendered iii://directory/skills index looks like:
// - [name](iii://<id>) — <description>
// or with a hyphen-minus instead of em-dash. The id may contain
// arbitrary slashes; the iii-directory worker already validates that
// it doesn't collide with the `iii://directory/skills` literal that
// renders the index itself.
const SKILL_LINE = /^-\s+\[([^\]]+)\]\((iii:\/\/[^)]+)\)\s*[—\-]\s*(.+)$/;

/**
* Parse the markdown body returned by `skill::fetch iii://skills` into
* MenuItems. Lines that don't match the expected shape are skipped silently.
* Returns [] if the index hasn't loaded yet.
* Parse the markdown body returned by
* `directory::skills::fetch-skill iii://directory/skills` into
* MenuItems. Lines that don't match the expected shape are skipped
* silently. Returns [] if the index hasn't loaded yet.
*/
export function skillsIndexToMenuItems(index: string | null): MenuItem[] {
if (index == null) return [];
Expand All @@ -124,8 +127,8 @@ export function skillsIndexToMenuItems(index: string | null): MenuItem[] {
const m = SKILL_LINE.exec(line);
if (!m) continue;
const [, name, uri, description] = m;
// Derive the id portion of the URI (everything after `iii://skills/`).
const idPart = uri.slice("iii://skills/".length);
// Derive the id portion of the URI (everything after `iii://`).
const idPart = uri.slice("iii://".length);
out.push({
kind: "skill",
id: `/${idPart}`,
Expand Down
2 changes: 1 addition & 1 deletion iii-directory/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading