Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
14 changes: 12 additions & 2 deletions packages/core/schema.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{
"version": "7",
"dialect": "sqlite",
"id": "f14a9b18-8207-487e-a3d3-227e629ba9ad",
"prevIds": ["169a0f0f-d58f-479f-b024-fa1c7b9a09db"],
"id": "19be7275-7d21-4de7-a12b-2addde7991da",
"prevIds": ["f14a9b18-8207-487e-a3d3-227e629ba9ad"],
"ddl": [
{
"name": "workspace",
Expand Down Expand Up @@ -1220,6 +1220,16 @@
"entityType": "columns",
"table": "session"
},
{
"type": "real",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "budget",
"entityType": "columns",
"table": "session"
},
{
"type": "integer",
"notNull": true,
Expand Down
2 changes: 1 addition & 1 deletion packages/core/script/migration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ export default { ...config, out: ${JSON.stringify(output)} }

async function generatedMigrations(directory: string) {
return (await Array.fromAsync(new Bun.Glob("*/migration.sql").scan({ cwd: directory })))
.map((file) => file.split("/")[0])
.map((file) => file.split(/[\\/]/)[0])
.filter((name): name is string => name !== undefined)
.sort()
}
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/database/migration.gen.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"

export default {
id: "20260812223059_session_budget",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`ALTER TABLE \`session\` ADD \`budget\` real;`)
})
},
} satisfies DatabaseMigration.Migration
1 change: 1 addition & 0 deletions packages/core/src/database/schema.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ export default {
\`summary_diffs\` text,
\`metadata\` text,
\`cost\` real DEFAULT 0 NOT NULL,
\`budget\` real,
\`tokens_input\` integer DEFAULT 0 NOT NULL,
\`tokens_output\` integer DEFAULT 0 NOT NULL,
\`tokens_reasoning\` integer DEFAULT 0 NOT NULL,
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/session/info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.In
}
: undefined,
cost: row.cost,
budget: row.budget ?? undefined,
tokens: {
input: row.tokens_input,
output: row.tokens_output,
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/session/projector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ function sessionRow(info: SessionV1.SessionInfo): typeof SessionTable.$inferInse
summary_diffs: info.summary?.diffs ? [...info.summary.diffs] : undefined,
metadata: info.metadata,
cost: info.cost ?? 0,
budget: info.budget ?? null,
tokens_input: (info.tokens ?? { input: 0 }).input,
tokens_output: (info.tokens ?? { output: 0 }).output,
tokens_reasoning: (info.tokens ?? { reasoning: 0 }).reasoning,
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/session/sql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export const SessionTable = sqliteTable(
summary_diffs: text({ mode: "json" }).$type<Snapshot.LegacyFileDiff[]>(),
metadata: text({ mode: "json" }).$type<Record<string, unknown>>(),
cost: real().notNull().default(0),
budget: real(),
tokens_input: integer().notNull().default(0),
tokens_output: integer().notNull().default(0),
tokens_reasoning: integer().notNull().default(0),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export const UpdatePayload = Schema.Struct({
title: Schema.optional(Schema.String),
metadata: Schema.optional(Session.Metadata),
permission: Schema.optional(PermissionV1.Ruleset),
budget: Schema.optional(Schema.NullOr(Schema.Finite)),
time: Schema.optional(
Schema.Struct({
archived: Schema.optional(Session.ArchivedTimestamp),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,9 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
permission: Permission.merge(current.permission ?? [], ctx.payload.permission),
})
}
if (ctx.payload.budget !== undefined) {
yield* session.setBudget({ sessionID: ctx.params.sessionID, budget: ctx.payload.budget ?? undefined })
}
if (ctx.payload.time?.archived !== undefined) {
yield* session.setArchived({ sessionID: ctx.params.sessionID, time: ctx.payload.time.archived })
}
Expand Down
23 changes: 22 additions & 1 deletion packages/opencode/src/session/processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@ interface ProcessorContext extends Input {
needsCompaction: boolean
currentText: SessionV1.TextPart | undefined
reasoningMap: Record<string, SessionV1.ReasoningPart>
baseCost: number
budget: number | undefined
turnCost: number
budgetExceeded: boolean
}

type StreamEvent = LLMEvent
Expand Down Expand Up @@ -100,6 +104,7 @@ const layer = Layer.effect(
// may execute tools internally before emitting start-step events,
// so capturing inside the event handler can be too late.
const initialSnapshot = yield* snapshot.track()
const current = yield* session.get(input.sessionID).pipe(Effect.orDie)
const ctx: ProcessorContext = {
assistantMessage: input.assistantMessage,
sessionID: input.sessionID,
Expand All @@ -111,6 +116,10 @@ const layer = Layer.effect(
needsCompaction: false,
currentText: undefined,
reasoningMap: {},
baseCost: current.cost ?? 0,
budget: current.budget,
turnCost: 0,
budgetExceeded: false,
}
let aborted = false

Expand Down Expand Up @@ -454,6 +463,18 @@ const layer = Layer.effect(
cost: usage.cost,
})
yield* session.updateMessage(ctx.assistantMessage)
ctx.turnCost += usage.cost
if (ctx.budget !== undefined && ctx.baseCost + ctx.turnCost >= ctx.budget && !ctx.budgetExceeded) {
ctx.budgetExceeded = true
yield* session.updatePart({
id: PartID.ascending(),
messageID: ctx.assistantMessage.id,
sessionID: ctx.sessionID,
type: "text",
text: "Session budget reached. Increase the budget to continue.",
time: { start: Date.now(), end: Date.now() },
})
}
if (ctx.snapshot) {
const patch = yield* snapshot.patch(ctx.snapshot)
if (patch.files.length) {
Expand Down Expand Up @@ -677,7 +698,7 @@ const layer = Layer.effect(
)

if (ctx.needsCompaction) return "compact"
if (ctx.blocked || ctx.assistantMessage.error) return "stop"
if (ctx.budgetExceeded || ctx.blocked || ctx.assistantMessage.error) return "stop"
return "continue"
})
})
Expand Down
6 changes: 6 additions & 0 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1129,6 +1129,12 @@ const layer = Layer.effect(
break
}

const sessionState = yield* sessions.get(sessionID).pipe(Effect.orDie)
if (sessionState.budget !== undefined && (sessionState.cost ?? 0) >= sessionState.budget) {
yield* status.set(sessionID, { type: "idle" })
break
}

step++
if (step === 1)
yield* title({
Expand Down
15 changes: 15 additions & 0 deletions packages/opencode/src/session/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ export function fromRow(row: SessionRow): Info {
version: row.version,
summary,
cost: row.cost,
budget: row.budget ?? undefined,
tokens: {
input: row.tokens_input,
output: row.tokens_output,
Expand Down Expand Up @@ -137,6 +138,7 @@ export function toRow(info: Info) {
summary_diffs: info.summary?.diffs,
metadata: info.metadata,
cost: info.cost ?? 0,
budget: info.budget ?? null,
tokens_input: (info.tokens ?? EmptyTokens).input,
tokens_output: (info.tokens ?? EmptyTokens).output,
tokens_reasoning: (info.tokens ?? EmptyTokens).reasoning,
Expand Down Expand Up @@ -231,6 +233,7 @@ export const Info = Schema.Struct({
parentID: optional(SessionID),
summary: optional(Summary),
cost: optional(Schema.Finite),
budget: optional(Schema.Finite),
tokens: optional(Tokens),
share: optional(Share),
title: Schema.String,
Expand Down Expand Up @@ -265,6 +268,7 @@ export const CreateInput = Schema.optional(
model: Schema.optional(Model),
metadata: Schema.optional(Metadata),
permission: Schema.optional(PermissionV1.Ruleset),
budget: Schema.optional(Schema.Finite),
workspaceID: Schema.optional(WorkspaceV2.ID),
}),
)
Expand Down Expand Up @@ -422,6 +426,7 @@ export interface Interface {
model?: Schema.Schema.Type<typeof Model>
metadata?: typeof Metadata.Type
permission?: PermissionV1.Ruleset
budget?: number
workspaceID?: WorkspaceV2.ID
}) => Effect.Effect<Info>
readonly fork: (input: { sessionID: SessionID; messageID?: MessageID }) => Effect.Effect<Info, NotFound>
Expand All @@ -430,6 +435,7 @@ export interface Interface {
readonly setTitle: (input: { sessionID: SessionID; title: string }) => Effect.Effect<void>
readonly setArchived: (input: { sessionID: SessionID; time?: number }) => Effect.Effect<void>
readonly setMetadata: (input: typeof SetMetadataInput.Type) => Effect.Effect<void>
readonly setBudget: (input: { sessionID: SessionID; budget?: number }) => Effect.Effect<void>
readonly setAgentModel: (input: {
sessionID: SessionID
agent: string
Expand Down Expand Up @@ -509,6 +515,7 @@ const layer: Layer.Layer<
path?: string
metadata?: typeof Metadata.Type
permission?: PermissionV1.Ruleset
budget?: number
}) {
const ctx = yield* InstanceState.context
const result: Info = {
Expand All @@ -526,6 +533,7 @@ const layer: Layer.Layer<
metadata: input.metadata,
permission: input.permission ? [...input.permission] : undefined,
cost: 0,
budget: input.budget,
tokens: EmptyTokens,
time: {
created: Date.now(),
Expand Down Expand Up @@ -673,6 +681,7 @@ const layer: Layer.Layer<
model?: Schema.Schema.Type<typeof Model>
metadata?: typeof Metadata.Type
permission?: PermissionV1.Ruleset
budget?: number
workspaceID?: WorkspaceV2.ID
}) {
const ctx = yield* InstanceState.context
Expand All @@ -686,6 +695,7 @@ const layer: Layer.Layer<
model: input?.model,
metadata: input?.metadata,
permission: input?.permission,
budget: input?.budget,
workspaceID: input?.workspaceID ?? workspace,
})
})
Expand Down Expand Up @@ -764,6 +774,10 @@ const layer: Layer.Layer<
yield* patch(input.sessionID, { metadata: input.metadata, time: { updated: Date.now() } }).pipe(Effect.orDie)
})

const setBudget = Effect.fn("Session.setBudget")(function* (input: { sessionID: SessionID; budget?: number }) {
yield* patch(input.sessionID, { budget: input.budget, time: { updated: Date.now() } }).pipe(Effect.orDie)
})

const setAgentModel = Effect.fn("Session.setAgentModel")(function* (input: {
sessionID: SessionID
agent: string
Expand Down Expand Up @@ -915,6 +929,7 @@ const layer: Layer.Layer<
setTitle,
setArchived,
setMetadata,
setBudget,
setAgentModel,
setPermission,
setRevert,
Expand Down
47 changes: 46 additions & 1 deletion packages/opencode/test/session/processor-effect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,8 +285,53 @@ it.live("session.processor effect tests capture llm input cleanly", () =>
),
)

it.live("session.processor effect tests preserve text start time", () =>
it.live("session.processor effect tests stop when the session budget is exceeded", () =>
provideTmpdirServer(
({ dir, llm }) =>
Effect.gen(function* () {
const { processors, session, provider } = yield* boot()

yield* llm.text("hello")

const chat = yield* session.create({ budget: 0 })
const parent = yield* user(chat.id, "hi")
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
const mdl = yield* provider.getModel(ref.providerID, ref.modelID)
const handle = yield* processors.create({
assistantMessage: msg,
sessionID: chat.id,
model: mdl,
})

const value = yield* handle.process({
user: {
id: parent.id,
sessionID: chat.id,
role: "user",
time: parent.time,
agent: parent.agent,
model: { providerID: ref.providerID, modelID: ref.modelID },
} satisfies SessionV1.User,
sessionID: chat.id,
model: mdl,
agent: agent(),
system: [],
messages: [{ role: "user", content: "hi" }],
tools: {},
} satisfies LLM.StreamInput)
const parts = yield* MessageV2.parts(msg.id)

expect(value).toBe("stop")
expect(
parts.some((part) => part.type === "text" && part.text === "Session budget reached. Increase the budget to continue."),
).toBe(true)
expect(msg.finish).toBe("stop")
}),
{ config: (url) => providerCfg(url) },
),
)

it.live("session.processor effect tests preserve text start time", () => provideTmpdirServer(
({ dir, llm }) =>
Effect.gen(function* () {
const database = yield* Database.Service
Expand Down
21 changes: 21 additions & 0 deletions packages/opencode/test/session/prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,27 @@ noLLMServer.instance(
{ config: cfg },
)

it.instance("loop stops without an LLM request when the session budget is exhausted", () =>
Effect.gen(function* () {
const { llm } = yield* useServerConfig(providerCfg)
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const chat = yield* sessions.create({ title: "Pinned", budget: 0 })
const seeded = yield* seed(chat.id, { finish: "stop" })
yield* prompt.prompt({
sessionID: chat.id,
agent: "build",
noReply: true,
parts: [{ type: "text", text: "continue please" }],
})

const result = yield* prompt.loop({ sessionID: chat.id })

expect(result.info.id).toBe(seeded.assistant.id)
expect(yield* llm.hits).toHaveLength(0)
}),
)

noLLMServer.instance(
"loop exits for a completed parent turn with nonmonotonic message IDs",
() =>
Expand Down
1 change: 1 addition & 0 deletions packages/opencode/test/session/schema-decoding.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ describe("Session.Info", () => {
title: "Full session",
version: "1.0.0",
metadata: { source: "test" },
budget: 2.5,
time: { created: 100, updated: 200, compacting: 150, archived: 300 },
permission: [{ action: "allow" as const, pattern: "*", permission: "read" }],
revert: {
Expand Down
1 change: 1 addition & 0 deletions packages/schema/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export const Info = Schema.Struct({
agent: Agent.ID.pipe(optional),
model: Model.Ref.pipe(optional),
cost: Schema.Finite,
budget: Schema.Finite.pipe(optional),
tokens: Schema.Struct({
input: Schema.Finite,
output: Schema.Finite,
Expand Down
1 change: 1 addition & 0 deletions packages/schema/src/v1/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -550,6 +550,7 @@ export const SessionInfo = Schema.Struct({
parentID: optional(SessionID),
summary: optional(SessionSummary),
cost: optional(Schema.Finite),
budget: optional(Schema.Finite),
tokens: optional(SessionTokens),
share: optional(SessionShare),
title: Schema.String,
Expand Down
Loading
Loading