Harness spec 1.1.0: one plan, prototypes and the library shared; the CLI implements it

The spec: plan_write and plan_update (new, shared, offered in every mode) and
plan_submit (now shared) keep one structured plan — findings, objectives,
phases of tasks — in place of the todo list, which lives on as a former name
of plan_write. The shared system text is one text for both projects, with
variables for what differs; the mode texts, a prototypes block and a library
block are shared. acp.md: the client hands the device what only it knows
(context, library, prototypes), the plan travels as a structure (plan_state),
library calls carry the chat's scope, and a client may rewind a session.
loop.json says its numbers as numbers. Four conformance areas: plan,
prototype, library_scope, toolcall_index.

The CLI: the plan is kept per session in the store and drawn above the prompt
and on the approval card; the prompt is assembled from the spec's texts with
their variables filled; the link reads the instance's instructions,
personalization, knowledge scope and prototype offer, sends the plan as a
structure and answers rewind; library calls to the instance carry the session
as scope; a prototype written in the terminal is saved as a file and named in
the transcript; a tool-call fragment without an index continues the last call;
the default theme is Moria; dead exports and Hermes leftovers removed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
HomerandClaude Fable 5.1 committed 2026-10-18 04:02:12 +02:00
1 parent 6e70ff46d0
commit 837f38c744
96 files changed
+3259 -527

No files matched your search

+55 -27
View File
@@ -8,7 +8,9 @@ import type { Asker, AttachmentInfo, Bus } from "../bus/index.ts"
import type { Effort, Mode } from "../config/schema.ts"
import { evaluate, shadowsDeny, type Context as PermContext, type Rule } from "../permission/evaluate.ts"
import { ProviderError, type Client, type ImagePart, type Message, type ResolvedModel, type ToolCallPart, type Usage } from "../provider/types.ts"
import type { Todo } from "../tool/todo.ts"
import type { Plan } from "../plan/plan.ts"
import { LOOP } from "../harness.ts"
import { fillPrompt } from "../prompt/fill.ts"
import { splitPurpose, toSpec, ToolError, type Tool, type ToolContext, type ToolResult } from "../tool/tool.ts"
import { fill, promptText } from "../prompt/assemble.ts"
import { resolveCall } from "../tool/names.ts"
@@ -25,7 +27,8 @@ export interface EngineOptions {
tools: Tool[]
store?: Store
sessionId?: string
system: (mode: Mode, model: ResolvedModel) => string
/** The system prompt for a step; `plan`: the session's plan, for `{{plan_context}}`. */
system: (mode: Mode, model: ResolvedModel, plan?: Plan) => string
mode: Mode
effort: Effort | null
perm: Omit<PermContext, "mode">
@@ -50,7 +53,7 @@ export interface PromptMeta {
attachments?: AttachmentInfo[]
}
const MAX_PARALLEL = 4
const MAX_PARALLEL = LOOP.parallel.max
const PRUNED = "[This output was removed to save context. Run the tool again if you need it.]"
/** What a message sent mid-turn starts with. Said, not hidden: Hermes found that a model refuses an
* unmarked one as an injection. A resumed session shows the message without it. */
@@ -79,9 +82,11 @@ const CUT_THINKING =
"Your last reply hit the output limit while you were still thinking, so nothing was said or done. Think less this time: act now with a tool call, or answer."
const CUT_TEXT = "Your last reply was cut off at the output limit. Carry on from exactly where it stopped."
/** Replies in a row that say nothing (only thinking, or cut off) get asked again this many times. */
const MAX_NUDGES = 2
const wrapUpText = (why: string) =>
`You have reached the ${why} for this reply. Do not call any more tools. Answer now from what you have: say what is done, what is not, and what you would do next.`
const MAX_NUDGES = LOOP.nudges.max
/** The wrap-up: the spec's words, with which limit was reached. */
const wrapUpText = (limit: string) => fillPrompt(LOOP.wrap_up.message, { limit })
/** The session_meta key the plan is kept under. */
export const PLAN_META = "plan"
export class Engine {
messages: Message[] = []
@@ -113,8 +118,9 @@ export class Engine {
private turnSteers: string[] = []
/** busy_input: steer — the inbox goes in at the next step; queue — it waits for the task to end. */
busyInput: "steer" | "queue" = "steer"
/** The todo tool's list, for this session. */
todos: { items: Todo[]; changed?: (items: Todo[]) => void } = { items: [] }
/** The session's plan (plan_write / plan_update), kept with the session and put into the
* prompt on every step while it exists. */
plan: Plan | undefined
private sessionRules: Rule[] = []
private recentCalls: string[] = []
/** Tools refused with no one able to approve them: no longer offered. */
@@ -131,7 +137,22 @@ export class Engine {
this.model = o.model
this.client = o.client
this.sessionId = o.sessionId
this.todos.changed = (items) => o.bus.emit({ type: "todos", items })
this.plan = this.storedPlan()
}
/** The plan the store holds for this session, if any. */
private storedPlan(): Plan | undefined {
if (!this.o.store || !this.sessionId) return undefined
const p = this.o.store.meta<Plan | null>(this.sessionId, PLAN_META)
return p && typeof p === "object" && "version" in p ? p : undefined
}
/** Replace the plan (null clears it): kept with the session, and said on the bus so every
* front end's card follows. */
setPlan(plan: Plan | null) {
this.plan = plan ?? undefined
if (this.o.store && this.sessionId) this.o.store.setMeta(this.sessionId, PLAN_META, plan)
this.o.bus.emit({ type: "plan", plan: this.plan })
}
get effort(): Effort | null {
@@ -162,8 +183,10 @@ export class Engine {
this.sessionRules = []
this.recentCalls = []
this.withdrawn.clear()
this.todos.items = []
this.o.bus.emit({ type: "todos", items: [] })
// A new session's plan is whatever the store holds for it: none for a fresh one, the old one
// on a resume.
this.plan = this.storedPlan()
this.o.bus.emit({ type: "plan", plan: this.plan })
this.o.toolCtx.readFiles.clear()
this.o.toolCtx.fileStamps.clear()
}
@@ -302,18 +325,20 @@ export class Engine {
}
}
/** Which budget this prompt has run past, said for the user, or "" while there is room. */
private overBudget(started: number): string {
/** Which budget this prompt has run past — `limit` names it for the wrap-up message, `said` is
* for the user's notice — or undefined while there is room. */
private overBudget(started: number): { limit: string; said: string } | undefined {
const b = this.o.budget
if (!b) return ""
if (!b) return undefined
const spent = (performance.now() - started - this.turnWaitedMs) / 1000
if (b.wall_seconds && spent > b.wall_seconds) return `after ${duration(spent * 1000, true)}`
if (b.output_bytes && this.turnOutputBytes > b.output_bytes) return "with too much tool output to read"
if (b.completion_tokens && this.turnCompletion > b.completion_tokens) return `after writing about ${this.turnCompletion.toLocaleString("en")} tokens`
return ""
if (b.wall_seconds && spent > b.wall_seconds) return { limit: "time budget", said: `after ${duration(spent * 1000, true)}` }
if (b.output_bytes && this.turnOutputBytes > b.output_bytes) return { limit: "tool output budget", said: "with too much tool output to read" }
if (b.completion_tokens && this.turnCompletion > b.completion_tokens) return { limit: "output budget", said: `after writing about ${this.turnCompletion.toLocaleString("en")} tokens` }
return undefined
}
/** Tools a model can use at all: view_image only with vision. */
/** Tools a model can use at all: view_image only with vision, plan_submit only in plan mode
* (`requires: "plan"`; plan_write and plan_update are offered in every mode). */
private offered(name: string): boolean {
const t = this.o.tools.find((x) => x.name === name)
if (name === "task" && !this.o.spawn) return false
@@ -424,14 +449,14 @@ export class Engine {
this.autoCompactions = 0
const toolMap = new Map(this.o.tools.map((t) => [t.name, t]))
const allSpecs = this.o.tools.map(toSpec)
const maxSteps = this.o.maxSteps ?? 200
const maxSteps = this.o.maxSteps ?? LOOP.steps.default
const started = performance.now()
this.turnWaitedMs = 0
this.turnOutputBytes = 0
this.turnCompletion = 0
// Why the prompt is being wound up early, once a budget has run out: then, as past the step
// limit, the tools are withdrawn and the model answers from what it has.
let ranOut = ""
let ranOut: { limit: string; said: string } | undefined
let nudges = 0
let midRetries = 0
const tried = new Set<string>()
@@ -440,9 +465,9 @@ export class Engine {
// Checked between steps, never mid-reply: a reply cut off mid-sentence is worth nothing.
if (!ranOut && step > 1) {
ranOut = this.overBudget(started)
if (ranOut) bus.emit({ type: "notice", message: `stopped ${ranOut} — the model answers from what it has; send a message to carry on` })
if (ranOut) bus.emit({ type: "notice", message: `stopped ${ranOut.said} — the model answers from what it has; send a message to carry on` })
}
const wrapUp = step > maxSteps || ranOut !== ""
const wrapUp = step > maxSteps || ranOut !== undefined
if (!wrapUp) await this.manageContext(signal)
// After compacting, so what was just sent is never folded into a summary.
if (!wrapUp) this.deliverInbox()
@@ -457,8 +482,8 @@ export class Engine {
let partial = ""
try {
const stream = this.client.stream({
system: this.o.system(this.mode, this.model),
messages: mergeUsers(wrapUp ? [...this.messages, { role: "user", parts: [{ type: "text", text: wrapUpText(ranOut ? "budget" : "step limit") }] }] : this.messages),
system: this.o.system(this.mode, this.model, this.plan),
messages: mergeUsers(wrapUp ? [...this.messages, { role: "user", parts: [{ type: "text", text: wrapUpText(ranOut ? ranOut.limit : "step limit") }] }] : this.messages),
tools: wrapUp ? [] : allSpecs.filter((s) => !this.withdrawn.has(s.name) && this.offered(s.name)),
effort: this.effort,
signal,
@@ -581,12 +606,15 @@ export class Engine {
...this.o.toolCtx,
signal,
question: this.o.asker.question ? (req, id) => this.waiting(this.o.asker.question!(req, id)) : undefined,
plan: this.o.asker.plan ? (req, id) => this.waiting(this.o.asker.plan!(req, id)) : undefined,
plan: {
get: () => this.plan,
set: (p) => this.setPlan(p),
ask: this.o.asker.plan ? (req, id) => this.waiting(this.o.asker.plan!(req, id)) : undefined,
},
setMode: (m) => {
this.mode = m
bus.emit({ type: "mode", mode: m })
},
todos: this.todos,
spawn: this.o.spawn,
}
// Consecutive ordinary calls run together (up to MAX_PARALLEL); an exclusive one — it waits