From fbefe7f844d84805f7798e5bf07118839ae1e711 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaroslav=20Bene=C5=A1?= Date: Mon, 9 Nov 2026 08:36:55 +0000 Subject: [PATCH] Harness spec 3.2.0 in the CLI: effort levels, reasoning round-trip, no absolute paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 --- schema/config.schema.json | 35 ++- schema/connections.schema.json | 20 +- src/acp/agent.ts | 18 +- src/acp/share.ts | 7 +- src/app.ts | 35 +-- src/cli.ts | 16 +- src/config/load.ts | 6 + src/config/schema.ts | 19 +- src/config/settings.ts | 9 +- src/harness.ts | 18 ++ src/lembas/client.ts | 4 +- src/lembas/webui.ts | 7 +- src/project/attach.ts | 15 +- src/prompt/assemble.ts | 40 ++- src/provider/common.ts | 15 + src/provider/effort.ts | 96 +++++- src/provider/openai-chat.ts | 8 +- src/provider/types.ts | 17 +- src/session/capacity.ts | 28 +- src/session/commands.ts | 1 - src/session/engine.ts | 40 +-- src/team/team.ts | 558 +++++++++++++++++++++++++++++++++ src/tool/search.ts | 2 +- src/tool/show.ts | 2 +- src/tool/tool.ts | 8 +- src/tui/app.tsx | 13 +- src/tui/commands.ts | 41 ++- tests/acp-v2.test.ts | 3 +- tests/acp.test.ts | 6 +- tests/conformance.ts | 94 +++++- tests/cutoff.test.ts | 12 +- tests/e2e.test.ts | 5 +- tests/provider.test.ts | 15 +- tests/settings.test.ts | 2 +- tests/spec320.test.ts | 102 ++++++ tests/subagent.test.ts | 4 +- 36 files changed, 1168 insertions(+), 153 deletions(-) create mode 100644 src/team/team.ts create mode 100644 tests/spec320.test.ts diff --git a/schema/config.schema.json b/schema/config.schema.json index 0fdd475..0feaee9 100644 --- a/schema/config.schema.json +++ b/schema/config.schema.json @@ -870,7 +870,7 @@ "type": "string" }, "effort": { - "description": "The effort a session starts with, when the model takes it; otherwise the model's own default.", + "description": "The effort level a session starts with, when the model offers it: an effort, off (thinking switched off, on a model with the switch) or default; otherwise the model's own start level.", "anyOf": [ { "type": "string", @@ -886,9 +886,42 @@ { "type": "string", "const": "off" + }, + { + "type": "string", + "const": "default" } ] }, + "agents": { + "description": "Teams (harness team.md): how many agents a session's team may have. Offered only where subagents are on and the model serves two requests at once. /agents changes it for the session.", + "type": "object", + "properties": { + "min": { + "description": "The fewest agents a team works with, the lead included (default 1). 2 or more: the lead works through a team from the start.", + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "max": { + "description": "The most agents a team may have, the lead included (default 4). 1: one agent, no team and no helper.", + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "cap": { + "description": "Global config only. The highest max a session may set (default 32).", + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + "additionalProperties": false + }, + "subagents": { + "description": "false: no subagents — no task tool and no team.", + "type": "boolean" + }, "settings_tool": { "description": "Global config only. The agent's settings tool: ask (default) — each change is approved like any tool call; allow — changes go through without asking (a less strict mode still asks); off — no settings tool.", "type": "string", diff --git a/schema/connections.schema.json b/schema/connections.schema.json index 14e2f99..8e3c6e1 100644 --- a/schema/connections.schema.json +++ b/schema/connections.schema.json @@ -127,7 +127,7 @@ "maximum": 1 }, "efforts": { - "description": "The model's own effort vocabulary, in offering order.", + "description": "The model's own effort vocabulary, in offering order. [] — it takes none (its one level is `default`).", "type": "array", "items": { "type": "string", @@ -141,8 +141,12 @@ ] } }, + "thinking_switch": { + "description": "The server turns thinking off for a request with chat_template_kwargs.enable_thinking false: only then is the level `off` offered.", + "type": "boolean" + }, "effort": { - "description": "Default effort; `off` sends none.", + "description": "The level a conversation starts with, when the model offers it: an effort, `off` (thinking switched off, with thinking_switch) or `default` (nothing sent, for a model that takes no efforts).", "anyOf": [ { "type": "string", @@ -158,6 +162,10 @@ { "type": "string", "const": "off" + }, + { + "type": "string", + "const": "default" } ] }, @@ -202,9 +210,15 @@ "maxLength": 300 }, "single_session": { - "description": "The server serves one request at a time: a subagent on this same model would take its only slot and push the session's cached prompt out, so it is refused.", + "description": "The server serves one request at a time: a subagent on this same model would take its only slot and push the session's cached prompt out, so it is refused. The same as parallel: 1.", "type": "boolean" }, + "parallel": { + "description": "How many requests the model's server serves at once (capacity.parallel); wins over single_session. Absent with no single_session: no limit (a hosted API). A team is offered only on a model that serves at least 2.", + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, "group": { "description": "Models sharing one GPU server that holds one model at a time (llama-swap), as the LLeMbas server says (capacity.group): a subagent on another model of the same group would unload this one, so it is refused.", "type": "string" diff --git a/src/acp/agent.ts b/src/acp/agent.ts index 710f357..97c52e0 100644 --- a/src/acp/agent.ts +++ b/src/acp/agent.ts @@ -33,7 +33,8 @@ import { addPendingDeleted, clearPendingDeleted, pendingDeleted } from "./pendin import { shellLaunch } from "./shellrc.ts" import { sessionTurnFile, sessionTurns, TurnLog } from "./turns.ts" import { expandHome } from "../config/paths.ts" -import { asMode, EFFORTS, MODES, type Effort, type Mode } from "../config/schema.ts" +import { asMode, MODES, type Mode } from "../config/schema.ts" +import { isLevel, readLevel, type Level } from "../provider/effort.ts" import { HARNESS_VERSION } from "../harness.ts" import { findProject, trustedRoots, trustedWithin, trustOf } from "../project/root.ts" import { VERSION } from "../version.ts" @@ -131,10 +132,10 @@ interface Terminal { /** Most terminals one link may hold at once. */ const MAX_TERMINALS = 4 -/** An effort as the instance sends it: a level, or "off"; anything else is not one. */ -export function effortOf(v: unknown): Effort | "off" | undefined { - if (v === "off" || v === "" || v === null) return "off" - return typeof v === "string" && (EFFORTS as readonly string[]).includes(v) ? (v as Effort) : undefined +/** An effort level as the instance sends it (an effort, "off" or "default"); anything else is not + * one. Whether the model offers it is the session's to decide (readLevel). */ +export function effortOf(v: unknown): Level | undefined { + return isLevel(v) ? v : undefined } const MODE_RANK: Record = { auto: 0, edit: 1, manual: 2, plan: 3 } @@ -619,7 +620,7 @@ export class AcpAgent { sessionId: id, modes: { currentModeId: session.app.engine.mode, availableModes: this.modes().map((m) => ({ id: m, name: m })) }, // `instance` (spec 3.0.0): the model is one this link's instance serves, as the model event says. - _meta: { lembas: { model: session.app.engine.model.ref, mode: session.app.engine.mode, effort: session.app.engine.effort ?? "off", instance: this.isInstance(session.app.engine.model.connectionName) } }, + _meta: { lembas: { model: session.app.engine.model.ref, mode: session.app.engine.mode, effort: session.app.engine.effort, instance: this.isInstance(session.app.engine.model.connectionName) } }, } } @@ -672,8 +673,9 @@ export class AcpAgent { s.app.switchModel(ref) } const effort = effortOf(p?.effort) - if (effort !== undefined) s.app.engine.effort = effort === "off" ? null : effort - return { model: s.app.engine.model.ref, effort: s.app.engine.effort ?? "off" } + // A level the model does not offer is read as its start level; the answer says the one used. + if (effort !== undefined) s.app.engine.effort = readLevel(s.app.engine.model, effort) + return { model: s.app.engine.model.ref, effort: s.app.engine.effort } } private modes(): Mode[] { diff --git a/src/acp/share.ts b/src/acp/share.ts index 5ed297e..942c583 100644 --- a/src/acp/share.ts +++ b/src/acp/share.ts @@ -14,6 +14,7 @@ import type { ImagePart } from "../provider/types.ts" import { commandSet, sourcesOf } from "../session/commands.ts" import type { PlanReply, PlanRequest } from "../tool/plan_submit.ts" import type { QuestionReply, QuestionRequest } from "../tool/ask_user.ts" +import { readLevel } from "../provider/effort.ts" import { clampMode, effortOf, markRemote, permissionParams, planParams, planReplyFrom, questionParams, questionReplyFrom, replyFrom, STOP, type PromptResult } from "./agent.ts" import { jobList, jobOutput, jobSend, jobStop, jobTranscript } from "./jobs.ts" import type { SessionContext } from "../app.ts" @@ -390,7 +391,7 @@ export class Share { // is the instance's. modelConnection: this.app.engine.model.connectionName, mode: this.app.engine.mode, - effort: this.app.engine.effort ?? "off", + effort: this.app.engine.effort, busy: Boolean(this.app.turns.current), explicit, }, @@ -596,9 +597,9 @@ export class Share { // same model as `deepseek/x`, and switching to it again would reset the effort. if (typeof p?.model === "string" && p.model && this.app.canonicalRef(p.model) !== this.app.engine.model.ref) this.app.switchModel(p.model) const effort = effortOf(p?.effort) - if (effort !== undefined) this.app.engine.effort = effort === "off" ? null : effort + if (effort !== undefined) this.app.engine.effort = readLevel(this.app.engine.model, effort) this.hooks.changed() - return { model: this.app.engine.model.ref, effort: this.app.engine.effort ?? "off" } + return { model: this.app.engine.model.ref, effort: this.app.engine.effort } }) } } diff --git a/src/app.ts b/src/app.ts index bfd0d61..dd0d56a 100644 --- a/src/app.ts +++ b/src/app.ts @@ -12,10 +12,9 @@ import { boardSummary } from "./project/board.ts" import { unsafeProjectFile } from "./project/safe.ts" import { Bus, type Asker } from "./bus/index.ts" import { loadConfig, type Loaded } from "./config/load.ts" -import type { Effort, Mode } from "./config/schema.ts" +import type { Mode } from "./config/schema.ts" import { DEFAULT_RULES, toRules } from "./permission/evaluate.ts" import { hardlineRules } from "./permission/hardline.ts" -import { defaultEffort } from "./provider/effort.ts" import { clientFor, familyOf, resolveModel } from "./provider/index.ts" import { assembleSystem, instructionFiles, knowledgeScope, modeText } from "./prompt/assemble.ts" import type { Changing } from "./session/prefix.ts" @@ -45,14 +44,15 @@ import type { Plan } from "./plan/plan.ts" import { LIBRARY_SERVER, mcpInstructions } from "./mcp/index.ts" import type { McpServer } from "./config/schema.ts" import { embedderFor } from "./library/embed.ts" -import { effortsFor } from "./provider/effort.ts" +import { isLevel, levelsFor, readLevel, type Level } from "./provider/effort.ts" export interface AppOptions { cwd: string model?: string mode?: Mode - /** undefined = the model's default; "off" = none */ - effort?: Effort | "off" + /** The effort level asked for (an effort, "off" or "default"); undefined, or one the model does + * not offer: its start level. */ + effort?: string asker: Asker store?: Store | false /** Nobody can answer an approval (headless, no --yes). Said in the system prompt up front. */ @@ -215,13 +215,10 @@ export function createApp(o: AppOptions): App { // An untrusted project is read-only: plan mode, and its own config is ignored. let mode: Mode = o.mode ?? loaded.config.mode ?? "manual" if (trust === "readonly") mode = "plan" - // `effort:` in config when this model takes it, else the model's own default. - const startEffort = (m: ResolvedModel) => { - const e = loaded.config.effort - if (e === "off") return null - return e && effortsFor(m).includes(e) ? e : defaultEffort(m) - } - const effort = o.effort === "off" ? null : (o.effort ?? startEffort(model)) + // `effort:` in config when this model offers that level, else the model's own start level + // (harness models/schema.json `levels`). + const startEffort = (m: ResolvedModel): Level => readLevel(m, loaded.config.effort) + const effort: Level = o.effort !== undefined && isLevel(o.effort) && (levelsFor(model).levels as string[]).includes(o.effort) ? o.effort : startEffort(model) // One object for the session's lifetime, so a setting changed later reaches the tools. loaded.config.search ??= {} const bus = o.bus ?? new Bus() @@ -468,7 +465,9 @@ export function createApp(o: AppOptions): App { // its server can take it beside the session's and beside every subagent already running // (src/session/capacity.ts), before anything is made. const m = opts.model ? resolveModel(loaded, opts.model) : spec.model ? resolveModel(loaded, spec.model) : engine.model - const refused = capacityRefusal(engine.model, m) || registry.running(jobsSessionId()).filter((j) => j.kind === "subagent" && j.subagent).map((j) => capacityBeside(j.subagent!.engine.model, m)).find(Boolean) + const running = registry.running(jobsSessionId()).filter((j) => j.kind === "subagent" && j.subagent) + const onIt = running.filter((j) => j.subagent!.engine.model.ref === m.ref).length + const refused = capacityRefusal(engine.model, m, onIt) || running.map((j) => capacityBeside(j.subagent!.engine.model, m, onIt)).find(Boolean) if (refused) throw new ToolError(`${refused} Do this part yourself, or hand it to a model on another connection.`) // A checkout and branch of its own, when asked for (the task's worktree, the agent's isolation). let wt: Worktree | undefined @@ -545,7 +544,7 @@ export function createApp(o: AppOptions): App { { name: spec.name, instructions: wt - ? `${spec.instructions}\n\nYou are working in a separate checkout of the repository, at ${at!.root}, on the branch ${wt.branch} (made from the last commit — uncommitted work of the main agent is not here). Work only inside it. When you finish, your changes are committed on that branch for the main agent to merge: do not commit, merge or push yourself.` + ? `${spec.instructions}\n\nYou are working in a separate checkout of the repository — its root is your project root, so relative paths reach it — on the branch ${wt.branch} (made from the last commit — uncommitted work of the main agent is not here). Work only inside it. When you finish, your changes are committed on that branch for the main agent to merge: do not commit, merge or push yourself.` : spec.instructions, report: childTools.some((t) => t.name === "report"), skills: childTools.some((t) => t.name === "skill_view"), @@ -721,19 +720,19 @@ export function createApp(o: AppOptions): App { }, live(key) { if (key === "model") return engine.model.ref - if (key === "effort") return engine.effort ?? "off" + if (key === "effort") return engine.effort if (key === "mode") return engine.mode return key.split(".").reduce((x, k) => (x && typeof x === "object" ? (x as Record)[k] : undefined), loaded.config) }, check(key, v) { if (key === "model" || key === "small_model") resolveModel(loaded, String(v)) - if (key === "effort" && v !== "off" && !effortsFor(engine.model).includes(v as Effort)) - throw new Error(`${engine.model.ref} takes ${effortsFor(engine.model).join(", ")} or off`) + if (key === "effort" && !(levelsFor(engine.model).levels as string[]).includes(String(v))) + throw new Error(`${engine.model.ref} offers the levels ${levelsFor(engine.model).levels.join(", ")}`) if (key === "knowledge") for (const b of v as string[]) if (!library.base(b)) throw new Error(`there is no knowledge base "${b}" — lembas kb lists them`) }, apply(key, v) { if (key === "model") switchModel(String(v)) - else if (key === "effort") engine.effort = v === "off" ? null : (v as Effort) + else if (key === "effort") engine.effort = v as Level else if (key === "mode") engine.mode = v as Mode else if (key === "limits.steps") engine.o.maxSteps = Number(v) else if (key === "busy_input") engine.busyInput = v as "steer" | "queue" diff --git a/src/cli.ts b/src/cli.ts index ca53ef2..52d186e 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -5,13 +5,13 @@ import { parseArgs } from "node:util" import { createApp } from "./app.ts" import { attachmentsFor } from "./project/attach.ts" import { ConfigError, loadConfig } from "./config/load.ts" -import { asMode, EFFORTS, MODES, type Effort, type Mode } from "./config/schema.ts" +import { asMode, EFFORTS, MODES, type Mode } from "./config/schema.ts" import { isLocal, localRoot } from "./config/paths.ts" import { SettingError } from "./config/settings.ts" import { renderJson, renderPlain, settleJobs, terminalAsker } from "./headless.ts" import { enterLocal, localFor, LocalError, localRefusal, localTmp, LOCAL_FLAGS } from "./project/local.ts" import { findProject, setTrust, trustOf } from "./project/root.ts" -import { effortsFor } from "./provider/effort.ts" +import { isLevel, levelsFor } from "./provider/effort.ts" import { clientFor, ModelError, NOT_LOGGED_IN, resolveModel } from "./provider/index.ts" import { Store } from "./session/store.ts" import { promptTitle } from "./session/title.ts" @@ -84,7 +84,7 @@ Local mode: Options for run: -m, --model conn/model model to use (default: config.yaml \`model\`) --mode MODE manual | edit | auto | plan - -e, --effort LEVEL ${EFFORTS.join(" | ")} | off + -e, --effort LEVEL ${EFFORTS.join(" | ")} | off | default (one the model offers) -y, --yes approve every ask (still never the hardline) --json events as JSON lines --reasoning show the model's reasoning @@ -240,7 +240,7 @@ async function main() { cwd: process.cwd(), model: values.model, mode: asMode(values.mode), - effort: values.effort as Effort | "off" | undefined, + effort: values.effort, resume: values.session ?? (values.continue ? "last" : undefined), }) if (done.reload) await (await import("./reload.ts")).restart(done.reload) @@ -299,14 +299,13 @@ async function main() { if (!prompt && !process.stdin.isTTY) prompt = (await new Response(Bun.stdin.stream()).text()).trim() if (!prompt) fail('nothing to do: lembas run "your prompt"', 2) if (values.mode && !asMode(values.mode)) fail(`--mode must be one of ${MODES.join(", ")}`, 2) - if (values.effort && values.effort !== "off" && !(EFFORTS as readonly string[]).includes(values.effort)) - fail(`--effort must be one of ${EFFORTS.join(", ")}, off`, 2) + if (values.effort && !isLevel(values.effort)) fail(`--effort must be one of ${EFFORTS.join(", ")}, off, default`, 2) const app = createApp({ cwd: process.cwd(), model: values.model, mode: asMode(values.mode), - effort: values.effort as Effort | "off" | undefined, + effort: values.effort, asker: terminalAsker({ yes: values.yes ?? false, unattended: loadConfig().config.question?.unattended, theme: loadConfig().config.theme }), unattended: !values.yes && !process.stdin.isTTY, store: values["no-store"] ? false : undefined, @@ -342,7 +341,8 @@ async function main() { spec.max_output ? `out ${spec.max_output}` : "", spec.vision ? "vision" : "", spec.tools === false ? "no-tools" : "", - spec.effort || spec.efforts ? `effort ${spec.effort ?? "off"} of ${effortsFor(m).join("/")}` : "", + ((l) => (l.control === "none" ? "" : `effort ${l.start} of ${l.levels.join("/")}`))(levelsFor(m)), + spec.parallel ? `parallel ${spec.parallel}` : spec.single_session ? "parallel 1" : "", ].filter(Boolean) console.log(` ${m.ref}${bits.length ? " — " + bits.join(", ") : ""}`) } diff --git a/src/config/load.ts b/src/config/load.ts index 54ece62..8f83619 100644 --- a/src/config/load.ts +++ b/src/config/load.ts @@ -154,6 +154,12 @@ function readConfig(file: string, label: string, notes: string[], dropped?: stri delete all[k] dropped.push(k) } + // The team's cap is the global configuration's: a project never raises it (harness team.md §2). + if (dropped && all.agents && typeof all.agents === "object" && !Array.isArray(all.agents) && "cap" in all.agents) { + const { cap: _cap, ...agents } = all.agents as Record + all.agents = agents + dropped.push("agents.cap") + } // mcp, voice and search are filled in later, one server / half / service at a time: a key that // cannot be read turns off that one thing instead of the whole file. // update: filled in when an update is looked for — a token file missing must not stop a start. diff --git a/src/config/schema.ts b/src/config/schema.ts index bacb8f1..b2648a4 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -51,15 +51,17 @@ export const ModelSpec = z.strictObject({ max_output: z.number().int().positive().optional().describe("Max tokens per reply."), temperature: z.number().min(0).max(2).optional().describe("Sampling temperature; absent = the server's default."), top_p: z.number().min(0).max(1).optional().describe("Nucleus sampling; absent = the server's default."), - efforts: z.array(z.enum(EFFORTS)).optional().describe("The model's own effort vocabulary, in offering order."), - effort: z.union([z.enum(EFFORTS), z.literal("off")]).optional().describe("Default effort; `off` sends none."), + efforts: z.array(z.enum(EFFORTS)).optional().describe("The model's own effort vocabulary, in offering order. [] — it takes none (its one level is `default`)."), + thinking_switch: z.boolean().optional().describe("The server turns thinking off for a request with chat_template_kwargs.enable_thinking false: only then is the level `off` offered."), + effort: z.union([z.enum(EFFORTS), z.literal("off"), z.literal("default")]).optional().describe("The level a conversation starts with, when the model offers it: an effort, `off` (thinking switched off, with thinking_switch) or `default` (nothing sent, for a model that takes no efforts)."), effort_style: z.enum(["top", "kwargs", "both"]).optional().describe("Where the effort goes in a request: top-level, in chat_template_kwargs, or both (the default: llama.cpp drops top-level)."), vision: z.boolean().optional().describe("The model reads images: @image files, pasted paths and view_image."), tools: z.boolean().optional().describe("false: a model without tool calls — it can only talk."), headers: z.record(z.string(), z.string()).optional().describe("Extra HTTP headers for this model's requests."), body: z.record(z.string(), z.unknown()).optional().describe("Merged into every request body for this model."), notes: z.string().max(300).optional().describe("What this model is good at, in a line: the model in use is told which others there are, for handing work over (task) or switching."), - single_session: z.boolean().optional().describe("The server serves one request at a time: a subagent on this same model would take its only slot and push the session's cached prompt out, so it is refused."), + single_session: z.boolean().optional().describe("The server serves one request at a time: a subagent on this same model would take its only slot and push the session's cached prompt out, so it is refused. The same as parallel: 1."), + parallel: z.number().int().positive().optional().describe("How many requests the model's server serves at once (capacity.parallel); wins over single_session. Absent with no single_session: no limit (a hosted API). A team is offered only on a model that serves at least 2."), group: z.string().optional().describe("Models sharing one GPU server that holds one model at a time (llama-swap), as the LLeMbas server says (capacity.group): a subagent on another model of the same group would unload this one, so it is refused."), }) export type ModelSpec = z.infer @@ -293,7 +295,16 @@ export const Config = z.strictObject({ mcp: z.record(z.string(), McpServer).optional().describe("MCP servers by name. `{env:}`/`{file:}` are filled in per server (a missing one disables that server only). A project's servers count only once the project is trusted."), knowledge: z.array(z.string()).optional().describe("The knowledge bases searched (lembas kb); absent: all of them. A project's own config narrows it to what that project needs."), embedding: z.string().optional().describe("Global config only. An embedding model the LLeMbas server serves, by its id there: knowledge bases are then searched by meaning as well as by words. lembas kb reindex after changing it."), - effort: z.union([z.enum(EFFORTS), z.literal("off")]).optional().describe("The effort a session starts with, when the model takes it; otherwise the model's own default."), + effort: z.union([z.enum(EFFORTS), z.literal("off"), z.literal("default")]).optional().describe("The effort level a session starts with, when the model offers it: an effort, off (thinking switched off, on a model with the switch) or default; otherwise the model's own start level."), + agents: z + .strictObject({ + min: z.number().int().min(1).optional().describe("The fewest agents a team works with, the lead included (default 1). 2 or more: the lead works through a team from the start."), + max: z.number().int().min(1).optional().describe("The most agents a team may have, the lead included (default 4). 1: one agent, no team and no helper."), + cap: z.number().int().min(1).optional().describe("Global config only. The highest max a session may set (default 32)."), + }) + .optional() + .describe("Teams (harness team.md): how many agents a session's team may have. Offered only where subagents are on and the model serves two requests at once. /agents changes it for the session."), + subagents: z.boolean().optional().describe("false: no subagents — no task tool and no team."), settings_tool: z.enum(["ask", "allow", "off"]).optional().describe("Global config only. The agent's settings tool: ask (default) — each change is approved like any tool call; allow — changes go through without asking (a less strict mode still asks); off — no settings tool."), update: Update.optional().describe("Global config only. Updating LLeMbas CLI itself."), prototypes: z.boolean().optional().describe("Global config only. Offer the model prototypes: a complete HTML page in a `prototype-chat` or `prototype-page` fence, saved as a file under the state directory (prototypes//) and listed by /prototype; the LLeMbas web UI renders the same fences live. Default false."), diff --git a/src/config/settings.ts b/src/config/settings.ts index df11038..1c33a10 100644 --- a/src/config/settings.ts +++ b/src/config/settings.ts @@ -19,7 +19,7 @@ export type Source = "default" | "global" | "project" | "session" export interface SettingDef { key: string - /** model: a model the server serves, by its id; effort: one the model takes, or off; list: comma-separated. */ + /** model: a model the server serves, by its id; effort: one of the model's levels; list: comma-separated. */ kind: "enum" | "model" | "effort" | "number" | "boolean" | "list" | "string" values?: readonly string[] /** The agent may change it (through the settings tool, with approval). */ @@ -36,7 +36,10 @@ const USER: Scope[] = ["session", "global"] export const SETTINGS: SettingDef[] = [ { key: "model", kind: "model", agent: true, scopes: ALL }, - { key: "effort", kind: "effort", agent: true, scopes: ALL, fallback: "the model's default" }, + { key: "effort", kind: "effort", agent: true, scopes: ALL, fallback: "the model's start level" }, + // The team's size is the person's: the agent never sets how many agents it may have. + { key: "agents.min", kind: "number", agent: false, scopes: ALL, fallback: "1" }, + { key: "agents.max", kind: "number", agent: false, scopes: ALL, fallback: "4" }, { key: "mode", kind: "enum", values: MODES, agent: true, scopes: ALL, fallback: "manual" }, { key: "small_model", kind: "model", agent: true, scopes: ALL, fallback: "the session's model" }, { key: "knowledge", kind: "list", agent: true, scopes: ALL, fallback: "every base" }, @@ -104,7 +107,7 @@ export function parseValue(def: SettingDef, raw: unknown): unknown { if (def.key === "mode") v = asMode(v) ?? v // "none" is how a dialog or a person says it; the file keeps the web UI's empty string. if (def.key === "personality" && typeof v === "string" && ["none", "off", "default"].includes(v.toLowerCase())) v = "" - if (def.kind === "effort" && v !== "off" && !(EFFORTS as readonly string[]).includes(String(v))) throw new SettingError(`effort: one of ${EFFORTS.join(", ")} or off`) + if (def.kind === "effort" && !["off", "default", ...EFFORTS].includes(String(v))) throw new SettingError(`effort: one of ${EFFORTS.join(", ")}, off or default`) const schema = schemaAt(def.key.split(".")) const r = schema?.safeParse(v) if (!r || !r.success) throw new SettingError(`${def.key}: ${def.values ? `one of ${def.values.join(", ")}` : (r?.error.issues.map((i) => i.message).join("; ") ?? "not settable")}`) diff --git a/src/harness.ts b/src/harness.ts index 7234f71..1f62a58 100644 --- a/src/harness.ts +++ b/src/harness.ts @@ -118,6 +118,17 @@ export const LOOP: { 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. */ @@ -138,6 +149,8 @@ export const LOOP: { 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 @@ -147,6 +160,11 @@ export const LOOP: { 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 diff --git a/src/lembas/client.ts b/src/lembas/client.ts index 64f9e33..b872fe8 100644 --- a/src/lembas/client.ts +++ b/src/lembas/client.ts @@ -58,10 +58,12 @@ export interface ServedModel { top_p?: number efforts?: string[] effort?: string + /** Spec 3.2.0: the server turns thinking off for enable_thinking false. */ + thinking_switch?: boolean vision?: boolean tools?: boolean notes?: string - capacity?: { group?: string; single_session?: boolean } + capacity?: { group?: string; single_session?: boolean; parallel?: number } default?: boolean /** The provider's slug — the part of `id` before its `/` — and what kind of model it is. */ provider?: string diff --git a/src/lembas/webui.ts b/src/lembas/webui.ts index 9afb7ca..328c7cc 100644 --- a/src/lembas/webui.ts +++ b/src/lembas/webui.ts @@ -18,7 +18,7 @@ import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs" import { join } from "node:path" import { parse } from "yaml" import { connectionFiles, isLocal, paths } from "../config/paths.ts" -import type { Connection, ModelSpec, WebuiConnection } from "../config/schema.ts" +import { EFFORTS, type Connection, type ModelSpec, type WebuiConnection } from "../config/schema.ts" import { substituteDeep } from "../config/substitute.ts" import { instanceInfo, LembasError, models as listModels, normalise, personalization, type InstanceInfo, type Personalization, type ServedModel, type Tls } from "./client.ts" @@ -94,12 +94,15 @@ export function specFor(m: ServedModel, connection: string): ModelSpec { if (m.max_output) out.max_output = m.max_output if (typeof m.temperature === "number" && m.temperature >= 0 && m.temperature <= 2) out.temperature = m.temperature if (typeof m.top_p === "number" && m.top_p >= 0 && m.top_p <= 1) out.top_p = m.top_p - if (m.efforts?.length) out.efforts = m.efforts + // [] is said: the model takes no efforts (its one level is `default`), unlike an absent list. + if (Array.isArray(m.efforts)) out.efforts = m.efforts.filter((e) => (EFFORTS as readonly string[]).includes(e)) if (m.effort) out.effort = m.effort + if (m.thinking_switch === true) out.thinking_switch = true if (m.vision !== undefined) out.vision = m.vision if (m.tools !== undefined) out.tools = m.tools if (m.notes) out.notes = m.notes.slice(0, 300) if (m.capacity?.single_session) out.single_session = true + if (Number.isInteger(m.capacity?.parallel) && m.capacity!.parallel! >= 1) out.parallel = m.capacity!.parallel if (m.capacity?.group) out.group = `${connection}:${m.capacity.group}` return out as ModelSpec } diff --git a/src/project/attach.ts b/src/project/attach.ts index 778e2c0..90cf482 100644 --- a/src/project/attach.ts +++ b/src/project/attach.ts @@ -3,6 +3,7 @@ // them, and counted as read so the model may edit them at once. The prompt text is unchanged. import { readdirSync, readFileSync, statSync } from "node:fs" import { join, resolve } from "node:path" +import { promptPath } from "../prompt/assemble.ts" import type { ImagePart } from "../provider/types.ts" import type { ToolContext } from "../tool/tool.ts" import { isImage, loadImage } from "./image.ts" @@ -34,6 +35,8 @@ export function attachmentsFor(text: string, ctx: Pick (e.isDirectory() ? `${e.name}/` : e.name)) .sort() - out.push({ path: rel, text: `\n${entries.join("\n")}\n` }) + out.push({ path: rel, text: `\n${entries.join("\n")}\n` }) continue } if (isImage(abs)) { if (!vision) { - out.push({ path: rel, text: `(not attached: this model has no vision)` }) + out.push({ path: rel, text: `(not attached: this model has no vision)` }) continue } const img = loadImage(abs) - out.push(typeof img === "string" ? { path: rel, text: `(not attached: ${img})` } : { path: rel, text: ``, image: img }) + out.push(typeof img === "string" ? { path: rel, text: `(not attached: ${img})` } : { path: rel, text: ``, image: img }) continue } if (st.size > MAX_BYTES && !m[2]) { - out.push({ path: rel, text: `\n(${st.size} bytes — too large to attach whole; read it in parts, or reference a range like @${rel}#1-200)\n` }) + out.push({ path: rel, text: `\n(${st.size} bytes — too large to attach whole; read it in parts, or reference a range like @${named}#1-200)\n` }) continue } const raw = readFileSync(abs) if (raw.subarray(0, 8192).includes(0)) { - out.push({ path: rel, text: `\n(binary file, ${st.size} bytes)\n` }) + out.push({ path: rel, text: `\n(binary file, ${st.size} bytes)\n` }) continue } const lines = raw.toString("utf8").split("\n") @@ -75,7 +78,7 @@ export function attachmentsFor(text: string, ctx: Pick `${from + i}: ${l}`).join("\n") const range = m[2] ? ` lines="${from}-${to}"` : "" const more = !m[2] && lines.length > to ? `\n(${lines.length - to} more lines — read with offset ${to + 1})` : "" - out.push({ path: rel, text: `\n${body}${more}\n` }) + out.push({ path: rel, text: `\n${body}${more}\n` }) if (!m[2]) { ctx.readFiles.add(abs) ctx.fileStamps.set(abs, st.mtimeMs) diff --git a/src/prompt/assemble.ts b/src/prompt/assemble.ts index 3067b08..8a6eed8 100644 --- a/src/prompt/assemble.ts +++ b/src/prompt/assemble.ts @@ -1,6 +1,7 @@ import { existsSync, readFileSync, realpathSync, statSync } from "node:fs" -import { platform, release } from "node:os" -import { dirname, join, relative, resolve, sep } from "node:path" +import { homedir, platform, release } from "node:os" +import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path" +import { LOOP } from "../harness.ts" import type { Mode } from "../config/schema.ts" import { expandHome, paths } from "../config/paths.ts" import { render, type Plan } from "../plan/plan.ts" @@ -268,6 +269,29 @@ export interface PromptContext { const MAX_INSTRUCTION_CHARS = 40_000 +/** The environment block's lines about where the session works (loop.json `context.env`, spec + * 3.2.0): the project by its root's last path component, and — started below the root — that + * directory relative to it. No absolute path. */ +export function envLines(root: string, started: string): string[] { + const r = resolve(root) + const name = basename(r) || r + const lines = [fillPrompt(LOOP.context.env.project, { name })] + const rel = relative(r, resolve(started)) + if (rel && !rel.startsWith("..") && !isAbsolute(rel)) lines.push(fillPrompt(LOOP.context.env.started, { path: rel })) + return lines +} + +/** A path as a prompt names it (context.md §11): relative to the project root inside it, `~/…` + * under the person's home, absolute only outside both. */ +export function promptPath(root: string, path: string): string { + const rel = relative(resolve(root), path) + if (rel === "") return "." + if (!rel.startsWith("..") && !isAbsolute(rel)) return rel + const home = homedir() + if (home && home !== "/" && (path === home || path.startsWith(home + sep))) return `~${path.slice(home.length)}` + return path +} + /** The system prompt (harness spec; its stability is context.md §10, from 3.1.0). It is made once * per session — at the start, after a compaction, on a model switch — and the engine resends * those bytes on every request, so nothing in it may be a clock or anything else that moves on @@ -305,8 +329,8 @@ export function assembleSystem(ctx: PromptContext): string { sections.push( [ "", - `Working directory: ${ctx.cwd}`, - `Project root: ${ctx.root}`, + // No absolute path (context.md §11): the project by its name, relative paths against its root. + ...envLines(ctx.root, ctx.cwd), `Git repository: ${ctx.isGit ? "yes" : "no"}`, `Platform: ${platform()} ${release()}`, // No date: it would change at midnight and the whole session be read again (§10 rule 2). @@ -322,10 +346,10 @@ export function assembleSystem(ctx: PromptContext): string { const files = ctx.files ?? instructionFiles(ctx.cwd, ctx.root, ctx.instructions) for (const f of files) { let text = f.text.trim() + // A file in the project by its path from the root, one under home as ~/… (context.md §11). + const named = promptPath(ctx.root, f.path) if (text.length > MAX_INSTRUCTION_CHARS) - text = text.slice(0, MAX_INSTRUCTION_CHARS) + `\n[…truncated: kept ${MAX_INSTRUCTION_CHARS} of ${f.text.length} characters; read ${f.path} for the rest]` - // The chain's files by their path in the project (the spec's "relative to the boundary"). - const named = f.chain ? relative(ctx.root, f.path) || f.path : f.path + text = text.slice(0, MAX_INSTRUCTION_CHARS) + `\n[…truncated: kept ${MAX_INSTRUCTION_CHARS} of ${f.text.length} characters; read ${named} for the rest]` sections.push(`Instructions from ${named}:\n\n${text}\n`) } @@ -393,7 +417,7 @@ function agentsBlock(ctx: PromptContext, files: { path: string; text: string; ch // Files handed in without the mark (an older caller): anything but the global file, as before. const marked = files.some((f) => f.chain) const own = files.filter((f) => (marked ? f.chain : f.path !== join(paths.config, "AGENTS.md"))) - const vars = { root: ctx.root, git_question: ctx.isGit ? GIT_QUESTION : GIT_QUESTION_NO_REPO } + const vars = { git_question: ctx.isGit ? GIT_QUESTION : GIT_QUESTION_NO_REPO } if (!own.length) return fillPrompt(promptText("blocks/agents-missing.md"), vars) const keep = promptText("blocks/agents-keep.md") return own.some((f) => /^#{1,3}\s*git\b/im.test(f.text)) ? keep : `${fillPrompt(promptText("blocks/agents-git.md"), vars)}\n\n${keep}` diff --git a/src/provider/common.ts b/src/provider/common.ts index 4b15f18..bdeb763 100644 --- a/src/provider/common.ts +++ b/src/provider/common.ts @@ -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)) +} diff --git a/src/provider/effort.ts b/src/provider/effort.ts index 56d8a60..bd45a1d 100644 --- a/src/provider/effort.ts +++ b/src/provider/effort.ts @@ -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, 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) ?? {}) } - 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 } { + 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, 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) ?? {}), ...f.chat_template_kwargs } } export function stripEffort(body: Record): Record { @@ -40,6 +94,7 @@ export function stripEffort(body: Record): Record) ?? {}) } 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 +} diff --git a/src/provider/openai-chat.ts b/src/provider/openai-chat.ts index 91287bc..5a0e30e 100644 --- a/src/provider/openai-chat.ts +++ b/src/provider/openai-chat.ts @@ -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 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 = { role: "assistant", content: text || null } + // The current turn's reasoning goes back as the model gave it (context.md §8) — inline + // 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) diff --git a/src/provider/types.ts b/src/provider/types.ts index 6b7136c..c388fab 100644 --- a/src/provider/types.ts +++ b/src/provider/types.ts @@ -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 } diff --git a/src/session/capacity.ts b/src/session/capacity.ts index beba049..ad6935d 100644 --- a/src/session/capacity.ts +++ b/src/session/capacity.ts @@ -1,7 +1,8 @@ // Whether a subagent may run on a model beside the session's own, judged by what its server can do // at once — the harness spec's capacity rule, after LLeMbas services/helpers.py (© Jaroslav Beneš, // MIT). Both flags name the restrictive state and default off, so a configuration that -// never sets them works exactly as before. +// never sets them works exactly as before. From spec 3.2.0 a model says how many requests it serves +// at once (`parallel`; single_session is 1). // // - A connection that holds one model at a time (llama-swap in front of one GPU): a subagent on // another of its models unloads the session's model, and the session's cached prompt with it, @@ -11,10 +12,23 @@ // minutes, on a long session with a small GPU. import type { ResolvedModel } from "../provider/types.ts" -export function capacityRefusal(main: ResolvedModel, helper: ResolvedModel): string { +/** How many requests a model's server serves at once (models/schema.json capacity, spec 3.2.0): + * `parallel` when given, else 1 for single_session, else no limit (a hosted API). */ +export function parallelOf(m: Pick): number { + const p = m.spec.parallel + if (Number.isInteger(p) && p! >= 1) return p! + return m.spec.single_session ? 1 : Infinity +} + +/** `busy`: this session's requests the helper's model already serves besides the main prompt's + * (subagents running on it). */ +export function capacityRefusal(main: ResolvedModel, helper: ResolvedModel, busy = 0): string { if (helper.ref === main.ref) { - if (helper.spec.single_session) - return `${helper.ref} serves one request at a time (single_session), so it cannot be its own subagent: the subagent would take the server's only slot and push this session's cached prompt out of it.` + const n = parallelOf(helper) + if (n === 1) + return `${helper.ref} serves one request at a time, so it cannot be its own subagent: the subagent would take the server's only slot and push this session's cached prompt out of it.` + if (1 + busy >= n) + return `${helper.ref} serves ${n} requests at once, and this session already has ${n} on it; another subagent would wait, or push this session's cached prompt out.` return "" } const shared = groupOf(main) @@ -28,9 +42,11 @@ export function capacityRefusal(main: ResolvedModel, helper: ResolvedModel): str /** The same rule between two subagents: a new one is not started where its model would unload, or * take the only slot of, a subagent already running (the spec's "beside the model of a subagent * already running"). */ -export function capacityBeside(running: ResolvedModel, helper: ResolvedModel): string { +export function capacityBeside(running: ResolvedModel, helper: ResolvedModel, busy = 1): string { if (helper.ref === running.ref) { - if (helper.spec.single_session) return `${helper.ref} serves one request at a time (single_session), and a subagent is already running on it; a second would wait for the first, or push it out.` + const n = parallelOf(helper) + if (n === 1) return `${helper.ref} serves one request at a time, and a subagent is already running on it; a second would wait for the first, or push it out.` + if (busy >= n) return `${helper.ref} serves ${n} requests at once, and as many subagents already run on it; another would wait, or push one out.` return "" } const shared = groupOf(running) diff --git a/src/session/commands.ts b/src/session/commands.ts index 1ef8004..ccc9ebe 100644 --- a/src/session/commands.ts +++ b/src/session/commands.ts @@ -151,7 +151,6 @@ export function changelogPrompt(app: App, arg: string): { prompt: string } | { m /** `/init [focus]`: make or update AGENTS.md. */ export function initPrompt(app: App, arg: string): string { return fill(promptText("tasks/init.md"), { - root: app.project.root, focus: arg.trim() ? `What the user wants it to focus on: ${arg.trim()}` : "", }).replace(/\n{3,}/g, "\n\n") } diff --git a/src/session/engine.ts b/src/session/engine.ts index ef57391..ab63ef7 100644 --- a/src/session/engine.ts +++ b/src/session/engine.ts @@ -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 toolCtx: Omit 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" diff --git a/src/team/team.ts b/src/team/team.ts new file mode 100644 index 0000000..4995dd2 --- /dev/null +++ b/src/team/team.ts @@ -0,0 +1,558 @@ +// Teams (harness spec 3.2.0, harness/team.md): a lead session's agents, their room, who receives +// what, the size the person allows, a manager's refusals, and the slots of the model they run on. +// This file is the rules alone, without an engine: the conformance area `team` drives it step by +// step, and the app (team/runtime.ts) drives it from real turns — the effects say when an agent's +// turn starts (it was given a slot), when a message reaches an agent that is working, and when an +// agent is stopped. +import { LOOP, TOOL_SPECS } from "../harness.ts" +import { fillPrompt } from "../prompt/fill.ts" +import { DEFAULT_RULES, evaluate, toRules } from "../permission/evaluate.ts" +import { BUILTIN_HARDLINE } from "../permission/hardline.ts" +import { duration } from "../duration.ts" + +export const TEAM = LOOP.team +const TEXTS = TEAM.texts +const LEAD = TEAM.names.lead +export const TEAM_TOOLS = ["team_add", "team_read", "team_send", "team_stop"] as const +const NAME = new RegExp(TEAM.names.pattern) + +export type AgentState = "working" | "waiting" | "finished" | "idle" + +/** What a model's server can do at once (models/schema.json capacity). */ +export interface Capacity { + parallel?: number + single_session?: boolean + group?: string +} + +/** How many requests a model serves at once: `parallel`, else 1 for single_session, else no limit. */ +export function parallelFrom(c: Capacity | undefined): number { + if (c && Number.isInteger(c.parallel) && c.parallel! >= 1) return c.parallel! + return c?.single_session ? 1 : Infinity +} + +export interface TeamAgent { + name: string + role: string + /** Who added it; null for the lead. */ + manager: string | null + /** May add members of its own (team_add's `lead`); the lead may. */ + leads: boolean + /** The team's goal as it was told it (the lead's: the last one it gave). */ + goal: string + /** The model it runs on (its ref). */ + model: string + agent: string + state: AgentState + /** Messages that reached it while it was not working: its next turn's text. */ + inbox: string[] + /** Its brief, until its first turn starts. */ + brief?: string + /** When it began waiting for a slot (the queue's order). */ + waitSince?: number + /** When its last turn ended. */ + ended?: number + /** Added in this order (tree order among siblings). */ + seq: number +} + +export interface RoomMessage { + n: number + from: string + /** As addressed: names, or ["all"]. */ + to: string[] + recipients: string[] + text: string + time: number + report: boolean +} + +export interface TeamEffects { + /** The agent was given a slot: its turn starts now with `text` (a member's brief, or the messages + * that woke it; for the lead, what woke it — "" when the person's own message did). */ + start(name: string, text: string): void + /** A message to an agent whose turn runs: at its next step boundary. */ + deliver(name: string, text: string): void + /** It left the team: its turn stops where it is, its background commands with it. */ + stop(name: string): void +} + +export interface TeamOptions { + min?: number + max?: number + cap?: number + /** Subagents are on for the session (config `subagents`). */ + subagents?: boolean + /** The lead's model (the chat's), and every model's capacity. */ + model: string + capacity: (model: string) => Capacity + effects?: TeamEffects + /** A bash command a manager may still run: one the default rules alone allow in manual mode. */ + readingBash?: (command: string) => boolean + now?: () => number + /** The lead's state as the team begins: working (its first prompt runs) or idle. */ + leadState?: "working" | "idle" +} + +/** The default rules' answer for a command in manual mode, with no rules of the person's. */ +export function readingBashByDefault(command: string, root = "/project"): boolean { + const req = { permission: "bash", class: "execute" as const, patterns: [command], command, paths: [root] } + return evaluate(req, { mode: "manual", rules: toRules(DEFAULT_RULES, "default"), hardline: BUILTIN_HARDLINE, root, planDir: `${root}/.agent/plans`, projectDir: `${root}/.agent` }).action === "allow" +} + +const fill = (text: string, vars: Record) => fillPrompt(text, Object.fromEntries(Object.entries(vars).map(([k, v]) => [k, String(v)]))) + +export class Team { + min: number + max: number + readonly cap: number + subagents: boolean + /** The model the chat runs on (the lead's). */ + model: string + private agents = new Map() + room: RoomMessage[] = [] + /** `task` helpers running in the team: who started each, and on which model. */ + private helpers: { id: number; by: string; model: string }[] = [] + private seq = 0 + private helperSeq = 0 + /** Whether the team is offered, fixed as the prompt is made (§2). */ + offered = false + /** The setting as the lead was last told it. */ + private told: { min: number; max: number } | undefined + /** Members the person stopped, not yet said to the lead. */ + private stoppedByPerson: string[] = [] + private waits = 0 + + constructor(private o: TeamOptions) { + this.cap = o.cap ?? TEAM.agents.cap + this.subagents = o.subagents !== false + this.model = o.model + this.min = TEAM.agents.min + this.max = TEAM.agents.max + this.setSetting(o.min ?? TEAM.agents.min, o.max ?? TEAM.agents.max) + this.agents.set(LEAD, { name: LEAD, role: TEAM.read.lead_role, manager: null, leads: true, goal: "", model: o.model, agent: "", state: o.leadState ?? "working", inbox: [], seq: this.seq++ }) + this.fix() + } + + set effects(e: TeamEffects | undefined) { + this.o.effects = e + } + + private now() { + return (this.o.now ?? Date.now)() + } + + // ── the setting ────────────────────────────────────────────────────────────── + + /** A stored setting under the cap is read as max = min(max, cap), min = min(min, max). */ + setSetting(min: number, max: number) { + this.max = Math.max(1, Math.min(Math.floor(max), this.cap)) + this.min = Math.max(1, Math.min(Math.floor(min), this.max)) + } + + /** Where it is set: a value outside 1 ≤ min ≤ max ≤ cap is refused with the bounds. */ + static check(min: number, max: number, cap: number): string | undefined { + if (!Number.isInteger(min) || !Number.isInteger(max) || min < 1 || min > max || max > cap) return `agents: 1 ≤ min ≤ max ≤ ${cap} (the cap) — whole numbers; min ${min}, max ${max} is outside` + return undefined + } + + /** How many requests the lead's model serves at once now. */ + parallelNow(): number { + return parallelFrom(this.o.capacity(this.model)) + } + + /** §2: offered when subagents are on and the chat's model serves two at once — fixed as the + * prompt is made (and made again on a model switch). */ + fix(model = this.model): boolean { + this.model = model + const lead = this.agents.get(LEAD) + if (lead) lead.model = model + this.offered = this.subagents && this.parallelNow() >= 2 + return this.offered + } + + // ── the tree ───────────────────────────────────────────────────────────────── + + get(name: string): TeamAgent | undefined { + return this.agents.get(name) + } + + /** Every agent, in tree order: an agent, then its members in the order added, depth first. */ + tree(): TeamAgent[] { + const out: TeamAgent[] = [] + const walk = (a: TeamAgent) => { + out.push(a) + for (const m of this.membersOf(a.name)) walk(m) + } + walk(this.agents.get(LEAD)!) + return out + } + + membersOf(name: string): TeamAgent[] { + return [...this.agents.values()].filter((a) => a.manager === name).sort((a, b) => a.seq - b.seq) + } + + /** The agent and everything under it, in tree order. */ + subtree(name: string): TeamAgent[] { + const a = this.agents.get(name) + if (!a) return [] + return [a, ...this.membersOf(name).flatMap((m) => this.subtree(m.name))] + } + + hasMembers(): boolean { + return this.agents.size > 1 + } + + /** §3: the lead, every member, and every running `task` helper. */ + size(): number { + return this.agents.size + this.helpers.length + } + + /** §6: an agent with a member, or the lead from the start when min ≥ 2 and the team is offered. */ + isManager(name: string): boolean { + if (this.membersOf(name).length) return true + return name === LEAD && this.offered && this.min >= 2 + } + + // ── what a call answers ────────────────────────────────────────────────────── + + /** §2: the team tools' requirement `team`; the text a call answers while it is not met. */ + unavailable(): string | undefined { + if ((this.max >= 2 && this.parallelNow() >= 2) || this.hasMembers()) return undefined + return this.parallelNow() < 2 ? TEAM.unavailable.one_request : TEAM.unavailable.one_agent + } + + /** §3: what `task` answers in a team, or undefined when it may run (and then counts). */ + taskRefusal(): string | undefined { + if (!this.offered) return undefined + if (this.max === 1 && !this.hasMembers()) return TEAM.unavailable.one_agent + if (this.size() + 1 > this.max) return fill(TEXTS.full, { max: this.max }) + return undefined + } + + /** §6: whether a call is work a manager gives away. `risk`: the tool's class where the spec does + * not have the tool (an MCP tool: read when it says readOnlyHint, else execute). */ + isWork(tool: string, args: unknown, risk?: string): boolean { + if ((TEAM.work.kept as string[]).includes(tool)) return false + const r = TOOL_SPECS[tool]?.risk ?? risk ?? "execute" + if (r !== "write" && r !== "execute") return false + if (tool === "bash") { + const command = (args as { command?: unknown } | undefined)?.command + return !(typeof command === "string" && (this.o.readingBash ?? readingBashByDefault)(command)) + } + return true + } + + /** What a call by `by` answers before it runs — the team tools' requirement, task's size, a + * manager's work — or undefined when it goes on to its own checks (permission first). */ + refusal(by: string, tool: string, args: unknown, risk?: string): string | undefined { + if ((TEAM_TOOLS as readonly string[]).includes(tool)) return this.offered ? this.unavailable() : TEAM.unavailable.one_agent + if (tool === "task") return this.taskRefusal() + if (this.isManager(by) && this.isWork(tool, args, risk)) return TEXTS.manager + return undefined + } + + // ── slots ─────────────────────────────────────────────────────────────────── + + private busyOn(model: string): number { + return [...this.agents.values()].filter((a) => a.state === "working" && a.model === model).length + this.helpers.filter((h) => h.model === model).length + } + + private free(model: string): boolean { + const group = this.o.capacity(model).group + if (group !== undefined) { + for (const a of this.agents.values()) if (a.state === "working" && a.model !== model && this.o.capacity(a.model).group === group) return false + for (const h of this.helpers) if (h.model !== model && this.o.capacity(h.model).group === group) return false + } + return this.busyOn(model) < parallelFrom(this.o.capacity(model)) + } + + private want(a: TeamAgent) { + if (a.state === "working" || a.state === "waiting") return + a.state = "waiting" + a.waitSince = this.waits++ + } + + /** §8: waiting agents start in order — the lead first, then as they began waiting. */ + private fillSlots() { + const queue = [...this.agents.values()].filter((a) => a.state === "waiting").sort((a, b) => Number(b.name === LEAD) - Number(a.name === LEAD) || a.waitSince! - b.waitSince!) + for (const a of queue) { + if (!this.free(a.model)) continue + a.state = "working" + a.waitSince = undefined + const text = a.brief ?? a.inbox.join("\n\n") + a.brief = undefined + a.inbox = [] + this.o.effects?.start(a.name, text) + } + } + + /** The person's message to the lead: it works, or waits for a slot (§8). True when it works. */ + leadWants(): boolean { + const lead = this.agents.get(LEAD)! + this.want(lead) + this.fillSlots() + return lead.state === "working" + } + + /** A `task` helper started by `by` (it counts and holds a slot until helperDone). */ + helperStart(by: string, model = this.model): number { + const id = ++this.helperSeq + this.helpers.push({ id, by, model }) + return id + } + + helperDone(id: number) { + this.helpers = this.helpers.filter((h) => h.id !== id) + this.fillSlots() + } + + /** The first helper `by` started, done (the conformance area's `helper_done`). */ + helperDoneBy(by: string) { + const h = this.helpers.find((x) => x.by === by) + if (h) this.helperDone(h.id) + } + + // ── delivery ──────────────────────────────────────────────────────────────── + + /** §7: to a working agent at its next step; otherwise it wakes (and may wait for a slot). */ + private deliver(name: string, text: string) { + const a = this.agents.get(name) + if (!a) return + this.delivered.push({ name, text }) + if (a.state === "working") return this.o.effects?.deliver(name, text) + a.inbox.push(text) + this.want(a) + } + + /** What was delivered, to whom, since this was last read (for a front end and the conformance). */ + delivered: { name: string; text: string }[] = [] + + private post(from: string, to: string[], recipients: string[], text: string, report: boolean): RoomMessage { + const m: RoomMessage = { n: this.room.length + 1, from, to, recipients, text, time: this.now(), report } + this.room.push(m) + const mark = fill(report ? TEAM.marks.report : TEAM.marks.message, { from, to: to.join(", ") }) + for (const r of recipients) this.deliver(r, `${mark}\n${text}`) + return m + } + + // ── the tools ─────────────────────────────────────────────────────────────── + + /** team_add (§4). `brief` is what the member's first turn is given. */ + add(by: string, a: { name: string; role: string; task: string; goal?: string; lead?: boolean; agent?: string; model?: string }): { text: string; added?: TeamAgent } { + const no = this.offered ? this.unavailable() : TEAM.unavailable.one_agent + if (no) return { text: no } + if (a.name === "all") return { text: TEXTS.all_name } + if (!NAME.test(a.name)) return { text: fill(TEXTS.bad_name, { name: a.name }) } + if (this.agents.has(a.name)) return { text: fill(TEXTS.taken, { name: a.name }) } + const lead = this.agents.get(LEAD)! + const goal = a.goal?.trim() + if (by === LEAD && !lead.goal && !goal) return { text: TEXTS.no_goal } + if (this.size() + 1 > this.max) return { text: fill(TEXTS.full, { max: this.max }) } + const caller = this.agents.get(by) ?? lead + if (by === LEAD && goal) lead.goal = goal + const member: TeamAgent = { + name: a.name, + role: a.role.trim().split("\n")[0]!, + manager: by, + leads: a.lead === true, + goal: goal || caller.goal, + model: a.model ?? caller.model, + agent: a.agent ?? "general", + state: "finished", + inbox: [], + brief: a.task, + seq: this.seq++, + } + this.agents.set(member.name, member) + this.want(member) + this.fillSlots() + const vars = { name: member.name, role: member.role, size: this.size(), max: this.max, parallel: parallelFrom(this.o.capacity(member.model)) } + return { text: fill(member.state === "working" ? TEXTS.added : TEXTS.added_waiting, vars), added: member } + } + + /** team_send (§5). */ + send(by: string, a: { to: string[] | string; message: string }): string { + const no = this.offered ? this.unavailable() : TEAM.unavailable.one_agent + if (no) return no + const list = [...new Set(typeof a.to === "string" ? [a.to] : a.to)] + const all = list.includes("all") + const others = [...this.agents.keys()].filter((n) => n !== by) + if (all) { + if (!others.length) return TEXTS.nobody + const recipients = this.tree().map((x) => x.name).filter((n) => n !== by) + this.post(by, ["all"], recipients, a.message, false) + this.fillSlots() + return fill(TEXTS.sent, { to: "all" }) + } + const unknown = list.find((n) => !this.agents.has(n)) + if (unknown !== undefined) return fill(TEXTS.unknown, { name: unknown, names: this.names() }) + if (list.includes(by)) return TEXTS.self + this.post(by, list, list, a.message, false) + this.fillSlots() + return fill(TEXTS.sent, { to: list.join(", ") }) + } + + /** A member's `report`: a message to its manager before its final reply (§4). */ + message(by: string, text: string): string | undefined { + const a = this.agents.get(by) + if (!a?.manager) return undefined + this.post(by, [a.manager], [a.manager], text, false) + this.fillSlots() + return fill(TEXTS.sent, { to: a.manager }) + } + + /** Who may see a room message (§5): the lead every one; another agent those whose sender or a + * recipient is in its subtree. */ + visible(by: string, m: RoomMessage): boolean { + if (by === LEAD) return true + const mine = new Set(this.subtree(by).map((a) => a.name)) + return mine.has(m.from) || m.recipients.some((r) => mine.has(r)) + } + + /** team_read (§5): the messages above `since` the caller may see, at most read_max. */ + readRoom(by: string, since = 0): { messages: RoomMessage[]; more: boolean } { + const all = this.room.filter((m) => m.n > since && this.visible(by, m)) + return { messages: all.slice(0, TEAM.read_max), more: all.length > TEAM.read_max } + } + + /** team_read's answer as text (loop.json team.read). */ + read(by: string, since = 0): string { + const no = this.offered ? this.unavailable() : TEAM.unavailable.one_agent + if (no) return no + const R = TEAM.read + const lines = [fill(R.tree, { size: this.size(), max: this.max })] + const depth = (a: TeamAgent): number => (a.manager ? 1 + depth(this.agents.get(a.manager)!) : 0) + for (const a of this.tree()) + lines.push( + fill(R.agent, { + indent: " ".repeat(depth(a)), + name: a.name, + role: a.role, + state: R.states[a.state], + model: a.model, + last: a.state === "working" ? R.active : a.ended !== undefined ? `${duration(this.now() - a.ended)} ago` : "—", + }), + ) + const { messages, more } = this.readRoom(by, since) + lines.push("") + if (!messages.length) lines.push(fill(R.none, { since })) + else { + lines.push(fill(R.room, { since })) + for (const m of messages) { + const [first, ...rest] = m.text.split("\n") + const text = [first ?? "", ...rest.map((l) => ` ${l}`)].join("\n") + lines.push(fill(m.report ? R.report : R.message, { n: m.n, from: m.from, to: m.to.join(", "), text })) + } + if (more) lines.push(fill(R.more, { last: messages[messages.length - 1]!.n })) + } + return lines.join("\n") + } + + /** team_stop (§9). */ + stopCmd(by: string, name: string): string { + const no = this.offered ? this.unavailable() : TEAM.unavailable.one_agent + if (no) return no + if (name === "all") { + const under = this.membersOf(by) + if (!under.length) return TEXTS.nobody_to_stop + const gone = under.flatMap((m) => this.subtree(m.name)) + this.remove(gone) + return fill(TEXTS.stopped, { names: gone.map((a) => a.name).join(", "), size: this.size(), max: this.max }) + } + if (!this.agents.has(name)) return fill(TEXTS.unknown, { name, names: this.names() }) + if (name === by || !this.subtree(by).some((a) => a.name === name)) return fill(TEXTS.not_yours, { name }) + const gone = this.subtree(name) + this.remove(gone) + return fill(TEXTS.stopped, { names: gone.map((a) => a.name).join(", "), size: this.size(), max: this.max }) + } + + /** The person stopped a member in the UI: it and its subtree leave; the lead is told in a note. */ + personStop(name: string): string[] { + if (name === LEAD || !this.agents.has(name)) return [] + const gone = this.subtree(name) + this.remove(gone) + this.stoppedByPerson.push(...gone.map((a) => a.name)) + return gone.map((a) => a.name) + } + + private remove(gone: TeamAgent[]) { + for (const a of gone) { + this.agents.delete(a.name) + this.o.effects?.stop(a.name) + } + this.fillSlots() + } + + /** An agent's turn ended (§5): a member's final reply is its report to its manager. `reason`: + * stop, steps, budget, or `error — `. */ + finish(name: string, reply = "", reason = "stop") { + const a = this.agents.get(name) + if (!a || a.state !== "working") return + a.state = name === LEAD ? "idle" : "finished" + a.ended = this.now() + if (a.manager) { + let text = reply.trim() || TEXTS.no_report + if (reason !== "stop") text += `\n\n${fill(TEXTS.ended, { reason })}` + this.post(name, [a.manager], [a.manager], text, true) + } + // Messages it was sent at its last step, too late for it: they wake it again. + if (a.inbox.length) this.want(a) + this.fillSlots() + } + + /** Messages delivered to a working agent that its turn ended before taking in: back to its + * inbox, so they wake it. */ + undelivered(name: string, texts: string[]) { + const a = this.agents.get(name) + if (!a || !texts.length) return + a.inbox.push(...texts) + this.want(a) + this.fillSlots() + } + + /** The lead's turn is starting without having asked (the engine's own): it holds a slot. */ + leadWorking() { + const lead = this.agents.get(LEAD)! + lead.state = "working" + lead.waitSince = undefined + } + + // ── what the lead is told ─────────────────────────────────────────────────── + + /** §10: the note's entries for the lead's next user message — the size when it was not said yet + * or changed (one block), and the members the person stopped (a block of its own). */ + noteLines(): string[] { + if (!this.offered) return [] + const N = LOOP.context.note + const out: string[] = [] + if (!this.told || this.told.min !== this.min || this.told.max !== this.max) { + this.told = { min: this.min, max: this.max } + if (this.max === 1) out.push(N.team_one) + else out.push([fill(N.team, { min: this.min, max: this.max }), ...(this.min >= 2 ? [fill(N.team_lead, { members: this.min - 1 })] : [])].join("\n")) + } + if (this.stoppedByPerson.length) out.push(fill(N.team_stopped, { names: this.stoppedByPerson.splice(0).join(", ") })) + return out + } + + /** A note said once already is said again after a rewind or a new prompt (compaction). */ + forgetTold() { + this.told = undefined + } + + /** The team, in tree order: name, manager, state. */ + state(): { name: string; manager: string | null; state: AgentState }[] { + return this.tree().map((a) => ({ name: a.name, manager: a.manager, state: a.state })) + } + + private names(): string { + return this.tree() + .map((a) => a.name) + .join(", ") + } + + /** Members working or waiting now (for a status line). */ + active(): number { + return [...this.agents.values()].filter((a) => a.name !== LEAD && (a.state === "working" || a.state === "waiting")).length + } +} diff --git a/src/tool/search.ts b/src/tool/search.ts index 93d6c84..02f2632 100644 --- a/src/tool/search.ts +++ b/src/tool/search.ts @@ -82,7 +82,7 @@ export const grepTool = defineTool({ if (a.literal) args.push("-F") if (a.include) args.push("--glob", a.include) args.push("-e", a.pattern, target) - const r = await run(args, ctx.cwd, ctx.signal) + const r = await run(args, ctx.root, ctx.signal) if (r.code > 1) throw new ToolError(r.err.trim() || `rg exited ${r.code}`) for (const l of r.out.split("\n")) if (l) lines.push(l) } else { diff --git a/src/tool/show.ts b/src/tool/show.ts index b260e12..466e429 100644 --- a/src/tool/show.ts +++ b/src/tool/show.ts @@ -68,7 +68,7 @@ export const showTool = defineTool({ if (size > SHOW_MAX_BYTES) throw new ToolError(`${showPath(abs, ctx)} is ${Math.round(size / 1024)} KB; at most 1 MB can be shown. Give the user the path instead.`) const text = await readText(abs) if (text.includes("\u0000")) throw new ToolError(`${showPath(abs, ctx)} is not a text file; it cannot be shown. Give the user the path instead.`) - const rel = relative(ctx.cwd, abs) || basename(abs) + const rel = relative(ctx.root, abs) || basename(abs) const reply = await ctx.show({ path: rel, ...showKind(abs), ...(a.line ? { line: a.line } : {}), text }, ctx.callId) if (!reply.shown) return { output: `Not shown: ${reply.reason}`, isError: true } return { output: `Shown to the user: ${showPath(abs, ctx)}${a.line ? ` at line ${a.line}` : ""}.`, title: showPath(abs, ctx) } diff --git a/src/tool/tool.ts b/src/tool/tool.ts index 7ab7d6c..02400b5 100644 --- a/src/tool/tool.ts +++ b/src/tool/tool.ts @@ -9,7 +9,8 @@ import { fillParameters, PURPOSE_DEF, TOOL_SPECS } from "../harness.ts" export interface ToolContext { /** Absolute project root. */ root: string - /** Absolute working directory for relative paths and shells. */ + /** Where the session was started (absolute): it ends the instruction chain and is told to the + * model relative to the root. Relative paths and the shell are the root's (context.md §11). */ cwd: string signal: AbortSignal /** Absolute paths read in this session — `edit`/`write` of an existing file requires one. */ @@ -189,12 +190,13 @@ export async function readText(abs: string): Promise { return new TextDecoder("utf-8", { ignoreBOM: true }).decode(await Bun.file(abs).bytes()) } -/** A tool's path argument, absolute. Models send both absolute and relative paths. */ +/** A tool's path argument, absolute. Models send both absolute and relative paths; a relative one + * is read against the project root, wherever the session was started (harness context.md §11). */ export function absPath(p: string, ctx: ToolContext): string { // A model writes ~/x as a shell would; without this it becomes a directory named "~". if (p === "~" || p.startsWith("~/")) return resolve(expandHome(p)) if (isAbsolute(p)) return resolve(p) - const rel = resolve(ctx.cwd, p) + const rel = resolve(ctx.root, p) // Small models often drop the leading "/" of an absolute path ("home/me/proj/a.py"). When the // relative reading does not exist and the absolute one does, inside the project, take that. if (!existsSync(rel)) { diff --git a/src/tui/app.tsx b/src/tui/app.tsx index 45c17cf..e6335e1 100644 --- a/src/tui/app.tsx +++ b/src/tui/app.tsx @@ -3,7 +3,8 @@ import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid" import { createEffect, createMemo, createSignal, onCleanup, Show } from "solid-js" import { stricterMode, type App as Runtime } from "../app.ts" import { isLocal } from "../config/paths.ts" -import type { Effort, Mode } from "../config/schema.ts" +import type { Mode } from "../config/schema.ts" +import { levelLabel, type Level } from "../provider/effort.ts" import { COMMANDS, findCommand, type CommandCtx } from "./commands.ts" import { Dialog, type DialogSpec } from "./components/dialog.tsx" import { Messages } from "./components/messages.tsx" @@ -58,7 +59,7 @@ export function Root(p: { app: Runtime; view: View; theme: Theme; onQuit: () => const refresh = () => setRev((n) => n + 1) const mode = () => (rev(), app.engine.mode) const model = () => (rev(), app.engine.model.ref) - const effort = () => (rev(), app.engine.effort ?? "") + const effort = () => (rev(), levelLabel(app.engine.model, app.engine.effort)) const busy = () => view.state.phase !== "idle" const branch = () => (rev(), app.project.gitRoot ? currentBranch(app.project.gitRoot) : undefined) // A word in the status bar that goes by itself (a copy): it says something happened without @@ -223,8 +224,8 @@ export function Root(p: { app: Runtime; view: View; theme: Theme; onQuit: () => setMode(m: Mode) { app.settings.set("mode", m) }, - setEffort(e: Effort | null) { - app.settings.set("effort", e ?? "off") + setEffort(e: Level) { + app.settings.set("effort", e) }, setTheme(name: string) { try { @@ -246,7 +247,7 @@ export function Root(p: { app: Runtime; view: View; theme: Theme; onQuit: () => confirmStop("reload", () => { // Back on this session, with what it was using; everything else comes from the files again. const e = app.engine - const args = [...(isLocal() ? ["-l"] : []), ...(e.sessionId ? ["-s", e.sessionId] : []), "-m", e.model.ref, "--mode", e.mode, "-e", e.effort ?? "off"] + const args = [...(isLocal() ? ["-l"] : []), ...(e.sessionId ? ["-s", e.sessionId] : []), "-m", e.model.ref, "--mode", e.mode, "-e", e.effort] void app .close() .catch(() => {}) @@ -402,7 +403,7 @@ export function Root(p: { app: Runtime; view: View; theme: Theme; onQuit: () => const id = view.shellStart(command) const t0 = performance.now() bashTool - .run({ command }, { ...app.engine.o.toolCtx, signal: new AbortController().signal, bashTimeline: false }) + .run({ command, cwd: app.engine.o.toolCtx.cwd }, { ...app.engine.o.toolCtx, signal: new AbortController().signal, bashTimeline: false }) .then((r) => { view.shellEnd(id, r, performance.now() - t0) app.engine.pendingContext.push(`\n${r.output}\n`) diff --git a/src/tui/commands.ts b/src/tui/commands.ts index 883a510..0df9e23 100644 --- a/src/tui/commands.ts +++ b/src/tui/commands.ts @@ -3,8 +3,8 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs" import { join } from "node:path" import { paths } from "../config/paths.ts" -import { asMode, EFFORTS, MODES, type Effort, type Mode } from "../config/schema.ts" -import { effortsFor } from "../provider/effort.ts" +import { asMode, MODES, type Mode } from "../config/schema.ts" +import { levelsFor, type Level } from "../provider/effort.ts" import { setTrust } from "../project/root.ts" import { commit, commitPlan, draftMessage } from "../git/commit.ts" import { doRelease, planRelease } from "../git/release.ts" @@ -47,7 +47,7 @@ export interface CommandCtx extends Ctx { sendAs?(prompt: string, shown: string, opts?: { model?: string; mode?: string; attachFrom?: string }): void openDialog(spec: DialogSpec): void setMode(m: Mode): void - setEffort(e: Effort | null): void + setEffort(e: Level): void setTheme(name: string): void setIcons(set: import("./theme.ts").IconSet): void /** A short word in the status bar that goes by itself. */ @@ -171,7 +171,7 @@ export const COMMANDS: Command[] = [ options: refs.map((r) => { const known = c.app.loaded.refs.find((x) => x.ref === r)! const spec = c.app.loaded.connections[known.connection]!.models[known.id]! - const bits = [spec.context ? `${Math.round(spec.context / 1024)}k ctx` : "", spec.vision ? "vision" : "", spec.efforts ? "effort" : ""].filter(Boolean).join(" · ") + const bits = [spec.context ? `${Math.round(spec.context / 1024)}k ctx` : "", spec.vision ? "vision" : "", levelsFor(c.app.engine.model.ref === r ? c.app.engine.model : { ...c.app.engine.model, ref: r, spec }).control === "switch" ? "thinking switch" : spec.efforts?.length ? "effort" : ""].filter(Boolean).join(" · ") return { label: r, value: r, description: bits, current: r === c.app.engine.model.ref } }), onSelect: pick, @@ -180,19 +180,34 @@ export const COMMANDS: Command[] = [ }, { name: "effort", - args: `[${EFFORTS.join("|")}|off]`, - description: "reasoning effort for this model", + args: "[level|on|off]", + description: "how hard the model thinks: its levels, a Thinking switch, or nothing to choose", run(c, arg) { - const allowed = effortsFor(c.app.engine.model) + // The control follows the model's levels (harness models/schema.json `levels`): one level — + // nothing to choose; [off, X] — a Thinking switch; more — a list. + const m = c.app.engine.model + const { levels, control } = levelsFor(m) + const on = levels.find((l) => l !== "off")! const set = (v: string) => { - if (v === "off") return c.setEffort(null) - if (!allowed.includes(v as Effort)) return c.view.info(`${c.app.engine.model.ref} takes ${allowed.join(", ")} or off`, "warn") - c.setEffort(v as Effort) + if (control === "switch" && (v === "on" || v === "thinking")) v = on + if (!(levels as string[]).includes(v)) return c.view.info(control === "switch" ? `${m.ref} has a Thinking switch: /effort on or /effort off` : `${m.ref} offers ${levels.join(", ")}`, "warn") + c.setEffort(v as Level) + c.view.info(control === "switch" ? `thinking ${v === "off" ? "off" : `on (${v})`}` : `effort: ${v}`) } + if (control === "none") return c.view.info(`${m.ref} has one level (${levels[0]}): there is nothing to choose`) if (arg) return set(arg) + if (control === "switch") + return c.openDialog({ + title: `Thinking — ${m.ref}`, + options: [ + { label: "on", value: on, description: on === "default" ? "the model's own" : on, current: c.app.engine.effort !== "off" }, + { label: "off", value: "off", current: c.app.engine.effort === "off" }, + ], + onSelect: set, + }) c.openDialog({ - title: `Effort — ${c.app.engine.model.ref}`, - options: [...allowed, "off"].map((e) => ({ label: e, value: e, current: (c.app.engine.effort ?? "off") === e })), + title: `Effort — ${m.ref}`, + options: levels.map((e) => ({ label: e === "off" ? "off (no thinking)" : e, value: e, current: c.app.engine.effort === e })), onSelect: set, }) }, @@ -934,7 +949,7 @@ export const COMMANDS: Command[] = [ e.def.kind === "model" ? c.app.modelRefs() : e.def.kind === "effort" - ? [...effortsFor(c.app.engine.model), "off"] + ? [...levelsFor(c.app.engine.model).levels] : e.def.kind === "boolean" ? ["true", "false"] : key === "theme" diff --git a/tests/acp-v2.test.ts b/tests/acp-v2.test.ts index 956e9e7..647292f 100644 --- a/tests/acp-v2.test.ts +++ b/tests/acp-v2.test.ts @@ -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 }) }) diff --git a/tests/acp.test.ts b/tests/acp.test.ts index 0b6e51b..bad9848 100644 --- a/tests/acp.test.ts +++ b/tests/acp.test.ts @@ -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("_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 () => { diff --git a/tests/conformance.ts b/tests/conformance.ts index 1692a98..067ad0d 100644 --- a/tests/conformance.ts +++ b/tests/conformance.ts @@ -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 @@ -68,6 +72,14 @@ export const AREAS: Record 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 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[]).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[]).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 = {} + for (const d of team.delivered.splice(0)) (out[d.name] ??= []).push(d.text) + return out + } + return (c.steps as Record[]).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 + 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`. diff --git a/tests/cutoff.test.ts b/tests/cutoff.test.ts index 7a58e9f..08a9df5 100644 --- a/tests/cutoff.test.ts +++ b/tests/cutoff.test.ts @@ -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) }) }) diff --git a/tests/e2e.test.ts b/tests/e2e.test.ts index 3a683e1..2012f32 100644 --- a/tests/e2e.test.ts +++ b/tests/e2e.test.ts @@ -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). diff --git a/tests/provider.test.ts b/tests/provider.test.ts index 109af0b..9617cf7 100644 --- a/tests/provider.test.ts +++ b/tests/provider.test.ts @@ -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 () => { diff --git a/tests/settings.test.ts b/tests/settings.test.ts index bc92ad2..cfae129 100644 --- a/tests/settings.test.ts +++ b/tests/settings.test.ts @@ -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") }) diff --git a/tests/spec320.test.ts b/tests/spec320.test.ts new file mode 100644 index 0000000..df4c095 --- /dev/null +++ b/tests/spec320.test.ts @@ -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[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 => ({ 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 tags goes back as reasoning_content, never as text", async () => { + const dir = setup([ + { chunks: [delta({ content: "Look at the list." }), 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") + }) +}) diff --git a/tests/subagent.test.ts b/tests/subagent.test.ts index 54273cc..9ae6537 100644 --- a/tests/subagent.test.ts +++ b/tests/subagent.test.ts @@ -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]