diff --git a/src/acp/agent.ts b/src/acp/agent.ts index e8ebca7..d0e9ba4 100644 --- a/src/acp/agent.ts +++ b/src/acp/agent.ts @@ -274,6 +274,9 @@ export const CAPABILITIES = [ // `_lembas/jobs` and `_lembas/job/*` methods, `jobs` in `status`, `job` on an approval, and a // note's turn marked `machine`. "jobs", + // Harness spec 3.0.0: the show tool, offered in a session the client opened when the client + // said it can show files; the device sends the file with `_lembas/show`. + "show", ] as const export const STOP: Record = { stop: "end_turn", steps: "max_turn_requests", budget: "max_tokens", cancelled: "cancelled", error: "end_turn" } @@ -427,6 +430,9 @@ export class AcpAgent { private get cards() { return this.lembas && this.clientProtocol >= 2 } + /** The client shows files (`show: true` in `clientCapabilities._meta.lembas`, spec 3.0.0): the + * show tool is offered in the sessions it opens, and nowhere else. */ + private shows = false /** initialize has been answered: notifications about sessions may go out from now on. */ private ready = false @@ -486,6 +492,8 @@ export class AcpAgent { const said = p?.clientCapabilities?._meta?.lembas this.lembas = Boolean(said) this.clientProtocol = typeof said?.protocol === "number" ? said.protocol : 1 + this.shows = said?.show === true + for (const s of this.sessions.values()) if (s.agent === this) this.equipShow(s) // The sessions this link took over (the hub's) get their command set once the client // knows the agent — after this answer, not before it. setTimeout(() => { @@ -686,6 +694,7 @@ export class AcpAgent { private adopt(s: Session) { s.off?.() s.agent = this + this.equipShow(s) s.off = s.app.bus.on((e) => { if (s.agent !== this) return this.forward(s, e) @@ -694,6 +703,18 @@ export class AcpAgent { }) } + /** The show tool's display, for a session this link holds: the client's viewer, reached with + * `_lembas/show`, where the client said it shows files; otherwise none, and show is not offered. */ + private equipShow(s: Session) { + const ctx = s.app.engine.o.toolCtx + if (!this.shows) return void delete ctx.show + ctx.show = async (req, callId) => { + if (s.agent !== this || this.peer.isClosed) return { shown: false, reason: "the client is not connected" } + const r = (await this.peer.request("_lembas/show", { sessionId: s.id, toolCallId: callId ?? "", ...req }, 30_000)) as { shown?: unknown; reason?: unknown } | undefined + return r?.shown === true ? { shown: true } : { shown: false, reason: typeof r?.reason === "string" && r.reason ? r.reason : "the client did not show it" } + } + } + /** `available_commands_update`: the session's command set, when it changed (or always). */ private sendCommands(s: Session, always = false) { if (!this.ready || this.peer.isClosed || s.agent !== this) return diff --git a/src/harness.ts b/src/harness.ts index f7afd47..88a90f9 100644 --- a/src/harness.ts +++ b/src/harness.ts @@ -7,6 +7,8 @@ import LOOP_JSON from "../harness/loop.json" import t_apply_patch from "../harness/tools/apply_patch.json" import t_ask_user from "../harness/tools/ask_user.json" import t_bash from "../harness/tools/bash.json" +import t_branch from "../harness/tools/branch.json" +import t_commit from "../harness/tools/commit.json" import t_decisions from "../harness/tools/decisions.json" import t_edit from "../harness/tools/edit.json" import t_glob from "../harness/tools/glob.json" @@ -19,20 +21,31 @@ import t_knowledge_get from "../harness/tools/knowledge_get.json" import t_knowledge_search from "../harness/tools/knowledge_search.json" import t_list from "../harness/tools/list.json" import t_memory from "../harness/tools/memory.json" +import t_merge from "../harness/tools/merge.json" +import t_mkdir from "../harness/tools/mkdir.json" import t_multiedit from "../harness/tools/multiedit.json" +import t_mv from "../harness/tools/mv.json" import t_note_manage from "../harness/tools/note_manage.json" import t_note_view from "../harness/tools/note_view.json" import t_notes_search from "../harness/tools/notes_search.json" import t_plan_submit from "../harness/tools/plan_submit.json" import t_plan_update from "../harness/tools/plan_update.json" import t_plan_write from "../harness/tools/plan_write.json" +import t_pr from "../harness/tools/pr.json" +import t_python from "../harness/tools/python.json" import t_read from "../harness/tools/read.json" +import t_recall from "../harness/tools/recall.json" +import t_release from "../harness/tools/release.json" import t_report from "../harness/tools/report.json" +import t_rm from "../harness/tools/rm.json" +import t_search from "../harness/tools/search.json" import t_session_search from "../harness/tools/session_search.json" import t_settings from "../harness/tools/settings.json" +import t_show from "../harness/tools/show.json" import t_skill_manage from "../harness/tools/skill_manage.json" import t_skill_view from "../harness/tools/skill_view.json" import t_skills_list from "../harness/tools/skills_list.json" +import t_tag from "../harness/tools/tag.json" import t_task from "../harness/tools/task.json" import t_tasks from "../harness/tools/tasks.json" import t_view_image from "../harness/tools/view_image.json" @@ -43,9 +56,11 @@ import { fillPrompt } from "./prompt/fill.ts" export interface ToolSpecDef { name: string - /** shared: both projects implement it; cli / llembas: only that one does. The execution tools - * (bash, edit, read…) are the CLI's: the web UI runs no agent of its own. */ + /** shared: both projects implement it; cli / llembas: only that one does. */ scope: "shared" | "cli" | "llembas" + /** Spec 3.0.0: where it is offered — a chat on the server (its file system), an agent session + * on a device (this CLI, on its own disk), or both. */ + sessions: "chat" | "agent" | "both" risk: "read" | "write" | "execute" | "interact" exclusive: boolean /** How a call is shown in a transcript: a shell command, an edit with its diff, a @@ -60,6 +75,13 @@ export interface ToolSpecDef { /** `subagent`: offered only to a subagent (report), never to a session's own model. */ offered?: "subagent" argument_aliases?: Record + /** Spec 3.0.0: what must hold for it to be offered — `display` (the session is shown where a file + * can be opened: over the link, to a client that offers `show`), `page`, `forge` (the chat's). */ + requires?: ("display" | "page" | "forge")[] + /** What a call answers, word for word, when a requirement went away after the offer. */ + unavailable?: string + /** `always`: put to the person in every mode, auto included; never remembered. */ + confirm?: "always" /** `{{name}}` in the description: a fact the projects differ in, filled in by each (fillDescription). */ variables?: Record /** What a project offers of the parameters, where it offers less than all (narrowParameters). */ @@ -68,6 +90,8 @@ export interface ToolSpecDef { parameters: Record } +/** `X.Y.Z`, or a SemVer pre-release while the spec is written ahead of both implementations + * (`3.0.0-draft`). */ export const HARNESS_VERSION = VERSION.trim() /** How one prompt runs (harness/loop.json): the step backstop, the budgets' defaults, the wrap-up @@ -163,9 +187,16 @@ export function narrowParameters(parameters: Record, narrow: Na } export const TOOL_SPECS: Record = Object.fromEntries( - ([t_apply_patch, t_ask_user, t_bash, t_decisions, t_edit, t_glob, t_grep, t_job_list, t_job_output, t_job_send, t_job_stop, t_knowledge_get, t_knowledge_search, t_list, t_memory, t_multiedit, t_note_manage, t_note_view, t_notes_search, t_plan_submit, t_plan_update, t_plan_write, t_read, t_report, t_session_search, t_settings, t_skill_manage, t_skill_view, t_skills_list, t_task, t_tasks, t_view_image, t_web_fetch, t_web_search, t_write] as ToolSpecDef[]).map((t) => [t.name, t]), + ([t_apply_patch, t_ask_user, t_bash, t_branch, t_commit, t_decisions, t_edit, t_glob, t_grep, t_job_list, t_job_output, t_job_send, t_job_stop, t_knowledge_get, t_knowledge_search, t_list, t_memory, t_merge, t_mkdir, t_multiedit, t_mv, t_note_manage, t_note_view, t_notes_search, t_plan_submit, t_plan_update, t_plan_write, t_pr, t_python, t_read, t_recall, t_release, t_report, t_rm, t_search, t_session_search, t_settings, t_show, t_skill_manage, t_skill_view, t_skills_list, t_tag, t_task, t_tasks, t_view_image, t_web_fetch, t_web_search, t_write] as ToolSpecDef[]).map((t) => [t.name, t]), ) +/** Whether the spec gives a tool to this CLI: not the server's alone (`scope: llembas`), and + * offered in an agent session (`sessions` agent or both) — a chat's tools over the server's tree + * (mkdir, mv, rm, search, python, the forge verbs) are not the CLI's. */ +export function forCli(t: ToolSpecDef): boolean { + return t.scope !== "llembas" && t.sessions !== "chat" +} + /** A tool's name, description and `exclusive` flag as the spec has them, for its definition: * `...described("read")`. With `vars`, the description filled and the values kept for its * parameters (toSpec). */ diff --git a/src/prompt/assemble.ts b/src/prompt/assemble.ts index 6e28250..a516f9e 100644 --- a/src/prompt/assemble.ts +++ b/src/prompt/assemble.ts @@ -16,6 +16,7 @@ import blockAgentsKeep from "../../harness/prompts/blocks/agents-keep.md" with { import blockLibrary from "../../harness/prompts/blocks/library.md" with { type: "text" } import blockPrototypes from "../../harness/prompts/blocks/prototypes.md" with { type: "text" } import blockSubagent from "../../harness/prompts/blocks/subagent.md" with { type: "text" } +import blockContext from "../../harness/prompts/blocks/context.md" with { type: "text" } import agentExplore from "../../harness/prompts/agents/explore.md" with { type: "text" } import agentGeneral from "../../harness/prompts/agents/general.md" with { type: "text" } import modeManual from "../../harness/prompts/modes/manual.md" with { type: "text" } @@ -39,8 +40,9 @@ import personalityFunny from "../../harness/prompts/personality/funny.md" with { import personalityFormal from "../../harness/prompts/personality/formal.md" with { type: "text" } import personalitySocratic from "../../harness/prompts/personality/socratic.md" with { type: "text" } -// Built-in prompt text, by path relative to harness/prompts (the harness spec shared with LLeMbas). -// Any of these can be replaced by a file at the same path under ~/.config/lembas/prompts/. +// Built-in prompt text, by path relative to harness/prompts (the harness spec shared with LLeMbas): +// every text whose scope is shared or cli. The server's own (blocks/workspace.md, a chat's view of +// its file system) is not here. Any of these can be replaced by a file at the same path under ~/.config/lembas/prompts/. const BUILTIN: Record = { "system/default.md": defaultSystem, "system/identity.md": identity, @@ -53,6 +55,7 @@ const BUILTIN: Record = { "blocks/library.md": blockLibrary, "blocks/prototypes.md": blockPrototypes, "blocks/subagent.md": blockSubagent, + "blocks/context.md": blockContext, "agents/explore.md": agentExplore, "agents/general.md": agentGeneral, "modes/manual.md": modeManual, diff --git a/src/session/engine.ts b/src/session/engine.ts index 60cdcff..c044977 100644 --- a/src/session/engine.ts +++ b/src/session/engine.ts @@ -353,6 +353,7 @@ export class Engine { if (!t?.requires) return true if (t.requires === "vision") return this.model.spec.vision === true if (t.requires === "project") return this.o.toolCtx.projectDir !== undefined + if (t.requires === "display") return this.o.toolCtx.show !== undefined if (t.requires === "knowledge") { const L = this.o.toolCtx.library const have = L?.lib.bases().filter((b) => b.documents > 0).map((b) => b.name) ?? [] diff --git a/src/tool/registry.ts b/src/tool/registry.ts index 6d82654..52dc015 100644 --- a/src/tool/registry.ts +++ b/src/tool/registry.ts @@ -17,6 +17,7 @@ import { memoryTool } from "./memory.ts" import { sessionSearchTool } from "./session_search.ts" import { skillManageTool, skillsListTool, skillViewTool } from "./skills.ts" import { settingsTool } from "./settings.ts" +import { showTool } from "./show.ts" import { knowledgeGetTool, knowledgeSearchTool, noteManageTool, notesSearchTool, noteViewTool } from "./library.ts" import type { Tool } from "./tool.ts" import { writeTool } from "./write.ts" @@ -56,6 +57,7 @@ export const BUILTIN_TOOLS: Tool[] = [ skillManageTool, settingsTool, viewImageTool, + showTool, // Offered to subagents only (the spec's `offered: subagent`; app.ts SUBAGENT_ONLY). reportTool, ] as Tool[] diff --git a/src/tool/show.ts b/src/tool/show.ts new file mode 100644 index 0000000..7807d68 --- /dev/null +++ b/src/tool/show.ts @@ -0,0 +1,76 @@ +// show (harness spec 3.0.0): a file opened in the user's viewer beside the conversation. A terminal +// has no such viewer, so the tool is offered only in a session a client opened over the link and +// only when that client said it can show files (`show: true`, harness/acp.md `_lembas/show`): the +// device reads the file and sends it, since the client cannot reach this disk. +import { statSync } from "node:fs" +import { basename, extname, relative } from "node:path" +import { z } from "zod" +import { described, TOOL_SPECS } from "../harness.ts" +import { absPath, defineTool, readText, showPath, ToolError } from "./tool.ts" + +/** The most a file may be to be sent (acp.md: at most 1 MB; a larger one is refused before sending). */ +export const SHOW_MAX_BYTES = 1024 * 1024 + +/** The answer when nothing can be shown (spec `unavailable`), word for word. */ +export const SHOW_UNAVAILABLE = TOOL_SPECS.show!.unavailable! + +/** What `_lembas/show` carries for one file. */ +export interface ShowRequest { + path: string + kind: "markdown" | "code" | "text" | "html" + language?: string + line?: number + text: string +} + +export type ShowReply = { shown: true } | { shown: false; reason: string } + +// A known source extension is code, with its language; the list is the viewer's highlighters. +const LANGUAGES: Record = { + ts: "typescript", tsx: "tsx", js: "javascript", mjs: "javascript", cjs: "javascript", jsx: "jsx", json: "json", py: "python", rs: "rust", go: "go", + rb: "ruby", php: "php", java: "java", kt: "kotlin", swift: "swift", c: "c", h: "c", cc: "cpp", cpp: "cpp", hpp: "cpp", cs: "csharp", + sh: "bash", bash: "bash", zsh: "bash", fish: "fish", css: "css", scss: "scss", sql: "sql", yaml: "yaml", yml: "yaml", toml: "toml", + xml: "xml", svg: "xml", lua: "lua", zig: "zig", dart: "dart", vue: "vue", svelte: "svelte", +} + +/** The viewer's kind for a file, by its name (acp.md): `.md` markdown, `.html`/`.htm` html, a known + * source extension code, anything else text. */ +export function showKind(path: string): { kind: ShowRequest["kind"]; language?: string } { + const ext = extname(path).slice(1).toLowerCase() + if (ext === "md" || ext === "markdown") return { kind: "markdown" } + if (ext === "html" || ext === "htm") return { kind: "html" } + const language = LANGUAGES[ext] ?? (basename(path) === "Dockerfile" ? "dockerfile" : basename(path) === "Makefile" ? "makefile" : undefined) + return language ? { kind: "code", language } : { kind: "text" } +} + +export const showTool = defineTool({ + ...described("show"), + access: "show", + requires: "display", + schema: z.object({ path: z.string(), line: z.number().int().min(1).optional() }), + permission: (a, ctx) => { + const abs = absPath(a.path, ctx) + return { permission: "show", class: "interact", patterns: [showPath(abs, ctx)], paths: [abs] } + }, + async run(a, ctx) { + // Offered with a display; one that went away since answers the spec's sentence. + if (!ctx.show) return { output: SHOW_UNAVAILABLE, isError: true } + const abs = absPath(a.path, ctx) + let size: number + try { + const st = statSync(abs) + if (!st.isFile()) throw new ToolError(`${showPath(abs, ctx)} is not a file.`) + size = st.size + } catch (e) { + if (e instanceof ToolError) throw e + throw new ToolError(`${showPath(abs, ctx)} does not exist. Write it first.`) + } + if (size > SHOW_MAX_BYTES) throw new ToolError(`${showPath(abs, ctx)} is ${Math.round(size / 1024)} KB; at most 1 MB can be shown. Give the user the path instead.`) + const text = await readText(abs) + if (text.includes("\u0000")) throw new ToolError(`${showPath(abs, ctx)} is not a text file; it cannot be shown. Give the user the path instead.`) + const rel = relative(ctx.cwd, abs) || basename(abs) + const reply = await ctx.show({ path: rel, ...showKind(abs), ...(a.line ? { line: a.line } : {}), text }, ctx.callId) + if (!reply.shown) return { output: `Not shown: ${reply.reason}`, isError: true } + return { output: `Shown to the user: ${showPath(abs, ctx)}${a.line ? ` at line ${a.line}` : ""}.`, title: showPath(abs, ctx) } + }, +}) diff --git a/src/tool/tool.ts b/src/tool/tool.ts index 6be820a..1e44111 100644 --- a/src/tool/tool.ts +++ b/src/tool/tool.ts @@ -67,6 +67,9 @@ export interface ToolContext { set(key: string, value: unknown, scope?: import("../config/settings.ts").Scope): import("../settings.ts").SetResult models(): string[] } + /** Open a file in the user's viewer (the show tool): set only where a client over the link said + * it can show files (harness/acp.md, capability `show`). Absent: no display, and show is not offered. */ + show?: (req: import("./show.ts").ShowRequest, callId?: string) => Promise /** The scope sent on every call to the instance's library (`_meta.lembas.scope`, harness/acp.md): * the session's id, where the client has said what the session may search. */ libraryScope?: string @@ -112,8 +115,10 @@ export interface Tool { access: string /** Runs alone, never alongside other calls of the same step (it waits on the user). */ exclusive?: boolean - /** Offered only when this holds: a model with vision, plan mode, or a project directory. */ - requires?: "vision" | "plan" | "project" | "knowledge" + /** Offered only when this holds: a model with vision, plan mode, a project directory, knowledge + * bases with documents, or a display (a client over the link that shows files: the spec's + * `requires: display`). */ + requires?: "vision" | "plan" | "project" | "knowledge" | "display" description: string schema: S /** The JSON Schema sent to the model, when it does not come from `schema` (MCP tools). */ diff --git a/tests/acp-v2.test.ts b/tests/acp-v2.test.ts index 029a59e..c8b9950 100644 --- a/tests/acp-v2.test.ts +++ b/tests/acp-v2.test.ts @@ -64,7 +64,7 @@ const limitsFor = (root: string, extra: Partial = {}): Limits => ({ root /** A fake LLeMbas on one side of the agent. `lembas: false`: an editor that is not LLeMbas. */ /** `protocol`: what the client says it speaks (2 by default; 1 is an older client). */ -async function client(limits?: Limits, o: { lembas?: boolean; protocol?: number; instance?: string; hub?: Hub; handlers?: Record unknown> } = {}) { +async function client(limits?: Limits, o: { lembas?: boolean; protocol?: number; instance?: string; hub?: Hub; show?: boolean; handlers?: Record unknown> } = {}) { const [ta, tb] = pair() new AcpAgent(new Peer(ta), limits, o.instance, o.hub) const c = new Peer(tb) @@ -76,7 +76,7 @@ async function client(limits?: Limits, o: { lembas?: boolean; protocol?: number; c.on("_lembas/permission/settled", (p) => void notes.push({ method: "settled", params: p })) c.handle("session/request_permission", () => ({ outcome: { outcome: "selected", optionId: "once" } })) for (const [m, fn] of Object.entries(o.handlers ?? {})) c.handle(m, fn) - const init: any = await c.request("initialize", { protocolVersion: 1, clientCapabilities: o.lembas === false ? {} : { _meta: { lembas: o.protocol === 1 ? true : { protocol: o.protocol ?? 2 } } } }) + const init: any = await c.request("initialize", { protocolVersion: 1, clientCapabilities: o.lembas === false ? {} : { _meta: { lembas: o.protocol === 1 ? true : { protocol: o.protocol ?? 2, ...(o.show ? { show: true } : {}) } } } }) return { c, updates, events, notes, init } } @@ -312,6 +312,26 @@ test("initialize lists the 1.1.0 capabilities", async () => { for (const cap of ["context", "library", "prototypes", "plan_state", "rewind"]) expect(init._meta.lembas.capabilities).toContain(cap) }) +test("show (spec 3.0.0): offered only to a client that shows files; the file goes over _lembas/show", async () => { + const page = "

Hi

" + fake = fakeProvider([{ chunks: [toolCall(0, "s1", "show", JSON.stringify({ path: "out/page.html", line: 1 }))] }, { chunks: [delta({ content: "there" })] }, { chunks: [delta({ content: "plain" })] }]) + config(fake.url) + const root = project({ "out/page.html": page }) + let shown: any + const { c, init } = await client(limitsFor(root), { show: true, handlers: { "_lembas/show": (p) => ((shown = p), { shown: true }) } }) + expect(init._meta.lembas.capabilities).toContain("show") + const s: any = await c.request("session/new", { cwd: root }) + await c.request("session/prompt", { sessionId: s.sessionId, prompt: [{ type: "text", text: "make a page" }] }) + expect(fake.requests[0].tools.map((t: any) => t.function.name)).toContain("show") + expect(shown).toEqual({ sessionId: s.sessionId, toolCallId: "s1", path: "out/page.html", kind: "html", line: 1, text: page }) + expect(fake.requests[1].messages.find((m: any) => m.role === "tool").content).toContain("Shown to the user: out/page.html") + // A client that did not say it shows files: no show tool. + const other = await client(limitsFor(root)) + const s2: any = await other.c.request("session/new", { cwd: root }) + await other.c.request("session/prompt", { sessionId: s2.sessionId, prompt: [{ type: "text", text: "hi" }] }) + expect(fake.requests[2].tools.map((t: any) => t.function.name)).not.toContain("show") +}) + test("the context reaches the prompt: the instance's instructions, its personalization, the library's scope, prototypes", async () => { fake = fakeProvider([{ chunks: [delta({ content: "one" })] }, { chunks: [delta({ content: "two" })] }, { chunks: [delta({ content: "three" })] }]) config(fake.url, "personality: concise\ninstructions: local words\n") diff --git a/tests/acp.test.ts b/tests/acp.test.ts index 4ba5d93..63fbaae 100644 --- a/tests/acp.test.ts +++ b/tests/acp.test.ts @@ -233,7 +233,7 @@ test("the terminal: a real shell through the link, only where remote.terminal sa config(fake.url) const root = project() const off = await client({ roots: [root], maxMode: "edit", approvalTimeoutMs: 50, requireTrust: true }) - expect(off.init._meta.lembas.capabilities).toEqual(["directories", "configure", "steer", "sessions", "status", "delete", "compact", "title", "files", "commands", "attachments", "ask", "plan", "model", "turns", "context", "library", "prototypes", "plan_state", "rewind", "jobs"]) + expect(off.init._meta.lembas.capabilities).toEqual(["directories", "configure", "steer", "sessions", "status", "delete", "compact", "title", "files", "commands", "attachments", "ask", "plan", "model", "turns", "context", "library", "prototypes", "plan_state", "rewind", "jobs", "show"]) await expect(off.c.request("_lembas/terminal/open", { cwd: root })).rejects.toThrow("remote.terminal is off") const { c, init } = await client({ roots: [root], maxMode: "edit", approvalTimeoutMs: 50, requireTrust: true, terminal: true }) diff --git a/tests/harness.test.ts b/tests/harness.test.ts index c7dc1fe..93715d4 100644 --- a/tests/harness.test.ts +++ b/tests/harness.test.ts @@ -5,7 +5,7 @@ import { expect, test } from "bun:test" import { readdirSync, readFileSync } from "node:fs" import { join, relative } from "node:path" import { z } from "zod" -import { fillDescription, HARNESS_VERSION, narrowParameters, PURPOSE_DEF, TOOL_SPECS } from "../src/harness.ts" +import { fillDescription, forCli, HARNESS_VERSION, narrowParameters, PURPOSE_DEF, TOOL_SPECS } from "../src/harness.ts" import { createApp, stricterMode } from "../src/app.ts" import { paths } from "../src/config/paths.ts" import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs" @@ -19,7 +19,7 @@ import { purposeKey, toSpec, type Tool, type ToolContext } from "../src/tool/too const HARNESS = join(import.meta.dir, "..", "harness") const ours: Tool[] = [...BUILTIN_TOOLS] -const forUs = Object.values(TOOL_SPECS).filter((t) => t.scope !== "llembas") +const forUs = Object.values(TOOL_SPECS).filter(forCli) /** A JSON Schema with its prose and zod's integer bounds taken out: the shape a call must have. */ function shape(o: unknown): unknown { @@ -52,7 +52,8 @@ function sample(schema: Record): Record { const ctx = { root: "/tmp/ph-spec", cwd: "/tmp/ph-spec", signal: new AbortController().signal, readFiles: new Set(), fileStamps: new Map(), bashTimeoutMs: 1000 } as unknown as ToolContext test("the spec has a version, and one file per tool, named as the tool", () => { - expect(HARNESS_VERSION).toMatch(/^\d+\.\d+\.\d+$/) + // X.Y.Z, or a pre-release while the spec runs ahead of both implementations (3.0.0-draft). + expect(HARNESS_VERSION).toMatch(/^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/) const files = readdirSync(join(HARNESS, "tools")).filter((f) => f.endsWith(".json")) expect(files.length).toBe(Object.keys(TOOL_SPECS).length) for (const f of files) expect(JSON.parse(readFileSync(join(HARNESS, "tools", f), "utf8")).name).toBe(f.replace(/\.json$/, "")) @@ -68,7 +69,8 @@ test("every tool says how it is shown (block), from the spec's list", () => { }) test("every tool the spec gives the CLI is built in, and every built-in tool is in the spec", () => { - expect(ours.map((t) => t.name).sort()).toEqual(forUs.map((t) => t.name).sort()) + // recall comes with context hygiene (harness/context.md), in the commit that implements it. + expect(ours.map((t) => t.name).sort()).toEqual(forUs.map((t) => t.name).filter((n) => n !== "recall").sort()) }) for (const t of ours) { @@ -112,10 +114,14 @@ test("read and write never share a tool: every tool carries one risk, and a read } }) -test("every prompt text is in harness/prompts, and every file there is one the CLI knows", () => { +test("every prompt text is in harness/prompts, and every file there that is not the server's alone is one the CLI knows", () => { const walk = (d: string): string[] => readdirSync(d, { withFileTypes: true }).flatMap((e) => (e.isDirectory() ? walk(join(d, e.name)) : e.name.endsWith(".md") ? [join(d, e.name)] : [])) const files = walk(join(HARNESS, "prompts")).map((f) => relative(join(HARNESS, "prompts"), f)) - for (const f of files) expect(() => promptText(f)).not.toThrow() + const index = JSON.parse(readFileSync(join(HARNESS, "prompts", "index.json"), "utf8")) as { prompts: Record } + for (const f of files) { + if (index.prompts[f]?.scope === "llembas") expect(() => promptText(f)).toThrow() + else expect(() => promptText(f)).not.toThrow() + } }) test("prompts/index.json lists every prompt text, each with a scope", () => { @@ -126,18 +132,29 @@ test("prompts/index.json lists every prompt text, each with a scope", () => { for (const p of Object.values(index.prompts)) expect(["shared", "cli", "llembas"]).toContain(p.scope) }) -test("the execution tools are the CLI's alone; the web UI runs no agent", () => { - // LLeMbas removed its SSH workplace, so nothing that runs a command or touches a file is shared. - const exec = ["apply_patch", "bash", "edit", "glob", "grep", "list", "multiedit", "read", "write"] - for (const n of exec) expect(TOOL_SPECS[n]!.scope).toBe("cli") +test("spec 3.0.0: every tool says where it is offered; the CLI's are the agent's, never a chat's", () => { + for (const t of Object.values(TOOL_SPECS)) expect(["chat", "agent", "both"]).toContain(t.sessions) + // The file tools are shared and offered in both worlds: here on the device's disk, in a chat over + // the server's tree. multiedit, apply_patch and bash stay the agent's. + for (const n of ["read", "write", "edit", "glob", "grep", "list"]) expect([TOOL_SPECS[n]!.scope, TOOL_SPECS[n]!.sessions]).toEqual(["shared", "both"]) + for (const n of ["apply_patch", "bash", "multiedit"]) expect([TOOL_SPECS[n]!.scope, TOOL_SPECS[n]!.sessions]).toEqual(["cli", "agent"]) + // A chat's own tools over the server's tree are not the CLI's, and none is built in here. + const chats = ["branch", "commit", "merge", "mkdir", "mv", "pr", "python", "release", "rm", "search", "tag"] + for (const n of chats) { + expect(TOOL_SPECS[n]!.sessions).toBe("chat") + expect(forCli(TOOL_SPECS[n]!)).toBe(false) + expect(ours.some((t) => t.name === n)).toBe(false) + } + // The local library stays the CLI's alone; a chat's notes and knowledge are files now. + for (const n of ["knowledge_get", "knowledge_search", "note_manage", "note_view", "notes_search"]) expect([TOOL_SPECS[n]!.scope, TOOL_SPECS[n]!.sessions]).toEqual(["cli", "agent"]) + // show needs a display: offered only where a client over the link shows files. + expect(TOOL_SPECS.show!.requires).toEqual(["display"]) + expect(ours.find((t) => t.name === "show")!.requires).toBe("display") // The plan is one structure both keep (spec 1.1.0): its three tools are shared, and todo is // plan_write's former name rather than a tool. expect(TOOL_SPECS.todo).toBeUndefined() expect(TOOL_SPECS.plan_write!.aliases.cli).toEqual(["todo"]) - // Spec 2.0.0: the job tools cover both kinds of background work and are shared (the CLI's former - // bash_* names are their aliases); report is a subagent's. - const shared = ["ask_user", "job_list", "job_output", "job_send", "job_stop", "knowledge_get", "knowledge_search", "memory", "note_manage", "note_view", "notes_search", "plan_submit", "plan_update", "plan_write", "report", "skill_manage", "skill_view", "skills_list", "task", "web_fetch", "web_search"] - for (const n of shared) expect(TOOL_SPECS[n]!.scope).toBe("shared") + const shared = ["ask_user", "edit", "glob", "grep", "job_list", "job_output", "job_send", "job_stop", "list", "memory", "plan_submit", "plan_update", "plan_write", "read", "recall", "report", "session_search", "show", "skill_manage", "skill_view", "skills_list", "task", "web_fetch", "web_search", "write"] expect(Object.values(TOOL_SPECS).filter((t) => t.scope === "shared").map((t) => t.name).sort()).toEqual(shared.sort()) })