Harness spec 3.2.0 in the CLI: effort levels, reasoning round-trip, no absolute paths
ci / check (push) Waiting to run
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:
36 files changed
+1168
-153
No files matched your search
+22
-18
@@ -6,7 +6,8 @@
|
||||
import continueText from "../../harness/prompts/tasks/continue.md" with { type: "text" }
|
||||
import { isAbsolute } from "node:path"
|
||||
import type { Asker, AttachmentInfo, Bus } from "../bus/index.ts"
|
||||
import type { Effort, Mode } from "../config/schema.ts"
|
||||
import type { Mode } from "../config/schema.ts"
|
||||
import type { Level } from "../provider/effort.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 { Plan } from "../plan/plan.ts"
|
||||
@@ -46,7 +47,8 @@ export interface EngineOptions {
|
||||
/** Today's date, as the note says it (tests set it). */
|
||||
today?: () => string
|
||||
mode: Mode
|
||||
effort: Effort | null
|
||||
/** The effort level (provider/effort.ts): an effort, `off` or `default`. */
|
||||
effort: Level
|
||||
perm: Omit<PermContext, "mode">
|
||||
toolCtx: Omit<ToolContext, "signal">
|
||||
maxSteps?: number
|
||||
@@ -89,7 +91,8 @@ export function mergeUsers(messages: Message[]): Message[] {
|
||||
const out: Message[] = []
|
||||
for (const m of messages) {
|
||||
const last = out[out.length - 1]
|
||||
if (m.role === "user" && last?.role === "user") out[out.length - 1] = { role: "user", parts: [...last.parts, ...m.parts] }
|
||||
// One that starts a turn makes the pair start one (context.md §8).
|
||||
if (m.role === "user" && last?.role === "user") out[out.length - 1] = { role: "user", parts: [...last.parts, ...m.parts], ...(last.turn === false && m.turn === false ? { turn: false as const } : {}) }
|
||||
else out.push(m)
|
||||
}
|
||||
return out
|
||||
@@ -109,7 +112,7 @@ export const PLAN_META = "plan"
|
||||
export class Engine {
|
||||
messages: Message[] = []
|
||||
mode: Mode
|
||||
private _effort: Effort | null
|
||||
private _effort: Level
|
||||
usage: Usage | undefined
|
||||
/** messages.length when `usage` was reported: everything after it is not yet counted. */
|
||||
usageAt = 0
|
||||
@@ -314,25 +317,25 @@ export class Engine {
|
||||
this.o.bus.emit({ type: "plan", plan: this.plan })
|
||||
}
|
||||
|
||||
get effort(): Effort | null {
|
||||
get effort(): Level {
|
||||
return this._effort
|
||||
}
|
||||
/** Said on the bus when it changes: the web UI's chat shows the device's model and effort,
|
||||
* whoever changed them. */
|
||||
set effort(v: Effort | null) {
|
||||
set effort(v: Level) {
|
||||
if (v === this._effort) return
|
||||
this._effort = v
|
||||
this.o.bus.emit({ type: "model", ref: this.model.ref, effort: v ?? "off", connection: this.model.connectionName })
|
||||
this.o.bus.emit({ type: "model", ref: this.model.ref, effort: v, connection: this.model.connectionName })
|
||||
}
|
||||
|
||||
/** Switch model mid-session. The conversation carries over; the effort resets to the new model's.
|
||||
* The prompt is made again for it at the next request (prefix(): it was made for another). */
|
||||
setModel(model: ResolvedModel, client: Client, effort: Effort | null) {
|
||||
setModel(model: ResolvedModel, client: Client, effort: Level) {
|
||||
const changed = model.ref !== this.model.ref || effort !== this._effort
|
||||
this.model = model
|
||||
this.client = client
|
||||
this._effort = effort
|
||||
if (changed) this.o.bus.emit({ type: "model", ref: model.ref, effort: effort ?? "off", connection: model.connectionName })
|
||||
if (changed) this.o.bus.emit({ type: "model", ref: model.ref, effort, connection: model.connectionName })
|
||||
}
|
||||
|
||||
/** Start over: a fresh conversation (and session row). Approvals given "for the session" end. */
|
||||
@@ -405,9 +408,10 @@ export class Engine {
|
||||
return summary
|
||||
}
|
||||
|
||||
/** Tokens in use now: the last reported usage plus an estimate of what was added since. The
|
||||
* reply's thinking is left out where the dialect never sends it back (openai-chat): it is in the
|
||||
* last reply's output but will not be in the next request. */
|
||||
/** Tokens in use now: the last reported usage plus an estimate of what was added since. A final
|
||||
* reply's thinking is left out: it is in its output, but the next request starts a turn and
|
||||
* sends no earlier turn's reasoning (context.md §8). A reply that called tools keeps it: its turn
|
||||
* goes on, and its reasoning goes back. */
|
||||
contextUsed(): number | undefined {
|
||||
if (!this.usage) return undefined
|
||||
return this.usage.input + this.usage.output - this.droppedThinking() + estimateValue(this.messages.slice(this.usageAt))
|
||||
@@ -416,7 +420,7 @@ export class Engine {
|
||||
private droppedThinking(): number {
|
||||
if (!this.usage) return 0
|
||||
const reply = this.messages[this.usageAt - 1]
|
||||
if (reply?.role !== "assistant") return 0
|
||||
if (reply?.role !== "assistant" || reply.parts.some((p) => p.type === "tool_call")) return 0
|
||||
const estimate = reply.parts.reduce((n, p) => n + (p.type === "reasoning" ? estimateTokens(p.text) : 0), 0)
|
||||
return Math.min(this.usage.output, this.usage.reasoning ?? estimate)
|
||||
}
|
||||
@@ -553,7 +557,7 @@ export class Engine {
|
||||
private deliverInbox(): boolean {
|
||||
const notes = this.notes.splice(0)
|
||||
if (notes.length) {
|
||||
this.push({ role: "user", parts: [{ type: "text", text: notes.join("\n\n") }] })
|
||||
this.push({ role: "user", parts: [{ type: "text", text: notes.join("\n\n") }], turn: false })
|
||||
// Said as what happened, not as its mark: the line after it.
|
||||
this.o.bus.emit({ type: "notice", message: notes.map((n) => n.split("\n").find((l, i) => i > 0 && l.trim()) ?? n.split("\n")[0]!).join("; ") })
|
||||
}
|
||||
@@ -561,7 +565,7 @@ export class Engine {
|
||||
const texts = this.inbox.splice(0)
|
||||
const ids = this.inboxIds.splice(0).filter((i): i is string => typeof i === "string")
|
||||
this.turnSteers.push(...texts)
|
||||
this.push({ role: "user", parts: [{ type: "text", text: `${this.o.steerMark ?? STEER_MARK}\n${texts.join("\n\n")}` }] })
|
||||
this.push({ role: "user", parts: [{ type: "text", text: `${this.o.steerMark ?? STEER_MARK}\n${texts.join("\n\n")}` }], turn: false })
|
||||
this.o.bus.emit({ type: "steered", texts, ...(ids.length ? { ids } : {}) })
|
||||
this.o.bus.emit({ type: "inbox", texts: [], mode: this.busyInput })
|
||||
return true
|
||||
@@ -638,7 +642,7 @@ export class Engine {
|
||||
// The same system prompt and tool list on every request of the session (§10).
|
||||
const stream = this.client.stream({
|
||||
system: this.systemPrompt(),
|
||||
messages: mergeUsers(wrapUp ? [...this.messages, { role: "user", parts: [{ type: "text", text: wrapUpText(ranOut ? ranOut.limit : "step limit") }] }] : this.messages),
|
||||
messages: mergeUsers(wrapUp ? [...this.messages, { role: "user", parts: [{ type: "text", text: wrapUpText(ranOut ? ranOut.limit : "step limit") }], turn: false }] : this.messages),
|
||||
// The wrap-up too (loop.json wrap_up): its message says the tools are withdrawn, and a
|
||||
// call in its reply is not run.
|
||||
tools: this.toolSpecs(),
|
||||
@@ -721,7 +725,7 @@ export class Engine {
|
||||
? `the reply was cut off at the output limit${limit}; asking it to carry on`
|
||||
: `the model used the whole output limit${limit} thinking; asking it to act`,
|
||||
})
|
||||
this.push({ role: "user", parts: [{ type: "text", text: !cut ? NO_ANSWER : said ? CUT_TEXT : CUT_THINKING }] })
|
||||
this.push({ role: "user", parts: [{ type: "text", text: !cut ? NO_ANSWER : said ? CUT_TEXT : CUT_THINKING }], turn: false })
|
||||
continue
|
||||
}
|
||||
bus.emit({
|
||||
@@ -828,7 +832,7 @@ export class Engine {
|
||||
}
|
||||
// No dialect takes an image inside a tool result the same way; a user turn right after the
|
||||
// results works everywhere.
|
||||
if (images.length) this.push({ role: "user", parts: [{ type: "text", text: "The image(s) you asked to view:" }, ...images] })
|
||||
if (images.length) this.push({ role: "user", parts: [{ type: "text", text: "The image(s) you asked to view:" }, ...images], turn: false })
|
||||
if (signal.aborted) {
|
||||
bus.emit({ type: "done", reason: "cancelled" })
|
||||
return "cancelled"
|
||||
|
||||
Reference in new issue
Block a user