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
2 changes: 1 addition & 1 deletion src/config/schema/ralph-loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ export const RalphLoopConfigSchema = z.object({
default_max_iterations: z.number().min(1).max(1000).default(100),
/** Custom state file directory relative to project root (default: .opencode/) */
state_dir: z.string().optional(),
default_strategy: z.enum(["reset", "continue"]).default("continue"),
default_strategy: z.enum(["reset", "continue"]).default("reset"),
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Changing the schema default from continue to reset silently changes behavior for configs that omit default_strategy.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/config/schema/ralph-loop.ts, line 8:

<comment>Changing the schema default from `continue` to `reset` silently changes behavior for configs that omit `default_strategy`.</comment>

<file context>
@@ -5,7 +5,7 @@ export const RalphLoopConfigSchema = z.object({
   /** Custom state file directory relative to project root (default: .opencode/) */
   state_dir: z.string().optional(),
-  default_strategy: z.enum(["reset", "continue"]).default("continue"),
+  default_strategy: z.enum(["reset", "continue"]).default("reset"),
 })
 
</file context>
Suggested change
default_strategy: z.enum(["reset", "continue"]).default("reset"),
+ default_strategy: z.enum(["reset", "continue"]).default("continue"),
Fix with Cubic

})

export type RalphLoopConfig = z.infer<typeof RalphLoopConfigSchema>
63 changes: 63 additions & 0 deletions src/features/background-agent/spawner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ import {
clearSessionPromptParams,
getSessionPromptParams,
} from "../../shared/session-prompt-params-state"
import {
_resetMemCacheForTesting,
writeProviderModelsCache,
} from "../../shared/connected-providers-cache"

describe("background-agent spawner agent-not-found fallback", () => {
afterEach(() => {
Expand Down Expand Up @@ -324,6 +328,65 @@ describe("background-agent spawner agent-not-found fallback", () => {
describe("background-agent spawner fallback model promotion", () => {
afterEach(() => {
clearSessionPromptParams("session-123")
_resetMemCacheForTesting()
})

test("detaches parent context for small-context models", async () => {
//#given
writeProviderModelsCache({
connected: ["ollama"],
models: {
ollama: [{ id: "qwen2.5:14b", context: 16_384 }],
},
})

const createCalls: Array<Record<string, unknown>> = []
const client = {
session: {
get: async () => ({ data: { directory: "/parent/dir" } }),
create: async (input: Record<string, unknown>) => {
createCalls.push(input)
return { data: { id: "ses_child_detached" } }
},
promptAsync: async () => ({}),
},
}

const task = createTask({
description: "Test task",
prompt: "Do work",
agent: "sisyphus-junior",
parentSessionID: "ses_parent",
parentMessageID: "msg_parent",
model: { providerID: "ollama", modelID: "qwen2.5:14b" },
})

//#when
await startTask({
task,
input: {
description: task.description,
prompt: task.prompt,
agent: task.agent,
parentSessionID: task.parentSessionID,
parentMessageID: task.parentMessageID,
parentModel: task.parentModel,
parentAgent: task.parentAgent,
model: task.model,
},
} as never, {
client: client as never,
directory: "/fallback",
concurrencyManager: { release: () => {} } as never,
tmuxEnabled: false,
onTaskError: () => {},
})
await new Promise((resolve) => setTimeout(resolve, 0))

//#then
expect(createCalls[0]?.body).toEqual({
title: "Test task",
})
})

test("passes promoted fallback model settings through supported prompt channels", async () => {
Expand Down
16 changes: 11 additions & 5 deletions src/features/background-agent/spawner.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import type { BackgroundTask, LaunchInput, ResumeInput } from "./types"
import type { OpencodeClient, OnSubagentSessionCreated, QueueItem } from "./constants"
import { TMUX_CALLBACK_DELAY_MS } from "./constants"
import { log, getAgentToolRestrictions, promptWithModelSuggestionRetry, createInternalAgentTextPart } from "../../shared"
import { log } from "../../shared/logger"
import { getAgentToolRestrictions } from "../../shared/agent-tool-restrictions"
import { promptWithModelSuggestionRetry } from "../../shared/model-suggestion-retry"
import { createInternalAgentTextPart } from "../../shared/internal-initiator-marker"
import { buildSubagentSessionCreateBody } from "../../shared/subagent-context-window"
import { applySessionPromptParams } from "../../shared/session-prompt-params-helpers"
import { subagentSessions } from "../claude-code-session-state"
import { getTaskToastManager } from "../task-toast-manager"
Expand Down Expand Up @@ -95,10 +99,12 @@ export async function startTask(
log(`[background-agent] Parent dir: ${parentSession?.data?.directory}, using: ${parentDirectory}`)

const createResult = await client.session.create({
body: {
parentID: input.parentSessionID,
...(input.sessionPermission ? { permission: input.sessionPermission } : {}),
} as Record<string, unknown>,
body: buildSubagentSessionCreateBody({
parentSessionID: input.parentSessionID,
title: input.description,
permission: input.sessionPermission,
model: input.model,
}),
Comment on lines +102 to +107
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Move detached-session logic into the active background path

This change applies buildSubagentSessionCreateBody(...) only in spawner.startTask, but background delegations in production go through BackgroundManager.launch() (used by both src/tools/delegate-task/background-task.ts and src/tools/call-omo-agent/background-executor.ts), which calls BackgroundManager.startTask in src/features/background-agent/manager.ts and still unconditionally sets parentID. That means small-context background subagents still inherit the full parent transcript and can hit the same prompt/context overflow this commit is trying to prevent.

Useful? React with 👍 / 👎.

query: {
directory: parentDirectory,
},
Expand Down
87 changes: 87 additions & 0 deletions src/hooks/ralph-loop/default-reset-strategy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { describe, expect, test } from "bun:test"
import { createLoopStateController } from "./loop-state-controller"
import { continueIteration } from "./iteration-continuation"
import type { RalphLoopState } from "./types"

describe("ralph-loop default reset strategy", () => {
test("#given no strategy is configured #when loop starts #then state defaults to reset", () => {
const loopState = createLoopStateController({
directory: process.cwd(),
stateDir: ".tmp-ralph-loop-default-reset-test",
config: undefined,
})

const started = loopState.startLoop("session-123", "Ship fix")

expect(started).toBe(true)
expect(loopState.getState()?.strategy).toBe("reset")
loopState.clear()
})

test("#given legacy state without strategy #when loop continues #then it creates a fresh session", async () => {
const createCalls: Array<{ parentID?: string; directory?: string }> = []
const promptCalls: Array<{ sessionID: string; text: string }> = []
const selectCalls: string[] = []
let currentSessionID = "session-123"

const state: RalphLoopState = {
active: true,
iteration: 2,
max_iterations: 100,
completion_promise: "DONE",
started_at: new Date().toISOString(),
prompt: "Ship fix",
session_id: currentSessionID,
}

await continueIteration(
{
directory: process.cwd(),
client: {
session: {
create: async (options: { body: { parentID?: string }; query?: { directory?: string } }) => {
createCalls.push({
parentID: options.body.parentID,
directory: options.query?.directory,
})

return { data: { id: "session-456" } }
},
promptAsync: async (options: { path: { id: string }; body: { parts: Array<{ text: string }> } }) => {
promptCalls.push({
sessionID: options.path.id,
text: options.body.parts[0]?.text ?? "",
})

return {}
},
},
tui: {
selectSession: async (options: { body: { sessionID: string } }) => {
selectCalls.push(options.body.sessionID)
return {}
},
},
},
} as Parameters<typeof continueIteration>[0],
state,
{
directory: process.cwd(),
apiTimeoutMs: 100,
previousSessionID: "session-123",
loopState: {
setSessionID: (sessionID: string) => {
currentSessionID = sessionID
return { ...state, session_id: sessionID }
},
},
},
)

expect(createCalls).toEqual([{ parentID: "session-123", directory: process.cwd() }])
expect(promptCalls).toHaveLength(1)
expect(promptCalls[0]?.sessionID).toBe("session-456")
expect(selectCalls).toEqual(["session-456"])
expect(currentSessionID).toBe("session-456")
})
})
2 changes: 1 addition & 1 deletion src/hooks/ralph-loop/iteration-continuation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export async function continueIteration(
state: RalphLoopState,
options: ContinuationOptions,
): Promise<void> {
const strategy = state.strategy ?? "continue"
const strategy = state.strategy ?? "reset"
const continuationPrompt = buildContinuationPrompt(state)

if (strategy === "reset") {
Expand Down
2 changes: 1 addition & 1 deletion src/hooks/ralph-loop/loop-state-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export function createLoopStateController(options: {
verification_session_id: undefined,
ultrawork: loopOptions?.ultrawork,
verification_pending: undefined,
strategy: loopOptions?.strategy ?? config?.default_strategy ?? "continue",
strategy: loopOptions?.strategy ?? config?.default_strategy ?? "reset",
started_at: new Date().toISOString(),
prompt,
session_id: sessionID,
Expand Down
1 change: 1 addition & 0 deletions src/shared/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,4 @@ export { SessionCategoryRegistry } from "./session-category-registry"
export * from "./plugin-identity"
export * from "./log-legacy-plugin-startup-warning"
export * from "./task-system-enabled"
export * from "./subagent-context-window"
40 changes: 39 additions & 1 deletion src/shared/opencode-config-dir.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
import { homedir } from "node:os"
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
import { homedir, tmpdir } from "node:os"
import { join, resolve, win32 } from "node:path"
import {
getOpenCodeConfigDir,
Expand All @@ -13,6 +14,7 @@ import {
describe("opencode-config-dir", () => {
let originalPlatform: NodeJS.Platform
let originalEnv: Record<string, string | undefined>
const tempDirs: string[] = []

beforeEach(() => {
originalPlatform = process.platform
Expand All @@ -33,6 +35,10 @@ describe("opencode-config-dir", () => {
delete process.env[key]
}
}

for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true })
}
})

describe("OPENCODE_CONFIG_DIR environment variable", () => {
Expand Down Expand Up @@ -307,6 +313,38 @@ describe("opencode-config-dir", () => {
expect(paths.packageJson).toBe(join(expectedDir, "package.json"))
expect(paths.omoConfig).toBe(join(expectedDir, "oh-my-openagent.json"))
})

test("returns the detected legacy plugin config when it is the only plugin config file", () => {
// given
const configDir = mkdtempSync(join(tmpdir(), "omo-config-paths-legacy-"))
tempDirs.push(configDir)
writeFileSync(join(configDir, "oh-my-opencode.jsonc"), "{}")
process.env.OPENCODE_CONFIG_DIR = configDir
Object.defineProperty(process, "platform", { value: "linux" })

// when
const paths = getOpenCodeConfigPaths({ binary: "opencode", version: "1.0.200" })

// then
expect(paths.omoConfig).toBe(join(configDir, "oh-my-opencode.jsonc"))
})

test("prefers the canonical basename when canonical and legacy plugin config files both exist", () => {
// given
const configDir = mkdtempSync(join(tmpdir(), "omo-config-paths-canonical-"))
tempDirs.push(configDir)
mkdirSync(configDir, { recursive: true })
writeFileSync(join(configDir, "oh-my-opencode.jsonc"), "{}")
writeFileSync(join(configDir, "oh-my-openagent.json"), "{}")
process.env.OPENCODE_CONFIG_DIR = configDir
Object.defineProperty(process, "platform", { value: "linux" })

// when
const paths = getOpenCodeConfigPaths({ binary: "opencode", version: "1.0.200" })

// then
expect(paths.omoConfig).toBe(join(configDir, "oh-my-openagent.json"))
})
})

describe("detectExistingConfigDir", () => {
Expand Down
7 changes: 6 additions & 1 deletion src/shared/opencode-config-dir.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { existsSync, realpathSync } from "node:fs"
import { homedir } from "node:os"
import { join, resolve, win32 } from "node:path"

import { detectPluginConfigFile } from "./jsonc-parser"
import { CONFIG_BASENAME } from "./plugin-identity"

import type {
Expand Down Expand Up @@ -93,13 +94,17 @@ export function getOpenCodeConfigDir(options: OpenCodeConfigDirOptions): string

export function getOpenCodeConfigPaths(options: OpenCodeConfigDirOptions): OpenCodeConfigPaths {
const configDir = getOpenCodeConfigDir(options)
const detectedPluginConfig = detectPluginConfigFile(configDir)
const omoConfig = detectedPluginConfig.format !== "none"
? detectedPluginConfig.path
: join(configDir, `${CONFIG_BASENAME}.json`)

return {
configDir,
configJson: join(configDir, "opencode.json"),
configJsonc: join(configDir, "opencode.jsonc"),
packageJson: join(configDir, "package.json"),
omoConfig: join(configDir, `${CONFIG_BASENAME}.json`),
omoConfig,
}
}

Expand Down
72 changes: 72 additions & 0 deletions src/shared/subagent-context-window.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import type { DelegatedModelConfig } from "./model-resolution-types"
import { findProviderModelMetadata, readProviderModelsCache } from "./connected-providers-cache"

const SMALL_SUBAGENT_CONTEXT_WINDOW_TOKENS = 32_768
const SUBAGENT_PROMPT_CONTEXT_RATIO = 0.5
const MIN_SUBAGENT_PROMPT_BUDGET_TOKENS = 2_048

function normalizeContextLimit(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) && value > 0
? value
: undefined
}

export function resolveModelContextWindowTokens(
model: DelegatedModelConfig | undefined,
): number | undefined {
if (!model) {
return undefined
}

const metadata = findProviderModelMetadata(
model.providerID,
model.modelID,
readProviderModelsCache(),
)

return normalizeContextLimit(metadata?.limit?.context)
?? normalizeContextLimit(metadata?.limit?.input)
?? normalizeContextLimit(metadata?.context)
}

export function shouldDetachSubagentSession(
model: DelegatedModelConfig | undefined,
): boolean {
const contextWindow = resolveModelContextWindowTokens(model)
return contextWindow !== undefined && contextWindow <= SMALL_SUBAGENT_CONTEXT_WINDOW_TOKENS
}

export function resolveSubagentPromptTokenBudget(
model: DelegatedModelConfig | undefined,
): number | undefined {
const contextWindow = resolveModelContextWindowTokens(model)
if (contextWindow === undefined || contextWindow > SMALL_SUBAGENT_CONTEXT_WINDOW_TOKENS) {
return undefined
}

return Math.max(
MIN_SUBAGENT_PROMPT_BUDGET_TOKENS,
Math.floor(contextWindow * SUBAGENT_PROMPT_CONTEXT_RATIO),
)
}

export function buildSubagentSessionCreateBody(input: {
parentSessionID: string
title: string
permission?: unknown
model?: DelegatedModelConfig
}): Record<string, unknown> {
const body: Record<string, unknown> = {
title: input.title,
}

if (input.permission !== undefined) {
body.permission = input.permission
}

if (!shouldDetachSubagentSession(input.model)) {
body.parentID = input.parentSessionID
}

return body
}
Loading
Loading