CLI loads harness spec 3.0.0-draft; the show tool over the link
The spec's version may be a pre-release (3.0.0-draft). Every tool file is loaded, with the new fields `sessions`, `requires`, `unavailable` and `confirm`; the CLI's tools are those not the server's alone and offered in an agent session (forCli) — a chat's own tools over the server's tree (mkdir, mv, rm, search, python, the forge verbs) are not built in. blocks/context.md is known; blocks/workspace.md is the server's alone. show is built in with `requires: display`: offered only in a session a client opened over the link when that client said `show: true` (capability `show`); the device reads the file (at most 1 MB) and sends it with `_lembas/show`. The harness tests now say 3.0.0's split of tools instead of "the execution tools are the CLI's alone". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
10 files changed
+200
-24
No files matched your search
@@ -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<string, string> = { 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
|
||||
|
||||
+34
-3
@@ -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<string, string>
|
||||
/** 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<string, string>
|
||||
/** What a project offers of the parameters, where it offers less than all (narrowParameters). */
|
||||
@@ -68,6 +90,8 @@ export interface ToolSpecDef {
|
||||
parameters: Record<string, unknown>
|
||||
}
|
||||
|
||||
/** `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<string, unknown>, narrow: Na
|
||||
}
|
||||
|
||||
export const TOOL_SPECS: Record<string, ToolSpecDef> = 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). */
|
||||
|
||||
@@ -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<string, string> = {
|
||||
"system/default.md": defaultSystem,
|
||||
"system/identity.md": identity,
|
||||
@@ -53,6 +55,7 @@ const BUILTIN: Record<string, string> = {
|
||||
"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,
|
||||
|
||||
@@ -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) ?? []
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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) }
|
||||
},
|
||||
})
|
||||
+7
-2
@@ -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<import("./show.ts").ShowReply>
|
||||
/** 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<S extends z.ZodType = z.ZodType> {
|
||||
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). */
|
||||
|
||||
+22
-2
@@ -64,7 +64,7 @@ const limitsFor = (root: string, extra: Partial<Limits> = {}): 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<string, (p: any) => unknown> } = {}) {
|
||||
async function client(limits?: Limits, o: { lembas?: boolean; protocol?: number; instance?: string; hub?: Hub; show?: boolean; handlers?: Record<string, (p: any) => 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 = "<!doctype html><h1>Hi</h1>"
|
||||
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")
|
||||
|
||||
+1
-1
@@ -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 })
|
||||
|
||||
+31
-14
@@ -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<string, unknown>): Record<string, unknown> {
|
||||
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<string, { scope: string }> }
|
||||
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())
|
||||
})
|
||||
|
||||
|
||||
Reference in new issue
Block a user