Files
LLeMbas-CLI/src/tool/tool.ts
T
HomerandClaude Fable 5.1 2183040db8 Harness spec 2.0.0: background work is a job — commands and subagents alike, seen and steered
One registry per process, every job owned by a session: bash's background commands (and a command
still running at its timeout, which now goes to the background instead of being killed) and
subagents started with task background: true. job_list, job_output, job_stop (the former
bash_list, bash_output, bash_kill) and the new job_send and report; a finished job wakes the owning
session in every front end with its marked note. The TUI counts jobs, lists them (/jobs), opens a
command's output, enters a subagent and messages it, moves a command to the background (ctrl+b)
and asks before quitting with jobs running. The link gains the jobs capability. Subagents get a
session row of their own, the spec's preamble and agent texts, and their errors reach the report.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-20 16:17:00 +00:00

212 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { existsSync } from "node:fs"
import { isAbsolute, relative, resolve } from "node:path"
import { z } from "zod"
import type { PermissionRequest } from "../permission/evaluate.ts"
import { expandHome } from "../config/paths.ts"
import type { ToolSpec } from "../provider/types.ts"
import { fillParameters, PURPOSE_DEF, TOOL_SPECS } from "../harness.ts"
export interface ToolContext {
/** Absolute project root. */
root: string
/** Absolute working directory for relative paths and shells. */
cwd: string
signal: AbortSignal
/** Absolute paths read in this session — `edit`/`write` of an existing file requires one. */
readFiles: Set<string>
/** mtime (ms) of each file when it was last read or written by a tool, to detect outside edits. */
fileStamps: Map<string, number>
bashTimeoutMs: number
/** Instruction files (AGENTS.md / CLAUDE.md) the model has already been given. */
instructionsSeen?: Set<string>
/** Ask the user questions (the ask_user tool). Absent where nobody can answer. */
question?: (req: import("./ask_user.ts").QuestionRequest, callId?: string) => Promise<import("./ask_user.ts").QuestionReply>
/** Start a subagent (the task tool): a handle on its job, whose `done` is its report. `signal`:
* the parent's turn, which stops a synchronous subagent with it — never wired for a background
* one. Absent inside a subagent: no recursion. */
spawn?: (agent: string, prompt: string, opts: SpawnOptions) => Promise<SubagentHandleInfo>
/** Background jobs (jobs/registry.ts): the registry, and whose jobs these tools see — the
* session's, or a subagent's own (`job`: the subagent's job, which `report` speaks for). */
jobs?: { registry: import("../jobs/registry.ts").JobRegistry; owner(): { sessionId: string; job?: import("../jobs/registry.ts").Job } }
/** false: a foreground command is killed at its timeout rather than moved to the background —
* the TUI's own `!` commands, which no model owns. Default true. */
bashTimeline?: boolean
/** Aborted when the person wants the foreground command of this step in the background now
* (the TUI's ctrl+b): bash hands it to the registry as it would at its timeout. Set by the
* engine for the step's calls. */
backgroundNow?: AbortSignal
/** The id of the call being run (set by the engine per call). */
callId?: string
/** The project directory (.agent), when the project has one. */
projectDir?: string
/** The plans directory (plan mode writes only there). */
planDir?: string
/** The session's plan (plan_write, plan_update, plan_submit): read it, replace it (null clears),
* and present it for approval — `ask` is absent where nobody can answer. */
plan?: {
get(): import("../plan/plan.ts").Plan | undefined
set(plan: import("../plan/plan.ts").Plan | null): void
ask?: (req: import("./plan_submit.ts").PlanRequest, callId?: string) => Promise<import("./plan_submit.ts").PlanReply>
}
/** Change the permission mode (plan_submit, on approval). */
setMode?: (mode: import("../config/schema.ts").Mode) => void
/** Web search and fetch settings (config `search:`). */
search?: import("../search/index.ts").SearchConfig
/** Persistent memory (the memory tool); the default store when absent. */
memory?: import("../memory/store.ts").MemoryStore
/** This project's own memory (.agent/local/MEMORY.md), in a trusted project with a .agent. */
projectMemory?: import("../memory/store.ts").MemoryStore
/** Skills as they are on disk now, and where a new one may go. */
skills?: { list(): import("../skill/index.ts").Skill[]; projectDir?: string; changed?(): void }
/** Past sessions (session_search). */
sessions?: { store: import("../session/store.ts").Store; current(): string | undefined }
/** Settings (the settings tool): read and change them; the configured models. */
settings?: {
get(key: string): import("../config/settings.ts").Entry
list(): import("../config/settings.ts").Entry[]
set(key: string, value: unknown, scope?: import("../config/settings.ts").Scope): import("../settings.ts").SetResult
models(): string[]
}
/** 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
/** The library: notes, and the knowledge bases this project searches (all, when
* `bases` is undefined); `project` is the scope of this project's notes. */
library?: {
lib: import("../library/store.ts").Library
project?: string
bases?: string[]
embedder?: import("../library/store.ts").Embedder
}
}
export interface SpawnOptions {
signal?: AbortSignal
callId?: string
worktree?: boolean
description?: string
model?: string
background?: boolean
}
/** What the task tool gets back from spawn: the job's id and the report when it ends. */
export interface SubagentHandleInfo {
id: string
done: Promise<import("./task.ts").SubagentResult>
}
export interface ToolResult {
output: string
/** One-line summary for the UI: `src/app.ts +12 −3`, `exit 0 · 0.4s`. */
title?: string
isError?: boolean
/** UI-only extras (a unified diff, exit code…). Never sent to the model. */
meta?: Record<string, unknown>
/** Images for the model (view_image): sent in a user turn after the tool results. */
images?: import("../provider/types.ts").ImagePart[]
}
export interface Tool<S extends z.ZodType = z.ZodType> {
name: string
/** The permission key the tool is checked under; several tools can share one (edit, write). */
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"
description: string
schema: S
/** The JSON Schema sent to the model, when it does not come from `schema` (MCP tools). */
jsonSchema?: Record<string, unknown>
/** The values of the spec's `{{variables}}` for this tool: its description is filled with them
* already (described), its parameters' descriptions when it is sent (toSpec). */
vars?: Record<string, string>
/** Where the model says what a call is for, shown when the user is asked to allow it: the
* `purpose` argument added to every tool (the default), a field of the tool's own (bash's
* `description`), or false for a tool that never asks. */
purpose?: string | false
permission(args: z.infer<S>, ctx: ToolContext): PermissionRequest
/** What the call would change, as a unified diff, shown when the user is asked. Writes nothing. */
preview?(args: z.infer<S>, ctx: ToolContext): Promise<string>
run(args: z.infer<S>, ctx: ToolContext): Promise<ToolResult>
}
export function defineTool<S extends z.ZodType>(t: Tool<S>): Tool<S> {
return t
}
export const PURPOSE = PURPOSE_DEF.name
// The argument's schema is the harness spec's (harness/purpose.json), as LLeMbas sends it too.
const PURPOSE_SPEC = PURPOSE_DEF.schema
/** The argument that carries a call's purpose for this tool, or undefined when it has none. A
* tool (an MCP server's) that already takes an argument named `purpose` keeps it as its own. */
export function purposeKey(t: Tool): string | undefined {
if (t.purpose === false) return undefined
if (typeof t.purpose === "string") return t.purpose
const props = t.jsonSchema?.properties as Record<string, unknown> | undefined
return props && PURPOSE in props ? undefined : PURPOSE
}
/** Take the injected `purpose` out of a call's arguments: the tool itself never sees it. */
export function splitPurpose(t: Tool, raw: unknown): { args: unknown; purpose?: string } {
const key = purposeKey(t)
if (!key || !raw || typeof raw !== "object" || Array.isArray(raw)) return { args: raw }
const v = (raw as Record<string, unknown>)[key]
const purpose = typeof v === "string" && v.trim() ? v.trim() : undefined
if (key !== PURPOSE) return { args: raw, purpose }
const { [PURPOSE]: _, ...rest } = raw as Record<string, unknown>
return { args: rest, purpose }
}
export function toSpec(t: Tool): ToolSpec {
let schema: Record<string, unknown>
if (t.jsonSchema) schema = t.jsonSchema
// A built-in tool's parameters are the harness spec's, as LLeMbas sends them too; its zod schema
// validates the call, and tests/harness.test.ts keeps the two the same shape.
else if (TOOL_SPECS[t.name]) schema = t.vars ? fillParameters(TOOL_SPECS[t.name]!.parameters, t.vars) : structuredClone(TOOL_SPECS[t.name]!.parameters)
else {
schema = z.toJSONSchema(t.schema, { target: "draft-7", io: "input" }) as Record<string, unknown>
delete schema.$schema
}
if (purposeKey(t) === PURPOSE) schema = { ...schema, properties: { ...((schema.properties as Record<string, unknown>) ?? {}), [PURPOSE]: PURPOSE_SPEC } }
return { name: t.name, description: t.description, parameters: schema }
}
/** A file's text exactly as it is, byte-order mark included. Bun's `.text()` drops a BOM, so a tool
* that read with it and wrote the result back took the mark off every file it changed. */
export async function readText(abs: string): Promise<string> {
return new TextDecoder("utf-8", { ignoreBOM: true }).decode(await Bun.file(abs).bytes())
}
/** A tool's path argument, absolute. Models send both absolute and relative paths. */
export function absPath(p: string, ctx: ToolContext): string {
// A model writes ~/x as a shell would; without this it becomes a directory named "~".
if (p === "~" || p.startsWith("~/")) return resolve(expandHome(p))
if (isAbsolute(p)) return resolve(p)
const rel = resolve(ctx.cwd, p)
// Small models often drop the leading "/" of an absolute path ("home/me/proj/a.py"). When the
// relative reading does not exist and the absolute one does, inside the project, take that.
if (!existsSync(rel)) {
const abs = resolve("/", p)
if ((abs === ctx.root || abs.startsWith(ctx.root + "/")) && existsSync(abs)) return abs
}
return rel
}
/** For display and rule matching: relative to the root when inside it. */
export function showPath(abs: string, ctx: ToolContext): string {
const rel = relative(ctx.root, abs)
return rel && !rel.startsWith("..") && !isAbsolute(rel) ? rel : abs
}
export class ToolError extends Error {}
/** Cap tool output sent to the model; the head and tail are what matter. */
export function truncate(text: string, max = 30_000): string {
if (text.length <= max) return text
const head = Math.floor(max * 0.7)
const tail = max - head
return `${text.slice(0, head)}\n\n[… ${text.length - max} characters omitted …]\n\n${text.slice(-tail)}`
}