diff --git a/packages/core/src/v1/config/agent.ts b/packages/core/src/v1/config/agent.ts index b220bd7ef8..a3e1d000bb 100644 --- a/packages/core/src/v1/config/agent.ts +++ b/packages/core/src/v1/config/agent.ts @@ -59,6 +59,21 @@ const KNOWN_KEYS = new Set([ "tools", ]) +// altimate_change start — callers can name the top-level keys that `normalize` folds into `options` +// The fold is a published contract: `AgentConfig` allows additional properties, an agent's +// `audience` is read from `options`, and legacy markdown agents carry provider request options +// as top-level keys that `migrateAgent` moves into `request.body`. It stays; the loaders log +// the keys it folded so a typo (`budget` for `steps`) is no longer silent. +/** Top-level keys the agent schema interprets; every other top-level key is folded into `options`. */ +export const knownKeys: readonly string[] = [...KNOWN_KEYS] + +/** The top-level keys of a raw agent config that `normalize` folds into `options`. */ +export function unknownKeys(agent: unknown): string[] { + if (typeof agent !== "object" || agent === null || Array.isArray(agent)) return [] + return Object.keys(agent).filter((key) => !KNOWN_KEYS.has(key)) +} +// altimate_change end + const normalize = (agent: Schema.Schema.Type): Schema.Schema.Type => { const options: Record = { ...agent.options } for (const [key, value] of Object.entries(agent)) { diff --git a/packages/core/test/config/agent-v1-keys.test.ts b/packages/core/test/config/agent-v1-keys.test.ts new file mode 100644 index 0000000000..e33037d60d --- /dev/null +++ b/packages/core/test/config/agent-v1-keys.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from "bun:test" +import { Schema } from "effect" +import { ConfigAgentV1 } from "../../src/v1/config/agent" + +const decode = Schema.decodeUnknownSync(ConfigAgentV1.Info) + +describe("ConfigAgentV1 top-level keys", () => { + test("a known key is accepted as itself", () => { + const agent = decode({ model: "test/model", steps: 3, description: "Known keys only" }) + expect(agent).toMatchObject({ model: "test/model", steps: 3, description: "Known keys only" }) + expect(agent.options).toEqual({}) + expect(ConfigAgentV1.unknownKeys({ model: "test/model", steps: 3 })).toEqual([]) + }) + + test("an unknown top-level key is folded into options and can be named", () => { + const raw = { model: "test/model", budget: 5, audience: "executive" } + const agent = decode(raw) + expect(agent.options).toEqual({ budget: 5, audience: "executive" }) + expect(agent).not.toHaveProperty("steps") + expect(ConfigAgentV1.unknownKeys(raw)).toEqual(["budget", "audience"]) + }) + + test("keys nested under options keep working and are not reported", () => { + const raw = { model: "test/model", options: { audience: "executive", reasoningEffort: "high" } } + const agent = decode(raw) + expect(agent.options).toEqual({ audience: "executive", reasoningEffort: "high" }) + expect(ConfigAgentV1.unknownKeys(raw)).toEqual([]) + }) + + test("the known-key list is the schema's own", () => { + for (const key of ["name", "model", "variant", "prompt", "steps", "maxSteps", "options", "permission", "tools"]) { + expect(ConfigAgentV1.knownKeys).toContain(key) + } + expect(ConfigAgentV1.unknownKeys(undefined)).toEqual([]) + expect(ConfigAgentV1.unknownKeys(["budget"])).toEqual([]) + }) +}) diff --git a/packages/opencode/bin/altimate-code b/packages/opencode/bin/altimate-code index 2fc6dd92cd..ef10ce9f17 100755 --- a/packages/opencode/bin/altimate-code +++ b/packages/opencode/bin/altimate-code @@ -27,6 +27,20 @@ function findAllNodeModules(startDir) { return paths } +function hasControllingTerminal() { + // A controlling terminal delivers Ctrl-C (SIGINT) and hangup (SIGHUP) to the + // whole foreground process group, so the child already has them. Opening + // /dev/tty is the portable test for one; it fails in CI, under harnesses, + // and after setsid, where the wrapper is the only process that was signalled. + if (process.platform === "win32") return true + try { + fs.closeSync(fs.openSync("/dev/tty", "r")) + return true + } catch { + return false + } +} + function run(target) { // Resolve NODE_PATH so the compiled Bun binary can find external packages // installed alongside the wrapper (e.g. @altimateai/altimate-core NAPI module). @@ -54,37 +68,58 @@ function run(target) { } } catch { // realpathSync failed (e.g. target doesn't exist) — continue without - // NODE_PATH; spawnSync will report the missing binary via result.error. + // NODE_PATH; spawn will report the missing binary via the "error" event. } - const result = childProcess.spawnSync(target, process.argv.slice(2), { + // Asynchronous spawn so this process can forward termination signals: with + // spawnSync a `kill ` ended the wrapper alone and left the + // binary running as an orphan (observed: a run kept going past its budget). + const child = childProcess.spawn(target, process.argv.slice(2), { stdio: "inherit", env, }) - if (result.error) { - console.error(result.error.message) - process.exit(1) - } - // A child killed by a signal (e.g. the kernel OOM killer) has no exit status; report it the way - // a shell would (128 + signal number) instead of exiting 0 as if the run had succeeded. - if (result.signal) { - console.error(`altimate-code terminated by ${result.signal}`) - process.exit(128 + (os.constants.signals[result.signal] ?? 1)) + const forward = (signal) => { + try { + child.kill(signal) + } catch { + // The child already exited; its "exit" handler below decides the status. + } } - const code = typeof result.status === "number" ? result.status : 1 - process.exit(code) + // SIGTERM is never terminal-generated, so it is always ours to deliver. + process.on("SIGTERM", () => forward("SIGTERM")) + // SIGINT and SIGHUP reach the child directly from a controlling terminal; + // forwarding them again would deliver a second signal, which `run` treats as + // "exit now" and skips its shutdown flush. Keep waiting for the child either + // way so its exit status is the wrapper's. + const terminal = hasControllingTerminal() + process.on("SIGINT", () => { + if (!terminal) forward("SIGINT") + }) + process.on("SIGHUP", () => { + if (!terminal) forward("SIGHUP") + }) + child.on("error", (error) => { + console.error(error.message) + process.exit(1) + }) + child.on("exit", (status, signal) => { + // A child killed by a signal (e.g. the kernel OOM killer) has no exit status; report it the way + // a shell would (128 + signal number) instead of exiting 0 as if the run had succeeded. + if (signal) { + console.error(`altimate-code terminated by ${signal}`) + process.exit(128 + (os.constants.signals[signal] ?? 1)) + } + const code = typeof status === "number" ? status : 1 + process.exit(code) + }) } +// run() returns while the child is still running, so every branch below must +// be the only one taken: the first match wins, and the fallback search runs +// only when neither override is present. const envPath = process.env.ALTIMATE_CODE_BIN_PATH -if (envPath) { - run(envPath) -} - -// const cached = path.join(scriptDir, ".altimate-code") -if (fs.existsSync(cached)) { - run(cached) -} +const override = envPath || (fs.existsSync(cached) ? cached : undefined) const platformMap = { darwin: "darwin", @@ -222,7 +257,7 @@ function findBinary(startDir) { } } -const resolved = findBinary(scriptDir) +const resolved = override ?? findBinary(scriptDir) if (!resolved) { console.error( "It seems that your package manager failed to install the right version of the altimate-code CLI for your platform. You can try manually installing " + diff --git a/packages/opencode/src/altimate/observability/tracing.ts b/packages/opencode/src/altimate/observability/tracing.ts index 1218d559cf..ca49d866c5 100644 --- a/packages/opencode/src/altimate/observability/tracing.ts +++ b/packages/opencode/src/altimate/observability/tracing.ts @@ -167,6 +167,10 @@ export interface RunBudgetMetadata { /** Unix milliseconds, matching generation and span timestamps. */ effort_downgraded_at?: number generation_interrupted_at?: number + /** `--max-tokens` accounting; present only when a token budget is set. */ + max_tokens?: number + tokens_used?: number + token_budget_exhausted?: boolean } interface TracePartIdentity { diff --git a/packages/opencode/src/cli/cmd/run-token-budget.ts b/packages/opencode/src/cli/cmd/run-token-budget.ts new file mode 100644 index 0000000000..a3d5d86704 --- /dev/null +++ b/packages/opencode/src/cli/cmd/run-token-budget.ts @@ -0,0 +1,103 @@ +// altimate_change start — fork-only token budget for `run` (--max-tokens) +// Sums the billed tokens of a run across its root session and every session +// descended from it (task children, and their children), and says when the +// running total reaches the limit. Pure: it observes the SDK event stream and +// never talks to the server, so the decision is testable without one. + +/** Token counts as a `step-finish` part reports them. */ +export type StepTokens = { + input: number + output: number + cache: { read: number; write: number } +} + +/** Billed weight of one step: a cache read costs a tenth of an input token. */ +export function billedTokens(tokens: StepTokens): number { + return tokens.input + tokens.output + tokens.cache.write + 0.1 * tokens.cache.read +} + +/** A `--max-tokens` value the budget can enforce: a positive integer. */ +export function isValidMaxTokens(value: unknown): boolean { + return typeof value === "number" && Number.isInteger(value) && value >= 1 +} + +type Event = { type: string; properties: Record } + +export function create(rootSessionID: string, limit: number | undefined) { + // Sessions known to descend from the root. A child announces itself through + // `session.created`/`session.updated` (parentID) or through the parent's + // task tool part (state.metadata.sessionId); either may arrive first, so + // edges are kept until their parent is admitted. + const sessions = new Set([rootSessionID]) + const parents = new Map() + const counted = new Set() + let total = 0 + let exhausted = false + + function admit() { + let changed = true + while (changed) { + changed = false + for (const [id, parentID] of parents) { + if (sessions.has(id) || !sessions.has(parentID)) continue + sessions.add(id) + changed = true + } + } + } + + function link(id: unknown, parentID: unknown) { + if (typeof id !== "string" || typeof parentID !== "string" || !id || !parentID) return + if (id === rootSessionID) return + parents.set(id, parentID) + admit() + } + + return { + get total() { + return total + }, + get exhausted() { + return exhausted + }, + get limit() { + return limit + }, + isDescendant(sessionID: string) { + return sessions.has(sessionID) + }, + /** + * Feed one SDK event. Returns true exactly once: on the step whose usage + * takes the total to (or past) the limit. Without a limit it only counts. + */ + observe(event: Event): boolean { + const props = event.properties + if (!props) return false + if (event.type === "session.created" || event.type === "session.updated") { + link(props.info?.id, props.info?.parentID) + } + if (event.type !== "message.part.updated") return false + const part = props.part + if (!part || typeof part.sessionID !== "string") return false + if (part.type === "tool" && part.tool === "task") { + link(part.state?.metadata?.sessionId ?? part.state?.input?.task_id, part.sessionID) + } + if (part.type !== "step-finish" || !sessions.has(part.sessionID)) return false + const tokens = part.tokens as Partial | undefined + if (!tokens || typeof part.id !== "string" || counted.has(part.id)) return false + counted.add(part.id) + total += billedTokens({ + input: tokens.input ?? 0, + output: tokens.output ?? 0, + cache: { read: tokens.cache?.read ?? 0, write: tokens.cache?.write ?? 0 }, + }) + if (limit === undefined || exhausted || total < limit) return false + exhausted = true + return true + }, + } +} +export type Info = ReturnType + +export * as RunTokenBudget from "./run-token-budget" +// altimate_change end diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index f6208dbbb5..6b497e454c 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -43,6 +43,9 @@ import { Tracer, FileExporter, HttpExporter, type TraceExporter } from "../../al import { RunAccounting } from "./run-accounting" // altimate_change start — wall-clock budget validation and deadline decisions import { RunTimeBudget } from "./run-time-budget" +// altimate_change end +// altimate_change start — billed-token budget across the run and its task sessions (fork-only module) +import { RunTokenBudget } from "./run-token-budget" import { defer } from "../../util/defer" import { setTimeout as sleep } from "node:timers/promises" // altimate_change end @@ -424,6 +427,13 @@ export const RunCommand = cmd({ describe: "maximum time in seconds including startup (env: ALTIMATE_RUN_TIME_BUDGET_S); bounded finalization may follow", }) // altimate_change end + // altimate_change start — billed-token budget: input + output + cache writes + 0.1 × cache reads + .option("max-tokens", { + type: "number", + describe: + "maximum billed tokens (input + output + cache writes + 0.1 × cache reads) across the run and its task sessions; bounded finalization may follow", + }) + // altimate_change end // altimate_change end // altimate_change start — backport upstream PR #21266 (dropped during v1.4.0 merge) .option("dangerously-skip-permissions", { @@ -458,6 +468,12 @@ export const RunCommand = cmd({ process.exit(1) } // altimate_change end + // altimate_change start — validate --max-tokens before anything runs (same reasoning as --max-turns) + if (args.maxTokens !== undefined && !RunTokenBudget.isValidMaxTokens(args.maxTokens)) { + UI.error(`--max-tokens must be a positive integer (got ${String(args.maxTokens)})`) + process.exit(1) + } + // altimate_change end // altimate_change start — `run` is the only entrypoint without an answer // channel for the question tool: no TUI is mounted and the in-process // Server.Default() shim below does not bind a port, so a connected IDE @@ -859,13 +875,23 @@ You are speaking to a non-technical business executive. Follow these rules stric let timeBudgetReminder = Promise.resolve() let timeBudgetAbort = Promise.resolve() let timeAbortRequested = false - const timeExhausted = () => timeBudgetDeadline === "hard" + // altimate_change start — a reached token budget takes the hard-deadline path: the same abort, + // the same one-shot finalization allowance, and the same exit status recovery on an answer. + // Created once the session id is known; absent without --max-tokens, so nothing else changes. + let tokenBudget: RunTokenBudget.Info | undefined + const timeExhausted = () => timeBudgetDeadline === "hard" || tokenBudget?.exhausted === true + // altimate_change end const elapsedSeconds = () => timeStarted === undefined ? 0 : (performance.now() - timeStarted) / 1000 const budgetMetadata = () => ({ max_turns: args.maxTurns, time_budget_s: timeBudget, elapsed_s: elapsedSeconds(), time_budget_deadline: timeBudgetDeadline, + // altimate_change start — token accounting, only when a token budget is set + max_tokens: tokenBudget?.limit, + tokens_used: tokenBudget === undefined ? undefined : Math.round(tokenBudget.total), + token_budget_exhausted: tokenBudget?.exhausted, + // altimate_change end // altimate_change start — actual server request-boundary timestamps, in Unix milliseconds effort_downgraded_at: effortDowngradedAt, generation_interrupted_at: generationInterruptedAt, @@ -891,6 +917,20 @@ You are speaking to a non-technical business executive. Follow these rules stric }) // altimate_change end } + // altimate_change start — the step that reached the token limit ends the work phase like a hard deadline + const exhaustTokenBudget = () => { + if (!sessionID || !tokenBudget || timeBudgetDeadline === "hard") return + timeBudgetStop.abort() + stopTimeBudget() + accounting.onBudgetExhausted("time") + tracer?.setRunBudget(budgetMetadata()) + const used = Math.round(tokenBudget.total) + const warning = `Budget exceeded: reached ${tokenBudget.limit} token limit (${used} billed). Aborting work and requesting a final reply.` + emit("budget_exhausted", { limit_tokens: tokenBudget.limit, tokens_used: used }) + warnBudget(warning) + abortForTimeBudget() + } + // altimate_change end const checkTimeBudget = () => { if (!sessionID || !workActive || timeExhausted()) return const deadline = RunTimeBudget.deadline(elapsedSeconds(), timeBudget) @@ -993,6 +1033,12 @@ You are speaking to a non-technical business executive. Follow these rules stric if (options?.signal?.aborted) break await runTracing?.observe(event) if (options?.signal?.aborted) break + // altimate_change start — count every session of the run before the root-session filters below; + // finalization turns are not cut off by the budget they are closing out + if (tokenBudget?.observe(event as { type: string; properties: Record }) && options?.turnLimit === undefined) { + exhaustTokenBudget() + } + // altimate_change end // altimate_change start — preserve request boundaries and internal continuation ownership on attached streams const requestEvent = event as { type: string @@ -1182,8 +1228,8 @@ You are speaking to a non-technical business executive. Follow these rules stric // one-shot). Firing aborts the churning prompt and hands off to the // confirm-DONE challenge phase after the event loop drains. Checked // BEFORE the json-mode emit-continue so headless drivers take this path too. - // altimate_change start — do not issue a completion challenge after a time abort - if (budgetAborted || timeBudgetDeadline === "hard") continue + // altimate_change start — do not issue a completion challenge after a time or token abort + if (budgetAborted || timeExhausted()) continue // altimate_change end if (idleDone.shouldChallenge()) { idleDone.markChallengeIssued() @@ -1462,6 +1508,9 @@ You are speaking to a non-technical business executive. Follow these rules stric UI.error("Session not found") process.exit(1) } + // altimate_change start — the token budget follows this session and every session descended from it + if (args.maxTokens !== undefined) tokenBudget = RunTokenBudget.create(sessionID, args.maxTokens) + // altimate_change end await share(sdk, sessionID) // Start trace now that sessionID is available diff --git a/packages/opencode/src/config/agent.ts b/packages/opencode/src/config/agent.ts index a74450ce07..a61c31f1fa 100644 --- a/packages/opencode/src/config/agent.ts +++ b/packages/opencode/src/config/agent.ts @@ -7,6 +7,31 @@ import { ConfigAgentV1 } from "@opencode-ai/core/v1/config/agent" import { configEntryNameFromPath } from "./entry-name" import * as ConfigMarkdown from "./markdown" import { ConfigParse } from "./parse" +// altimate_change start — a folded agent key is logged, not silently accepted +import { Log } from "../util/log" + +/** Log the top-level keys of one agent config that the schema folds into `options`. */ +export function warnUnknownKeys(name: string, data: unknown, source: string) { + const keys = ConfigAgentV1.unknownKeys(data) + if (!keys.length) return + Log.Default.warn("agent config: keys outside the agent schema were folded into options", { + agent: name, + source, + keys: keys.join(","), + known: ConfigAgentV1.knownKeys.join(","), + }) +} + +/** The same for every entry of a parsed config's `agent` and `mode` sections. */ +export function warnUnknownKeysInConfig(config: unknown, source: string) { + if (typeof config !== "object" || config === null) return + for (const section of ["agent", "mode"] as const) { + const entries = (config as Record)[section] + if (typeof entries !== "object" || entries === null) continue + for (const [name, data] of Object.entries(entries)) warnUnknownKeys(name, data, source) + } +} +// altimate_change end export async function load(dir: string) { const result: Record = {} @@ -27,6 +52,9 @@ export async function load(dir: string) { prompt: md.content.trim(), } result[config.name] = ConfigParse.schema(ConfigAgentV1.Info, config, item) + // altimate_change start — name the frontmatter keys the schema folded into options + warnUnknownKeys(name, md.data, item) + // altimate_change end } return result } @@ -53,6 +81,9 @@ export async function loadMode(dir: string) { ...parsed.value, mode: "primary" as const, } + // altimate_change start — name the frontmatter keys the schema folded into options + warnUnknownKeys(config.name, md.data, item) + // altimate_change end } } return result diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index d49756dc52..f62a534b52 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -370,6 +370,9 @@ export const layer = Layer.effect( // altimate_change start — validate opencode-local lesson settings with the shared config const data = ConfigParse.schema(LocalInfo, normalizeLoadedConfig(parsed), source) // altimate_change end + // altimate_change start — name the agent keys the schema folded into options (see config/agent.ts) + ConfigAgent.warnUnknownKeysInConfig(parsed, source) + // altimate_change end if (!("path" in options)) return data yield* Effect.promise(() => resolveLoadedPlugins(data, options.path)) diff --git a/packages/opencode/test/bin/altimate-code-wrapper.test.ts b/packages/opencode/test/bin/altimate-code-wrapper.test.ts new file mode 100644 index 0000000000..8223241fbb --- /dev/null +++ b/packages/opencode/test/bin/altimate-code-wrapper.test.ts @@ -0,0 +1,149 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { spawn, type ChildProcess } from "node:child_process" +import fs from "node:fs" +import os from "node:os" +import path from "node:path" + +// The wrapper spawns its binary asynchronously and forwards termination +// signals to it. These tests stand in a shell script for the binary: it reports +// readiness on stdout, records the signal it received, and exits with a code +// that identifies the path it took. +const WRAPPER_SOURCE = path.resolve(import.meta.dir, "../../bin/altimate-code") +const unixtest = process.platform !== "win32" ? test : test.skip + +let dir: string | undefined +let running: ChildProcess | undefined + +afterEach(() => { + if (running && running.exitCode === null && running.signalCode === null) running.kill("SIGKILL") + running = undefined + if (dir) fs.rmSync(dir, { recursive: true, force: true }) + dir = undefined +}) + +// The package is ESM ("type": "module"), so node would load the extensionless +// wrapper as ESM from inside it. Copy it out, as an installed wrapper is. +function wrapperCopy() { + dir ??= fs.mkdtempSync(path.join(os.tmpdir(), "altimate-wrapper-test-")) + const bin = path.join(dir, "bin") + fs.mkdirSync(bin, { recursive: true }) + const file = path.join(bin, "altimate-code") + fs.copyFileSync(WRAPPER_SOURCE, file) + return file +} + +function fakeBinary(body: string | ((signalFile: string) => string)) { + dir ??= fs.mkdtempSync(path.join(os.tmpdir(), "altimate-wrapper-test-")) + const file = path.join(dir, "fake-altimate-code") + const signalFile = path.join(dir, "signal") + fs.writeFileSync(file, `#!/bin/sh\n${typeof body === "string" ? body : body(signalFile)}\n`) + fs.chmodSync(file, 0o755) + return { file, signalFile } +} + +function waiting(signalFile: string) { + return [ + `trap 'echo SIGTERM > "${signalFile}"; exit 7' TERM`, + `trap 'echo SIGINT > "${signalFile}"; exit 8' INT`, + `trap 'echo SIGHUP > "${signalFile}"; exit 9' HUP`, + "echo ready", + "while :; do sleep 0.05; done", + ].join("\n") +} + +function start(binary: string, options?: { detached?: boolean }) { + const child = spawn("node", [wrapperCopy()], { + env: { ...process.env, ALTIMATE_CODE_BIN_PATH: binary }, + stdio: ["ignore", "pipe", "pipe"], + detached: options?.detached ?? false, + }) + running = child + let stdout = "" + let stderr = "" + child.stdout!.on("data", (chunk) => (stdout += chunk)) + child.stderr!.on("data", (chunk) => (stderr += chunk)) + const ready = new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`fake binary never reported ready; stderr: ${stderr}`)), 10_000) + child.stdout!.on("data", () => { + if (stdout.includes("ready")) { + clearTimeout(timer) + resolve() + } + }) + child.on("exit", () => { + clearTimeout(timer) + resolve() + }) + }) + const exit = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve) => { + child.on("exit", (code, signal) => resolve({ code, signal })) + }) + return { + child, + ready, + exit, + get stdout() { + return stdout + }, + get stderr() { + return stderr + }, + } +} + +describe("bin/altimate-code signal forwarding", () => { + unixtest("SIGTERM to the wrapper reaches the binary and its exit code is propagated", async () => { + const { file, signalFile } = fakeBinary(waiting) + const run = start(file) + await run.ready + expect(run.child.kill("SIGTERM")).toBe(true) + const result = await run.exit + expect(fs.readFileSync(signalFile, "utf8").trim()).toBe("SIGTERM") + expect(result).toEqual({ code: 7, signal: null }) + expect(run.stderr).toBe("") + }) + + unixtest("SIGINT is forwarded when the wrapper has no controlling terminal", async () => { + const { file, signalFile } = fakeBinary(waiting) + // A new session has no controlling terminal, so the terminal cannot have + // delivered the signal to the binary already; the wrapper must. + const run = start(file, { detached: true }) + await run.ready + expect(run.child.kill("SIGINT")).toBe(true) + const result = await run.exit + expect(fs.readFileSync(signalFile, "utf8").trim()).toBe("SIGINT") + expect(result).toEqual({ code: 8, signal: null }) + }) + + unixtest("SIGHUP is forwarded when the wrapper has no controlling terminal", async () => { + const { file, signalFile } = fakeBinary(waiting) + const run = start(file, { detached: true }) + await run.ready + expect(run.child.kill("SIGHUP")).toBe(true) + const result = await run.exit + expect(fs.readFileSync(signalFile, "utf8").trim()).toBe("SIGHUP") + expect(result).toEqual({ code: 9, signal: null }) + }) + + unixtest("a normal exit code is propagated unchanged", async () => { + const { file } = fakeBinary("echo ready\nexit 3") + const run = start(file) + expect(await run.exit).toEqual({ code: 3, signal: null }) + expect(run.stdout).toContain("ready") + expect(run.stderr).toBe("") + }) + + unixtest("a binary killed by a signal exits 128 + signal with the same message as before", async () => { + const { file } = fakeBinary("echo ready\nkill -KILL $$") + const run = start(file) + expect(await run.exit).toEqual({ code: 128 + os.constants.signals.SIGKILL, signal: null }) + expect(run.stderr.trim()).toBe("altimate-code terminated by SIGKILL") + }) + + test("a missing binary exits 1 with the spawn error", async () => { + const missing = path.join(os.tmpdir(), "altimate-wrapper-test-missing", "altimate-code") + const run = start(missing) + expect((await run.exit).code).toBe(1) + expect(run.stderr).toContain("ENOENT") + }) +}) diff --git a/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap b/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap index 484001e989..50bdf28a1c 100644 --- a/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap +++ b/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap @@ -82,7 +82,6 @@ Options: [string]" `; -// altimate_change start — document the committed-answer echo allowance in run help exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode run --help 1`] = ` "altimate-code run [message..] @@ -141,10 +140,12 @@ Options: --time-budget maximum time in seconds including startup (env: ALTIMATE_RUN_TIME_BUDGET_S); bounded finalization may follow [number] + --max-tokens maximum billed tokens (input + output + cache writes + 0.1 × + cache reads) across the run and its task sessions; bounded + finalization may follow [number] --dangerously-skip-permissions auto-approve permissions that are not explicitly denied (dangerous!) [boolean] [default: false]" `; -// altimate_change end exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode debug --help 1`] = ` "altimate-code debug diff --git a/packages/opencode/test/cli/run/budget-finalization.test.ts b/packages/opencode/test/cli/run/budget-finalization.test.ts index 90ddbec384..a423f28b21 100644 --- a/packages/opencode/test/cli/run/budget-finalization.test.ts +++ b/packages/opencode/test/cli/run/budget-finalization.test.ts @@ -4,6 +4,9 @@ import { createOpencodeClient } from "@opencode-ai/sdk/v2" import fs from "node:fs/promises" import path from "node:path" import { RunAccounting } from "@/cli/cmd/run-accounting" +// altimate_change start — --max-tokens shares the budget finalization path +import { RunTokenBudget } from "@/cli/cmd/run-token-budget" +// altimate_change end import { SessionTermination } from "@/session/termination" import { cliIt } from "../../lib/cli-process" import { raw, reply } from "../../lib/llm-server" @@ -211,6 +214,101 @@ describe("budget finalization accounting", () => { }) }) +// altimate_change start — token budget: child-session usage counts, exhaustion fires once, no flag means no change +describe("token budget accounting", () => { + const root = "ses_root" + const stepFinish = (sessionID: string, id: string, tokens: Partial) => ({ + type: "message.part.updated", + properties: { part: { id, sessionID, messageID: `${id}_msg`, type: "step-finish", reason: "tool-calls", tokens } }, + }) + const child = (id: string, parentID: string) => ({ type: "session.created", properties: { info: { id, parentID } } }) + + test("bills cache reads at a tenth and everything else at face value", () => { + expect(RunTokenBudget.billedTokens({ input: 100, output: 20, cache: { read: 1000, write: 30 } })).toBe(250) + }) + + test.each([0, -1, 1.5, NaN, Infinity, "100", undefined])("rejects an unenforceable limit: %p", (value) => { + expect(RunTokenBudget.isValidMaxTokens(value)).toBe(false) + }) + + test("a child session's usage counts toward the root's total", () => { + const budget = RunTokenBudget.create(root, 100) + expect(budget.observe(stepFinish(root, "root_1", { input: 50, output: 10, cache: { read: 0, write: 0 } }))).toBe(false) + expect(budget.total).toBe(60) + budget.observe(child("ses_child", root)) + expect(budget.isDescendant("ses_child")).toBe(true) + expect(budget.observe(stepFinish("ses_child", "child_1", { input: 30, output: 10, cache: { read: 0, write: 0 } }))).toBe( + true, + ) + expect(budget.total).toBe(100) + expect(budget.exhausted).toBe(true) + }) + + test("a grandchild linked before its parent is admitted once the chain closes", () => { + const budget = RunTokenBudget.create(root, 10) + budget.observe(child("ses_grandchild", "ses_child")) + expect(budget.isDescendant("ses_grandchild")).toBe(false) + budget.observe(child("ses_child", root)) + expect(budget.isDescendant("ses_grandchild")).toBe(true) + expect(budget.observe(stepFinish("ses_grandchild", "gc_1", { input: 10, output: 0, cache: { read: 0, write: 0 } }))).toBe( + true, + ) + }) + + test("a task tool part links the child before its session event arrives", () => { + const budget = RunTokenBudget.create(root, 10) + budget.observe({ + type: "message.part.updated", + properties: { + part: { id: "task_1", sessionID: root, messageID: "m", type: "tool", tool: "task", state: { metadata: { sessionId: "ses_task" } } }, + }, + }) + expect(budget.isDescendant("ses_task")).toBe(true) + }) + + test("another session's usage and a repeated step are not counted", () => { + const budget = RunTokenBudget.create(root, 100) + expect(budget.observe(stepFinish("ses_other", "other_1", { input: 500, output: 0, cache: { read: 0, write: 0 } }))).toBe(false) + expect(budget.total).toBe(0) + budget.observe(stepFinish(root, "root_1", { input: 40, output: 0, cache: { read: 0, write: 0 } })) + budget.observe(stepFinish(root, "root_1", { input: 40, output: 0, cache: { read: 0, write: 0 } })) + expect(budget.total).toBe(40) + }) + + test("exhaustion is reported once and later steps keep counting", () => { + const budget = RunTokenBudget.create(root, 50) + expect(budget.observe(stepFinish(root, "root_1", { input: 60, output: 0, cache: { read: 0, write: 0 } }))).toBe(true) + expect(budget.observe(stepFinish(root, "root_2", { input: 60, output: 0, cache: { read: 0, write: 0 } }))).toBe(false) + expect(budget.total).toBe(120) + expect(budget.exhausted).toBe(true) + }) + + test("without a limit nothing is ever exhausted", () => { + const budget = RunTokenBudget.create(root, undefined) + expect(budget.observe(stepFinish(root, "root_1", { input: 1_000_000, output: 0, cache: { read: 0, write: 0 } }))).toBe(false) + expect(budget.exhausted).toBe(false) + expect(budget.limit).toBeUndefined() + }) + + test("a token abort recovers the exit status only through an answered finalization, like a time abort", () => { + const accounting = RunAccounting.create() + accounting.onStepStart("work") + accounting.onBudgetExhausted("time") + accounting.onSessionError("MessageAbortedError", "intentional token-budget abort") + expect(accounting.fatal).toBe(true) + expect(accounting.takeBudgetFinalizationAllowance()).toBe(2) + expect(accounting.takeBudgetFinalizationAllowance()).toBeUndefined() + accounting.onStepStart("finalization") + accounting.onText("finalization", "The partial result.") + accounting.onStepFinish("finalization", "stop") + accounting.onPromptResult({ finish: "stop" }) + expect(accounting.completeBudgetFinalization(true)).toBe(true) + expect(accounting.fatal).toBe(false) + expect(accounting.termination().why_harness_stopped).toBe("budget-exhausted") + }) +}) +// altimate_change end + function isFinalization(body: Record) { const messages = body.messages as { role?: string; content?: unknown }[] | undefined const lastUser = messages?.findLast((message) => message.role === "user") @@ -754,13 +852,16 @@ describe("opencode run: budget finalization subprocess", () => { ) // altimate_change end // altimate_change start — exhausted budgets must disable execution even if the model calls a tool - for (const budget of ["turn", "time"] as const) { + for (const budget of ["turn", "time", "token"] as const) { cliIt.concurrent( `${budget}-budget finalization omits tools and rejects a model-requested write`, ({ llm, opencode, home }) => Effect.gen(function* () { const forbidden = path.join(home, "after-budget.txt") - yield* llm.tool("glob", { pattern: "*.sql", path: home }) + // The token budget is reached by this one step's usage: 80 + 30 billed against a limit of 100. + if (budget === "token") { + yield* llm.push(reply().tool("glob", { pattern: "*.sql", path: home }).usage({ input: 80, output: 30 }).item()) + } else yield* llm.tool("glob", { pattern: "*.sql", path: home }) yield* llm.toolMatch(({ body }) => isFinalization(body), "write", { filePath: forbidden, content: "work continued after the hard budget", @@ -769,7 +870,8 @@ describe("opencode run: budget finalization subprocess", () => { if (budget === "time") yield* llm.pushMatch(({ body }) => !isFinalization(body), reply().hang()) const result = yield* opencode.run("Inspect SQL files and report the available evidence.", { format: "json", - extraArgs: budget === "turn" ? ["--max-turns", "1"] : ["--time-budget", "5"], + extraArgs: + budget === "turn" ? ["--max-turns", "1"] : budget === "time" ? ["--time-budget", "5"] : ["--max-tokens", "100"], env: { OPENCODE_CONFIG_CONTENT: JSON.stringify({ ...testProviderConfig(llm.url), @@ -783,7 +885,18 @@ describe("opencode run: budget finalization subprocess", () => { const events = opencode.parseJsonEvents(result.stdout) const exhausted = events.filter((event) => event.type === "budget_exhausted") expect(exhausted).toHaveLength(1) - expect(exhausted[0]).toMatchObject(budget === "turn" ? { limit: 1 } : { limit_seconds: 5 }) + expect(exhausted[0]).toMatchObject( + budget === "turn" ? { limit: 1 } : budget === "time" ? { limit_seconds: 5 } : { limit_tokens: 100, tokens_used: 110 }, + ) + // altimate_change start — the token budget is reported where the time budget is + if (budget === "token") { + expect(events.filter((event) => event.type === "termination")).toMatchObject([ + { why_harness_stopped: "budget-exhausted", max_tokens: 100, tokens_used: 110, token_budget_exhausted: true }, + ]) + } else { + expect(events.filter((event) => event.type === "termination")[0]).not.toHaveProperty("max_tokens") + } + // altimate_change end expect(events.filter((event) => event.type === "tool_use").map((event) => event.part)).toContainEqual( expect.objectContaining({ tool: "glob", state: expect.objectContaining({ status: "completed" }) }), ) diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index f32e56f635..02dcf0d1d3 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -35,6 +35,8 @@ import { pathToFileURL } from "url" // altimate_change — config.ts reads Global from "@/global" (opencode, app=altimate-code); the // test must swap THAT module's Path.config, not core's, or the global config never loads. import { Global } from "@/global" +// altimate_change — the agent loaders log folded keys through Log.Default +import { Log } from "@/util/log" import { ProjectV2 } from "@opencode-ai/core/project" import { Filesystem } from "@/util/filesystem" import { ConfigPlugin } from "@/config/plugin" @@ -2444,3 +2446,87 @@ describe("learn keys set above project files", () => { ) }) // altimate_change end + +// altimate_change start — a top-level agent key outside the schema is folded into options and logged +describe("agent config keys outside the schema", () => { + let warn: ReturnType> + beforeEach(() => { + warn = spyOn(Log.Default, "warn").mockImplementation(() => {}) + }) + afterEach(() => { + warn.mockRestore() + }) + const folded = () => + warn.mock.calls + .filter(([message]) => String(message).includes("folded into options")) + .map(([, extra]) => extra as Record) + + it.instance("a markdown agent's unknown frontmatter key is kept under options and named in a warning", () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* FSUtil.use.writeWithDirs( + path.join(test.directory, ".opencode", "agent", "exec.md"), + `--- +model: test/model +audience: executive +budget: 100 +--- +Executive summaries only`, + ) + const config = yield* Config.use.get() + expect(config.agent?.["exec"]).toMatchObject({ + model: "test/model", + options: { audience: "executive", budget: 100 }, + }) + expect(folded()).toEqual([ + expect.objectContaining({ + agent: "exec", + keys: "audience,budget", + source: expect.stringContaining("exec.md"), + known: expect.stringContaining("steps"), + }), + ]) + }), + ) + + it.instance("a JSON agent's unknown key is kept under options and named in a warning", () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* writeConfigEffect(test.directory, { + agent: { helper: { model: "test/model", budget: 100 } }, + mode: { focus: { model: "test/model", retries: 2 } }, + }) + const config = yield* Config.use.get() + expect(config.agent?.["helper"]).toMatchObject({ model: "test/model", options: { budget: 100 } }) + expect(config.agent?.["focus"]).toMatchObject({ mode: "primary", options: { retries: 2 } }) + expect(folded()).toEqual([ + expect.objectContaining({ agent: "helper", keys: "budget" }), + expect.objectContaining({ agent: "focus", keys: "retries" }), + ]) + }), + ) + + it.instance("known keys and keys nested under options are not reported", () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* writeConfigEffect(test.directory, { + agent: { quiet: { model: "test/model", steps: 4, options: { audience: "executive" } } }, + }) + yield* FSUtil.use.writeWithDirs( + path.join(test.directory, ".opencode", "agent", "plain.md"), + `--- +model: test/model +steps: 2 +options: + audience: technical +--- +Plain agent`, + ) + const config = yield* Config.use.get() + expect(config.agent?.["quiet"]).toMatchObject({ steps: 4, options: { audience: "executive" } }) + expect(config.agent?.["plain"]).toMatchObject({ steps: 2, options: { audience: "technical" } }) + expect(folded()).toEqual([]) + }), + ) +}) +// altimate_change end