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>
212 lines
11 KiB
TypeScript
212 lines
11 KiB
TypeScript
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)}`
|
||
}
|