Harness spec 3.2.0 in the CLI: effort levels, reasoning round-trip, no absolute paths
ci / check (push) Waiting to run
ci / check (push) Waiting to run
Levels (models/schema.json levels): provider/effort.ts levelsOf/levelFields/ readLevel — `off` only with thinking_switch and sends enable_thinking false and no effort, `default` for a model that takes no efforts and sends nothing, a level not offered is read as the start level. The engine's effort is a level; /effort shows nothing for one level, a Thinking switch for [off, X], a list otherwise; the status bar follows. /v1/models' thinking_switch, efforts [] and capacity.parallel are read (lembas/webui.ts); session/capacity.ts counts parallel slots. Reasoning (context.md §8): an assistant message of the current turn carries reasoning_content (provider/common.ts turnReasoning, openai-chat.ts); step-boundary deliveries, nudges and the wrap-up are marked turn: false and do not start a turn. Paths (context.md §11): the <env> block names the project and, started below the root, that directory relative to it (assemble.ts envLines); instruction files and @ attachments are named from the root or as ~/…; file tools and bash resolve relative paths against the project root. The team rules (team/team.ts) and conformance runners for levels, reasoning, paths and team; the team runtime follows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
36 files changed
+1168
-153
No files matched your search
@@ -870,7 +870,7 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"effort": {
|
"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": [
|
"anyOf": [
|
||||||
{
|
{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -886,9 +886,42 @@
|
|||||||
{
|
{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"const": "off"
|
"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": {
|
"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.",
|
"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",
|
"type": "string",
|
||||||
|
|||||||
@@ -127,7 +127,7 @@
|
|||||||
"maximum": 1
|
"maximum": 1
|
||||||
},
|
},
|
||||||
"efforts": {
|
"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",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
"type": "string",
|
"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": {
|
"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": [
|
"anyOf": [
|
||||||
{
|
{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -158,6 +162,10 @@
|
|||||||
{
|
{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"const": "off"
|
"const": "off"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"const": "default"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -202,9 +210,15 @@
|
|||||||
"maxLength": 300
|
"maxLength": 300
|
||||||
},
|
},
|
||||||
"single_session": {
|
"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"
|
"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": {
|
"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.",
|
"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"
|
"type": "string"
|
||||||
|
|||||||
+10
-8
@@ -33,7 +33,8 @@ import { addPendingDeleted, clearPendingDeleted, pendingDeleted } from "./pendin
|
|||||||
import { shellLaunch } from "./shellrc.ts"
|
import { shellLaunch } from "./shellrc.ts"
|
||||||
import { sessionTurnFile, sessionTurns, TurnLog } from "./turns.ts"
|
import { sessionTurnFile, sessionTurns, TurnLog } from "./turns.ts"
|
||||||
import { expandHome } from "../config/paths.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 { HARNESS_VERSION } from "../harness.ts"
|
||||||
import { findProject, trustedRoots, trustedWithin, trustOf } from "../project/root.ts"
|
import { findProject, trustedRoots, trustedWithin, trustOf } from "../project/root.ts"
|
||||||
import { VERSION } from "../version.ts"
|
import { VERSION } from "../version.ts"
|
||||||
@@ -131,10 +132,10 @@ interface Terminal {
|
|||||||
/** Most terminals one link may hold at once. */
|
/** Most terminals one link may hold at once. */
|
||||||
const MAX_TERMINALS = 4
|
const MAX_TERMINALS = 4
|
||||||
|
|
||||||
/** An effort as the instance sends it: a level, or "off"; anything else is not one. */
|
/** An effort level as the instance sends it (an effort, "off" or "default"); anything else is not
|
||||||
export function effortOf(v: unknown): Effort | "off" | undefined {
|
* one. Whether the model offers it is the session's to decide (readLevel). */
|
||||||
if (v === "off" || v === "" || v === null) return "off"
|
export function effortOf(v: unknown): Level | undefined {
|
||||||
return typeof v === "string" && (EFFORTS as readonly string[]).includes(v) ? (v as Effort) : undefined
|
return isLevel(v) ? v : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
const MODE_RANK: Record<Mode, number> = { auto: 0, edit: 1, manual: 2, plan: 3 }
|
const MODE_RANK: Record<Mode, number> = { auto: 0, edit: 1, manual: 2, plan: 3 }
|
||||||
@@ -619,7 +620,7 @@ export class AcpAgent {
|
|||||||
sessionId: id,
|
sessionId: id,
|
||||||
modes: { currentModeId: session.app.engine.mode, availableModes: this.modes().map((m) => ({ id: m, name: m })) },
|
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.
|
// `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)
|
s.app.switchModel(ref)
|
||||||
}
|
}
|
||||||
const effort = effortOf(p?.effort)
|
const effort = effortOf(p?.effort)
|
||||||
if (effort !== undefined) s.app.engine.effort = effort === "off" ? null : effort
|
// A level the model does not offer is read as its start level; the answer says the one used.
|
||||||
return { model: s.app.engine.model.ref, effort: s.app.engine.effort ?? "off" }
|
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[] {
|
private modes(): Mode[] {
|
||||||
|
|||||||
+4
-3
@@ -14,6 +14,7 @@ import type { ImagePart } from "../provider/types.ts"
|
|||||||
import { commandSet, sourcesOf } from "../session/commands.ts"
|
import { commandSet, sourcesOf } from "../session/commands.ts"
|
||||||
import type { PlanReply, PlanRequest } from "../tool/plan_submit.ts"
|
import type { PlanReply, PlanRequest } from "../tool/plan_submit.ts"
|
||||||
import type { QuestionReply, QuestionRequest } from "../tool/ask_user.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 { 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 { jobList, jobOutput, jobSend, jobStop, jobTranscript } from "./jobs.ts"
|
||||||
import type { SessionContext } from "../app.ts"
|
import type { SessionContext } from "../app.ts"
|
||||||
@@ -390,7 +391,7 @@ export class Share {
|
|||||||
// is the instance's.
|
// is the instance's.
|
||||||
modelConnection: this.app.engine.model.connectionName,
|
modelConnection: this.app.engine.model.connectionName,
|
||||||
mode: this.app.engine.mode,
|
mode: this.app.engine.mode,
|
||||||
effort: this.app.engine.effort ?? "off",
|
effort: this.app.engine.effort,
|
||||||
busy: Boolean(this.app.turns.current),
|
busy: Boolean(this.app.turns.current),
|
||||||
explicit,
|
explicit,
|
||||||
},
|
},
|
||||||
@@ -596,9 +597,9 @@ export class Share {
|
|||||||
// same model as `deepseek/x`, and switching to it again would reset the effort.
|
// 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)
|
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)
|
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()
|
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 }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+17
-18
@@ -12,10 +12,9 @@ import { boardSummary } from "./project/board.ts"
|
|||||||
import { unsafeProjectFile } from "./project/safe.ts"
|
import { unsafeProjectFile } from "./project/safe.ts"
|
||||||
import { Bus, type Asker } from "./bus/index.ts"
|
import { Bus, type Asker } from "./bus/index.ts"
|
||||||
import { loadConfig, type Loaded } from "./config/load.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 { DEFAULT_RULES, toRules } from "./permission/evaluate.ts"
|
||||||
import { hardlineRules } from "./permission/hardline.ts"
|
import { hardlineRules } from "./permission/hardline.ts"
|
||||||
import { defaultEffort } from "./provider/effort.ts"
|
|
||||||
import { clientFor, familyOf, resolveModel } from "./provider/index.ts"
|
import { clientFor, familyOf, resolveModel } from "./provider/index.ts"
|
||||||
import { assembleSystem, instructionFiles, knowledgeScope, modeText } from "./prompt/assemble.ts"
|
import { assembleSystem, instructionFiles, knowledgeScope, modeText } from "./prompt/assemble.ts"
|
||||||
import type { Changing } from "./session/prefix.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 { LIBRARY_SERVER, mcpInstructions } from "./mcp/index.ts"
|
||||||
import type { McpServer } from "./config/schema.ts"
|
import type { McpServer } from "./config/schema.ts"
|
||||||
import { embedderFor } from "./library/embed.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 {
|
export interface AppOptions {
|
||||||
cwd: string
|
cwd: string
|
||||||
model?: string
|
model?: string
|
||||||
mode?: Mode
|
mode?: Mode
|
||||||
/** undefined = the model's default; "off" = none */
|
/** The effort level asked for (an effort, "off" or "default"); undefined, or one the model does
|
||||||
effort?: Effort | "off"
|
* not offer: its start level. */
|
||||||
|
effort?: string
|
||||||
asker: Asker
|
asker: Asker
|
||||||
store?: Store | false
|
store?: Store | false
|
||||||
/** Nobody can answer an approval (headless, no --yes). Said in the system prompt up front. */
|
/** 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.
|
// An untrusted project is read-only: plan mode, and its own config is ignored.
|
||||||
let mode: Mode = o.mode ?? loaded.config.mode ?? "manual"
|
let mode: Mode = o.mode ?? loaded.config.mode ?? "manual"
|
||||||
if (trust === "readonly") mode = "plan"
|
if (trust === "readonly") mode = "plan"
|
||||||
// `effort:` in config when this model takes it, else the model's own default.
|
// `effort:` in config when this model offers that level, else the model's own start level
|
||||||
const startEffort = (m: ResolvedModel) => {
|
// (harness models/schema.json `levels`).
|
||||||
const e = loaded.config.effort
|
const startEffort = (m: ResolvedModel): Level => readLevel(m, loaded.config.effort)
|
||||||
if (e === "off") return null
|
const effort: Level = o.effort !== undefined && isLevel(o.effort) && (levelsFor(model).levels as string[]).includes(o.effort) ? o.effort : startEffort(model)
|
||||||
return e && effortsFor(m).includes(e) ? e : defaultEffort(m)
|
|
||||||
}
|
|
||||||
const effort = o.effort === "off" ? null : (o.effort ?? startEffort(model))
|
|
||||||
// One object for the session's lifetime, so a setting changed later reaches the tools.
|
// One object for the session's lifetime, so a setting changed later reaches the tools.
|
||||||
loaded.config.search ??= {}
|
loaded.config.search ??= {}
|
||||||
const bus = o.bus ?? new Bus()
|
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
|
// its server can take it beside the session's and beside every subagent already running
|
||||||
// (src/session/capacity.ts), before anything is made.
|
// (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 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.`)
|
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).
|
// A checkout and branch of its own, when asked for (the task's worktree, the agent's isolation).
|
||||||
let wt: Worktree | undefined
|
let wt: Worktree | undefined
|
||||||
@@ -545,7 +544,7 @@ export function createApp(o: AppOptions): App {
|
|||||||
{
|
{
|
||||||
name: spec.name,
|
name: spec.name,
|
||||||
instructions: wt
|
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,
|
: spec.instructions,
|
||||||
report: childTools.some((t) => t.name === "report"),
|
report: childTools.some((t) => t.name === "report"),
|
||||||
skills: childTools.some((t) => t.name === "skill_view"),
|
skills: childTools.some((t) => t.name === "skill_view"),
|
||||||
@@ -721,19 +720,19 @@ export function createApp(o: AppOptions): App {
|
|||||||
},
|
},
|
||||||
live(key) {
|
live(key) {
|
||||||
if (key === "model") return engine.model.ref
|
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
|
if (key === "mode") return engine.mode
|
||||||
return key.split(".").reduce<unknown>((x, k) => (x && typeof x === "object" ? (x as Record<string, unknown>)[k] : undefined), loaded.config)
|
return key.split(".").reduce<unknown>((x, k) => (x && typeof x === "object" ? (x as Record<string, unknown>)[k] : undefined), loaded.config)
|
||||||
},
|
},
|
||||||
check(key, v) {
|
check(key, v) {
|
||||||
if (key === "model" || key === "small_model") resolveModel(loaded, String(v))
|
if (key === "model" || key === "small_model") resolveModel(loaded, String(v))
|
||||||
if (key === "effort" && v !== "off" && !effortsFor(engine.model).includes(v as Effort))
|
if (key === "effort" && !(levelsFor(engine.model).levels as string[]).includes(String(v)))
|
||||||
throw new Error(`${engine.model.ref} takes ${effortsFor(engine.model).join(", ")} or off`)
|
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`)
|
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) {
|
apply(key, v) {
|
||||||
if (key === "model") switchModel(String(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 === "mode") engine.mode = v as Mode
|
||||||
else if (key === "limits.steps") engine.o.maxSteps = Number(v)
|
else if (key === "limits.steps") engine.o.maxSteps = Number(v)
|
||||||
else if (key === "busy_input") engine.busyInput = v as "steer" | "queue"
|
else if (key === "busy_input") engine.busyInput = v as "steer" | "queue"
|
||||||
|
|||||||
+8
-8
@@ -5,13 +5,13 @@ import { parseArgs } from "node:util"
|
|||||||
import { createApp } from "./app.ts"
|
import { createApp } from "./app.ts"
|
||||||
import { attachmentsFor } from "./project/attach.ts"
|
import { attachmentsFor } from "./project/attach.ts"
|
||||||
import { ConfigError, loadConfig } from "./config/load.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 { isLocal, localRoot } from "./config/paths.ts"
|
||||||
import { SettingError } from "./config/settings.ts"
|
import { SettingError } from "./config/settings.ts"
|
||||||
import { renderJson, renderPlain, settleJobs, terminalAsker } from "./headless.ts"
|
import { renderJson, renderPlain, settleJobs, terminalAsker } from "./headless.ts"
|
||||||
import { enterLocal, localFor, LocalError, localRefusal, localTmp, LOCAL_FLAGS } from "./project/local.ts"
|
import { enterLocal, localFor, LocalError, localRefusal, localTmp, LOCAL_FLAGS } from "./project/local.ts"
|
||||||
import { findProject, setTrust, trustOf } from "./project/root.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 { clientFor, ModelError, NOT_LOGGED_IN, resolveModel } from "./provider/index.ts"
|
||||||
import { Store } from "./session/store.ts"
|
import { Store } from "./session/store.ts"
|
||||||
import { promptTitle } from "./session/title.ts"
|
import { promptTitle } from "./session/title.ts"
|
||||||
@@ -84,7 +84,7 @@ Local mode:
|
|||||||
Options for run:
|
Options for run:
|
||||||
-m, --model conn/model model to use (default: config.yaml \`model\`)
|
-m, --model conn/model model to use (default: config.yaml \`model\`)
|
||||||
--mode MODE manual | edit | auto | plan
|
--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)
|
-y, --yes approve every ask (still never the hardline)
|
||||||
--json events as JSON lines
|
--json events as JSON lines
|
||||||
--reasoning show the model's reasoning
|
--reasoning show the model's reasoning
|
||||||
@@ -240,7 +240,7 @@ async function main() {
|
|||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
model: values.model,
|
model: values.model,
|
||||||
mode: asMode(values.mode),
|
mode: asMode(values.mode),
|
||||||
effort: values.effort as Effort | "off" | undefined,
|
effort: values.effort,
|
||||||
resume: values.session ?? (values.continue ? "last" : undefined),
|
resume: values.session ?? (values.continue ? "last" : undefined),
|
||||||
})
|
})
|
||||||
if (done.reload) await (await import("./reload.ts")).restart(done.reload)
|
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 && !process.stdin.isTTY) prompt = (await new Response(Bun.stdin.stream()).text()).trim()
|
||||||
if (!prompt) fail('nothing to do: lembas run "your prompt"', 2)
|
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.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))
|
if (values.effort && !isLevel(values.effort)) fail(`--effort must be one of ${EFFORTS.join(", ")}, off, default`, 2)
|
||||||
fail(`--effort must be one of ${EFFORTS.join(", ")}, off`, 2)
|
|
||||||
|
|
||||||
const app = createApp({
|
const app = createApp({
|
||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
model: values.model,
|
model: values.model,
|
||||||
mode: asMode(values.mode),
|
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 }),
|
asker: terminalAsker({ yes: values.yes ?? false, unattended: loadConfig().config.question?.unattended, theme: loadConfig().config.theme }),
|
||||||
unattended: !values.yes && !process.stdin.isTTY,
|
unattended: !values.yes && !process.stdin.isTTY,
|
||||||
store: values["no-store"] ? false : undefined,
|
store: values["no-store"] ? false : undefined,
|
||||||
@@ -342,7 +341,8 @@ async function main() {
|
|||||||
spec.max_output ? `out ${spec.max_output}` : "",
|
spec.max_output ? `out ${spec.max_output}` : "",
|
||||||
spec.vision ? "vision" : "",
|
spec.vision ? "vision" : "",
|
||||||
spec.tools === false ? "no-tools" : "",
|
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)
|
].filter(Boolean)
|
||||||
console.log(` ${m.ref}${bits.length ? " — " + bits.join(", ") : ""}`)
|
console.log(` ${m.ref}${bits.length ? " — " + bits.join(", ") : ""}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -154,6 +154,12 @@ function readConfig(file: string, label: string, notes: string[], dropped?: stri
|
|||||||
delete all[k]
|
delete all[k]
|
||||||
dropped.push(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<string, unknown>
|
||||||
|
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
|
// 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.
|
// 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.
|
// update: filled in when an update is looked for — a token file missing must not stop a start.
|
||||||
|
|||||||
+15
-4
@@ -51,15 +51,17 @@ export const ModelSpec = z.strictObject({
|
|||||||
max_output: z.number().int().positive().optional().describe("Max tokens per reply."),
|
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."),
|
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."),
|
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."),
|
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`)."),
|
||||||
effort: z.union([z.enum(EFFORTS), z.literal("off")]).optional().describe("Default effort; `off` sends none."),
|
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)."),
|
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."),
|
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."),
|
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."),
|
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."),
|
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."),
|
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."),
|
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<typeof ModelSpec>
|
export type ModelSpec = z.infer<typeof ModelSpec>
|
||||||
@@ -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."),
|
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."),
|
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."),
|
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."),
|
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."),
|
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/<session>/) and listed by /prototype; the LLeMbas web UI renders the same fences live. Default false."),
|
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/<session>/) and listed by /prototype; the LLeMbas web UI renders the same fences live. Default false."),
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export type Source = "default" | "global" | "project" | "session"
|
|||||||
|
|
||||||
export interface SettingDef {
|
export interface SettingDef {
|
||||||
key: string
|
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"
|
kind: "enum" | "model" | "effort" | "number" | "boolean" | "list" | "string"
|
||||||
values?: readonly string[]
|
values?: readonly string[]
|
||||||
/** The agent may change it (through the settings tool, with approval). */
|
/** The agent may change it (through the settings tool, with approval). */
|
||||||
@@ -36,7 +36,10 @@ const USER: Scope[] = ["session", "global"]
|
|||||||
|
|
||||||
export const SETTINGS: SettingDef[] = [
|
export const SETTINGS: SettingDef[] = [
|
||||||
{ key: "model", kind: "model", agent: true, scopes: ALL },
|
{ 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: "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: "small_model", kind: "model", agent: true, scopes: ALL, fallback: "the session's model" },
|
||||||
{ key: "knowledge", kind: "list", agent: true, scopes: ALL, fallback: "every base" },
|
{ 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
|
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.
|
// "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.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 schema = schemaAt(def.key.split("."))
|
||||||
const r = schema?.safeParse(v)
|
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")}`)
|
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")}`)
|
||||||
|
|||||||
@@ -118,6 +118,17 @@ export const LOOP: {
|
|||||||
running_max: { default: number }
|
running_max: { default: number }
|
||||||
subagent_steps: { 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
|
/** 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
|
* header, a stub, the elided line, recall; the output cap; what is deduplicated; the table
|
||||||
* compaction adds. */
|
* compaction adds. */
|
||||||
@@ -138,6 +149,8 @@ export const LOOP: {
|
|||||||
dedupe: { by_hash: string[]; by_result: string[] }
|
dedupe: { by_hash: string[]; by_result: string[] }
|
||||||
compaction: { heading: string; line: string; rows: number }
|
compaction: { heading: string; line: string; rows: number }
|
||||||
reasoning: string
|
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
|
/** 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. */
|
* 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
|
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
|
plan_ended: string
|
||||||
/** For a chat, which does not resend earlier tool results; the CLI's model has them. */
|
/** For a chat, which does not resend earlier tool results; the CLI's model has them. */
|
||||||
plan_own: string
|
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. */
|
/** How long a session waits for its configured MCP servers before its first request. */
|
||||||
mcp_wait_ms: number
|
mcp_wait_ms: number
|
||||||
|
|||||||
@@ -58,10 +58,12 @@ export interface ServedModel {
|
|||||||
top_p?: number
|
top_p?: number
|
||||||
efforts?: string[]
|
efforts?: string[]
|
||||||
effort?: string
|
effort?: string
|
||||||
|
/** Spec 3.2.0: the server turns thinking off for enable_thinking false. */
|
||||||
|
thinking_switch?: boolean
|
||||||
vision?: boolean
|
vision?: boolean
|
||||||
tools?: boolean
|
tools?: boolean
|
||||||
notes?: string
|
notes?: string
|
||||||
capacity?: { group?: string; single_session?: boolean }
|
capacity?: { group?: string; single_session?: boolean; parallel?: number }
|
||||||
default?: boolean
|
default?: boolean
|
||||||
/** The provider's slug — the part of `id` before its `/` — and what kind of model it is. */
|
/** The provider's slug — the part of `id` before its `/` — and what kind of model it is. */
|
||||||
provider?: string
|
provider?: string
|
||||||
|
|||||||
+5
-2
@@ -18,7 +18,7 @@ import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
|
|||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
import { parse } from "yaml"
|
import { parse } from "yaml"
|
||||||
import { connectionFiles, isLocal, paths } from "../config/paths.ts"
|
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 { substituteDeep } from "../config/substitute.ts"
|
||||||
import { instanceInfo, LembasError, models as listModels, normalise, personalization, type InstanceInfo, type Personalization, type ServedModel, type Tls } from "./client.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 (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.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 (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.effort) out.effort = m.effort
|
||||||
|
if (m.thinking_switch === true) out.thinking_switch = true
|
||||||
if (m.vision !== undefined) out.vision = m.vision
|
if (m.vision !== undefined) out.vision = m.vision
|
||||||
if (m.tools !== undefined) out.tools = m.tools
|
if (m.tools !== undefined) out.tools = m.tools
|
||||||
if (m.notes) out.notes = m.notes.slice(0, 300)
|
if (m.notes) out.notes = m.notes.slice(0, 300)
|
||||||
if (m.capacity?.single_session) out.single_session = true
|
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}`
|
if (m.capacity?.group) out.group = `${connection}:${m.capacity.group}`
|
||||||
return out as ModelSpec
|
return out as ModelSpec
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
// them, and counted as read so the model may edit them at once. The prompt text is unchanged.
|
// 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 { readdirSync, readFileSync, statSync } from "node:fs"
|
||||||
import { join, resolve } from "node:path"
|
import { join, resolve } from "node:path"
|
||||||
|
import { promptPath } from "../prompt/assemble.ts"
|
||||||
import type { ImagePart } from "../provider/types.ts"
|
import type { ImagePart } from "../provider/types.ts"
|
||||||
import type { ToolContext } from "../tool/tool.ts"
|
import type { ToolContext } from "../tool/tool.ts"
|
||||||
import { isImage, loadImage } from "./image.ts"
|
import { isImage, loadImage } from "./image.ts"
|
||||||
@@ -34,6 +35,8 @@ export function attachmentsFor(text: string, ctx: Pick<ToolContext, "root" | "cw
|
|||||||
const rel = m[1]!.replace(/\\([ #])/g, "$1").replace(/[.,;:!?)]+$/, "")
|
const rel = m[1]!.replace(/\\([ #])/g, "$1").replace(/[.,;:!?)]+$/, "")
|
||||||
const abs = resolve(ctx.cwd, rel)
|
const abs = resolve(ctx.cwd, rel)
|
||||||
if (allow && !allow(abs)) continue
|
if (allow && !allow(abs)) continue
|
||||||
|
// Named to the model as its tools read paths: from the project root, ~/… under home (context.md §11).
|
||||||
|
const named = promptPath(ctx.root, abs) + (rel.endsWith("/") && abs !== "/" ? "/" : "")
|
||||||
const key = `${abs}#${m[2] ?? ""}-${m[3] ?? ""}`
|
const key = `${abs}#${m[2] ?? ""}-${m[3] ?? ""}`
|
||||||
if (seen.has(key)) continue
|
if (seen.has(key)) continue
|
||||||
seen.add(key)
|
seen.add(key)
|
||||||
@@ -47,25 +50,25 @@ export function attachmentsFor(text: string, ctx: Pick<ToolContext, "root" | "cw
|
|||||||
const entries = readdirSync(abs, { withFileTypes: true })
|
const entries = readdirSync(abs, { withFileTypes: true })
|
||||||
.map((e) => (e.isDirectory() ? `${e.name}/` : e.name))
|
.map((e) => (e.isDirectory() ? `${e.name}/` : e.name))
|
||||||
.sort()
|
.sort()
|
||||||
out.push({ path: rel, text: `<directory path="${rel}">\n${entries.join("\n")}\n</directory>` })
|
out.push({ path: rel, text: `<directory path="${named}">\n${entries.join("\n")}\n</directory>` })
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (isImage(abs)) {
|
if (isImage(abs)) {
|
||||||
if (!vision) {
|
if (!vision) {
|
||||||
out.push({ path: rel, text: `<image path="${rel}">(not attached: this model has no vision)</image>` })
|
out.push({ path: rel, text: `<image path="${named}">(not attached: this model has no vision)</image>` })
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const img = loadImage(abs)
|
const img = loadImage(abs)
|
||||||
out.push(typeof img === "string" ? { path: rel, text: `<image path="${rel}">(not attached: ${img})</image>` } : { path: rel, text: `<image path="${rel}"/>`, image: img })
|
out.push(typeof img === "string" ? { path: rel, text: `<image path="${named}">(not attached: ${img})</image>` } : { path: rel, text: `<image path="${named}"/>`, image: img })
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (st.size > MAX_BYTES && !m[2]) {
|
if (st.size > MAX_BYTES && !m[2]) {
|
||||||
out.push({ path: rel, text: `<file path="${rel}">\n(${st.size} bytes — too large to attach whole; read it in parts, or reference a range like @${rel}#1-200)\n</file>` })
|
out.push({ path: rel, text: `<file path="${named}">\n(${st.size} bytes — too large to attach whole; read it in parts, or reference a range like @${named}#1-200)\n</file>` })
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const raw = readFileSync(abs)
|
const raw = readFileSync(abs)
|
||||||
if (raw.subarray(0, 8192).includes(0)) {
|
if (raw.subarray(0, 8192).includes(0)) {
|
||||||
out.push({ path: rel, text: `<file path="${rel}">\n(binary file, ${st.size} bytes)\n</file>` })
|
out.push({ path: rel, text: `<file path="${named}">\n(binary file, ${st.size} bytes)\n</file>` })
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const lines = raw.toString("utf8").split("\n")
|
const lines = raw.toString("utf8").split("\n")
|
||||||
@@ -75,7 +78,7 @@ export function attachmentsFor(text: string, ctx: Pick<ToolContext, "root" | "cw
|
|||||||
const body = lines.slice(from - 1, to).map((l, i) => `${from + i}: ${l}`).join("\n")
|
const body = lines.slice(from - 1, to).map((l, i) => `${from + i}: ${l}`).join("\n")
|
||||||
const range = m[2] ? ` lines="${from}-${to}"` : ""
|
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})` : ""
|
const more = !m[2] && lines.length > to ? `\n(${lines.length - to} more lines — read with offset ${to + 1})` : ""
|
||||||
out.push({ path: rel, text: `<file path="${rel}"${range}>\n${body}${more}\n</file>` })
|
out.push({ path: rel, text: `<file path="${named}"${range}>\n${body}${more}\n</file>` })
|
||||||
if (!m[2]) {
|
if (!m[2]) {
|
||||||
ctx.readFiles.add(abs)
|
ctx.readFiles.add(abs)
|
||||||
ctx.fileStamps.set(abs, st.mtimeMs)
|
ctx.fileStamps.set(abs, st.mtimeMs)
|
||||||
|
|||||||
+32
-8
@@ -1,6 +1,7 @@
|
|||||||
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs"
|
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs"
|
||||||
import { platform, release } from "node:os"
|
import { homedir, platform, release } from "node:os"
|
||||||
import { dirname, join, relative, resolve, sep } from "node:path"
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"
|
||||||
|
import { LOOP } from "../harness.ts"
|
||||||
import type { Mode } from "../config/schema.ts"
|
import type { Mode } from "../config/schema.ts"
|
||||||
import { expandHome, paths } from "../config/paths.ts"
|
import { expandHome, paths } from "../config/paths.ts"
|
||||||
import { render, type Plan } from "../plan/plan.ts"
|
import { render, type Plan } from "../plan/plan.ts"
|
||||||
@@ -268,6 +269,29 @@ export interface PromptContext {
|
|||||||
|
|
||||||
const MAX_INSTRUCTION_CHARS = 40_000
|
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
|
/** 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
|
* 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
|
* 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(
|
sections.push(
|
||||||
[
|
[
|
||||||
"<env>",
|
"<env>",
|
||||||
`Working directory: ${ctx.cwd}`,
|
// No absolute path (context.md §11): the project by its name, relative paths against its root.
|
||||||
`Project root: ${ctx.root}`,
|
...envLines(ctx.root, ctx.cwd),
|
||||||
`Git repository: ${ctx.isGit ? "yes" : "no"}`,
|
`Git repository: ${ctx.isGit ? "yes" : "no"}`,
|
||||||
`Platform: ${platform()} ${release()}`,
|
`Platform: ${platform()} ${release()}`,
|
||||||
// No date: it would change at midnight and the whole session be read again (§10 rule 2).
|
// 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)
|
const files = ctx.files ?? instructionFiles(ctx.cwd, ctx.root, ctx.instructions)
|
||||||
for (const f of files) {
|
for (const f of files) {
|
||||||
let text = f.text.trim()
|
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)
|
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]`
|
text = text.slice(0, MAX_INSTRUCTION_CHARS) + `\n[…truncated: kept ${MAX_INSTRUCTION_CHARS} of ${f.text.length} characters; read ${named} 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
|
|
||||||
sections.push(`Instructions from ${named}:\n<instructions>\n${text}\n</instructions>`)
|
sections.push(`Instructions from ${named}:\n<instructions>\n${text}\n</instructions>`)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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.
|
// Files handed in without the mark (an older caller): anything but the global file, as before.
|
||||||
const marked = files.some((f) => f.chain)
|
const marked = files.some((f) => f.chain)
|
||||||
const own = files.filter((f) => (marked ? f.chain : f.path !== join(paths.config, "AGENTS.md")))
|
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)
|
if (!own.length) return fillPrompt(promptText("blocks/agents-missing.md"), vars)
|
||||||
const keep = promptText("blocks/agents-keep.md")
|
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}`
|
return own.some((f) => /^#{1,3}\s*git\b/im.test(f.text)) ? keep : `${fillPrompt(promptText("blocks/agents-git.md"), vars)}\n\n${keep}`
|
||||||
|
|||||||
@@ -168,3 +168,18 @@ export function historyArgs(args: string): string {
|
|||||||
} catch {}
|
} catch {}
|
||||||
return "{}"
|
return "{}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Harness context.md §8 (spec 3.2.0): the history as a request sends it — every assistant message
|
||||||
|
* of the current turn (after the last user message that started a turn) keeps its reasoning,
|
||||||
|
* earlier ones carry none. What is stored is unchanged; only the request is. */
|
||||||
|
export function turnReasoning(messages: Message[]): Message[] {
|
||||||
|
let start = -1
|
||||||
|
for (let i = messages.length - 1; i >= 0; i--) {
|
||||||
|
const m = messages[i]!
|
||||||
|
if (m.role === "user" && m.turn !== false) {
|
||||||
|
start = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return messages.map((m, i) => (m.role === "assistant" && i < start && m.parts.some((p) => p.type === "reasoning") ? { ...m, parts: m.parts.filter((p) => p.type !== "reasoning") } : m))
|
||||||
|
}
|
||||||
+80
-16
@@ -10,29 +10,83 @@ import { learned } from "./learned.ts"
|
|||||||
|
|
||||||
export const DEFAULT_EFFORTS: Effort[] = ["low", "medium", "high"]
|
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[] {
|
export function effortsFor(m: ResolvedModel): Effort[] {
|
||||||
const list = learned().efforts[m.ref] ?? m.spec.efforts ?? DEFAULT_EFFORTS
|
const list = learned().efforts[m.ref] ?? m.spec.efforts ?? DEFAULT_EFFORTS
|
||||||
return EFFORTS.filter((e) => list.includes(e))
|
return EFFORTS.filter((e) => list.includes(e))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The model's default effort, or null for off / not a reasoning model. */
|
/** An effort level (harness models/schema.json `levels`, spec 3.2.0): an effort; `off` — thinking
|
||||||
export function defaultEffort(m: ResolvedModel): Effort | null {
|
* switched off, only on a model with `thinking_switch`; `default` — nothing sent, the model's own,
|
||||||
const e = m.spec.effort
|
* only on a model that takes no efforts. */
|
||||||
if (!e || e === "off") return null
|
export type Level = Effort | "off" | "default"
|
||||||
return effortsFor(m).includes(e) ? e : null
|
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 isLevel(v: unknown): v is Level {
|
||||||
export function applyEffort(body: Record<string, unknown>, m: ResolvedModel, effort: Effort | null | undefined) {
|
return typeof v === "string" && (LEVEL_WORDS as readonly string[]).includes(v)
|
||||||
if (!effort || !effortsFor(m).includes(effort)) return
|
}
|
||||||
const style = m.spec.effort_style ?? "both"
|
|
||||||
if (style !== "kwargs") body.reasoning_effort = effort
|
/** The levels a chat offers, how they are shown, and where a new conversation starts. */
|
||||||
if (style !== "top") {
|
export function levelsOf(meta: LevelMeta): { levels: Level[]; control: Control; start: Level } {
|
||||||
const kwargs = { ...((body.chat_template_kwargs as Record<string, unknown>) ?? {}) }
|
const levels: Level[] = [...(meta.thinking_switch === true ? (["off"] as Level[]) : []), ...(meta.efforts.length ? meta.efforts : (["default"] as Level[]))]
|
||||||
kwargs.reasoning_effort = effort
|
const control: Control = levels.length === 1 ? "none" : levels.length === 2 && levels.includes("off") ? "switch" : "slider"
|
||||||
body.chat_template_kwargs = kwargs
|
const start: Level =
|
||||||
}
|
meta.effort && (levels as string[]).includes(meta.effort) ? (meta.effort as Level) : meta.efforts.includes("medium") ? "medium" : (meta.efforts[0] ?? "default")
|
||||||
|
return { levels, control, start }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A model's levels: its switch, its efforts (learned > the server's > the common three). */
|
||||||
|
export function levelsFor(m: ResolvedModel) {
|
||||||
|
return levelsOf({ thinking_switch: m.spec.thinking_switch, efforts: effortsFor(m), effort: m.spec.effort, effort_style: m.spec.effort_style })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The level a request uses for `asked`: itself when the model offers it, else the start level (a
|
||||||
|
* level the model does not offer — an `off` from an older client — is read as the start level). */
|
||||||
|
export function levelOf(meta: LevelMeta, asked: string | null | undefined): Level {
|
||||||
|
const { levels, start } = levelsOf(meta)
|
||||||
|
return asked && (levels as string[]).includes(asked) ? (asked as Level) : start
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A model's level for `asked` (levelOf), or its start level when nothing is asked. */
|
||||||
|
export function readLevel(m: ResolvedModel, asked?: string | null): Level {
|
||||||
|
return levelOf({ thinking_switch: m.spec.thinking_switch, efforts: effortsFor(m), effort: m.spec.effort }, asked)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The model's start level. */
|
||||||
|
export function defaultEffort(m: ResolvedModel): Level {
|
||||||
|
return levelsFor(m).start
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What an OpenAI chat request body carries for a level: `off` — enable_thinking false in
|
||||||
|
* chat_template_kwargs and no effort anywhere; `default` — nothing; an effort — where
|
||||||
|
* effort_style puts it, and no enable_thinking. A level the model does not offer is read as its
|
||||||
|
* start level. */
|
||||||
|
export function levelFields(meta: LevelMeta, asked: string | null | undefined): { reasoning_effort?: Effort; chat_template_kwargs?: Record<string, unknown> } {
|
||||||
|
const level = levelOf(meta, asked)
|
||||||
|
if (level === "default") return {}
|
||||||
|
if (level === "off") return { chat_template_kwargs: { enable_thinking: false } }
|
||||||
|
const style = meta.effort_style ?? "both"
|
||||||
|
return { ...(style !== "kwargs" ? { reasoning_effort: level } : {}), ...(style !== "top" ? { chat_template_kwargs: { reasoning_effort: level } } : {}) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Put the level into an openai-chat request body. `null`/undefined: nothing sent (a small job
|
||||||
|
* of the harness's own — a title, a commit message — sends no level). */
|
||||||
|
export function applyEffort(body: Record<string, unknown>, m: ResolvedModel, level: Level | null | undefined) {
|
||||||
|
if (!level) return
|
||||||
|
const f = levelFields({ thinking_switch: m.spec.thinking_switch, efforts: effortsFor(m), effort: m.spec.effort, effort_style: m.spec.effort_style }, level)
|
||||||
|
if (f.reasoning_effort) body.reasoning_effort = f.reasoning_effort
|
||||||
|
if (f.chat_template_kwargs) body.chat_template_kwargs = { ...((body.chat_template_kwargs as Record<string, unknown>) ?? {}), ...f.chat_template_kwargs }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stripEffort(body: Record<string, unknown>): Record<string, unknown> {
|
export function stripEffort(body: Record<string, unknown>): Record<string, unknown> {
|
||||||
@@ -40,6 +94,7 @@ export function stripEffort(body: Record<string, unknown>): Record<string, unkno
|
|||||||
delete out.reasoning_effort
|
delete out.reasoning_effort
|
||||||
const kwargs = { ...((out.chat_template_kwargs as Record<string, unknown>) ?? {}) }
|
const kwargs = { ...((out.chat_template_kwargs as Record<string, unknown>) ?? {}) }
|
||||||
delete kwargs.reasoning_effort
|
delete kwargs.reasoning_effort
|
||||||
|
delete kwargs.enable_thinking
|
||||||
if (Object.keys(kwargs).length) out.chat_template_kwargs = kwargs
|
if (Object.keys(kwargs).length) out.chat_template_kwargs = kwargs
|
||||||
else delete out.chat_template_kwargs
|
else delete out.chat_template_kwargs
|
||||||
return out
|
return out
|
||||||
@@ -77,3 +132,12 @@ export function effortsFromTemplate(template: string): Effort[] {
|
|||||||
const out = EFFORTS.filter((e) => found.has(e))
|
const out = EFFORTS.filter((e) => found.has(e))
|
||||||
return out.length >= 2 ? out : []
|
return out.length >= 2 ? out : []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The level as a status line says it, by how the model's levels are shown: nothing for a model
|
||||||
|
* with one level, "thinking" / "thinking off" for a Thinking switch, else the level's name. */
|
||||||
|
export function levelLabel(m: ResolvedModel, level: Level): string {
|
||||||
|
const { control } = levelsFor(m)
|
||||||
|
if (control === "none") return ""
|
||||||
|
if (control === "switch") return level === "off" ? "thinking off" : "thinking"
|
||||||
|
return level
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ import { resolveKey } from "../config/load.ts"
|
|||||||
import { advertisedEfforts, applyEffort, effortRefused, effortsFor, stripEffort } from "./effort.ts"
|
import { advertisedEfforts, applyEffort, effortRefused, effortsFor, stripEffort } from "./effort.ts"
|
||||||
import { authHeaders, request, joinUrl, tlsFor } from "./http.ts"
|
import { authHeaders, request, joinUrl, tlsFor } from "./http.ts"
|
||||||
import { learned, learnEfforts, learnNoProgress, learnNoStreamOptions } from "./learned.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 { sseJson } from "./sse.ts"
|
||||||
import { ThinkSplitter } from "./think.ts"
|
import { ThinkSplitter } from "./think.ts"
|
||||||
import { estimateTokens, estimateValue } from "./tokens.ts"
|
import { estimateTokens, estimateValue } from "./tokens.ts"
|
||||||
@@ -30,7 +30,7 @@ type Assistant = Extract<Message, { role: "assistant" }>
|
|||||||
export function toOpenAIMessages(system: string, messages: Message[], vision: boolean): unknown[] {
|
export function toOpenAIMessages(system: string, messages: Message[], vision: boolean): unknown[] {
|
||||||
const out: unknown[] = []
|
const out: unknown[] = []
|
||||||
if (system) out.push({ role: "system", content: system })
|
if (system) out.push({ role: "system", content: system })
|
||||||
for (const m of messages) {
|
for (const m of turnReasoning(messages)) {
|
||||||
if (m.role === "user") {
|
if (m.role === "user") {
|
||||||
const images = m.parts.filter((p) => p.type === "image")
|
const images = m.parts.filter((p) => p.type === "image")
|
||||||
if (images.length === 0 || !vision) {
|
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 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 calls = m.parts.filter((p): p is ToolCallPart => p.type === "tool_call")
|
||||||
const msg: Record<string, unknown> = { role: "assistant", content: text || null }
|
const msg: Record<string, unknown> = { role: "assistant", content: text || null }
|
||||||
|
// The current turn's reasoning goes back as the model gave it (context.md §8) — inline <think>
|
||||||
|
// tags were read as reasoning, and go back here, never as text. None: no field.
|
||||||
|
const reasoning = m.parts.filter((p): p is ReasoningPart => p.type === "reasoning").map((p) => p.text).join("")
|
||||||
|
if (reasoning) msg.reasoning_content = reasoning
|
||||||
if (calls.length)
|
if (calls.length)
|
||||||
msg.tool_calls = calls.map((c) => ({ id: c.id, type: "function", function: { name: c.name, arguments: historyArgs(c.args) } }))
|
msg.tool_calls = calls.map((c) => ({ id: c.id, type: "function", function: { name: c.name, arguments: historyArgs(c.args) } }))
|
||||||
out.push(msg)
|
out.push(msg)
|
||||||
|
|||||||
+14
-3
@@ -13,7 +13,17 @@ export type ToolCallPart = { type: "tool_call"; id: string; name: string; args:
|
|||||||
export type Message =
|
export type Message =
|
||||||
/** `turnId`: the turn a prompt started, kept with it in the store for
|
/** `turnId`: the turn a prompt started, kept with it in the store for
|
||||||
* `_lembas/session/history`; never sent to a model. */
|
* `_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: "assistant"; parts: (TextPart | ReasoningPart | ToolCallPart)[] }
|
||||||
| { role: "tool"; callId: string; name: string; content: string; isError?: boolean }
|
| { role: "tool"; callId: string; name: string; content: string; isError?: boolean }
|
||||||
|
|
||||||
@@ -50,8 +60,9 @@ export interface ChatRequest {
|
|||||||
system: string
|
system: string
|
||||||
messages: Message[]
|
messages: Message[]
|
||||||
tools: ToolSpec[]
|
tools: ToolSpec[]
|
||||||
/** null = off */
|
/** The effort level (provider/effort.ts); null or absent: nothing sent (a small job of the
|
||||||
effort?: Effort | null
|
* harness's own). */
|
||||||
|
effort?: Effort | "off" | "default" | null
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+22
-6
@@ -1,7 +1,8 @@
|
|||||||
// Whether a subagent may run on a model beside the session's own, judged by what its server can do
|
// 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š,
|
// 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
|
// 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
|
// - 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,
|
// 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.
|
// minutes, on a long session with a small GPU.
|
||||||
import type { ResolvedModel } from "../provider/types.ts"
|
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<ResolvedModel, "spec">): 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.ref === main.ref) {
|
||||||
if (helper.spec.single_session)
|
const n = parallelOf(helper)
|
||||||
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.`
|
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 ""
|
return ""
|
||||||
}
|
}
|
||||||
const shared = groupOf(main)
|
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
|
/** 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
|
* take the only slot of, a subagent already running (the spec's "beside the model of a subagent
|
||||||
* already running"). */
|
* 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.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 ""
|
return ""
|
||||||
}
|
}
|
||||||
const shared = groupOf(running)
|
const shared = groupOf(running)
|
||||||
|
|||||||
@@ -151,7 +151,6 @@ export function changelogPrompt(app: App, arg: string): { prompt: string } | { m
|
|||||||
/** `/init [focus]`: make or update AGENTS.md. */
|
/** `/init [focus]`: make or update AGENTS.md. */
|
||||||
export function initPrompt(app: App, arg: string): string {
|
export function initPrompt(app: App, arg: string): string {
|
||||||
return fill(promptText("tasks/init.md"), {
|
return fill(promptText("tasks/init.md"), {
|
||||||
root: app.project.root,
|
|
||||||
focus: arg.trim() ? `What the user wants it to focus on: ${arg.trim()}` : "",
|
focus: arg.trim() ? `What the user wants it to focus on: ${arg.trim()}` : "",
|
||||||
}).replace(/\n{3,}/g, "\n\n")
|
}).replace(/\n{3,}/g, "\n\n")
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-18
@@ -6,7 +6,8 @@
|
|||||||
import continueText from "../../harness/prompts/tasks/continue.md" with { type: "text" }
|
import continueText from "../../harness/prompts/tasks/continue.md" with { type: "text" }
|
||||||
import { isAbsolute } from "node:path"
|
import { isAbsolute } from "node:path"
|
||||||
import type { Asker, AttachmentInfo, Bus } from "../bus/index.ts"
|
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 { 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 { ProviderError, type Client, type ImagePart, type Message, type ResolvedModel, type ToolCallPart, type Usage } from "../provider/types.ts"
|
||||||
import type { Plan } from "../plan/plan.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's date, as the note says it (tests set it). */
|
||||||
today?: () => string
|
today?: () => string
|
||||||
mode: Mode
|
mode: Mode
|
||||||
effort: Effort | null
|
/** The effort level (provider/effort.ts): an effort, `off` or `default`. */
|
||||||
|
effort: Level
|
||||||
perm: Omit<PermContext, "mode">
|
perm: Omit<PermContext, "mode">
|
||||||
toolCtx: Omit<ToolContext, "signal">
|
toolCtx: Omit<ToolContext, "signal">
|
||||||
maxSteps?: number
|
maxSteps?: number
|
||||||
@@ -89,7 +91,8 @@ export function mergeUsers(messages: Message[]): Message[] {
|
|||||||
const out: Message[] = []
|
const out: Message[] = []
|
||||||
for (const m of messages) {
|
for (const m of messages) {
|
||||||
const last = out[out.length - 1]
|
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)
|
else out.push(m)
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
@@ -109,7 +112,7 @@ export const PLAN_META = "plan"
|
|||||||
export class Engine {
|
export class Engine {
|
||||||
messages: Message[] = []
|
messages: Message[] = []
|
||||||
mode: Mode
|
mode: Mode
|
||||||
private _effort: Effort | null
|
private _effort: Level
|
||||||
usage: Usage | undefined
|
usage: Usage | undefined
|
||||||
/** messages.length when `usage` was reported: everything after it is not yet counted. */
|
/** messages.length when `usage` was reported: everything after it is not yet counted. */
|
||||||
usageAt = 0
|
usageAt = 0
|
||||||
@@ -314,25 +317,25 @@ export class Engine {
|
|||||||
this.o.bus.emit({ type: "plan", plan: this.plan })
|
this.o.bus.emit({ type: "plan", plan: this.plan })
|
||||||
}
|
}
|
||||||
|
|
||||||
get effort(): Effort | null {
|
get effort(): Level {
|
||||||
return this._effort
|
return this._effort
|
||||||
}
|
}
|
||||||
/** Said on the bus when it changes: the web UI's chat shows the device's model and effort,
|
/** Said on the bus when it changes: the web UI's chat shows the device's model and effort,
|
||||||
* whoever changed them. */
|
* whoever changed them. */
|
||||||
set effort(v: Effort | null) {
|
set effort(v: Level) {
|
||||||
if (v === this._effort) return
|
if (v === this._effort) return
|
||||||
this._effort = v
|
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.
|
/** 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). */
|
* 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
|
const changed = model.ref !== this.model.ref || effort !== this._effort
|
||||||
this.model = model
|
this.model = model
|
||||||
this.client = client
|
this.client = client
|
||||||
this._effort = effort
|
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. */
|
/** Start over: a fresh conversation (and session row). Approvals given "for the session" end. */
|
||||||
@@ -405,9 +408,10 @@ export class Engine {
|
|||||||
return summary
|
return summary
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Tokens in use now: the last reported usage plus an estimate of what was added since. The
|
/** Tokens in use now: the last reported usage plus an estimate of what was added since. A final
|
||||||
* reply's thinking is left out where the dialect never sends it back (openai-chat): it is in the
|
* reply's thinking is left out: it is in its output, but the next request starts a turn and
|
||||||
* last reply's output but will not be in the next request. */
|
* 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 {
|
contextUsed(): number | undefined {
|
||||||
if (!this.usage) return undefined
|
if (!this.usage) return undefined
|
||||||
return this.usage.input + this.usage.output - this.droppedThinking() + estimateValue(this.messages.slice(this.usageAt))
|
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 {
|
private droppedThinking(): number {
|
||||||
if (!this.usage) return 0
|
if (!this.usage) return 0
|
||||||
const reply = this.messages[this.usageAt - 1]
|
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)
|
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)
|
return Math.min(this.usage.output, this.usage.reasoning ?? estimate)
|
||||||
}
|
}
|
||||||
@@ -553,7 +557,7 @@ export class Engine {
|
|||||||
private deliverInbox(): boolean {
|
private deliverInbox(): boolean {
|
||||||
const notes = this.notes.splice(0)
|
const notes = this.notes.splice(0)
|
||||||
if (notes.length) {
|
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.
|
// 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("; ") })
|
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 texts = this.inbox.splice(0)
|
||||||
const ids = this.inboxIds.splice(0).filter((i): i is string => typeof i === "string")
|
const ids = this.inboxIds.splice(0).filter((i): i is string => typeof i === "string")
|
||||||
this.turnSteers.push(...texts)
|
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: "steered", texts, ...(ids.length ? { ids } : {}) })
|
||||||
this.o.bus.emit({ type: "inbox", texts: [], mode: this.busyInput })
|
this.o.bus.emit({ type: "inbox", texts: [], mode: this.busyInput })
|
||||||
return true
|
return true
|
||||||
@@ -638,7 +642,7 @@ export class Engine {
|
|||||||
// The same system prompt and tool list on every request of the session (§10).
|
// The same system prompt and tool list on every request of the session (§10).
|
||||||
const stream = this.client.stream({
|
const stream = this.client.stream({
|
||||||
system: this.systemPrompt(),
|
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
|
// The wrap-up too (loop.json wrap_up): its message says the tools are withdrawn, and a
|
||||||
// call in its reply is not run.
|
// call in its reply is not run.
|
||||||
tools: this.toolSpecs(),
|
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 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`,
|
: `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
|
continue
|
||||||
}
|
}
|
||||||
bus.emit({
|
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
|
// No dialect takes an image inside a tool result the same way; a user turn right after the
|
||||||
// results works everywhere.
|
// 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) {
|
if (signal.aborted) {
|
||||||
bus.emit({ type: "done", reason: "cancelled" })
|
bus.emit({ type: "done", reason: "cancelled" })
|
||||||
return "cancelled"
|
return "cancelled"
|
||||||
|
|||||||
@@ -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<string, string | number>) => 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<string, TeamAgent>()
|
||||||
|
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 — <text>`. */
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -82,7 +82,7 @@ export const grepTool = defineTool({
|
|||||||
if (a.literal) args.push("-F")
|
if (a.literal) args.push("-F")
|
||||||
if (a.include) args.push("--glob", a.include)
|
if (a.include) args.push("--glob", a.include)
|
||||||
args.push("-e", a.pattern, target)
|
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}`)
|
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)
|
for (const l of r.out.split("\n")) if (l) lines.push(l)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+1
-1
@@ -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.`)
|
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)
|
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.`)
|
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)
|
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 }
|
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) }
|
return { output: `Shown to the user: ${showPath(abs, ctx)}${a.line ? ` at line ${a.line}` : ""}.`, title: showPath(abs, ctx) }
|
||||||
|
|||||||
+5
-3
@@ -9,7 +9,8 @@ import { fillParameters, PURPOSE_DEF, TOOL_SPECS } from "../harness.ts"
|
|||||||
export interface ToolContext {
|
export interface ToolContext {
|
||||||
/** Absolute project root. */
|
/** Absolute project root. */
|
||||||
root: string
|
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
|
cwd: string
|
||||||
signal: AbortSignal
|
signal: AbortSignal
|
||||||
/** Absolute paths read in this session — `edit`/`write` of an existing file requires one. */
|
/** 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<string> {
|
|||||||
return new TextDecoder("utf-8", { ignoreBOM: true }).decode(await Bun.file(abs).bytes())
|
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 {
|
export function absPath(p: string, ctx: ToolContext): string {
|
||||||
// A model writes ~/x as a shell would; without this it becomes a directory named "~".
|
// A model writes ~/x as a shell would; without this it becomes a directory named "~".
|
||||||
if (p === "~" || p.startsWith("~/")) return resolve(expandHome(p))
|
if (p === "~" || p.startsWith("~/")) return resolve(expandHome(p))
|
||||||
if (isAbsolute(p)) return resolve(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
|
// 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.
|
// relative reading does not exist and the absolute one does, inside the project, take that.
|
||||||
if (!existsSync(rel)) {
|
if (!existsSync(rel)) {
|
||||||
|
|||||||
+7
-6
@@ -3,7 +3,8 @@ import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
|
|||||||
import { createEffect, createMemo, createSignal, onCleanup, Show } from "solid-js"
|
import { createEffect, createMemo, createSignal, onCleanup, Show } from "solid-js"
|
||||||
import { stricterMode, type App as Runtime } from "../app.ts"
|
import { stricterMode, type App as Runtime } from "../app.ts"
|
||||||
import { isLocal } from "../config/paths.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 { COMMANDS, findCommand, type CommandCtx } from "./commands.ts"
|
||||||
import { Dialog, type DialogSpec } from "./components/dialog.tsx"
|
import { Dialog, type DialogSpec } from "./components/dialog.tsx"
|
||||||
import { Messages } from "./components/messages.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 refresh = () => setRev((n) => n + 1)
|
||||||
const mode = () => (rev(), app.engine.mode)
|
const mode = () => (rev(), app.engine.mode)
|
||||||
const model = () => (rev(), app.engine.model.ref)
|
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 busy = () => view.state.phase !== "idle"
|
||||||
const branch = () => (rev(), app.project.gitRoot ? currentBranch(app.project.gitRoot) : undefined)
|
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
|
// 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) {
|
setMode(m: Mode) {
|
||||||
app.settings.set("mode", m)
|
app.settings.set("mode", m)
|
||||||
},
|
},
|
||||||
setEffort(e: Effort | null) {
|
setEffort(e: Level) {
|
||||||
app.settings.set("effort", e ?? "off")
|
app.settings.set("effort", e)
|
||||||
},
|
},
|
||||||
setTheme(name: string) {
|
setTheme(name: string) {
|
||||||
try {
|
try {
|
||||||
@@ -246,7 +247,7 @@ export function Root(p: { app: Runtime; view: View; theme: Theme; onQuit: () =>
|
|||||||
confirmStop("reload", () => {
|
confirmStop("reload", () => {
|
||||||
// Back on this session, with what it was using; everything else comes from the files again.
|
// Back on this session, with what it was using; everything else comes from the files again.
|
||||||
const e = app.engine
|
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
|
void app
|
||||||
.close()
|
.close()
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
@@ -402,7 +403,7 @@ export function Root(p: { app: Runtime; view: View; theme: Theme; onQuit: () =>
|
|||||||
const id = view.shellStart(command)
|
const id = view.shellStart(command)
|
||||||
const t0 = performance.now()
|
const t0 = performance.now()
|
||||||
bashTool
|
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) => {
|
.then((r) => {
|
||||||
view.shellEnd(id, r, performance.now() - t0)
|
view.shellEnd(id, r, performance.now() - t0)
|
||||||
app.engine.pendingContext.push(`<shell command="${command.replaceAll('"', "'")}">\n${r.output}\n</shell>`)
|
app.engine.pendingContext.push(`<shell command="${command.replaceAll('"', "'")}">\n${r.output}\n</shell>`)
|
||||||
|
|||||||
+28
-13
@@ -3,8 +3,8 @@
|
|||||||
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"
|
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"
|
||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
import { paths } from "../config/paths.ts"
|
import { paths } 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 { effortsFor } from "../provider/effort.ts"
|
import { levelsFor, type Level } from "../provider/effort.ts"
|
||||||
import { setTrust } from "../project/root.ts"
|
import { setTrust } from "../project/root.ts"
|
||||||
import { commit, commitPlan, draftMessage } from "../git/commit.ts"
|
import { commit, commitPlan, draftMessage } from "../git/commit.ts"
|
||||||
import { doRelease, planRelease } from "../git/release.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
|
sendAs?(prompt: string, shown: string, opts?: { model?: string; mode?: string; attachFrom?: string }): void
|
||||||
openDialog(spec: DialogSpec): void
|
openDialog(spec: DialogSpec): void
|
||||||
setMode(m: Mode): void
|
setMode(m: Mode): void
|
||||||
setEffort(e: Effort | null): void
|
setEffort(e: Level): void
|
||||||
setTheme(name: string): void
|
setTheme(name: string): void
|
||||||
setIcons(set: import("./theme.ts").IconSet): void
|
setIcons(set: import("./theme.ts").IconSet): void
|
||||||
/** A short word in the status bar that goes by itself. */
|
/** A short word in the status bar that goes by itself. */
|
||||||
@@ -171,7 +171,7 @@ export const COMMANDS: Command[] = [
|
|||||||
options: refs.map((r) => {
|
options: refs.map((r) => {
|
||||||
const known = c.app.loaded.refs.find((x) => x.ref === r)!
|
const known = c.app.loaded.refs.find((x) => x.ref === r)!
|
||||||
const spec = c.app.loaded.connections[known.connection]!.models[known.id]!
|
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 }
|
return { label: r, value: r, description: bits, current: r === c.app.engine.model.ref }
|
||||||
}),
|
}),
|
||||||
onSelect: pick,
|
onSelect: pick,
|
||||||
@@ -180,19 +180,34 @@ export const COMMANDS: Command[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "effort",
|
name: "effort",
|
||||||
args: `[${EFFORTS.join("|")}|off]`,
|
args: "[level|on|off]",
|
||||||
description: "reasoning effort for this model",
|
description: "how hard the model thinks: its levels, a Thinking switch, or nothing to choose",
|
||||||
run(c, arg) {
|
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) => {
|
const set = (v: string) => {
|
||||||
if (v === "off") return c.setEffort(null)
|
if (control === "switch" && (v === "on" || v === "thinking")) v = on
|
||||||
if (!allowed.includes(v as Effort)) return c.view.info(`${c.app.engine.model.ref} takes ${allowed.join(", ")} or off`, "warn")
|
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 Effort)
|
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 (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({
|
c.openDialog({
|
||||||
title: `Effort — ${c.app.engine.model.ref}`,
|
title: `Effort — ${m.ref}`,
|
||||||
options: [...allowed, "off"].map((e) => ({ label: e, value: e, current: (c.app.engine.effort ?? "off") === e })),
|
options: levels.map((e) => ({ label: e === "off" ? "off (no thinking)" : e, value: e, current: c.app.engine.effort === e })),
|
||||||
onSelect: set,
|
onSelect: set,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -934,7 +949,7 @@ export const COMMANDS: Command[] = [
|
|||||||
e.def.kind === "model"
|
e.def.kind === "model"
|
||||||
? c.app.modelRefs()
|
? c.app.modelRefs()
|
||||||
: e.def.kind === "effort"
|
: e.def.kind === "effort"
|
||||||
? [...effortsFor(c.app.engine.model), "off"]
|
? [...levelsFor(c.app.engine.model).levels]
|
||||||
: e.def.kind === "boolean"
|
: e.def.kind === "boolean"
|
||||||
? ["true", "false"]
|
? ["true", "false"]
|
||||||
: key === "theme"
|
: key === "theme"
|
||||||
|
|||||||
@@ -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")
|
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)
|
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.
|
// `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 })
|
expect(models.at(-1)).toEqual({ type: "model", ref: "example/a", effort: "high", connection: "example", instance: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
+5
-1
@@ -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 () => {
|
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" })] }])
|
fake = fakeProvider([{ chunks: [delta({ content: "one" })] }, { chunks: [delta({ content: "two" })] }])
|
||||||
mkdirSync(paths.config, { recursive: true })
|
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")
|
writeFileSync(join(paths.config, "config.yaml"), "model: bonsai\n")
|
||||||
const root = project()
|
const root = project()
|
||||||
const { c } = await client({ roots: [root], maxMode: "auto", approvalTimeoutMs: 50, requireTrust: true }, undefined, "example")
|
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" })
|
expect(changed).toEqual({ model: "bonsai", effort: "off" })
|
||||||
await c.request("session/prompt", { sessionId: s.sessionId, prompt: [{ type: "text", text: "again" }] })
|
await c.request("session/prompt", { sessionId: s.sessionId, prompt: [{ type: "text", text: "again" }] })
|
||||||
expect(fake.requests.at(-1).model).toBe("bonsai")
|
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(JSON.stringify(fake.requests.at(-1))).not.toContain("reasoning_effort")
|
||||||
|
expect(fake.requests.at(-1).chat_template_kwargs).toEqual({ enable_thinking: false })
|
||||||
|
// A level the model does not offer is read as its start level, and the answer says which.
|
||||||
|
expect(await c.request<any>("_lembas/configure", { sessionId: s.sessionId, model: "deepseek-flash", effort: "off" })).toEqual({ model: "deepseek-flash", effort: "medium" })
|
||||||
})
|
})
|
||||||
|
|
||||||
test("the chat's model, full stop (spec 3.0.0): `instance` on session/new; a model the device cannot run is -32602, never its own", async () => {
|
test("the chat's model, full stop (spec 3.0.0): `instance` on session/new; a model the device cannot run is -32602, never its own", async () => {
|
||||||
|
|||||||
+91
-3
@@ -7,13 +7,14 @@ import { splitCommand, words } from "../src/permission/bash.ts"
|
|||||||
import { prefix } from "../src/permission/arity.ts"
|
import { prefix } from "../src/permission/arity.ts"
|
||||||
import { split } from "../src/library/chunks.ts"
|
import { split } from "../src/library/chunks.ts"
|
||||||
import { ThinkSplitter } from "../src/provider/think.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 { replace } from "../src/tool/replace.ts"
|
||||||
import { applyUnified, parseUnified } from "../src/tool/unidiff.ts"
|
import { applyUnified, parseUnified } from "../src/tool/unidiff.ts"
|
||||||
import { derive, joinBom, parse } from "../src/tool/patch.ts"
|
import { derive, joinBom, parse } from "../src/tool/patch.ts"
|
||||||
import type { Mode, PermissionConfig } from "../src/config/schema.ts"
|
import type { Mode, PermissionConfig } from "../src/config/schema.ts"
|
||||||
import { apply, fromTodos, normalise, type Plan } from "../src/plan/plan.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 { parseInfo, titleFor } from "../src/prototype/fence.ts"
|
||||||
import { resolveBase } from "../src/library/store.ts"
|
import { resolveBase } from "../src/library/store.ts"
|
||||||
import { NarrowError, narrowParameters, type Narrow } from "../src/harness.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 { applyOps, type Op, type Target } from "../src/memory/store.ts"
|
||||||
import { jobNote, type NoteCase } from "../src/jobs/note.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 { 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 { TOOL_SPECS } from "../src/harness.ts"
|
||||||
|
import { Team, TEAM_TOOLS, type Capacity } from "../src/team/team.ts"
|
||||||
|
import { noteBlock } from "../src/session/prefix.ts"
|
||||||
|
|
||||||
type Case = Record<string, unknown>
|
type Case = Record<string, unknown>
|
||||||
|
|
||||||
@@ -68,6 +72,14 @@ export const AREAS: Record<string, (c: Case) => unknown> = {
|
|||||||
return out
|
return out
|
||||||
},
|
},
|
||||||
effort: (c) => ({ refused: effortRefused(c.message as string), advertised: advertisedEfforts(c.message as string) }),
|
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) => {
|
edit: (c) => {
|
||||||
try {
|
try {
|
||||||
return { content: replace(c.content as string, c.old as string, c.new as string, c.all === true) }
|
return { content: replace(c.content as string, c.old as string, c.new as string, c.all === true) }
|
||||||
@@ -205,6 +217,82 @@ export const AREAS: Record<string, (c: Case) => unknown> = {
|
|||||||
throw new Error(`prefix: unknown op ${String(st.op)}`)
|
throw new Error(`prefix: unknown op ${String(st.op)}`)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
// Reasoning sent back during its turn (context.md §8): the history as the OpenAI chat dialect
|
||||||
|
// sends it — role, content and reasoning_content (tool calls and ids left out, as the cases do).
|
||||||
|
reasoning: (c) => {
|
||||||
|
const history: Message[] = (c.messages as Record<string, any>[]).map((m, i): Message => {
|
||||||
|
if (m.role === "user") return { role: "user", parts: [{ type: "text", text: m.content }], ...(m.turn === false ? { turn: false as const } : {}) }
|
||||||
|
if (m.role === "assistant") return { role: "assistant", parts: [...(m.reasoning !== undefined ? [{ type: "reasoning" as const, text: m.reasoning }] : []), ...(m.content ? [{ type: "text" as const, text: m.content }] : [])] }
|
||||||
|
return { role: "tool", callId: `c${i}`, name: "read", content: m.content }
|
||||||
|
})
|
||||||
|
return (toOpenAIMessages("", history, false) as Record<string, any>[]).map((m) => ({ role: m.role, content: m.content ?? "", ...(m.reasoning_content !== undefined ? { reasoning_content: m.reasoning_content } : {}) }))
|
||||||
|
},
|
||||||
|
// No absolute paths (context.md §11): the environment block's lines, and how a tool reads a path.
|
||||||
|
paths: (c) => {
|
||||||
|
if (c.op === "env") return envLines(c.root as string, c.started as string)
|
||||||
|
if (c.op === "resolve") return absPath(c.path as string, { root: (c.root as string).replace(/\/+$/, ""), cwd: c.started as string } as ToolContext)
|
||||||
|
throw new Error(`paths: unknown op ${String(c.op)}`)
|
||||||
|
},
|
||||||
|
// Teams (team.md): one lead session's team, step by step, through the CLI's own rules
|
||||||
|
// (team/team.ts); the app drives the same class from real turns.
|
||||||
|
team: (c) => {
|
||||||
|
const start = c.start as { min: number; max: number; subagents?: boolean; capacity: Capacity }
|
||||||
|
let capacity = start.capacity
|
||||||
|
const team = new Team({ min: start.min, max: start.max, subagents: start.subagents, model: "m", capacity: () => capacity })
|
||||||
|
const delivered = () => {
|
||||||
|
const out: Record<string, string[]> = {}
|
||||||
|
for (const d of team.delivered.splice(0)) (out[d.name] ??= []).push(d.text)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
return (c.steps as Record<string, any>[]).map((st) => {
|
||||||
|
const by = (st.by as string | undefined) ?? "lead"
|
||||||
|
switch (st.op) {
|
||||||
|
case "offered": {
|
||||||
|
const a = team.get(by)!
|
||||||
|
const tools = !team.offered ? [] : a.leads ? [...TEAM_TOOLS] : ["team_read", "team_send"]
|
||||||
|
return { tools: tools.sort(), setting: team.offered }
|
||||||
|
}
|
||||||
|
case "call": {
|
||||||
|
const args = st.args as Record<string, any>
|
||||||
|
let result: string | null
|
||||||
|
if (st.tool === "team_read") {
|
||||||
|
const no = team.offered ? team.unavailable() : "unavailable"
|
||||||
|
const room = no ? [] : team.readRoom(by, args.since ?? 0).messages.map((m) => ({ n: m.n, from: m.from, to: m.to.join(", ") }))
|
||||||
|
return { room, delivered: delivered() }
|
||||||
|
}
|
||||||
|
if (st.tool === "team_add") result = team.add(by, args as any).text
|
||||||
|
else if (st.tool === "team_send") result = team.send(by, args as any)
|
||||||
|
else if (st.tool === "team_stop") result = team.stopCmd(by, args.name)
|
||||||
|
else {
|
||||||
|
result = team.refusal(by, st.tool, args) ?? null
|
||||||
|
if (st.tool === "task" && result === null && team.offered) team.helperStart(by)
|
||||||
|
}
|
||||||
|
return { result, delivered: delivered() }
|
||||||
|
}
|
||||||
|
case "finish":
|
||||||
|
team.finish(by, st.reply ?? "", st.reason ?? "stop")
|
||||||
|
return { delivered: delivered() }
|
||||||
|
case "helper_done":
|
||||||
|
team.helperDoneBy(by)
|
||||||
|
return null
|
||||||
|
case "set":
|
||||||
|
if (st.capacity) capacity = st.capacity
|
||||||
|
if (st.min !== undefined || st.max !== undefined) team.setSetting(st.min ?? team.min, st.max ?? team.max)
|
||||||
|
return null
|
||||||
|
case "person_stop":
|
||||||
|
team.personStop(st.name)
|
||||||
|
return null
|
||||||
|
case "user": {
|
||||||
|
const lines = team.noteLines()
|
||||||
|
team.leadWants()
|
||||||
|
return { note: lines.length ? noteBlock(lines) : null }
|
||||||
|
}
|
||||||
|
case "state":
|
||||||
|
return team.state()
|
||||||
|
}
|
||||||
|
throw new Error(`team: unknown op ${String(st.op)}`)
|
||||||
|
})
|
||||||
|
},
|
||||||
library_scope: (c) => resolveBase(c.bases as { id: string; name: string }[], c.base as string | null),
|
library_scope: (c) => resolveBase(c.bases as { id: string; name: string }[], c.base as string | null),
|
||||||
toolcall_index: (c) => {
|
toolcall_index: (c) => {
|
||||||
// The deltas as the OpenAI stream carries them: `function.name` and `function.arguments`.
|
// The deltas as the OpenAI stream carries them: `function.name` and `function.arguments`.
|
||||||
|
|||||||
@@ -93,18 +93,20 @@ describe("history", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe("the context meter", () => {
|
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([
|
const dir = setup([
|
||||||
{ chunks: [delta({ reasoning_content: "x".repeat(4000) }), toolCall(0, "l1", "list", "{}"), usage(1000, 1100)] },
|
{ 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 app = createApp({ cwd: dir, mode: "edit", asker: noAsk, store: false })
|
||||||
const used: number[] = []
|
const used: number[] = []
|
||||||
app.bus.on((e: Event) => e.type === "usage" && e.used !== undefined && used.push(e.used))
|
app.bus.on((e: Event) => e.type === "usage" && e.used !== undefined && used.push(e.used))
|
||||||
await app.engine.prompt("go")
|
await app.engine.prompt("go")
|
||||||
// 1000 in + 1100 out, of which ~1000 thinking that is not sent again: ~1100, not 2100.
|
// The step that called a tool: its ~1000 tokens of thinking go back with the next step.
|
||||||
expect(used[0]).toBeLessThan(1200)
|
expect(used[0]).toBeGreaterThan(2000)
|
||||||
expect(used[0]).toBeGreaterThan(1000)
|
// The final reply: 2150 in + 1100 out, of which ~1000 thinking the next turn does not send.
|
||||||
|
expect(used[1]).toBeLessThan(2400)
|
||||||
|
expect(used[1]).toBeGreaterThan(2150)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -1,7 +1,7 @@
|
|||||||
import { afterEach, describe, expect, test } from "bun:test"
|
import { afterEach, describe, expect, test } from "bun:test"
|
||||||
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"
|
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"
|
||||||
import { tmpdir } from "node:os"
|
import { tmpdir } from "node:os"
|
||||||
import { join } from "node:path"
|
import { basename, join } from "node:path"
|
||||||
import { createApp } from "../src/app.ts"
|
import { createApp } from "../src/app.ts"
|
||||||
import type { AskReply, Event } from "../src/bus/index.ts"
|
import type { AskReply, Event } from "../src/bus/index.ts"
|
||||||
import { paths } from "../src/config/paths.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[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$/)
|
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.
|
// 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")
|
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
|
// 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).
|
// one LEMBAS_HOME: another file's base must not decide this list (found in the full run).
|
||||||
|
|||||||
+11
-4
@@ -139,11 +139,18 @@ describe("openai-chat streaming", () => {
|
|||||||
expect(fake.requests[0].chat_template_kwargs).toEqual({ reasoning_effort: "high" })
|
expect(fake.requests[0].chat_template_kwargs).toEqual({ reasoning_effort: "high" })
|
||||||
})
|
})
|
||||||
|
|
||||||
test("effort outside the vocabulary is not sent", async () => {
|
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" })] }])
|
fake = fakeProvider([{ chunks: [delta({ content: "ok" })] }, { chunks: [delta({ content: "ok" })] }, { chunks: [delta({ content: "ok" })] }])
|
||||||
await collect(new OpenAIChatClient(model(fake.url, { efforts: ["low"] })), "high")
|
await collect(new OpenAIChatClient(model(fake.url, { efforts: ["low"] })), "high")
|
||||||
expect(fake.requests[0].reasoning_effort).toBeUndefined()
|
expect(fake.requests[0].reasoning_effort).toBe("low")
|
||||||
expect(fake.requests[0].chat_template_kwargs).toBeUndefined()
|
// 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 () => {
|
test("template refuses an effort → retried without it, vocabulary learned from the message", async () => {
|
||||||
|
|||||||
@@ -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(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.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")
|
expect(() => s.set("update.channel", "beta", "project")).toThrow("can be set for: global")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
// Harness spec 3.2.0 through the whole app: effort levels as a request carries them, the model's
|
||||||
|
// reasoning sent back during its turn and not after it, and no absolute path in what the model is
|
||||||
|
// told — relative paths and the shell at the project root, wherever the session was started.
|
||||||
|
import { afterEach, describe, expect, test } from "bun:test"
|
||||||
|
import { mkdirSync, mkdtempSync, realpathSync, writeFileSync } from "node:fs"
|
||||||
|
import { tmpdir } from "node:os"
|
||||||
|
import { join } from "node:path"
|
||||||
|
import { createApp } from "../src/app.ts"
|
||||||
|
import type { AskReply } from "../src/bus/index.ts"
|
||||||
|
import { paths } from "../src/config/paths.ts"
|
||||||
|
import { delta, fakeProvider, toolCall, usage, type Fake } from "./fake-provider.ts"
|
||||||
|
|
||||||
|
let fake: Fake | undefined
|
||||||
|
afterEach(() => fake?.stop())
|
||||||
|
|
||||||
|
function setup(script: Parameters<typeof fakeProvider>[0], spec = "{ context: 32768 }") {
|
||||||
|
fake = fakeProvider(script)
|
||||||
|
mkdirSync(paths.config, { recursive: true })
|
||||||
|
writeFileSync(join(paths.config, "connections.yaml"), `connections:\n fake:\n type: webui\n url: ${fake.url}\n models:\n fake/m: ${spec}\n`, { mode: 0o600 })
|
||||||
|
writeFileSync(join(paths.config, "config.yaml"), "model: fake/m\n")
|
||||||
|
const dir = realpathSync(mkdtempSync(join(tmpdir(), "lembas-320-")))
|
||||||
|
Bun.spawnSync(["git", "init", "-q", dir])
|
||||||
|
mkdirSync(join(dir, "packages", "web"), { recursive: true })
|
||||||
|
writeFileSync(join(dir, "top.txt"), "at the root\n")
|
||||||
|
writeFileSync(join(dir, "packages", "web", "index.ts"), "export {}\n")
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
const yes = { ask: async (): Promise<AskReply> => ({ kind: "once" }) }
|
||||||
|
|
||||||
|
describe("reasoning goes back during its turn (context.md §8)", () => {
|
||||||
|
test("a step's reasoning is sent back within the turn, a step-boundary note does not end the turn, and the next turn sends none", async () => {
|
||||||
|
const dir = setup([
|
||||||
|
{ chunks: [delta({ reasoning_content: "List first." }), toolCall(0, "l1", "list", "{}"), usage(100, 10)] },
|
||||||
|
{ chunks: [delta({ reasoning_content: "Now answer." }), delta({ content: "Two files." }, "stop"), usage(150, 10)] },
|
||||||
|
{ chunks: [delta({ content: "Still two." }, "stop"), usage(200, 5)] },
|
||||||
|
])
|
||||||
|
const app = createApp({ cwd: dir, mode: "edit", asker: yes, store: false })
|
||||||
|
// A job's note delivered at the first step boundary: it does not start a turn.
|
||||||
|
app.engine.note("[A background job you started has finished:]\njob1 (true) exited with 0 after 0s.")
|
||||||
|
await app.engine.prompt("what is here?")
|
||||||
|
const second = fake!.requests[1].messages.filter((m: any) => m.role === "assistant")
|
||||||
|
expect(second).toEqual([expect.objectContaining({ reasoning_content: "List first." })])
|
||||||
|
await app.engine.prompt("and now?")
|
||||||
|
// A new turn: the earlier replies go without their reasoning.
|
||||||
|
const third = fake!.requests[2].messages.filter((m: any) => m.role === "assistant")
|
||||||
|
expect(third.length).toBe(2)
|
||||||
|
for (const m of third) expect(m.reasoning_content).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("reasoning read from inline <think> tags goes back as reasoning_content, never as text", async () => {
|
||||||
|
const dir = setup([
|
||||||
|
{ chunks: [delta({ content: "<think>Look at the list.</think>" }), toolCall(0, "l1", "list", "{}"), usage(100, 10)] },
|
||||||
|
{ chunks: [delta({ content: "Done." }, "stop"), usage(150, 10)] },
|
||||||
|
])
|
||||||
|
const app = createApp({ cwd: dir, mode: "edit", asker: yes, store: false })
|
||||||
|
await app.engine.prompt("look")
|
||||||
|
const reply = fake!.requests[1].messages.find((m: any) => m.role === "assistant")
|
||||||
|
expect(reply.reasoning_content).toBe("Look at the list.")
|
||||||
|
expect(reply.content ?? "").not.toContain("think")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("effort levels in a request (models/schema.json levels)", () => {
|
||||||
|
test("off on a model with the switch: enable_thinking false and no effort; the start level otherwise", async () => {
|
||||||
|
const dir = setup([{ chunks: [delta({ content: "a" }, "stop")] }, { chunks: [delta({ content: "b" }, "stop")] }], "{ thinking_switch: true, efforts: [medium] }")
|
||||||
|
const app = createApp({ cwd: dir, mode: "edit", asker: yes, store: false, effort: "off" })
|
||||||
|
expect(app.engine.effort).toBe("off")
|
||||||
|
await app.engine.prompt("hi")
|
||||||
|
expect(fake!.requests[0].chat_template_kwargs).toEqual({ enable_thinking: false })
|
||||||
|
expect(fake!.requests[0].reasoning_effort).toBeUndefined()
|
||||||
|
app.settings.set("effort", "medium")
|
||||||
|
await app.engine.prompt("again")
|
||||||
|
expect(fake!.requests[1].reasoning_effort).toBe("medium")
|
||||||
|
expect(fake!.requests[1].chat_template_kwargs).toEqual({ reasoning_effort: "medium" })
|
||||||
|
expect(() => app.settings.set("effort", "high")).toThrow("offers the levels off, medium")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("off asked of a model without the switch starts at its start level", async () => {
|
||||||
|
const dir = setup([{ chunks: [delta({ content: "a" }, "stop")] }], "{ efforts: [low, high] }")
|
||||||
|
const app = createApp({ cwd: dir, mode: "edit", asker: yes, store: false, effort: "off" })
|
||||||
|
expect(app.engine.effort).toBe("low")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("no absolute paths (context.md §11)", () => {
|
||||||
|
test("started below the root: the prompt names the project and the directory relative to it; read and bash work from the root", async () => {
|
||||||
|
const dir = setup([
|
||||||
|
{ chunks: [toolCall(0, "r1", "read", '{"path":"top.txt"}'), toolCall(1, "b1", "bash", '{"command":"pwd","description":"where"}'), usage(100, 10)] },
|
||||||
|
{ chunks: [delta({ content: "ok" }, "stop"), usage(150, 5)] },
|
||||||
|
])
|
||||||
|
const app = createApp({ cwd: join(dir, "packages", "web"), mode: "auto", asker: yes, store: false })
|
||||||
|
await app.engine.prompt("look")
|
||||||
|
const system = fake!.requests[0].messages[0].content as string
|
||||||
|
expect(system).toContain(`Project: ${dir.split("/").pop()} (paths are relative to its root)\nStarted in: packages/web (relative to the project root)`)
|
||||||
|
expect(system).not.toContain(dir)
|
||||||
|
const results = fake!.requests[1].messages.filter((m: any) => m.role === "tool").map((m: any) => m.content as string)
|
||||||
|
expect(results[0]).toContain("1: at the root")
|
||||||
|
expect(results[1]).toContain(dir)
|
||||||
|
expect(results[1]).not.toContain("packages/web")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -133,7 +133,9 @@ describe("worktrees", () => {
|
|||||||
await a.engine.prompt("fix it in a worktree")
|
await a.engine.prompt("fix it in a worktree")
|
||||||
const child = fake!.requests[1].messages[0].content as string
|
const child = fake!.requests[1].messages[0].content as string
|
||||||
expect(child).toContain("You are working in a separate checkout")
|
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")
|
expect(readFileSync(join(root, "calc.py"), "utf8")).toContain("len(v) - 1")
|
||||||
const result = fake!.requests[4].messages.at(-1).content as string
|
const result = fake!.requests[4].messages.at(-1).content as string
|
||||||
const branch = /branch (lembas\/general-fix-average-[a-z0-9]+)/.exec(result)?.[1]
|
const branch = /branch (lembas\/general-fix-average-[a-z0-9]+)/.exec(result)?.[1]
|
||||||
|
|||||||
Reference in new issue
Block a user