ci / check (push) Waiting to run
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>
266 lines
14 KiB
TypeScript
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 }
|
|
}
|