Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
41 changes: 18 additions & 23 deletions AGENTS-NEW-WORKER.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,8 +207,6 @@ links every worker.
- 1+ segments separated by `/`.
- Each segment: lowercase ASCII letters, digits, `-`, `_`; max 64 chars per segment.
- Total id length ≤ 1024 chars.
- First segment MUST NOT be the literal `fn` (reserved for section URIs).

For workers in this repo, the router id equals the folder name — a single
segment. Leaf ids are `<worker>/<sub>`.

Expand All @@ -219,28 +217,25 @@ 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
`directory::skills::fetch-skill`.
`directory::skills::get { id: "<worker>/<sub>" }`.
- **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://directory/skills`), then a non-heading paragraph (used as the
description, truncated at 140 chars). Everything else is up to the
worker.
The platform contract is minimal: H1 first (used as the link `title` on
each `directory::skills::list` row), then a non-heading paragraph (used
as the row's `description`). Everything else is up to the worker.

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

The body shape is a **nested list**: the worker id at the top, with each
sub-skill indented as a child. Renders as a tree in any markdown viewer and
makes the parent–child relationship explicit when the body is read raw (the
auto-rendered `iii://skills` index applies its own indentation on top of
this).
makes the parent–child relationship explicit when the body is read raw.

```markdown
# <worker-name>

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

- [`<worker>`](iii://<worker>)
- [`<namespace>::<fn>`](iii://<worker>/<sub>) — one-line purpose
Expand All @@ -250,19 +245,20 @@ 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 `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`.
the agent calls via `iii.trigger`. The link target is the **skill id**
written in legacy `iii://<worker>/<sub>` form for human readability — strip
the `iii://` prefix when calling `directory::skills::get` and pass the
remainder as `id`. 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 id
`auth-credentials/set_token`.

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

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

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

`(input) → output` — argument/return shape and any nuance the caller needs
(idempotency, side effects, bus failures).
Expand All @@ -276,11 +272,10 @@ registers functions under the `auth::*` namespace, so the function id
<Optional: required config, dependencies on other workers, operational caveats.>
```

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).
The leaf H1 is the function id with `::` so each `directory::skills::list`
row shows the calling shape directly as `title`. The skill id stays
path-form (`<worker>/<sub>`) — that's what `directory::skills::get`
expects 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
8 changes: 4 additions & 4 deletions harness/docs/iii-skill.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +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.

`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.
`directory::skills::get` is a real, callable function for loading one
skill body by id (the `id` returned from `directory::skills::list`,
which the worker also accepts in the legacy `iii://{id}` form) — the
blacklist below is about *function-listing* calls only.

Everything else in this document — discovery, schemas, listings —
applies as written.
Expand Down
2 changes: 1 addition & 1 deletion harness/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -525,7 +525,7 @@ export default function App() {
disabled={composerDisabled}
onSend={send}
cwd={cwd.trim()}
skillsIndex={null}
skillRows={null}
sessionMessages={messages}
callbacks={{
onNew: startNew,
Expand Down
17 changes: 9 additions & 8 deletions harness/web/src/components/Composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ import {
import {
BUILT_IN_COMMANDS,
filterCommands,
skillsIndexToMenuItems,
skillsListToMenuItems,
type SkillRow,
} from "../menuItems";
import type { AgentMessage, FsLsResponse, FsEntry } from "../types";

Expand All @@ -45,8 +46,8 @@ interface Props {
onSend: (prompt: string) => Promise<void>;
/** Working directory used as the @-mention browse root. Empty = unset. */
cwd: string;
/** Markdown skills index for slash menu (`null` → built-in commands only; full index is loaded server-side for the model). */
skillsIndex: string | null;
/** `directory::skills::list` rows for the slash menu (`null` → built-in commands only; the system-prompt bootstrap loads the full index server-side for the model). */
skillRows: SkillRow[] | null;
/** Prior messages of the active session — drives ↑ history walk. */
sessionMessages: AgentMessage[];
/** Per-builtin handlers. */
Expand Down Expand Up @@ -78,8 +79,8 @@ function joinPath(base: string, name: string): string {
return `${base}/${name}`;
}

function buildSkillsItems(index: string | null): MenuItem[] {
return [...BUILT_IN_COMMANDS, ...skillsIndexToMenuItems(index)];
function buildSkillsItems(rows: SkillRow[] | null): MenuItem[] {
return [...BUILT_IN_COMMANDS, ...skillsListToMenuItems(rows)];
}

function entriesToMenuItems(dir: string, entries: FsEntry[]): MenuItem[] {
Expand Down Expand Up @@ -121,7 +122,7 @@ export function Composer({
disabled,
onSend,
cwd,
skillsIndex,
skillRows,
sessionMessages,
callbacks,
}: Props) {
Expand All @@ -133,7 +134,7 @@ export function Composer({
const textareaRef = useRef<HTMLTextAreaElement | null>(null);

// Memoized so reference equality is stable for the menu effect.
const slashCatalog = useMemo(() => buildSkillsItems(skillsIndex), [skillsIndex]);
const slashCatalog = useMemo(() => buildSkillsItems(skillRows), [skillRows]);
const history = useMemo(() => userTexts(sessionMessages), [sessionMessages]);

// ─── @-mention IO ───────────────────────────────────────────────────────
Expand Down Expand Up @@ -353,7 +354,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 directory::skills::fetch-skill.
// skills index and directory::skills::get.
setText(`${item.id} `);
dispatch({ kind: "close" });
return;
Expand Down
78 changes: 48 additions & 30 deletions harness/web/src/menuItems.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest";
import {
BUILT_IN_COMMANDS,
filterCommands,
skillsIndexToMenuItems,
skillsListToMenuItems,
} from "./menuItems";
import type { MenuItem } from "./useCommandMenu";

Expand Down Expand Up @@ -48,48 +48,66 @@ describe("filterCommands", () => {
});
});

describe("skillsIndexToMenuItems", () => {
describe("skillsListToMenuItems", () => {
it("returns [] for null", () => {
expect(skillsIndexToMenuItems(null)).toEqual([]);
expect(skillsListToMenuItems(null)).toEqual([]);
});

it("returns [] for empty string", () => {
expect(skillsIndexToMenuItems("")).toEqual([]);
it("returns [] for undefined", () => {
expect(skillsListToMenuItems(undefined)).toEqual([]);
});

it("parses well-formed lines with em-dash", () => {
const md = [
"# skills",
"",
"- [tdd](iii://skills/tdd) — Write tests first",
"- [refactor](iii://skills/refactor) — Clean up dead code",
"",
].join("\n");
const out = skillsIndexToMenuItems(md);
it("returns [] for empty array", () => {
expect(skillsListToMenuItems([])).toEqual([]);
});

it("projects rows into /id menu items with secondary 'title — description'", () => {
const out = skillsListToMenuItems([
{ id: "tdd", title: "Write tests first", description: "Red, green, refactor." },
{ id: "refactor", title: "Refactor", description: "Clean up dead code" },
]);
expect(out.length).toBe(2);
expect(out[0].kind).toBe("skill");
expect(out[0].id).toBe("/tdd");
expect(out[0].label).toBe("/tdd");
expect(out[0].description).toContain("Write tests first");
expect((out[0].meta as { uri: string }).uri).toBe("iii://skills/tdd");
expect(out[0].description).toBe("Write tests first — Red, green, refactor.");
expect((out[0].meta as { id: string; uri: string })).toEqual({
id: "tdd",
uri: "iii://tdd",
});
});

it("parses lines with plain hyphen separator", () => {
const md = "- [foo](iii://skills/foo) - description here";
const out = skillsIndexToMenuItems(md);
expect(out.length).toBe(1);
expect(out[0].id).toBe("/foo");
it("falls back to id when title is missing or blank", () => {
const out = skillsListToMenuItems([
{ id: "shell" },
{ id: "harness", title: " " },
]);
expect(out[0].description).toBe("shell");
expect(out[1].description).toBe("harness");
});

it("omits the trailing em-dash when description is empty", () => {
const out = skillsListToMenuItems([{ id: "x", title: "X" }]);
expect(out[0].description).toBe("X");
});

it("preserves nested ids in label and uri", () => {
const out = skillsListToMenuItems([
{ id: "resend/email/send", title: "send", description: "Send an email" },
]);
expect(out[0].id).toBe("/resend/email/send");
expect((out[0].meta as { uri: string }).uri).toBe("iii://resend/email/send");
});

it("skips non-skill lines silently", () => {
const md = [
"Some intro paragraph",
"- not a skill link",
"- [valid](iii://skills/valid) — yes",
"- [external](https://example.com) — no",
].join("\n");
const out = skillsIndexToMenuItems(md);
it("drops rows whose id is missing or blank", () => {
const out = skillsListToMenuItems([
{ id: "ok", title: "ok" },
{ id: "", title: "blank" },
{ id: " ", title: "spaces" },
// @ts-expect-error — explicitly testing the runtime guard
{ title: "no id" },
]);
expect(out.length).toBe(1);
expect(out[0].id).toBe("/valid");
expect(out[0].id).toBe("/ok");
});
});
58 changes: 31 additions & 27 deletions harness/web/src/menuItems.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// Slash-menu items: built-in commands + skills parsed from the iii://skills
// markdown index. Plus a fuzzy filter the popover applies as the user types.
// Slash-menu items: built-in commands + skills surfaced from the
// `directory::skills::list` rows. Plus a fuzzy filter the popover
// applies as the user types.
//
// The filter ranking is intentionally simple — the slash menu has at most a
// few dozen entries, so we sort in-memory on every keystroke.
Expand Down Expand Up @@ -104,37 +105,40 @@ export function filterCommands(items: MenuItem[], query: string): MenuItem[] {
return scored.map((x) => x.item);
}

// 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*(.+)$/;
/**
* One row from `directory::skills::list`. The worker enriches each row
* with `title` + `description` so a picker doesn't need a follow-up
* `directory::skills::get` per entry.
*/
export interface SkillRow {
id: string;
title?: string;
description?: string;
}

/**
* 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.
* Project `directory::skills::list` rows into MenuItems for the slash
* popover. Entries without a non-empty `id` are dropped silently;
* everything else becomes a `/skill-id` mention with `<title> — <description>`
* (or just `<title>` when description is empty) as the secondary line.
*
* Returns [] when `rows` is null/undefined or empty.
*/
export function skillsIndexToMenuItems(index: string | null): MenuItem[] {
if (index == null) return [];
export function skillsListToMenuItems(rows: SkillRow[] | null | undefined): MenuItem[] {
if (rows == null) return [];
const out: MenuItem[] = [];
for (const raw of index.split("\n")) {
const line = raw.trim();
if (!line.startsWith("-")) continue;
const m = SKILL_LINE.exec(line);
if (!m) continue;
const [, name, uri, description] = m;
// Derive the id portion of the URI (everything after `iii://`).
const idPart = uri.slice("iii://".length);
for (const row of rows) {
const id = row.id?.trim();
if (!id) continue;
const title = row.title?.trim() || id;
const description = row.description?.trim() ?? "";
const secondary = description ? `${title} — ${description}` : title;
out.push({
kind: "skill",
id: `/${idPart}`,
label: `/${idPart}`,
description: `${name} — ${description}`,
meta: { uri },
id: `/${id}`,
label: `/${id}`,
description: secondary,
meta: { id, uri: `iii://${id}` },
});
}
return out;
Expand Down
Loading
Loading