Harness spec 3.2.0 in the CLI: effort levels, reasoning round-trip, no absolute paths
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>
This commit is contained in:
HomerandClaude Opus 5.5 committed 2026-11-09 08:36:55 +00:00
1 parent 5c23087cef
commit fbefe7f844
36 files changed
+1168 -153

No files matched your search

+2 -1
View File
@@ -553,7 +553,8 @@ test("the model event: said whenever the model or effort changes, forwarded to L
await until(() => events.filter((e) => e.event.type === "model").length >= 2, "two model events")
const models = events.filter((e) => e.event.type === "model").map((e) => e.event)
// `instance`: the model is spoken to through the connection this link's instance is.
expect(models[0]).toEqual({ type: "model", ref: "example/b", effort: "off", connection: "example", instance: true })
// b says nothing of its efforts: the common three, starting at medium (models/schema.json levels).
expect(models[0]).toEqual({ type: "model", ref: "example/b", effort: "medium", connection: "example", instance: true })
expect(models.at(-1)).toEqual({ type: "model", ref: "example/a", effort: "high", connection: "example", instance: true })
})
+5 -1
View File
@@ -210,7 +210,7 @@ test("_lembas/directories is for the link only: an editor's ACP session has no r
test("the chat's model and effort: named by the instance, at the start and between prompts", async () => {
fake = fakeProvider([{ chunks: [delta({ content: "one" })] }, { chunks: [delta({ content: "two" })] }])
mkdirSync(paths.config, { recursive: true })
writeFileSync(join(paths.config, "connections.yaml"), `connections:\n example:\n type: webui\n url: ${fake.url}\n models: { bonsai: {}, deepseek-flash: { efforts: [low, medium, high] } }\n`, { mode: 0o600 })
writeFileSync(join(paths.config, "connections.yaml"), `connections:\n example:\n type: webui\n url: ${fake.url}\n models: { bonsai: { thinking_switch: true, efforts: [medium] }, deepseek-flash: { efforts: [low, medium, high] } }\n`, { mode: 0o600 })
writeFileSync(join(paths.config, "config.yaml"), "model: bonsai\n")
const root = project()
const { c } = await client({ roots: [root], maxMode: "auto", approvalTimeoutMs: 50, requireTrust: true }, undefined, "example")
@@ -225,7 +225,11 @@ test("the chat's model and effort: named by the instance, at the start and betwe
expect(changed).toEqual({ model: "bonsai", effort: "off" })
await c.request("session/prompt", { sessionId: s.sessionId, prompt: [{ type: "text", text: "again" }] })
expect(fake.requests.at(-1).model).toBe("bonsai")
// off (spec 3.2.0): thinking switched off, and no effort anywhere.
expect(JSON.stringify(fake.requests.at(-1))).not.toContain("reasoning_effort")
expect(fake.requests.at(-1).chat_template_kwargs).toEqual({ enable_thinking: false })
// A level the model does not offer is read as its start level, and the answer says which.
expect(await c.request<any>("_lembas/configure", { sessionId: s.sessionId, model: "deepseek-flash", effort: "off" })).toEqual({ model: "deepseek-flash", effort: "medium" })
})
test("the chat's model, full stop (spec 3.0.0): `instance` on session/new; a model the device cannot run is -32602, never its own", async () => {
+91 -3
View File
@@ -7,13 +7,14 @@ import { splitCommand, words } from "../src/permission/bash.ts"
import { prefix } from "../src/permission/arity.ts"
import { split } from "../src/library/chunks.ts"
import { ThinkSplitter } from "../src/provider/think.ts"
import { advertisedEfforts, effortRefused } from "../src/provider/effort.ts"
import { advertisedEfforts, effortRefused, levelFields, levelsOf, type LevelMeta } from "../src/provider/effort.ts"
import { replace } from "../src/tool/replace.ts"
import { applyUnified, parseUnified } from "../src/tool/unidiff.ts"
import { derive, joinBom, parse } from "../src/tool/patch.ts"
import type { Mode, PermissionConfig } from "../src/config/schema.ts"
import { apply, fromTodos, normalise, type Plan } from "../src/plan/plan.ts"
import { ToolCallAccumulator } from "../src/provider/openai-chat.ts"
import { ToolCallAccumulator, toOpenAIMessages } from "../src/provider/openai-chat.ts"
import type { Message } from "../src/provider/types.ts"
import { parseInfo, titleFor } from "../src/prototype/fence.ts"
import { resolveBase } from "../src/library/store.ts"
import { NarrowError, narrowParameters, type Narrow } from "../src/harness.ts"
@@ -24,8 +25,11 @@ import { readBody } from "../src/tool/read.ts"
import { applyOps, type Op, type Target } from "../src/memory/store.ts"
import { jobNote, type NoteCase } from "../src/jobs/note.ts"
import { compactAsk, noteFor, offeredFor, toldAt, unavailable, type Now, type Requirement, type Told } from "../src/session/prefix.ts"
import { promptText } from "../src/prompt/assemble.ts"
import { envLines, promptText } from "../src/prompt/assemble.ts"
import { absPath, type ToolContext } from "../src/tool/tool.ts"
import { TOOL_SPECS } from "../src/harness.ts"
import { Team, TEAM_TOOLS, type Capacity } from "../src/team/team.ts"
import { noteBlock } from "../src/session/prefix.ts"
type Case = Record<string, unknown>
@@ -68,6 +72,14 @@ export const AREAS: Record<string, (c: Case) => unknown> = {
return out
},
effort: (c) => ({ refused: effortRefused(c.message as string), advertised: advertisedEfforts(c.message as string) }),
// Effort levels (models/schema.json `levels`): what a model offers, how it is shown, where it
// starts, and what a request carries for a level.
levels: (c) => {
const meta = c.model as LevelMeta
if (c.op === "levels") return levelsOf(meta)
if (c.op === "request") return levelFields(meta, c.level as string)
throw new Error(`levels: unknown op ${String(c.op)}`)
},
edit: (c) => {
try {
return { content: replace(c.content as string, c.old as string, c.new as string, c.all === true) }
@@ -205,6 +217,82 @@ export const AREAS: Record<string, (c: Case) => unknown> = {
throw new Error(`prefix: unknown op ${String(st.op)}`)
})
},
// Reasoning sent back during its turn (context.md §8): the history as the OpenAI chat dialect
// sends it — role, content and reasoning_content (tool calls and ids left out, as the cases do).
reasoning: (c) => {
const history: Message[] = (c.messages as Record<string, any>[]).map((m, i): Message => {
if (m.role === "user") return { role: "user", parts: [{ type: "text", text: m.content }], ...(m.turn === false ? { turn: false as const } : {}) }
if (m.role === "assistant") return { role: "assistant", parts: [...(m.reasoning !== undefined ? [{ type: "reasoning" as const, text: m.reasoning }] : []), ...(m.content ? [{ type: "text" as const, text: m.content }] : [])] }
return { role: "tool", callId: `c${i}`, name: "read", content: m.content }
})
return (toOpenAIMessages("", history, false) as Record<string, any>[]).map((m) => ({ role: m.role, content: m.content ?? "", ...(m.reasoning_content !== undefined ? { reasoning_content: m.reasoning_content } : {}) }))
},
// No absolute paths (context.md §11): the environment block's lines, and how a tool reads a path.
paths: (c) => {
if (c.op === "env") return envLines(c.root as string, c.started as string)
if (c.op === "resolve") return absPath(c.path as string, { root: (c.root as string).replace(/\/+$/, ""), cwd: c.started as string } as ToolContext)
throw new Error(`paths: unknown op ${String(c.op)}`)
},
// Teams (team.md): one lead session's team, step by step, through the CLI's own rules
// (team/team.ts); the app drives the same class from real turns.
team: (c) => {
const start = c.start as { min: number; max: number; subagents?: boolean; capacity: Capacity }
let capacity = start.capacity
const team = new Team({ min: start.min, max: start.max, subagents: start.subagents, model: "m", capacity: () => capacity })
const delivered = () => {
const out: Record<string, string[]> = {}
for (const d of team.delivered.splice(0)) (out[d.name] ??= []).push(d.text)
return out
}
return (c.steps as Record<string, any>[]).map((st) => {
const by = (st.by as string | undefined) ?? "lead"
switch (st.op) {
case "offered": {
const a = team.get(by)!
const tools = !team.offered ? [] : a.leads ? [...TEAM_TOOLS] : ["team_read", "team_send"]
return { tools: tools.sort(), setting: team.offered }
}
case "call": {
const args = st.args as Record<string, any>
let result: string | null
if (st.tool === "team_read") {
const no = team.offered ? team.unavailable() : "unavailable"
const room = no ? [] : team.readRoom(by, args.since ?? 0).messages.map((m) => ({ n: m.n, from: m.from, to: m.to.join(", ") }))
return { room, delivered: delivered() }
}
if (st.tool === "team_add") result = team.add(by, args as any).text
else if (st.tool === "team_send") result = team.send(by, args as any)
else if (st.tool === "team_stop") result = team.stopCmd(by, args.name)
else {
result = team.refusal(by, st.tool, args) ?? null
if (st.tool === "task" && result === null && team.offered) team.helperStart(by)
}
return { result, delivered: delivered() }
}
case "finish":
team.finish(by, st.reply ?? "", st.reason ?? "stop")
return { delivered: delivered() }
case "helper_done":
team.helperDoneBy(by)
return null
case "set":
if (st.capacity) capacity = st.capacity
if (st.min !== undefined || st.max !== undefined) team.setSetting(st.min ?? team.min, st.max ?? team.max)
return null
case "person_stop":
team.personStop(st.name)
return null
case "user": {
const lines = team.noteLines()
team.leadWants()
return { note: lines.length ? noteBlock(lines) : null }
}
case "state":
return team.state()
}
throw new Error(`team: unknown op ${String(st.op)}`)
})
},
library_scope: (c) => resolveBase(c.bases as { id: string; name: string }[], c.base as string | null),
toolcall_index: (c) => {
// The deltas as the OpenAI stream carries them: `function.name` and `function.arguments`.
+7 -5
View File
@@ -93,18 +93,20 @@ describe("history", () => {
})
describe("the context meter", () => {
test("counts the reply's thinking only until it is dropped from the next request", async () => {
test("counts a reply's thinking while its turn goes on (it is sent back), not after the turn's final reply (context.md §8)", async () => {
const dir = setup([
{ chunks: [delta({ reasoning_content: "x".repeat(4000) }), toolCall(0, "l1", "list", "{}"), usage(1000, 1100)] },
{ chunks: [delta({ content: "ok" }, "stop"), usage(1150, 5)] },
{ chunks: [delta({ reasoning_content: "y".repeat(4000) }), delta({ content: "ok" }, "stop"), usage(2150, 1100)] },
])
const app = createApp({ cwd: dir, mode: "edit", asker: noAsk, store: false })
const used: number[] = []
app.bus.on((e: Event) => e.type === "usage" && e.used !== undefined && used.push(e.used))
await app.engine.prompt("go")
// 1000 in + 1100 out, of which ~1000 thinking that is not sent again: ~1100, not 2100.
expect(used[0]).toBeLessThan(1200)
expect(used[0]).toBeGreaterThan(1000)
// The step that called a tool: its ~1000 tokens of thinking go back with the next step.
expect(used[0]).toBeGreaterThan(2000)
// The final reply: 2150 in + 1100 out, of which ~1000 thinking the next turn does not send.
expect(used[1]).toBeLessThan(2400)
expect(used[1]).toBeGreaterThan(2150)
})
})
+3 -2
View File
@@ -1,7 +1,7 @@
import { afterEach, describe, expect, test } from "bun:test"
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { basename, join } from "node:path"
import { createApp } from "../src/app.ts"
import type { AskReply, Event } from "../src/bus/index.ts"
import { paths } from "../src/config/paths.ts"
@@ -49,7 +49,8 @@ describe("end to end", () => {
expect(fake!.requests[1].messages.at(-1).content).toMatch(/^\[#0001 hello\.txt lines 1-1 of 1 sha [0-9a-f]{8}\]\n1: hello world$/)
expect(fake!.requests[2].messages.at(-1).content).toMatch(/^\[#0002 hello\.txt sha [0-9a-f]{8}\]\n@@ -1,1 \+1,1 @@\n-hello world\n\+hello lembas$/)
// System prompt carries env and the mode.
expect(fake!.requests[0].messages[0].content).toContain(`Project root: ${dir}`)
expect(fake!.requests[0].messages[0].content).toContain(`Project: ${basename(dir)} (paths are relative to its root)`)
expect(fake!.requests[0].messages[0].content).not.toContain(dir)
expect(fake!.requests[0].messages[0].content).toContain("Permission mode: edit")
// knowledge_search/get are offered once any knowledge base has documents, and the suite shares
// one LEMBAS_HOME: another file's base must not decide this list (found in the full run).
+11 -4
View File
@@ -139,11 +139,18 @@ describe("openai-chat streaming", () => {
expect(fake.requests[0].chat_template_kwargs).toEqual({ reasoning_effort: "high" })
})
test("effort outside the vocabulary is not sent", async () => {
fake = fakeProvider([{ chunks: [delta({ content: "ok" })] }])
test("a level the model does not offer is read as its start level (spec 3.2.0 levels)", async () => {
fake = fakeProvider([{ chunks: [delta({ content: "ok" })] }, { chunks: [delta({ content: "ok" })] }, { chunks: [delta({ content: "ok" })] }])
await collect(new OpenAIChatClient(model(fake.url, { efforts: ["low"] })), "high")
expect(fake.requests[0].reasoning_effort).toBeUndefined()
expect(fake.requests[0].chat_template_kwargs).toBeUndefined()
expect(fake.requests[0].reasoning_effort).toBe("low")
// off: only with the switch, and then enable_thinking false and no effort anywhere.
await collect(new OpenAIChatClient(model(fake.url, { efforts: ["medium"], thinking_switch: true })), "off")
expect(fake.requests[1].reasoning_effort).toBeUndefined()
expect(fake.requests[1].chat_template_kwargs).toEqual({ enable_thinking: false })
// default: a model that takes no efforts — nothing sent.
await collect(new OpenAIChatClient(model(fake.url, { efforts: [] })), "default")
expect(fake.requests[2].reasoning_effort).toBeUndefined()
expect(fake.requests[2].chat_template_kwargs).toBeUndefined()
})
test("template refuses an effort → retried without it, vocabulary learned from the message", async () => {
+1 -1
View File
@@ -68,7 +68,7 @@ test("session, global and project scope: what is written where, and where a valu
expect(readFileSync(join(cwd, ".agent/config.yaml"), "utf8")).toContain("auto_at: 0.6")
expect(s.get("compaction.auto_at")).toMatchObject({ value: 0.6, source: "project" })
expect(() => s.set("effort", "max")).toThrow("takes")
expect(() => s.set("effort", "max")).toThrow("offers the levels")
expect(() => s.set("update.channel", "beta", "project")).toThrow("can be set for: global")
})
+102
View File
@@ -0,0 +1,102 @@
// Harness spec 3.2.0 through the whole app: effort levels as a request carries them, the model's
// reasoning sent back during its turn and not after it, and no absolute path in what the model is
// told — relative paths and the shell at the project root, wherever the session was started.
import { afterEach, describe, expect, test } from "bun:test"
import { mkdirSync, mkdtempSync, realpathSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { createApp } from "../src/app.ts"
import type { AskReply } from "../src/bus/index.ts"
import { paths } from "../src/config/paths.ts"
import { delta, fakeProvider, toolCall, usage, type Fake } from "./fake-provider.ts"
let fake: Fake | undefined
afterEach(() => fake?.stop())
function setup(script: Parameters<typeof fakeProvider>[0], spec = "{ context: 32768 }") {
fake = fakeProvider(script)
mkdirSync(paths.config, { recursive: true })
writeFileSync(join(paths.config, "connections.yaml"), `connections:\n fake:\n type: webui\n url: ${fake.url}\n models:\n fake/m: ${spec}\n`, { mode: 0o600 })
writeFileSync(join(paths.config, "config.yaml"), "model: fake/m\n")
const dir = realpathSync(mkdtempSync(join(tmpdir(), "lembas-320-")))
Bun.spawnSync(["git", "init", "-q", dir])
mkdirSync(join(dir, "packages", "web"), { recursive: true })
writeFileSync(join(dir, "top.txt"), "at the root\n")
writeFileSync(join(dir, "packages", "web", "index.ts"), "export {}\n")
return dir
}
const yes = { ask: async (): Promise<AskReply> => ({ kind: "once" }) }
describe("reasoning goes back during its turn (context.md §8)", () => {
test("a step's reasoning is sent back within the turn, a step-boundary note does not end the turn, and the next turn sends none", async () => {
const dir = setup([
{ chunks: [delta({ reasoning_content: "List first." }), toolCall(0, "l1", "list", "{}"), usage(100, 10)] },
{ chunks: [delta({ reasoning_content: "Now answer." }), delta({ content: "Two files." }, "stop"), usage(150, 10)] },
{ chunks: [delta({ content: "Still two." }, "stop"), usage(200, 5)] },
])
const app = createApp({ cwd: dir, mode: "edit", asker: yes, store: false })
// A job's note delivered at the first step boundary: it does not start a turn.
app.engine.note("[A background job you started has finished:]\njob1 (true) exited with 0 after 0s.")
await app.engine.prompt("what is here?")
const second = fake!.requests[1].messages.filter((m: any) => m.role === "assistant")
expect(second).toEqual([expect.objectContaining({ reasoning_content: "List first." })])
await app.engine.prompt("and now?")
// A new turn: the earlier replies go without their reasoning.
const third = fake!.requests[2].messages.filter((m: any) => m.role === "assistant")
expect(third.length).toBe(2)
for (const m of third) expect(m.reasoning_content).toBeUndefined()
})
test("reasoning read from inline <think> tags goes back as reasoning_content, never as text", async () => {
const dir = setup([
{ chunks: [delta({ content: "<think>Look at the list.</think>" }), toolCall(0, "l1", "list", "{}"), usage(100, 10)] },
{ chunks: [delta({ content: "Done." }, "stop"), usage(150, 10)] },
])
const app = createApp({ cwd: dir, mode: "edit", asker: yes, store: false })
await app.engine.prompt("look")
const reply = fake!.requests[1].messages.find((m: any) => m.role === "assistant")
expect(reply.reasoning_content).toBe("Look at the list.")
expect(reply.content ?? "").not.toContain("think")
})
})
describe("effort levels in a request (models/schema.json levels)", () => {
test("off on a model with the switch: enable_thinking false and no effort; the start level otherwise", async () => {
const dir = setup([{ chunks: [delta({ content: "a" }, "stop")] }, { chunks: [delta({ content: "b" }, "stop")] }], "{ thinking_switch: true, efforts: [medium] }")
const app = createApp({ cwd: dir, mode: "edit", asker: yes, store: false, effort: "off" })
expect(app.engine.effort).toBe("off")
await app.engine.prompt("hi")
expect(fake!.requests[0].chat_template_kwargs).toEqual({ enable_thinking: false })
expect(fake!.requests[0].reasoning_effort).toBeUndefined()
app.settings.set("effort", "medium")
await app.engine.prompt("again")
expect(fake!.requests[1].reasoning_effort).toBe("medium")
expect(fake!.requests[1].chat_template_kwargs).toEqual({ reasoning_effort: "medium" })
expect(() => app.settings.set("effort", "high")).toThrow("offers the levels off, medium")
})
test("off asked of a model without the switch starts at its start level", async () => {
const dir = setup([{ chunks: [delta({ content: "a" }, "stop")] }], "{ efforts: [low, high] }")
const app = createApp({ cwd: dir, mode: "edit", asker: yes, store: false, effort: "off" })
expect(app.engine.effort).toBe("low")
})
})
describe("no absolute paths (context.md §11)", () => {
test("started below the root: the prompt names the project and the directory relative to it; read and bash work from the root", async () => {
const dir = setup([
{ chunks: [toolCall(0, "r1", "read", '{"path":"top.txt"}'), toolCall(1, "b1", "bash", '{"command":"pwd","description":"where"}'), usage(100, 10)] },
{ chunks: [delta({ content: "ok" }, "stop"), usage(150, 5)] },
])
const app = createApp({ cwd: join(dir, "packages", "web"), mode: "auto", asker: yes, store: false })
await app.engine.prompt("look")
const system = fake!.requests[0].messages[0].content as string
expect(system).toContain(`Project: ${dir.split("/").pop()} (paths are relative to its root)\nStarted in: packages/web (relative to the project root)`)
expect(system).not.toContain(dir)
const results = fake!.requests[1].messages.filter((m: any) => m.role === "tool").map((m: any) => m.content as string)
expect(results[0]).toContain("1: at the root")
expect(results[1]).toContain(dir)
expect(results[1]).not.toContain("packages/web")
})
})
+3 -1
View File
@@ -133,7 +133,9 @@ describe("worktrees", () => {
await a.engine.prompt("fix it in a worktree")
const child = fake!.requests[1].messages[0].content as string
expect(child).toContain("You are working in a separate checkout")
expect(child).toMatch(/Project root: .*worktrees/)
// Named by its directory, never by an absolute path (context.md §11).
expect(child).toMatch(/Project: general-fix-average-[a-z0-9]+ \(paths are relative to its root\)/)
expect(child).not.toContain(root)
expect(readFileSync(join(root, "calc.py"), "utf8")).toContain("len(v) - 1")
const result = fake!.requests[4].messages.at(-1).content as string
const branch = /branch (lembas\/general-fix-average-[a-z0-9]+)/.exec(result)?.[1]