Files
LLeMbas-CLI/src/harness.ts
T
HomerandClaude Opus 5.5 fbefe7f844
ci / check (push) Waiting to run
Harness spec 3.2.0 in the CLI: effort levels, reasoning round-trip, no absolute paths
Levels (models/schema.json levels): provider/effort.ts levelsOf/levelFields/
readLevel — `off` only with thinking_switch and sends enable_thinking false
and no effort, `default` for a model that takes no efforts and sends
nothing, a level not offered is read as the start level. The engine's
effort is a level; /effort shows nothing for one level, a Thinking switch
for [off, X], a list otherwise; the status bar follows. /v1/models'
thinking_switch, efforts [] and capacity.parallel are read
(lembas/webui.ts); session/capacity.ts counts parallel slots.

Reasoning (context.md §8): an assistant message of the current turn
carries reasoning_content (provider/common.ts turnReasoning,
openai-chat.ts); step-boundary deliveries, nudges and the wrap-up are
marked turn: false and do not start a turn.

Paths (context.md §11): the <env> block names the project and, started
below the root, that directory relative to it (assemble.ts envLines);
instruction files and @ attachments are named from the root or as ~/…;
file tools and bash resolve relative paths against the project root.

The team rules (team/team.ts) and conformance runners for levels,
reasoning, paths and team; the team runtime follows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 08:36:55 +00:00

266 lines
14 KiB
TypeScript

// The harness spec (harness/, shared with LLeMbas): its version and its tools, embedded at build
// time. The spec is the source of every tool's name, description and parameters as the model sees
// them; the code validates with its own zod schema, and tests/harness.test.ts keeps the two equal.
import VERSION from "../harness/VERSION" with { type: "text" }
import PURPOSE from "../harness/purpose.json"
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"
import t_grep from "../harness/tools/grep.json"
import t_job_list from "../harness/tools/job_list.json"
import t_job_output from "../harness/tools/job_output.json"
import t_job_send from "../harness/tools/job_send.json"
import t_job_stop from "../harness/tools/job_stop.json"
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_team_add from "../harness/tools/team_add.json"
import t_team_read from "../harness/tools/team_read.json"
import t_team_send from "../harness/tools/team_send.json"
import t_team_stop from "../harness/tools/team_stop.json"
import t_view_image from "../harness/tools/view_image.json"
import t_web_fetch from "../harness/tools/web_fetch.json"
import t_web_search from "../harness/tools/web_search.json"
import t_write from "../harness/tools/write.json"
import { fillPrompt } from "./prompt/fill.ts"
export interface ToolSpecDef {
name: string
/** 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
* file read, a search with its hits, a web lookup, a delegated task, a plan change (not drawn:
* the plan card refreshes), hidden, or a plain tool line. Both projects draw the same kind the
* same way. */
block: "shell" | "edit" | "read" | "search" | "web" | "task" | "plan" | "hidden" | "tool"
/** Where a call says what it is for: the injected `purpose`, a field of its own, or none. */
purpose: string | false
/** Names each project used before spec v1; a call under one is read as this tool. */
aliases: { cli: string[]; llembas: string[] }
/** `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);
* `plan` (3.1.0): plan mode. From 3.1.0 one that can come and go during a session does not
* leave the tool out: it is offered and answers `unavailable` while unmet (context.md §10).
* `team` (3.2.0): the team setting allows more than one agent (harness/team.md §2). */
requires?: ("display" | "page" | "forge" | "plan" | "team")[]
/** 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). */
narrow?: Partial<Record<"cli" | "llembas", Narrow>>
description: string
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
* message, how often a reply that says nothing is asked again, how many calls run together. */
export const LOOP: {
steps: { default: number }
budgets: { wall_seconds: { cli: number | null; llembas: number | null }; output_bytes: { cli: number | null; llembas: number | null }; completion_tokens: { cli: number | null; llembas: number | null } }
/** `message` holds `{{limit}}`: which limit was reached, as a phrase. */
wrap_up: { message: string }
nudges: { max: number }
parallel: { max: number }
/** Background work (spec 2.0.0): the marks every note begins with, and the caps. */
jobs: {
marks: { command: string; subagent: string; message: string; to_subagent: string }
note_tail_chars: { default: number }
finished_kept: { default: number }
running_max: { default: number }
subagent_steps: { default: number }
}
/** Teams (spec 3.2.0, harness/team.md): the size setting, names, marks, texts. */
team: {
agents: { min: number; max: number; cap: number }
names: { pattern: string; lead: string; reserved: string[] }
marks: { message: string; report: string }
unavailable: { one_agent: string; one_request: string }
work: { write: string[]; execute: string[]; kept: string[] }
read_max: number
texts: Record<"added" | "added_waiting" | "sent" | "stopped" | "manager" | "full" | "bad_name" | "all_name" | "taken" | "no_goal" | "unknown" | "self" | "nobody" | "not_yours" | "nobody_to_stop" | "no_report" | "ended", string>
read: { tree: string; agent: string; lead_role: string; states: Record<"working" | "waiting" | "finished" | "idle", string>; active: string; room: string; message: string; report: string; none: string; more: string }
}
/** Context hygiene (spec 3.0.0, harness/context.md): the result ids, the texts of a read's
* header, a stub, the elided line, recall; the output cap; what is deduplicated; the table
* compaction adds. */
context: {
ids: { digits: number }
read_header: string
read_header_empty: string
edit_header: string
header: string
unchanged: string
same_result: string
elided: string
created: string
no_result: string
recall_header: string
sha_digits: number
cap: { max_lines: number; max_chars: number; head_lines: number; head_chars: number; tail_lines: number; tail_chars: number }
dedupe: { by_hash: string[]; by_result: string[] }
compaction: { heading: string; line: string; rows: number }
reasoning: string
/** The environment block's lines about where an agent session works (spec 3.2.0, §11). */
env: { project: string; started: string }
/** A note on the next user message (spec 3.1.0, context.md §10): its tags and its lines, in
* the order they are said. */
note: { open: string; close: string; date: string; mode: string; plan: string; memory: string; instructions: string; personality: string; mcp: string; empty: string
/** What a note names the person's own instructions by (the instructions block's `{{path}}`). */
personal: string
/** For a chat, whose modes have no text of their own; the CLI says modes/<mode>.md. */
plan_ended: string
/** For a chat, which does not resend earlier tool results; the CLI's model has them. */
plan_own: string
/** Spec 3.2.0 (team.md §10): the size setting, said to the lead. */
team: string
team_lead: string
team_one: string
team_stopped: string
}
/** How long a session waits for its configured MCP servers before its first request. */
mcp_wait_ms: number
}
} = LOOP_JSON
/** The `purpose` argument (harness/purpose.json). */
export const PURPOSE_DEF: { name: string; schema: Record<string, unknown> } = PURPOSE
/** A description with its `{{variables}}` filled in, by the prompt texts' rules (fillPrompt): a
* line that is only a bullet and a variable goes when the value is empty, and a variable left
* unfilled is an error — it would reach the model as a literal `{{name}}` (conformance: fill). */
export function fillDescription(description: string, vars: Record<string, string>): string {
try {
return fillPrompt(description, vars)
} catch (e) {
throw new Error(`harness: ${(e as Error).message.replace(/^prompt: /, "")} in a tool description`)
}
}
/** A tool's parameters with every `description` in them filled the same way. */
export function fillParameters(parameters: Record<string, unknown>, vars: Record<string, string>): Record<string, unknown> {
const walk = (o: unknown): unknown => {
if (Array.isArray(o)) return o.map(walk)
if (!o || typeof o !== "object") return o
const out: Record<string, unknown> = {}
for (const [k, v] of Object.entries(o)) out[k] = k === "description" && typeof v === "string" ? fillDescription(v, vars) : walk(v)
return out
}
return walk(parameters) as Record<string, unknown>
}
/** What one project offers of a shared tool's parameters: optional properties left out (`drop`),
* a property's values narrowed (`enum`). `a.b` is b inside a, `a[].b` b inside a's items. */
export interface Narrow {
drop?: string[]
enum?: Record<string, string[]>
}
export class NarrowError extends Error {
constructor(readonly code: "required" | "unknown" | "not_subset", path: string) {
super(`harness: cannot narrow ${path}: ${code}`)
}
}
/** The parameters as a project offers them: a subset, never more and never retyped
* (conformance: narrow). The spec's own object is not changed. */
export function narrowParameters(parameters: Record<string, unknown>, narrow: Narrow = {}): Record<string, unknown> {
const out = structuredClone(parameters)
type Obj = { properties?: Record<string, Record<string, unknown>>; required?: string[]; items?: Obj }
const locate = (path: string): { parent: Obj; key: string } => {
let node = out as Obj
const steps = path.split(".")
for (const [i, raw] of steps.entries()) {
const items = raw.endsWith("[]")
const key = items ? raw.slice(0, -2) : raw
const props = node.properties
if (!props || !Object.hasOwn(props, key)) throw new NarrowError("unknown", path)
if (i === steps.length - 1 && !items) return { parent: node, key }
const next = props[key] as Obj
node = items ? (next.items as Obj) : next
if (!node) throw new NarrowError("unknown", path)
}
throw new NarrowError("unknown", path)
}
for (const path of narrow.drop ?? []) {
const { parent, key } = locate(path)
if (parent.required?.includes(key)) throw new NarrowError("required", path)
delete parent.properties![key]
}
for (const [path, values] of Object.entries(narrow.enum ?? {})) {
const { parent, key } = locate(path)
const own = parent.properties![key]!.enum as unknown[] | undefined
if (!own || !values.every((v) => own.includes(v))) throw new NarrowError("not_subset", path)
parent.properties![key]!.enum = own.filter((v) => values.includes(v as string))
}
return out
}
export const TOOL_SPECS: Record<string, ToolSpecDef> = Object.fromEntries(
([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_team_add, t_team_read, t_team_send, t_team_stop, 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). */
export function described(name: string, vars?: Record<string, string>): { name: string; description: string; exclusive: boolean; vars?: Record<string, string> } {
const t = TOOL_SPECS[name]
if (!t) throw new Error(`harness: no tool ${name} in the spec`)
return vars ? { name: t.name, description: fillDescription(t.description, vars), exclusive: t.exclusive, vars } : { name: t.name, description: t.description, exclusive: t.exclusive }
}