diff --git a/examples/lobu-crm/lobu.config.ts b/examples/lobu-crm/lobu.config.ts index ed489b33e..8fe47ceea 100644 --- a/examples/lobu-crm/lobu.config.ts +++ b/examples/lobu-crm/lobu.config.ts @@ -7,6 +7,7 @@ import { defineRelationshipType, defineWatcher, secret, + skillFromFile, } from "@lobu/cli/config"; const crm = defineAgent({ @@ -14,6 +15,7 @@ const crm = defineAgent({ name: "crm", description: "Maintains Lobu's funnel CRM — leads, pilots, inbound triage, weekly digest", + skills: [skillFromFile("./agents/crm/skills/crm-ops")], providers: [ { id: "z-ai", diff --git a/examples/office-bot/lobu.config.ts b/examples/office-bot/lobu.config.ts index bc5334525..7c6bc7c07 100644 --- a/examples/office-bot/lobu.config.ts +++ b/examples/office-bot/lobu.config.ts @@ -4,6 +4,7 @@ import { defineEntityType, defineWatcher, secret, + skillFromFile, } from "@lobu/cli/config"; const DELIVEROO_JUDGE = @@ -15,6 +16,7 @@ const foodOrdering = defineAgent({ description: "Runs the office lunch order — presence check, recommendations, options poll, order collection, Deliveroo basket handoff", dir: "./agents/food-ordering", + skills: [skillFromFile("./agents/food-ordering/skills/deliveroo-order")], providers: [ { id: "z-ai", diff --git a/packages/cli/src/commands/_lib/apply/__tests__/load-config.test.ts b/packages/cli/src/commands/_lib/apply/__tests__/load-config.test.ts index 05d364fa4..d8162e957 100644 --- a/packages/cli/src/commands/_lib/apply/__tests__/load-config.test.ts +++ b/packages/cli/src/commands/_lib/apply/__tests__/load-config.test.ts @@ -155,7 +155,7 @@ describe("loadDesiredStateFromConfig", () => { ]); }); - test("loads agent-dir markdown + skills and merges skill network config", async () => { + test("loads agent-dir markdown + a file skill, merging skill network/nix", async () => { dir = mkdtempSync(join(import.meta.dir, "agentdir-")); const agentDir = join(dir, "agents", "crm"); mkdirSync(join(agentDir, "skills", "crm-ops"), { recursive: true }); @@ -177,9 +177,15 @@ describe("loadDesiredStateFromConfig", () => { writeFileSync( join(dir, "lobu.config.ts"), [ - `import { defineAgent, defineConfig } from "@lobu/cli/config";`, + `import { defineAgent, defineConfig, skillFromFile } from "@lobu/cli/config";`, `export default defineConfig({`, - ` agents: [defineAgent({ id: "crm", network: { allowed: ["github.com"] } })],`, + ` agents: [`, + ` defineAgent({`, + ` id: "crm",`, + ` network: { allowed: ["github.com"] },`, + ` skills: [skillFromFile("./agents/crm/skills/crm-ops")],`, + ` }),`, + ` ],`, `});`, ``, ].join("\n") @@ -198,19 +204,71 @@ describe("loadDesiredStateFromConfig", () => { expect(settings?.nixConfig?.packages).toEqual(["jq"]); }); - test("two agents: custom + default dirs keep index alignment; project ./skills applies to all", async () => { + test("an inline defineSkill carries content + network with no files", async () => { + dir = mkdtempSync(join(import.meta.dir, "inline-")); + writeFileSync( + join(dir, "lobu.config.ts"), + [ + `import { defineAgent, defineConfig, defineSkill } from "@lobu/cli/config";`, + `const greet = defineSkill({`, + ` name: "greet",`, + ` description: "Greet someone.",`, + ` content: "Generate a warm greeting.",`, + ` network: { allowed: ["api.greet.com"] },`, + `});`, + `export default defineConfig({`, + ` agents: [defineAgent({ id: "a", skills: [greet] })],`, + `});`, + ``, + ].join("\n") + ); + + const { state } = await loadDesiredStateFromConfig({ cwd: dir }); + const skill = state.agents[0]?.settings.skillsConfig?.skills[0]; + expect(skill?.name).toBe("greet"); + expect(skill?.content).toBe("Generate a warm greeting."); + expect(skill?.description).toBe("Greet someone."); + expect(state.agents[0]?.settings.networkConfig?.allowedDomains).toEqual([ + "api.greet.com", + ]); + }); + + test("an inline skill MCP server merges into agent mcpServers", async () => { + dir = mkdtempSync(join(import.meta.dir, "skillmcp-")); + writeFileSync( + join(dir, "lobu.config.ts"), + [ + `import { defineAgent, defineConfig, defineSkill } from "@lobu/cli/config";`, + `const api = defineSkill({`, + ` name: "api",`, + ` content: "Use the API.",`, + ` mcpServers: { "support-api": { url: "https://api.example.com/mcp", type: "sse" } },`, + `});`, + `export default defineConfig({`, + ` agents: [defineAgent({ id: "a", skills: [api] })],`, + `});`, + ``, + ].join("\n") + ); + + const { state } = await loadDesiredStateFromConfig({ cwd: dir }); + const mcp = (state.agents[0]?.settings.mcpServers ?? {}) as Record< + string, + { url?: string; type?: string } + >; + expect(mcp["support-api"]).toEqual({ + url: "https://api.example.com/mcp", + type: "sse", + }); + }); + + test("two agents: custom + default dirs keep index alignment", async () => { dir = mkdtempSync(join(import.meta.dir, "multiagent-")); // Agent "a" uses a custom dir; agent "b" uses the default ./agents/b. mkdirSync(join(dir, "custom-a"), { recursive: true }); mkdirSync(join(dir, "agents", "b"), { recursive: true }); writeFileSync(join(dir, "custom-a", "SOUL.md"), "Agent A soul.\n"); writeFileSync(join(dir, "agents", "b", "SOUL.md"), "Agent B soul.\n"); - // Project-level shared skill (applies to every agent). - mkdirSync(join(dir, "skills", "shared"), { recursive: true }); - writeFileSync( - join(dir, "skills", "shared", "SKILL.md"), - "---\nname: shared\n---\nShared.\n" - ); writeFileSync( join(dir, "lobu.config.ts"), [ @@ -231,7 +289,31 @@ describe("loadDesiredStateFromConfig", () => { expect(state.agents[0]?.settings.soulMd).toBe("Agent A soul."); expect(state.agents[1]?.metadata.agentId).toBe("b"); expect(state.agents[1]?.settings.soulMd).toBe("Agent B soul."); - // The project-level skill is merged into both agents. + }); + + test("a skill shared by two agents via skillFromFile lands on both", async () => { + dir = mkdtempSync(join(import.meta.dir, "shared-")); + mkdirSync(join(dir, "skills", "shared"), { recursive: true }); + writeFileSync( + join(dir, "skills", "shared", "SKILL.md"), + "---\nname: shared\n---\nShared.\n" + ); + writeFileSync( + join(dir, "lobu.config.ts"), + [ + `import { defineAgent, defineConfig, skillFromFile } from "@lobu/cli/config";`, + `const shared = skillFromFile("./skills/shared");`, + `export default defineConfig({`, + ` agents: [`, + ` defineAgent({ id: "a", skills: [shared] }),`, + ` defineAgent({ id: "b", skills: [shared] }),`, + ` ],`, + `});`, + ``, + ].join("\n") + ); + + const { state } = await loadDesiredStateFromConfig({ cwd: dir }); expect(state.agents[0]?.settings.skillsConfig?.skills[0]?.name).toBe( "shared" ); @@ -240,33 +322,41 @@ describe("loadDesiredStateFromConfig", () => { ); }); - test("agent-dir skill overrides a project skill of the same name", async () => { - dir = mkdtempSync(join(import.meta.dir, "skilloverride-")); - mkdirSync(join(dir, "skills", "ops"), { recursive: true }); - mkdirSync(join(dir, "agents", "a", "skills", "ops"), { recursive: true }); + test("rejects duplicate skill names within an agent", async () => { + dir = mkdtempSync(join(import.meta.dir, "dup-")); writeFileSync( - join(dir, "skills", "ops", "SKILL.md"), - "---\nname: ops\n---\nProject ops.\n" + join(dir, "lobu.config.ts"), + [ + `import { defineAgent, defineConfig, defineSkill } from "@lobu/cli/config";`, + `export default defineConfig({`, + ` agents: [defineAgent({ id: "a", skills: [`, + ` defineSkill({ name: "ops", content: "one" }),`, + ` defineSkill({ name: "ops", content: "two" }),`, + ` ] })],`, + `});`, + ``, + ].join("\n") ); - writeFileSync( - join(dir, "agents", "a", "skills", "ops", "SKILL.md"), - "---\nname: ops\n---\nAgent ops.\n" + await expect(loadDesiredStateFromConfig({ cwd: dir })).rejects.toThrow( + /duplicate skill "ops"/ ); + }); + + test("skillFromFile with a missing SKILL.md fails clearly", async () => { + dir = mkdtempSync(join(import.meta.dir, "missing-")); writeFileSync( join(dir, "lobu.config.ts"), [ - `import { defineAgent, defineConfig } from "@lobu/cli/config";`, - `export default defineConfig({ agents: [defineAgent({ id: "a" })] });`, + `import { defineAgent, defineConfig, skillFromFile } from "@lobu/cli/config";`, + `export default defineConfig({`, + ` agents: [defineAgent({ id: "a", skills: [skillFromFile("./nope")] })],`, + `});`, ``, ].join("\n") ); - - const { state } = await loadDesiredStateFromConfig({ cwd: dir }); - const skills = state.agents[0]?.settings.skillsConfig?.skills; - // loadSkillFiles reads [./skills, /skills] in order, deduping by - // name — the agent-dir skill (read last) wins. - expect(skills).toHaveLength(1); - expect(skills?.[0]?.content).toBe("Agent ops."); + await expect(loadDesiredStateFromConfig({ cwd: dir })).rejects.toThrow( + /no SKILL\.md found/ + ); }); test("loads a watcher reaction script (raw source) referenced by path", async () => { diff --git a/packages/cli/src/commands/_lib/apply/desired-state.ts b/packages/cli/src/commands/_lib/apply/desired-state.ts index 6bc8b83bf..24f4534b3 100644 --- a/packages/cli/src/commands/_lib/apply/desired-state.ts +++ b/packages/cli/src/commands/_lib/apply/desired-state.ts @@ -1,8 +1,7 @@ import { existsSync, readFileSync } from "node:fs"; import { readdir, readFile, stat } from "node:fs/promises"; -import { isAbsolute, join, relative, resolve, sep } from "node:path"; +import { basename, isAbsolute, join, relative, resolve, sep } from "node:path"; import { pathToFileURL } from "node:url"; -import type { Project } from "../../../config/index.js"; import type { ConnectorAuthSchema, ConnectorDefinition, @@ -11,6 +10,7 @@ import type { import type { AgentSettings } from "@lobu/core"; import Ajv from "ajv"; import addFormats from "ajv-formats"; +import type { Project, Skill } from "../../../config/index.js"; import { ValidationError } from "../../memory/_lib/errors.js"; import { mapProjectToDesiredState, @@ -254,12 +254,6 @@ interface SkillFrontmatter { >; } -interface LoadedSkillFile { - name: string; - content: string; - frontmatter?: SkillFrontmatter; -} - function normalizeDomainPattern(pattern: string): string { const trimmed = pattern.trim().toLowerCase(); if (!trimmed) return ""; @@ -290,103 +284,167 @@ async function parseSkillFrontmatter(raw: string): Promise<{ }; } -async function loadSkillFiles(dirs: string[]): Promise { - const skillMap = new Map(); +type SkillConfigEntry = NonNullable< + AgentSettings["skillsConfig"] +>["skills"][number]; - for (const dir of dirs) { - let entries: string[]; - try { - entries = (await readdir(resolve(dir))).sort(); - } catch { - continue; - } +type SkillMcpInput = Record< + string, + { url?: string; type?: string; command?: string; args?: string[] } +>; - for (const entry of entries) { - const entryPath = join(dir, entry); - let entryStat; - try { - entryStat = await stat(entryPath); - } catch { - continue; - } +/** + * Map a resolved skill (inline `defineSkill` or file-loaded `skillFromFile`) + * into a `SkillConfig` entry — the shape stored on agent settings and synced to + * the worker's `.skills/`. The network/nix/mcp here merge into the agent's + * worker sandbox at apply time, which is why skills resolve eagerly. + */ +function skillToConfig(args: { + name: string; + content: string; + source: "inline" | "file"; + description?: string; + nixPackages?: string[]; + allow?: string[]; + deny?: string[]; + judged?: Array<{ domain: string; judge?: string }>; + judges?: Record; + mcpServers?: SkillMcpInput; +}): SkillConfigEntry { + const skill: SkillConfigEntry = { + repo: `${args.source}/${args.name}`, + name: args.name, + content: args.content, + enabled: true, + }; + if (args.description) skill.description = args.description; + if (args.nixPackages?.length) skill.nixPackages = args.nixPackages; + + const judgedDomains = (args.judged ?? []).map((entry) => ({ + domain: normalizeDomainPattern(entry.domain), + ...(entry.judge ? { judge: entry.judge } : {}), + })); + const allowedDomains = normalizeDomainPatterns(args.allow); + const deniedDomains = normalizeDomainPatterns(args.deny); + if ( + allowedDomains || + deniedDomains || + judgedDomains.length > 0 || + args.judges + ) { + skill.networkConfig = { + allowedDomains, + deniedDomains, + ...(judgedDomains.length > 0 ? { judgedDomains } : {}), + ...(args.judges ? { judges: args.judges } : {}), + }; + } - if (entryStat.isDirectory()) { - try { - const raw = await readFile(join(entryPath, "SKILL.md"), "utf-8"); - if (!raw.trim()) continue; - const { frontmatter, body } = await parseSkillFrontmatter(raw.trim()); - const name = frontmatter?.name || entry; - skillMap.set(name, { - name, - content: body, - ...(frontmatter ? { frontmatter } : {}), - }); - } catch { - // Directory without a SKILL.md is not a local skill. - } - continue; - } + const mcpEntries = Object.entries(args.mcpServers ?? {}); + if (mcpEntries.length > 0) { + skill.mcpServers = mcpEntries.map(([id, mcp]) => ({ + id, + url: mcp.url, + type: mcp.type as "sse" | "stdio" | undefined, + command: mcp.command, + args: mcp.args, + })); + } + return skill; +} - if (!entry.endsWith(".md")) continue; - try { - const content = await readFile(entryPath, "utf-8"); - if (content.trim()) { - skillMap.set(entry.slice(0, -3), { - name: entry.slice(0, -3), - content: content.trim(), - }); - } - } catch { - // Skip unreadable files. - } - } +/** Read a `SKILL.md` (a dir holding one, or a `.md` path) for `skillFromFile`. */ +async function readSkillFile( + cwd: string, + relPath: string, + nameOverride?: string +): Promise<{ name: string; content: string; fm?: SkillFrontmatter }> { + const abs = resolve(cwd, relPath); + const filePath = abs.endsWith(".md") ? abs : join(abs, "SKILL.md"); + let raw: string; + try { + raw = (await readFile(filePath, "utf-8")).trim(); + } catch { + throw new ValidationError( + `skillFromFile("${relPath}"): no SKILL.md found at ${filePath}` + ); } + if (!raw) { + throw new ValidationError( + `skillFromFile("${relPath}"): ${filePath} is empty` + ); + } + const { frontmatter, body } = await parseSkillFrontmatter(raw); + const name = + nameOverride ?? frontmatter?.name ?? basename(abs.replace(/\.md$/, "")); + return { name, content: body, ...(frontmatter ? { fm: frontmatter } : {}) }; +} - return Array.from(skillMap.values()); -} - -function buildLocalSkills( - skillFiles: LoadedSkillFile[] -): NonNullable["skills"] { - return skillFiles.map((skillFile) => { - const skill: NonNullable["skills"][number] = - { - repo: `local/${skillFile.name}`, - name: skillFile.name, - content: skillFile.content, - enabled: true, - }; - const fm = skillFile.frontmatter; - if (!fm) return skill; - if (fm.description) skill.description = fm.description; - if (fm.nixPackages?.length) skill.nixPackages = fm.nixPackages; - if (fm.network || fm.judges) { - const judgedDomains = (fm.network?.judge ?? []).map((entry) => - typeof entry === "string" - ? { domain: normalizeDomainPattern(entry) } - : { - domain: normalizeDomainPattern(entry.domain), - ...(entry.judge ? { judge: entry.judge } : {}), - } +/** Resolve one declared skill (inline `defineSkill` or `skillFromFile`). */ +async function resolveSkill( + skill: Skill, + cwd: string +): Promise { + if (skill.path !== undefined) { + const { name, content, fm } = await readSkillFile( + cwd, + skill.path, + skill.name + ); + return skillToConfig({ + name, + content, + source: "file", + description: fm?.description, + nixPackages: fm?.nixPackages, + allow: fm?.network?.allow, + deny: fm?.network?.deny, + judged: (fm?.network?.judge ?? []).map((e) => + typeof e === "string" + ? { domain: e } + : { domain: e.domain, ...(e.judge ? { judge: e.judge } : {}) } + ), + judges: fm?.judges, + mcpServers: fm?.mcpServers, + }); + } + if (!skill.name) { + throw new ValidationError("defineSkill requires a `name`."); + } + const net = skill.network; + return skillToConfig({ + name: skill.name, + content: skill.content ?? "", + source: "inline", + description: skill.description, + nixPackages: skill.nixPackages, + allow: net?.allowed, + deny: net?.denied, + judged: net?.judged, + judges: net?.judges, + mcpServers: skill.mcpServers, + }); +} + +/** + * Resolve an agent's declared `skills` into `SkillConfig` entries, deduped by + * name (a duplicate is an authoring error — explicit lists shouldn't collide). + */ +async function resolveAgentSkills( + skills: Skill[], + cwd: string +): Promise { + const resolved = await Promise.all(skills.map((s) => resolveSkill(s, cwd))); + const byName = new Map(); + for (const skill of resolved) { + if (byName.has(skill.name)) { + throw new ValidationError( + `duplicate skill "${skill.name}" — skill names must be unique within an agent.` ); - skill.networkConfig = { - allowedDomains: normalizeDomainPatterns(fm.network?.allow), - deniedDomains: normalizeDomainPatterns(fm.network?.deny), - ...(judgedDomains.length > 0 ? { judgedDomains } : {}), - ...(fm.judges ? { judges: fm.judges } : {}), - }; - } - if (fm.mcpServers && Object.keys(fm.mcpServers).length > 0) { - skill.mcpServers = Object.entries(fm.mcpServers).map(([id, mcp]) => ({ - id, - url: mcp.url, - type: mcp.type as "sse" | "stdio" | undefined, - command: mcp.command, - args: mcp.args, - })); } - return skill; - }); + byName.set(skill.name, skill); + } + return [...byName.values()]; } async function readMarkdown( @@ -840,21 +898,20 @@ export async function loadDesiredStateFromConfig( ); const state = mapProjectToDesiredState(typedProject, env, opts.only); - // Agent-directory artifacts: SOUL/IDENTITY/USER.md + local skills. The - // mapper stays pure (no file IO); we read the files here and merge them into - // each agent's settings, mirroring the TOML loader (project `./skills` + - // per-agent `/skills`; default dir `./agents/`). + // Agent artifacts: SOUL/IDENTITY/USER.md (convention, from the agent dir) + + // skills (explicit `defineAgent({ skills })`, inline or `skillFromFile`). The + // mapper stays pure (no file IO); we read the files here and merge them in. await Promise.all( typedProject.agents.map(async (agent, i) => { const settings = state.agents[i]?.settings; if (!settings) return; const agentDir = resolve(opts.cwd, agent.dir ?? join("agents", agent.id)); const markdown = await readMarkdown(agentDir); - const skillFiles = await loadSkillFiles([ - join(opts.cwd, "skills"), - join(agentDir, "skills"), - ]); - mergeAgentDirArtifacts(settings, markdown, buildLocalSkills(skillFiles)); + const localSkills = await resolveAgentSkills( + agent.skills ?? [], + opts.cwd + ); + mergeAgentDirArtifacts(settings, markdown, localSkills); }) ); diff --git a/packages/cli/src/commands/_lib/apply/map-config.ts b/packages/cli/src/commands/_lib/apply/map-config.ts index 8078fa4f5..81c73d9a3 100644 --- a/packages/cli/src/commands/_lib/apply/map-config.ts +++ b/packages/cli/src/commands/_lib/apply/map-config.ts @@ -9,6 +9,7 @@ */ import type { AgentSettings } from "@lobu/core"; +import { CronExpressionParser } from "cron-parser"; import type { Agent, AuthProfile, @@ -16,13 +17,12 @@ import type { ConnectorRef, EntityType, McpServer, - ProviderConfig, Project, + ProviderConfig, RelationshipType, Watcher, } from "../../../config/index.js"; import { isSecretRef } from "../../../config/index.js"; -import { CronExpressionParser } from "cron-parser"; import { ValidationError } from "../../memory/_lib/errors.js"; import type { DesiredAgent, @@ -173,7 +173,7 @@ function resolveCredentialValue( return value; } -/** Skill entries produced by `buildLocalSkills` (agent-dir + project `skills/`). */ +/** Skill entries resolved from `defineAgent({ skills })` (inline + file). */ type LocalSkills = NonNullable["skills"]; /** Agent-dir prompt markdown (read by the loader from SOUL/IDENTITY/USER.md). */ @@ -184,9 +184,10 @@ export interface AgentMarkdown { } /** - * Merge agent-directory artifacts (prompt markdown + local skills) into the - * already-mapped agent settings. Pure (no file IO — the loader reads the files - * and passes the results in) so it can be unit-tested directly. + * Merge an agent's file-resolved artifacts (prompt markdown from its dir + + * skills declared via `defineAgent({ skills })`) into the already-mapped agent + * settings. Pure (no file IO — the loader reads the files and passes the + * results in) so it can be unit-tested directly. * * Mirrors `buildAgentSettings`'s skill-merge semantics exactly: agent-level * network/nix/mcp is laid down first (already in `settings`), then skills are diff --git a/packages/cli/src/commands/_lib/init-from-org/bootstrap.ts b/packages/cli/src/commands/_lib/init-from-org/bootstrap.ts index 2839441bd..3eebc376d 100644 --- a/packages/cli/src/commands/_lib/init-from-org/bootstrap.ts +++ b/packages/cli/src/commands/_lib/init-from-org/bootstrap.ts @@ -17,7 +17,7 @@ import { mkdir, writeFile } from "node:fs/promises"; import { join, resolve } from "node:path"; import type { AgentSettings } from "@lobu/core"; import chalk from "chalk"; -import { resolveApplyClient } from "../apply/client.js"; +import { printText } from "../../memory/_lib/output.js"; import type { ApplyClient, RemoteAgent, @@ -30,7 +30,7 @@ import type { RemoteRelationshipType, RemoteWatcher, } from "../apply/client.js"; -import { printText } from "../../memory/_lib/output.js"; +import { resolveApplyClient } from "../apply/client.js"; export interface InitFromOrgOptions { /** Target directory to scaffold into (must be empty / not a Lobu project). */ @@ -236,6 +236,7 @@ const IMPORTABLE = [ "defineConnection", "defineAuthProfile", "secret", + "skillFromFile", ] as const; type Importable = (typeof IMPORTABLE)[number]; @@ -395,13 +396,21 @@ function emitAgent( }); } - // Local skills → skills//SKILL.md (with frontmatter for net/nix/mcp). + // Local skills → skills//SKILL.md (with frontmatter for net/nix/mcp), + // referenced explicitly via `skillFromFile` so the apply loader picks them up + // (there is no directory auto-discovery). System/runtime skills are skipped. + const skillRefs: string[] = []; for (const skill of settings?.skillsConfig?.skills ?? []) { - if (skill.repo && !skill.repo.startsWith("local/")) continue; + if (skill.system) continue; files.push({ relPath: `${dir}/skills/${skill.name}/SKILL.md`, body: emitSkillFile(skill), }); + skillRefs.push(`skillFromFile(${str(`./${dir}/skills/${skill.name}`)})`); + } + if (skillRefs.length > 0) { + imports.use("skillFromFile"); + fields.push(`skills: [\n ${skillRefs.join(",\n ")},\n ]`); } // platforms ← live platform bindings. The route stores `platform` inside diff --git a/packages/cli/src/config/define.ts b/packages/cli/src/config/define.ts index ce735a180..dffc1ca65 100644 --- a/packages/cli/src/config/define.ts +++ b/packages/cli/src/config/define.ts @@ -261,11 +261,17 @@ export interface Agent { name?: string; description?: string; /** - * Agent directory holding `SOUL.md` / `IDENTITY.md` / `USER.md` and a - * `skills/` folder. Relative to the config file; defaults to - * `./agents/`. + * Agent directory holding `SOUL.md` / `IDENTITY.md` / `USER.md`. Relative to + * the config file; defaults to `./agents/`. (Skills are referenced + * explicitly via {@link Agent.skills}, not auto-discovered from this dir.) */ dir?: string; + /** + * Skills this agent can use — built inline with {@link defineSkill} or loaded + * from a `SKILL.md` with {@link skillFromFile}. Explicit list, no directory + * auto-discovery; deduped by name. + */ + skills?: Skill[]; providers?: ProviderConfig[]; network?: NetworkConfig; egress?: EgressConfig; @@ -293,6 +299,82 @@ export function defineAgent(config: Omit): Agent { return { ...config, kind: "agent" }; } +// --------------------------------------------------------------------------- +// Skills +// --------------------------------------------------------------------------- + +/** + * MCP server a skill declares. Skills support the basic transport shape only; + * for servers that need auth (custom headers or OAuth), declare them on the + * agent via `defineAgent({ mcpServers })`, which has full secret support. + */ +export interface SkillMcpServer { + url?: string; + command?: string; + args?: string[]; + type?: "sse" | "streamable-http" | "stdio"; +} + +/** + * A skill an agent can use — an instruction block (`content`) plus the egress, + * nix, and MCP it declares. Skills are referenced explicitly from + * {@link Agent.skills}; there is no directory auto-discovery. + * + * Build one of two ways, both producing this same object: + * - {@link defineSkill} — inline: `content` is a string, the rest is JSON. + * - {@link skillFromFile} — from a `SKILL.md` file (a directory containing + * one, or a `.md` path). The loader reads it at `lobu apply` and fills the + * fields from its frontmatter + body. `path` is mutually exclusive with the + * inline fields. + * + * The frontmatter a skill declares (`network`, `nixPackages`, `mcpServers`) is + * merged into the agent's worker sandbox at apply time — that's why skills are + * resolved eagerly, not loaded by the worker at run time. + */ +export interface Skill { + readonly kind: "skill"; + /** + * Skill name — the reference and dedup key. Required for inline skills. For + * {@link skillFromFile}, derived from the file's frontmatter `name` (or its + * folder name) when omitted. + */ + name?: string; + description?: string; + /** The skill body (markdown instructions shown to the agent). */ + content?: string; + /** Nix packages provisioned into the worker when this skill is present. */ + nixPackages?: string[]; + /** Egress the skill needs — merged into the agent's network allowlist. */ + network?: NetworkConfig; + /** + * MCP servers the skill declares, keyed by id. Basic transport shape only; a + * server that needs auth (headers/OAuth) belongs on the agent's `mcpServers`. + */ + mcpServers?: Record; + /** + * Load body + frontmatter from a `SKILL.md`, relative to the config file. Set + * by {@link skillFromFile}; resolved by the loader. Mutually exclusive with + * the inline fields above. + */ + path?: string; +} + +/** Declare a skill inline — `content` is the body, the rest is JSON frontmatter. */ +export function defineSkill( + config: Omit & { name: string } +): Skill { + return { ...config, kind: "skill" }; +} + +/** + * Reference a skill stored as a `SKILL.md` file. `path` is a directory holding + * `SKILL.md` (or a `.md` file directly), relative to the config file. The + * loader reads it at apply time; pass `name` to override the frontmatter name. + */ +export function skillFromFile(path: string, opts?: { name?: string }): Skill { + return { kind: "skill", path, ...(opts?.name ? { name: opts.name } : {}) }; +} + // --------------------------------------------------------------------------- // Project (default export of lobu.config.ts) // --------------------------------------------------------------------------- diff --git a/packages/landing/src/content/docs/getting-started/skills.mdx b/packages/landing/src/content/docs/getting-started/skills.mdx index 59994dc81..c2804998f 100644 --- a/packages/landing/src/content/docs/getting-started/skills.mdx +++ b/packages/landing/src/content/docs/getting-started/skills.mdx @@ -12,7 +12,7 @@ A **skill** is a reusable capability bundle for an agent. In Lobu, skills can ad - system packages - network requirements -Lobu discovers local `SKILL.md` files at runtime. Bundled skills are enabled from the agent settings UI; local skills live in your project so you can commit and customize them. +You declare an agent's skills explicitly in `lobu.config.ts`. Bundled skills are enabled from the agent settings UI; local skills live in your project so you can commit and customize them. ## Lobu Starter Skill @@ -30,20 +30,34 @@ The [vercel-labs/skills](https://github.com/vercel-labs/skills) CLI auto-detects ### In a Lobu agent -Enable the bundled skill from the agent settings UI in [app.lobu.ai](https://app.lobu.ai). Local `SKILL.md` files under `skills/` and `agents//skills/` are also loaded at runtime — see [Local Skill Locations](#local-skill-locations) below. +Enable the bundled skill from the agent settings UI in [app.lobu.ai](https://app.lobu.ai). Local skills are declared on the agent in `lobu.config.ts`; see [Declaring Skills](#declaring-skills) below. -## Local Skill Locations - -Lobu supports two local skill locations: - -| Type | Path | Scope | -|---|---|---| -| Shared skill | `skills//SKILL.md` | Available to all agents | -| Agent skill | `agents//skills//SKILL.md` | Available to one agent | +## Declaring Skills + +List an agent's skills explicitly via `defineAgent({ skills: [...] })`. Build each one two ways, both producing the same skill: + +```ts +import { defineAgent, defineSkill, skillFromFile } from "@lobu/cli/config"; + +defineAgent({ + id: "support", + skills: [ + // Inline: the body is a string, the rest is frontmatter as fields. + defineSkill({ + name: "greet", + description: "Greet a customer.", + content: "Generate a warm, personalized greeting.", + }), + // From a file: reads a SKILL.md (a folder holding one, or a .md path), + // resolved relative to lobu.config.ts. + skillFromFile("./agents/support/skills/internal-api"), + ], +}); +``` -If the file exists, Lobu loads it automatically at startup. +Share a skill across agents by referencing the same handle in more than one agent's `skills`. Skills are deduped by name. There is no folder auto-discovery: a `SKILL.md` is loaded only when an agent references it with `skillFromFile`. ## Minimal Example diff --git a/packages/landing/src/content/docs/guides/agent-prompts.md b/packages/landing/src/content/docs/guides/agent-prompts.md index 0b9588586..93da61b5f 100644 --- a/packages/landing/src/content/docs/guides/agent-prompts.md +++ b/packages/landing/src/content/docs/guides/agent-prompts.md @@ -35,8 +35,7 @@ skills/ | Agent identity | `agents//IDENTITY.md` | Short description of who the agent is | | Agent behavior | `agents//SOUL.md` | Rules, workflows, constraints, tone | | User or deployment context | `agents//USER.md` | Shared context injected into every conversation | -| Agent-local skills | `agents//skills//SKILL.md` | Available only to one agent | -| Shared skills | `skills//SKILL.md` | Available to all agents in the project | +| Skill files | `agents//skills//SKILL.md` or `skills//SKILL.md` | Referenced from `lobu.config.ts` with `skillFromFile(...)`; not auto-loaded | | Evaluations | `agents//evals/` | Test cases for behavior and quality | | Providers, connections, network, tool policy, enabled registry skills | `lobu.config.ts` | Operator-controlled runtime config | @@ -117,12 +116,9 @@ This file is optional and can be left empty. ## Skills -Local skills live in one of two places: +A skill is declared on the agent in `lobu.config.ts`, either inline with `defineSkill(...)` or loaded from a `SKILL.md` with `skillFromFile(...)`. There is no folder auto-discovery, so a `SKILL.md` can live anywhere you reference it from. The conventional spots are `agents//skills//SKILL.md` (kept next to one agent) and `skills//SKILL.md` (shared, referenced by more than one agent). -- `agents//skills//SKILL.md` for agent-specific skills -- `skills//SKILL.md` for shared project-level skills - -Use this page to understand where those files live. Use the [`SKILL.md` Reference](/reference/skill-md/) for the skill file format, frontmatter, packages, MCP servers, and network declarations. +See [Skills](/getting-started/skills/) for declaring them and the [`SKILL.md` Reference](/reference/skill-md/) for the file format, frontmatter, packages, MCP servers, and network declarations. ## lobu.config.ts diff --git a/packages/landing/src/content/docs/reference/lobu-config.md b/packages/landing/src/content/docs/reference/lobu-config.md index b186ca73b..4029e915b 100644 --- a/packages/landing/src/content/docs/reference/lobu-config.md +++ b/packages/landing/src/content/docs/reference/lobu-config.md @@ -195,7 +195,8 @@ Connections, the memory schema, and watchers are declared at the project level ( | `id` | string | yes | Agent ID. Must match `^[a-z0-9][a-z0-9-]*$` (lowercase alphanumeric with hyphens) | | `name` | string | no | Display name shown in the admin UI | | `description` | string | no | Short description shown in the admin UI | -| `dir` | string | no | Path to the agent content directory holding `IDENTITY.md`, `SOUL.md`, `USER.md`, and an optional `skills/` folder. Relative to the config file; defaults to `./agents/` | +| `dir` | string | no | Path to the agent content directory holding `IDENTITY.md`, `SOUL.md`, `USER.md`. Relative to the config file; defaults to `./agents/` | +| `skills` | `Skill[]` | no | Skills the agent can use, built with `defineSkill(...)` (inline) or `skillFromFile(...)` (a `SKILL.md`). Explicit list, deduped by name; no folder auto-discovery | | `providers` | `ProviderConfig[]` | no | LLM provider list (order = priority) | | `network` | `NetworkConfig` | no | Network access policy + LLM egress-judge config | | `egress` | `EgressConfig` | no | Operator overrides for the LLM egress judge on this agent | diff --git a/packages/landing/src/content/docs/reference/skill-md.md b/packages/landing/src/content/docs/reference/skill-md.md index db2ce8622..401424001 100644 --- a/packages/landing/src/content/docs/reference/skill-md.md +++ b/packages/landing/src/content/docs/reference/skill-md.md @@ -17,12 +17,18 @@ Tool policy does **not** live in `SKILL.md`. Configure that in [`lobu.config.ts` ## Where Skills Live -Lobu discovers local skills from: +A `SKILL.md` is loaded when an agent references it from [`lobu.config.ts`](/reference/lobu-config/) with `skillFromFile`: -- `skills//SKILL.md` for shared project-level skills -- `agents//skills//SKILL.md` for agent-specific skills +```ts +import { defineAgent, skillFromFile } from "@lobu/cli/config"; -If the file exists, Lobu loads it automatically at startup. +defineAgent({ + id: "support", + skills: [skillFromFile("./agents/support/skills/internal-api")], +}); +``` + +The path is a folder holding a `SKILL.md` (or a `.md` file directly), resolved relative to `lobu.config.ts`. There is no folder auto-discovery. To declare a skill without a file, use `defineSkill({ name, content, ... })` instead; see [Skills](/getting-started/skills/). ## Minimal example