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
@@ -168,3 +168,18 @@ export function historyArgs(args: string): string {
|
||||
} catch {}
|
||||
return "{}"
|
||||
}
|
||||
|
||||
/** Harness context.md §8 (spec 3.2.0): the history as a request sends it — every assistant message
|
||||
* of the current turn (after the last user message that started a turn) keeps its reasoning,
|
||||
* earlier ones carry none. What is stored is unchanged; only the request is. */
|
||||
export function turnReasoning(messages: Message[]): Message[] {
|
||||
let start = -1
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const m = messages[i]!
|
||||
if (m.role === "user" && m.turn !== false) {
|
||||
start = i
|
||||
break
|
||||
}
|
||||
}
|
||||
return messages.map((m, i) => (m.role === "assistant" && i < start && m.parts.some((p) => p.type === "reasoning") ? { ...m, parts: m.parts.filter((p) => p.type !== "reasoning") } : m))
|
||||
}
|
||||
+80
-16
@@ -10,29 +10,83 @@ import { learned } from "./learned.ts"
|
||||
|
||||
export const DEFAULT_EFFORTS: Effort[] = ["low", "medium", "high"]
|
||||
|
||||
/** The efforts this model accepts, in offering order: learned > configured > the common three. */
|
||||
/** The efforts this model accepts, in offering order: learned > the server's > the common three.
|
||||
* `[]` from the server: it takes none (its one level is `default`). */
|
||||
export function effortsFor(m: ResolvedModel): Effort[] {
|
||||
const list = learned().efforts[m.ref] ?? m.spec.efforts ?? DEFAULT_EFFORTS
|
||||
return EFFORTS.filter((e) => list.includes(e))
|
||||
}
|
||||
|
||||
/** The model's default effort, or null for off / not a reasoning model. */
|
||||
export function defaultEffort(m: ResolvedModel): Effort | null {
|
||||
const e = m.spec.effort
|
||||
if (!e || e === "off") return null
|
||||
return effortsFor(m).includes(e) ? e : null
|
||||
/** An effort level (harness models/schema.json `levels`, spec 3.2.0): an effort; `off` — thinking
|
||||
* switched off, only on a model with `thinking_switch`; `default` — nothing sent, the model's own,
|
||||
* only on a model that takes no efforts. */
|
||||
export type Level = Effort | "off" | "default"
|
||||
export const LEVEL_WORDS: readonly Level[] = [...EFFORTS, "off", "default"]
|
||||
export type Control = "none" | "switch" | "slider"
|
||||
|
||||
/** What the levels are read from: the model's metadata, efforts as given (never absent here: an
|
||||
* absent list is each project's fallback, effortsFor's). */
|
||||
export interface LevelMeta {
|
||||
thinking_switch?: boolean
|
||||
efforts: readonly Effort[]
|
||||
effort?: string
|
||||
effort_style?: "top" | "kwargs" | "both"
|
||||
}
|
||||
|
||||
/** Put the effort into an openai-chat request body. An effort outside the vocabulary is dropped. */
|
||||
export function applyEffort(body: Record<string, unknown>, m: ResolvedModel, effort: Effort | null | undefined) {
|
||||
if (!effort || !effortsFor(m).includes(effort)) return
|
||||
const style = m.spec.effort_style ?? "both"
|
||||
if (style !== "kwargs") body.reasoning_effort = effort
|
||||
if (style !== "top") {
|
||||
const kwargs = { ...((body.chat_template_kwargs as Record<string, unknown>) ?? {}) }
|
||||
kwargs.reasoning_effort = effort
|
||||
body.chat_template_kwargs = kwargs
|
||||
}
|
||||
export function isLevel(v: unknown): v is Level {
|
||||
return typeof v === "string" && (LEVEL_WORDS as readonly string[]).includes(v)
|
||||
}
|
||||
|
||||
/** The levels a chat offers, how they are shown, and where a new conversation starts. */
|
||||
export function levelsOf(meta: LevelMeta): { levels: Level[]; control: Control; start: Level } {
|
||||
const levels: Level[] = [...(meta.thinking_switch === true ? (["off"] as Level[]) : []), ...(meta.efforts.length ? meta.efforts : (["default"] as Level[]))]
|
||||
const control: Control = levels.length === 1 ? "none" : levels.length === 2 && levels.includes("off") ? "switch" : "slider"
|
||||
const start: Level =
|
||||
meta.effort && (levels as string[]).includes(meta.effort) ? (meta.effort as Level) : meta.efforts.includes("medium") ? "medium" : (meta.efforts[0] ?? "default")
|
||||
return { levels, control, start }
|
||||
}
|
||||
|
||||
/** A model's levels: its switch, its efforts (learned > the server's > the common three). */
|
||||
export function levelsFor(m: ResolvedModel) {
|
||||
return levelsOf({ thinking_switch: m.spec.thinking_switch, efforts: effortsFor(m), effort: m.spec.effort, effort_style: m.spec.effort_style })
|
||||
}
|
||||
|
||||
/** The level a request uses for `asked`: itself when the model offers it, else the start level (a
|
||||
* level the model does not offer — an `off` from an older client — is read as the start level). */
|
||||
export function levelOf(meta: LevelMeta, asked: string | null | undefined): Level {
|
||||
const { levels, start } = levelsOf(meta)
|
||||
return asked && (levels as string[]).includes(asked) ? (asked as Level) : start
|
||||
}
|
||||
|
||||
/** A model's level for `asked` (levelOf), or its start level when nothing is asked. */
|
||||
export function readLevel(m: ResolvedModel, asked?: string | null): Level {
|
||||
return levelOf({ thinking_switch: m.spec.thinking_switch, efforts: effortsFor(m), effort: m.spec.effort }, asked)
|
||||
}
|
||||
|
||||
/** The model's start level. */
|
||||
export function defaultEffort(m: ResolvedModel): Level {
|
||||
return levelsFor(m).start
|
||||
}
|
||||
|
||||
/** What an OpenAI chat request body carries for a level: `off` — enable_thinking false in
|
||||
* chat_template_kwargs and no effort anywhere; `default` — nothing; an effort — where
|
||||
* effort_style puts it, and no enable_thinking. A level the model does not offer is read as its
|
||||
* start level. */
|
||||
export function levelFields(meta: LevelMeta, asked: string | null | undefined): { reasoning_effort?: Effort; chat_template_kwargs?: Record<string, unknown> } {
|
||||
const level = levelOf(meta, asked)
|
||||
if (level === "default") return {}
|
||||
if (level === "off") return { chat_template_kwargs: { enable_thinking: false } }
|
||||
const style = meta.effort_style ?? "both"
|
||||
return { ...(style !== "kwargs" ? { reasoning_effort: level } : {}), ...(style !== "top" ? { chat_template_kwargs: { reasoning_effort: level } } : {}) }
|
||||
}
|
||||
|
||||
/** Put the level into an openai-chat request body. `null`/undefined: nothing sent (a small job
|
||||
* of the harness's own — a title, a commit message — sends no level). */
|
||||
export function applyEffort(body: Record<string, unknown>, m: ResolvedModel, level: Level | null | undefined) {
|
||||
if (!level) return
|
||||
const f = levelFields({ thinking_switch: m.spec.thinking_switch, efforts: effortsFor(m), effort: m.spec.effort, effort_style: m.spec.effort_style }, level)
|
||||
if (f.reasoning_effort) body.reasoning_effort = f.reasoning_effort
|
||||
if (f.chat_template_kwargs) body.chat_template_kwargs = { ...((body.chat_template_kwargs as Record<string, unknown>) ?? {}), ...f.chat_template_kwargs }
|
||||
}
|
||||
|
||||
export function stripEffort(body: Record<string, unknown>): Record<string, unknown> {
|
||||
@@ -40,6 +94,7 @@ export function stripEffort(body: Record<string, unknown>): Record<string, unkno
|
||||
delete out.reasoning_effort
|
||||
const kwargs = { ...((out.chat_template_kwargs as Record<string, unknown>) ?? {}) }
|
||||
delete kwargs.reasoning_effort
|
||||
delete kwargs.enable_thinking
|
||||
if (Object.keys(kwargs).length) out.chat_template_kwargs = kwargs
|
||||
else delete out.chat_template_kwargs
|
||||
return out
|
||||
@@ -77,3 +132,12 @@ export function effortsFromTemplate(template: string): Effort[] {
|
||||
const out = EFFORTS.filter((e) => found.has(e))
|
||||
return out.length >= 2 ? out : []
|
||||
}
|
||||
|
||||
/** The level as a status line says it, by how the model's levels are shown: nothing for a model
|
||||
* with one level, "thinking" / "thinking off" for a Thinking switch, else the level's name. */
|
||||
export function levelLabel(m: ResolvedModel, level: Level): string {
|
||||
const { control } = levelsFor(m)
|
||||
if (control === "none") return ""
|
||||
if (control === "switch") return level === "off" ? "thinking off" : "thinking"
|
||||
return level
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import { resolveKey } from "../config/load.ts"
|
||||
import { advertisedEfforts, applyEffort, effortRefused, effortsFor, stripEffort } from "./effort.ts"
|
||||
import { authHeaders, request, joinUrl, tlsFor } from "./http.ts"
|
||||
import { learned, learnEfforts, learnNoProgress, learnNoStreamOptions } from "./learned.ts"
|
||||
import { historyArgs, Parts, record, recordFailure, retrying, statusOf, SwapBanner } from "./common.ts"
|
||||
import { historyArgs, Parts, record, recordFailure, retrying, statusOf, SwapBanner, turnReasoning } from "./common.ts"
|
||||
import { sseJson } from "./sse.ts"
|
||||
import { ThinkSplitter } from "./think.ts"
|
||||
import { estimateTokens, estimateValue } from "./tokens.ts"
|
||||
@@ -30,7 +30,7 @@ type Assistant = Extract<Message, { role: "assistant" }>
|
||||
export function toOpenAIMessages(system: string, messages: Message[], vision: boolean): unknown[] {
|
||||
const out: unknown[] = []
|
||||
if (system) out.push({ role: "system", content: system })
|
||||
for (const m of messages) {
|
||||
for (const m of turnReasoning(messages)) {
|
||||
if (m.role === "user") {
|
||||
const images = m.parts.filter((p) => p.type === "image")
|
||||
if (images.length === 0 || !vision) {
|
||||
@@ -50,6 +50,10 @@ export function toOpenAIMessages(system: string, messages: Message[], vision: bo
|
||||
const text = m.parts.filter((p): p is TextPart => p.type === "text").map((p) => p.text).join("")
|
||||
const calls = m.parts.filter((p): p is ToolCallPart => p.type === "tool_call")
|
||||
const msg: Record<string, unknown> = { role: "assistant", content: text || null }
|
||||
// The current turn's reasoning goes back as the model gave it (context.md §8) — inline <think>
|
||||
// tags were read as reasoning, and go back here, never as text. None: no field.
|
||||
const reasoning = m.parts.filter((p): p is ReasoningPart => p.type === "reasoning").map((p) => p.text).join("")
|
||||
if (reasoning) msg.reasoning_content = reasoning
|
||||
if (calls.length)
|
||||
msg.tool_calls = calls.map((c) => ({ id: c.id, type: "function", function: { name: c.name, arguments: historyArgs(c.args) } }))
|
||||
out.push(msg)
|
||||
|
||||
+14
-3
@@ -13,7 +13,17 @@ export type ToolCallPart = { type: "tool_call"; id: string; name: string; args:
|
||||
export type Message =
|
||||
/** `turnId`: the turn a prompt started, kept with it in the store for
|
||||
* `_lembas/session/history`; never sent to a model. */
|
||||
| { role: "user"; parts: (TextPart | ImagePart)[]; turnId?: string; /** The harness wrote it (a job's note), not the person: shown as such. */ machine?: true }
|
||||
| {
|
||||
role: "user"
|
||||
parts: (TextPart | ImagePart)[]
|
||||
turnId?: string
|
||||
/** The harness wrote it (a job's note), not the person: shown as such. */
|
||||
machine?: true
|
||||
/** false: it does not start a turn — delivered at a step boundary while a turn runs (a job's
|
||||
* note, a team message, words the person added), or the harness's own nudge within the turn.
|
||||
* Absent: it does (harness context.md §8). */
|
||||
turn?: false
|
||||
}
|
||||
| { role: "assistant"; parts: (TextPart | ReasoningPart | ToolCallPart)[] }
|
||||
| { role: "tool"; callId: string; name: string; content: string; isError?: boolean }
|
||||
|
||||
@@ -50,8 +60,9 @@ export interface ChatRequest {
|
||||
system: string
|
||||
messages: Message[]
|
||||
tools: ToolSpec[]
|
||||
/** null = off */
|
||||
effort?: Effort | null
|
||||
/** The effort level (provider/effort.ts); null or absent: nothing sent (a small job of the
|
||||
* harness's own). */
|
||||
effort?: Effort | "off" | "default" | null
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user