Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
6377150
feat(opencode): A0 model tier resolution
linfangy-int Aug 15, 2026
df78498
feat(opencode): A1 tier-aware system prompt
linfangy-int Aug 15, 2026
40e59ce
feat(opencode): A2 tier-aware tool roster
linfangy-int Aug 15, 2026
9e2a440
feat(opencode): A3 grammar-safe tool schemas
linfangy-int Aug 15, 2026
2877aff
feat(opencode): A4 tier sampling and reasoning passthrough
linfangy-int Aug 15, 2026
73e2df0
feat(opencode): C2 proportional compaction reserve
linfangy-int Aug 15, 2026
87e9d78
feat(opencode): C1 honest context limits
linfangy-int Aug 15, 2026
2e4fa62
feat(opencode): C5 truthful overflow recovery text
linfangy-int Aug 15, 2026
1b570b0
feat(opencode): C6 window-aware output budget
linfangy-int Aug 15, 2026
c5e3b7a
test(opencode): C7 runtime limit update invalidation coverage
linfangy-int Aug 15, 2026
9bec2c5
feat(opencode): C8 compaction prompt override
linfangy-int Aug 15, 2026
44eaaea
feat(opencode): B1 max-steps disables tools
linfangy-int Aug 15, 2026
55d2042
feat(opencode): B2 deterministic plugin load order
linfangy-int Aug 15, 2026
66401fb
feat(opencode): B3 plugin hook error isolation
linfangy-int Aug 15, 2026
30932ad
feat(opencode): B4 structural doom-loop stop
linfangy-int Aug 15, 2026
f76934a
feat(opencode): B5 in-engine malformed-JSON tool-call repair
linfangy-int Aug 15, 2026
7cde0a6
chore(opencode): format drift from earlier wave patches
linfangy-int Aug 15, 2026
6872f49
feat(opencode): D1 context-budget endpoint
linfangy-int Aug 15, 2026
0cc4790
feat(opencode): D2 request telemetry headers
linfangy-int Aug 15, 2026
2774368
feat(opencode): D3 budget and overflow events
linfangy-int Aug 16, 2026
57c6eab
feat(opencode): D4 toolset and prompt cost introspection
linfangy-int Aug 16, 2026
c28eeee
feat(opencode): D5 session turn terminal event
linfangy-int Aug 16, 2026
cfc7cfe
feat(opencode): C4 format-aware token estimator
linfangy-int Aug 16, 2026
ae6fb23
feat(opencode): E1 snake_case arg tolerance
linfangy-int Aug 16, 2026
9780d2d
feat(opencode): E2 task_id tolerance
linfangy-int Aug 16, 2026
3794b87
feat(opencode): E3 identity-line strip flag
linfangy-int Aug 16, 2026
075071b
feat(opencode): E4 small_model matching fix
linfangy-int Aug 16, 2026
4129ce6
feat(opencode): E5 think-tag scrub on minimal tier
linfangy-int Aug 16, 2026
69fb39c
feat(opencode): E6 stable cache prefix on minimal and default tiers
linfangy-int Aug 16, 2026
b5c4b67
feat(opencode): E7 tier timeout defaults
linfangy-int Aug 16, 2026
c2f1a5a
feat(opencode): B6 text tool-call parsing
linfangy-int Aug 16, 2026
922a552
fix(opencode): tolerate models without an upstream api id in tier res…
linfangy-int Aug 16, 2026
d29b13a
feat(opencode): W6 structural bounds, config-owned rosters, honest st…
linfangy-int Aug 18, 2026
1eab3aa
Merge remote-tracking branch 'origin/dev' into small-model-tiers
yanglinfang Aug 22, 2026
46d0aba
fix(opencode): yield tier sampling and small-model fallback to upstre…
yanglinfang Aug 22, 2026
bd93adc
Merge remote-tracking branch 'upstream/dev' into small-model-tiers
yanglinfang Aug 23, 2026
0ae0537
refactor(opencode): deduplicate PR-introduced logic
yanglinfang Aug 23, 2026
ffa3f58
Merge remote-tracking branch 'upstream/dev' into small-model-tiers
yanglinfang Aug 24, 2026
387bb27
Merge remote-tracking branch 'upstream/dev' into small-model-tiers
yanglinfang Aug 24, 2026
be63b8b
Merge remote-tracking branch 'upstream/dev' into small-model-tiers
yanglinfang Aug 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions packages/core/src/session/runner/max-steps.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
export const MAX_STEPS_PROMPT = `CRITICAL - MAXIMUM STEPS REACHED
// Marker phrases downstream consumers match on to recognize this directive in
// model output (e.g. the task tool strips blocks echoing it from subagent
// results). Interpolated into the prompt so a wording change cannot silently
// break the matchers.
export const MAX_STEPS_MARKERS = ["MAXIMUM STEPS REACHED", "maximum number of steps allowed"] as const

The maximum number of steps allowed for this task has been reached. Tools are disabled until next user input. Respond with text only.
export const MAX_STEPS_PROMPT = `CRITICAL - ${MAX_STEPS_MARKERS[0]}

The ${MAX_STEPS_MARKERS[1]} for this task has been reached. Tools are disabled until next user input. Respond with text only.

STRICT REQUIREMENTS:
1. Do NOT make any tool calls (no reads, writes, edits, searches, or any other tools)
Expand Down
29 changes: 27 additions & 2 deletions packages/core/src/util/token.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,30 @@
export * as Token from "./token"

const CHARS_PER_TOKEN = 4
// C4: bytes-per-token densities measured on real corpora (fork plan §C4).
// Dense structured text tokenizes far below the ~4 chars/token prose rule:
// chars ÷ 4 is 2.7–2.8x optimistic on CSV and 1.8x on logs. Chars ≈ bytes
// stays the working assumption throughout.
const DENSITY: Record<string, number> = {
csv: 1.3,
tsv: 1.3,
json: 1.5,
ndjson: 1.5,
jsonl: 1.5,
log: 2,
}

export const estimate = (input: string) => Math.max(0, Math.round(input.length / CHARS_PER_TOKEN))
const DEFAULT_DENSITY = 4

// Resolves a bytes-per-token density from a hint that is either a format tag
// ("csv") or a filename ("data.csv"); unknown or missing hints fall back to
// the prose default so general estimates keep today's behavior.
const density = (hint?: string) => {
if (!hint) return DEFAULT_DENSITY
const dot = hint.lastIndexOf(".")
const tag = dot === -1 ? hint : hint.slice(dot + 1)
return DENSITY[tag.toLowerCase()] ?? DEFAULT_DENSITY
}

export const estimate = (input: string, hint?: string) => {
return Math.max(0, Math.round(input.length / density(hint)))
}
8 changes: 8 additions & 0 deletions packages/core/src/v1/config/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,14 @@ const AgentSchema = Schema.StructWithRest(
steps: Schema.optional(PositiveInt).annotate({
description: "Maximum number of agentic iterations before forcing text-only response",
}),
turn_tokens: Schema.optional(PositiveInt).annotate({
description:
"Maximum tokens this agent may consume in a single turn before being forced to a text-only response. Complements 'steps': a turn can stay under the step limit while burning an unbounded number of tokens on large tool output.",
}),
turn_seconds: Schema.optional(PositiveInt).annotate({
description:
"Maximum wall-clock seconds this agent may spend in a single turn before being forced to a text-only response.",
}),
maxSteps: Schema.optional(PositiveInt).annotate({ description: "@deprecated Use 'steps' field instead." }),
permission: Schema.optional(ConfigPermissionV1.Info),
}),
Expand Down
8 changes: 8 additions & 0 deletions packages/core/src/v1/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,10 @@ export const Info = Schema.Struct({
reserved: Schema.optional(NonNegativeInt).annotate({
description: "Token buffer for compaction. Leaves enough window to avoid overflow during compaction.",
}),
prompt: Schema.optional(Schema.String).annotate({
description:
"Custom prompt that replaces the built-in compaction summary instructions. Supports {file:./path} substitution to load the prompt from a file.",
}),
}),
),
experimental: Schema.optional(
Expand All @@ -179,6 +183,10 @@ export const Info = Schema.Struct({
continue_loop_on_deny: Schema.optional(Schema.Boolean).annotate({
description: "Continue the agent loop when a tool call is denied",
}),
omit_model_identity: Schema.optional(Schema.Boolean).annotate({
description:
"Omit the model identity line ('You are powered by...') from the system prompt environment block. Defaults to false; the minimal tier omits the line regardless unless this is explicitly set to false.",
}),
mcp_timeout: Schema.optional(PositiveInt).annotate({
description: "Timeout in milliseconds for model context protocol (MCP) requests",
}),
Expand Down
30 changes: 30 additions & 0 deletions packages/core/src/v1/config/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,36 @@ export const Model = Schema.Struct({
),
experimental: Schema.optional(Schema.Boolean),
status: Schema.optional(ModelStatus),
tier: Schema.optional(
Schema.Literals(["minimal", "default"]).annotate({
description:
"Capability tier for this model. Overrides the built-in size heuristic; frontier family models resolve their vendor behavior when unset.",
}),
),
tier_tools: Schema.optional(
Schema.Struct({
include: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
exclude: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
}).annotate({
description:
"Adjust the minimal tier's tool roster for this model. `include` keeps additional tool ids through the tier cut (use it for host-integration tools registered by an embedding product); `exclude` drops ids from the built-in roster. Ignored on every other tier.",
}),
),
prompt: Schema.optional(
Schema.String.annotate({
description:
"Replace the model-family system prompt with this text. Use {file:./path} to load it from a file resolved relative to the config file.",
}),
),
sampling: Schema.optional(
Schema.Struct({
temperature: Schema.optional(Schema.Finite),
topP: Schema.optional(Schema.Finite),
topK: Schema.optional(Schema.Finite),
}).annotate({
description: "Sampling defaults for this model. Consulted before the built-in per-family sampling ladders.",
}),
),
provider: Schema.optional(
Schema.Struct({ npm: Schema.optional(Schema.String), api: Schema.optional(Schema.String) }),
),
Expand Down
32 changes: 32 additions & 0 deletions packages/core/test/token.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { describe, expect, test } from "bun:test"
import { Token } from "../src/util/token"

const text = "x".repeat(1200)

describe("Token.estimate", () => {
test("prose default stays at 4 chars per token", () => {
expect(Token.estimate(text)).toBe(300)
expect(Token.estimate("")).toBe(0)
})

test("format tags select the measured density", () => {
expect(Token.estimate(text, "csv")).toBe(Math.round(1200 / 1.3))
expect(Token.estimate(text, "tsv")).toBe(Math.round(1200 / 1.3))
expect(Token.estimate(text, "json")).toBe(800)
expect(Token.estimate(text, "ndjson")).toBe(800)
expect(Token.estimate(text, "jsonl")).toBe(800)
expect(Token.estimate(text, "log")).toBe(600)
})

test("filenames derive the hint from their extension", () => {
expect(Token.estimate(text, "data/report.CSV")).toBe(Math.round(1200 / 1.3))
expect(Token.estimate(text, "server.log")).toBe(600)
expect(Token.estimate(text, "payload.json")).toBe(800)
})

test("unknown hints fall back to the prose default", () => {
expect(Token.estimate(text, "notes.md")).toBe(300)
expect(Token.estimate(text, "Makefile")).toBe(300)
expect(Token.estimate(text, "weird")).toBe(300)
})
})
45 changes: 34 additions & 11 deletions packages/opencode/script/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,22 @@ const allTargets: {
},
]

const targets = singleFlag
// Distribution name for a target, e.g. "opencode-linux-x64-baseline". Also the
// dist/ directory name and (with `bun` swapped for the package name) the Bun
// compile target, so `--target` accepts exactly what the build prints.
const targetName = (item: (typeof allTargets)[number]) =>
[
pkg.name,
// changing to win32 flags npm for some reason
item.os === "win32" ? "windows" : item.os,
item.arch,
item.avx2 === false ? "baseline" : undefined,
item.abi === undefined ? undefined : item.abi,
]
.filter(Boolean)
.join("-")

const selectedTargets = singleFlag
? allTargets.filter((item) => {
if (item.os !== process.platform || item.arch !== process.arch) {
return false
Expand All @@ -134,6 +149,23 @@ const targets = singleFlag
})
: allTargets

// `--target <name>` builds exactly one target regardless of host platform, so a
// Linux container binary can be produced from a Windows box without a full
// 12-target run (`--single` is host-only and cannot express that).
const targetFlag = process.argv.indexOf("--target")
const onlyTarget = targetFlag === -1 ? undefined : process.argv[targetFlag + 1]
if (targetFlag !== -1 && !onlyTarget) {
console.error("--target requires a target name, e.g. --target opencode-linux-x64-baseline")
process.exit(1)
}

const targets = onlyTarget ? allTargets.filter((item) => targetName(item) === onlyTarget) : selectedTargets
if (onlyTarget && targets.length === 0) {
console.error(`--target ${onlyTarget} matched no target. Available targets:`)
for (const item of allTargets) console.error(` ${targetName(item)}`)
process.exit(1)
}

await $`rm -rf dist`

const binaries: Record<string, string> = {}
Expand All @@ -143,16 +175,7 @@ if (!skipInstall) {
await $`bun install --os="*" --cpu="*" @ff-labs/fff-bun@${pkg.dependencies["@ff-labs/fff-bun"]}`
}
for (const item of targets) {
const name = [
pkg.name,
// changing to win32 flags npm for some reason
item.os === "win32" ? "windows" : item.os,
item.arch,
item.avx2 === false ? "baseline" : undefined,
item.abi === undefined ? undefined : item.abi,
]
.filter(Boolean)
.join("-")
const name = targetName(item)
console.log(`building ${name}`)
await $`mkdir -p dist/${name}/bin`

Expand Down
7 changes: 7 additions & 0 deletions packages/opencode/src/agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ export const Info = Schema.Struct({
prompt: Schema.optional(Schema.String),
options: Schema.Record(Schema.String, Schema.Unknown),
steps: Schema.optional(Schema.Finite),
// W6-3: token/wall-clock companions to `steps`. A turn can respect the step
// limit while still running away — a single step that reads a large file can
// cost more than twenty small ones — so the loop bounds all three.
turnTokens: Schema.optional(Schema.Finite),
turnSeconds: Schema.optional(Schema.Finite),
}).annotate({ identifier: "Agent" })
export type Info = DeepMutable<Schema.Schema.Type<typeof Info>>

Expand Down Expand Up @@ -289,6 +294,8 @@ const layer = Layer.effect(
item.hidden = value.hidden ?? item.hidden
item.name = value.name ?? item.name
item.steps = value.steps ?? item.steps
item.turnTokens = value.turn_tokens ?? item.turnTokens
item.turnSeconds = value.turn_seconds ?? item.turnSeconds
item.options = mergeDeep(item.options, value.options ?? {})
item.permission = Permission.merge(item.permission, Permission.fromConfig(value.permission ?? {}))
}
Expand Down
16 changes: 10 additions & 6 deletions packages/opencode/src/config/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,16 @@ export type Origin = {
export async function load(dir: string) {
const plugins: ConfigPluginV1.Spec[] = []

for (const item of await Glob.scan("{plugin,plugins}/*.{ts,js}", {
cwd: dir,
absolute: true,
dot: true,
symlink: true,
})) {
// B2: Glob.scan order is filesystem-dependent; sort so numeric prefixes
// (00_, 05_, 10_) give a deterministic load order.
for (const item of (
await Glob.scan("{plugin,plugins}/*.{ts,js}", {
cwd: dir,
absolute: true,
dot: true,
symlink: true,
})
).toSorted()) {
plugins.push(pathToFileURL(item).href)
}
return plugins
Expand Down
22 changes: 21 additions & 1 deletion packages/opencode/src/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,16 @@ type TriggerName = {
[K in keyof Hooks]-?: NonNullable<Hooks[K]> extends (input: any, output: any) => Promise<void> ? K : never
}[keyof Hooks]

// B3: hooks where a throw is semantic — it blocks the action being asked
// about — keep first-throw propagation. Every other trigger hook accumulates
// output, so one plugin's failure is isolated (logged and skipped) instead of
// cancelling the remaining plugins' hooks for the turn.
const BLOCKING_HOOKS: ReadonlySet<TriggerName> = new Set<TriggerName>([
"tool.execute.before",
"permission.ask",
"command.execute.before",
])

export interface Interface {
readonly trigger: <
Name extends TriggerName,
Expand Down Expand Up @@ -291,7 +301,17 @@ const layer = Layer.effect(
for (const hook of s.hooks) {
const fn = hook[name] as any
if (!fn) continue
yield* Effect.promise(async () => fn(input, output))
if (BLOCKING_HOOKS.has(name)) {
yield* Effect.promise(async () => fn(input, output))
continue
}
yield* Effect.tryPromise({
try: async () => fn(input, output),
catch: errorMessage,
}).pipe(
Effect.tapError((error) => Effect.logError("plugin hook failed", { hook: name, error })),
Effect.ignore,
)
}
return output
})
Expand Down
50 changes: 49 additions & 1 deletion packages/opencode/src/provider/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { ModelV2 } from "@opencode-ai/core/model"
import { ModelStatus } from "./model-status"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { ProviderError } from "./error"
import { SessionTier } from "@/session/tier"

const OPENAI_HEADER_TIMEOUT_DEFAULT = 300_000

Expand Down Expand Up @@ -1076,6 +1077,24 @@ export const Model = Schema.Struct({
api: ProviderApiInfo,
name: Schema.String,
family: optional(Schema.String),
tier: optional(Schema.Literals(["minimal", "default"])),
// Per-model adjustment of the minimal tier's tool roster. Lets an embedding
// product declare which of its own registered tools must survive the cut
// instead of the engine guessing; see MINIMAL_TIER_TOOLS in tool/registry.ts.
tier_tools: optional(
Schema.Struct({
include: optional(Schema.mutable(Schema.Array(Schema.String))),
exclude: optional(Schema.mutable(Schema.Array(Schema.String))),
}),
),
prompt: optional(Schema.String),
sampling: optional(
Schema.Struct({
temperature: optional(Schema.Finite),
topP: optional(Schema.Finite),
topK: optional(Schema.Finite),
}),
),
capabilities: ProviderCapabilities,
cost: ProviderCost,
limit: ProviderLimit,
Expand Down Expand Up @@ -1258,11 +1277,15 @@ function cloudflareGatewayNpm(providerID: string, modelID: string) {
}

function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model): Model {
// The catalog has no declared `model-tier` field yet (upstream issue #41372);
// read it defensively from the raw payload so it takes effect when it lands.
const catalogTier = (model as unknown as Record<string, unknown>)["model-tier"]
const base: Model = {
id: ModelV2.ID.make(model.id),
providerID: ProviderV2.ID.make(provider.id),
name: model.name,
family: model.family,
tier: catalogTier === "minimal" || catalogTier === "default" ? catalogTier : undefined,
api: {
id: model.id,
url: model.provider?.api ?? provider.api ?? "",
Expand Down Expand Up @@ -1557,6 +1580,10 @@ const layer = Layer.effect(
},
headers: mergeDeep(existingModel?.headers ?? {}, model.headers ?? {}),
family: model.family ?? existingModel?.family ?? "",
tier: model.tier ?? existingModel?.tier,
tier_tools: model.tier_tools ?? existingModel?.tier_tools,
prompt: model.prompt ?? existingModel?.prompt,
sampling: model.sampling ?? existingModel?.sampling,
release_date: model.release_date ?? existingModel?.release_date ?? "",
variants: {},
}
Expand Down Expand Up @@ -1780,6 +1807,21 @@ const layer = Layer.effect(
...model.headers,
}

// E7: slow local decode needs a stall guard that tolerates long token
// gaps. When the provider config sets no explicit timeout values,
// minimal/default-tier models on openai-compatible endpoints default
// chunkTimeout to 300s. Scoped to @ai-sdk/openai-compatible so cloud
// SDK behavior is untouched; set before the cache key so tiered and
// vendor models on the same provider get distinct SDK instances.
if (
model.api.npm === "@ai-sdk/openai-compatible" &&
options["chunkTimeout"] === undefined &&
options["timeout"] === undefined &&
SessionTier.resolve(model) !== "vendor"
) {
options["chunkTimeout"] = 300_000
}

const key = Hash.fast(
JSON.stringify({
providerID: model.providerID,
Expand Down Expand Up @@ -1937,7 +1979,13 @@ const layer = Layer.effect(
if (cfg.small_model) {
const parsed = parseModel(cfg.small_model)
return yield* getModel(parsed.providerID, parsed.modelID).pipe(
Effect.catchTag("ProviderModelNotFoundError", () => Effect.succeed(undefined)),
// E4: a configured small_model that does not resolve used to fail
// silently; name the missing model so the misconfig is visible.
Effect.catchTag("ProviderModelNotFoundError", () =>
Effect.logWarning("configured small_model not found", { small_model: cfg.small_model }).pipe(
Effect.as(undefined),
),
),
)
}

Expand Down
Loading
Loading