From caf5d53ab93c593c0a5f2dfbc195c503de5cb2cb Mon Sep 17 00:00:00 2001 From: MoerAI Date: Wed, 15 Apr 2026 01:15:10 +0900 Subject: [PATCH 1/3] fix(ralph-loop): ensure new session starts on /ralph-loop invocation (fixes #1900) --- src/config/schema/ralph-loop.ts | 2 +- .../ralph-loop/default-reset-strategy.test.ts | 87 +++++++++++++++++++ .../ralph-loop/iteration-continuation.ts | 2 +- src/hooks/ralph-loop/loop-state-controller.ts | 2 +- 4 files changed, 90 insertions(+), 3 deletions(-) create mode 100644 src/hooks/ralph-loop/default-reset-strategy.test.ts diff --git a/src/config/schema/ralph-loop.ts b/src/config/schema/ralph-loop.ts index ba061d2876d..0390c72fe48 100644 --- a/src/config/schema/ralph-loop.ts +++ b/src/config/schema/ralph-loop.ts @@ -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"), }) export type RalphLoopConfig = z.infer diff --git a/src/hooks/ralph-loop/default-reset-strategy.test.ts b/src/hooks/ralph-loop/default-reset-strategy.test.ts new file mode 100644 index 00000000000..11109802867 --- /dev/null +++ b/src/hooks/ralph-loop/default-reset-strategy.test.ts @@ -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[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") + }) +}) diff --git a/src/hooks/ralph-loop/iteration-continuation.ts b/src/hooks/ralph-loop/iteration-continuation.ts index be067b76c8a..ab1a9cc0b65 100644 --- a/src/hooks/ralph-loop/iteration-continuation.ts +++ b/src/hooks/ralph-loop/iteration-continuation.ts @@ -20,7 +20,7 @@ export async function continueIteration( state: RalphLoopState, options: ContinuationOptions, ): Promise { - const strategy = state.strategy ?? "continue" + const strategy = state.strategy ?? "reset" const continuationPrompt = buildContinuationPrompt(state) if (strategy === "reset") { diff --git a/src/hooks/ralph-loop/loop-state-controller.ts b/src/hooks/ralph-loop/loop-state-controller.ts index 2a455412a18..06c9aa0a408 100644 --- a/src/hooks/ralph-loop/loop-state-controller.ts +++ b/src/hooks/ralph-loop/loop-state-controller.ts @@ -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, From cf5c2e3e2ec21af3e8457ab80f2d9af4e37f5e83 Mon Sep 17 00:00:00 2001 From: MoerAI Date: Wed, 15 Apr 2026 01:40:14 +0900 Subject: [PATCH 2/3] fix(plugin-config): ensure both legacy and renamed config files are discovered (fixes #3133) --- src/shared/opencode-config-dir.test.ts | 40 +++++++++++++++++++++++++- src/shared/opencode-config-dir.ts | 7 ++++- 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/src/shared/opencode-config-dir.test.ts b/src/shared/opencode-config-dir.test.ts index 7ddf8e009aa..1eb8d6c865f 100644 --- a/src/shared/opencode-config-dir.test.ts +++ b/src/shared/opencode-config-dir.test.ts @@ -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, @@ -13,6 +14,7 @@ import { describe("opencode-config-dir", () => { let originalPlatform: NodeJS.Platform let originalEnv: Record + const tempDirs: string[] = [] beforeEach(() => { originalPlatform = process.platform @@ -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", () => { @@ -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", () => { diff --git a/src/shared/opencode-config-dir.ts b/src/shared/opencode-config-dir.ts index 691d0e2c434..58cccd10135 100644 --- a/src/shared/opencode-config-dir.ts +++ b/src/shared/opencode-config-dir.ts @@ -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 { @@ -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, } } From 9da9119bc0e88191e085a410ab1d432e581d6a1d Mon Sep 17 00:00:00 2001 From: MoerAI Date: Wed, 15 Apr 2026 02:13:15 +0900 Subject: [PATCH 3/3] fix(delegate-task): guard subagent prompt size against model context limits (fixes #951) --- src/features/background-agent/spawner.test.ts | 63 ++++++++++++++++ src/features/background-agent/spawner.ts | 16 +++-- src/shared/index.ts | 1 + src/shared/subagent-context-window.ts | 72 +++++++++++++++++++ .../call-omo-agent/session-creator.test.ts | 59 +++++++++++++++ src/tools/call-omo-agent/session-creator.ts | 14 ++-- src/tools/call-omo-agent/sync-executor.ts | 2 +- .../delegate-task/prompt-builder.test.ts | 43 ++++++++++- src/tools/delegate-task/prompt-builder.ts | 2 + .../sync-session-creator.test.ts | 46 ++++++++++++ .../delegate-task/sync-session-creator.ts | 17 +++-- src/tools/delegate-task/sync-task.ts | 1 + src/tools/delegate-task/token-limiter.ts | 66 ++++++++++++++++- 13 files changed, 385 insertions(+), 17 deletions(-) create mode 100644 src/shared/subagent-context-window.ts diff --git a/src/features/background-agent/spawner.test.ts b/src/features/background-agent/spawner.test.ts index b1f486c52a3..b6ee3bce3a1 100644 --- a/src/features/background-agent/spawner.test.ts +++ b/src/features/background-agent/spawner.test.ts @@ -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(() => { @@ -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> = [] + const client = { + session: { + get: async () => ({ data: { directory: "/parent/dir" } }), + create: async (input: Record) => { + 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 () => { diff --git a/src/features/background-agent/spawner.ts b/src/features/background-agent/spawner.ts index 675aeb5d93c..13a2171952c 100644 --- a/src/features/background-agent/spawner.ts +++ b/src/features/background-agent/spawner.ts @@ -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" @@ -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, + body: buildSubagentSessionCreateBody({ + parentSessionID: input.parentSessionID, + title: input.description, + permission: input.sessionPermission, + model: input.model, + }), query: { directory: parentDirectory, }, diff --git a/src/shared/index.ts b/src/shared/index.ts index 485926bfd86..b1b25b0f59d 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -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" diff --git a/src/shared/subagent-context-window.ts b/src/shared/subagent-context-window.ts new file mode 100644 index 00000000000..3522a6e1c06 --- /dev/null +++ b/src/shared/subagent-context-window.ts @@ -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 { + const body: Record = { + title: input.title, + } + + if (input.permission !== undefined) { + body.permission = input.permission + } + + if (!shouldDetachSubagentSession(input.model)) { + body.parentID = input.parentSessionID + } + + return body +} diff --git a/src/tools/call-omo-agent/session-creator.test.ts b/src/tools/call-omo-agent/session-creator.test.ts index db231651df1..7e1c7458dfe 100644 --- a/src/tools/call-omo-agent/session-creator.test.ts +++ b/src/tools/call-omo-agent/session-creator.test.ts @@ -2,8 +2,65 @@ import { describe, expect, test } from "bun:test" import { createOrGetSession } from "./session-creator" import { _resetForTesting, subagentSessions } from "../../features/claude-code-session-state" +import { + _resetMemCacheForTesting, + writeProviderModelsCache, +} from "../../shared/connected-providers-cache" describe("call-omo-agent createOrGetSession", () => { + test("creates detached session for small-context models", async () => { + // given + _resetForTesting() + writeProviderModelsCache({ + connected: ["ollama"], + models: { + ollama: [{ id: "qwen2.5:14b", context: 16_384 }], + }, + }) + + const createCalls: Array = [] + const ctx = { + directory: "/project", + client: { + session: { + get: async () => ({ data: { directory: "/parent" } }), + create: async (args: unknown) => { + createCalls.push(args) + return { data: { id: "ses_child" } } + }, + }, + }, + } + + const toolContext = { + sessionID: "ses_parent", + messageID: "msg_parent", + agent: "sisyphus", + abort: new AbortController().signal, + } + + const args = { + description: "test", + prompt: "hello", + subagent_type: "explore", + run_in_background: true, + } + + // when + await createOrGetSession(args as never, toolContext as never, ctx as never, { + providerID: "ollama", + modelID: "qwen2.5:14b", + }) + + // then + const createBody = (createCalls[0] as { body?: Record })?.body + expect(createBody).toEqual({ + title: "test (@explore subagent)", + }) + + _resetMemCacheForTesting() + }) + test("creates child session without overriding permission and tracks it as subagent session", async () => { // given _resetForTesting() @@ -46,5 +103,7 @@ describe("call-omo-agent createOrGetSession", () => { expect(createBody?.parentID).toBe("ses_parent") expect(createBody?.permission).toBeUndefined() expect(subagentSessions.has("ses_child")).toBe(true) + + _resetMemCacheForTesting() }) }) diff --git a/src/tools/call-omo-agent/session-creator.ts b/src/tools/call-omo-agent/session-creator.ts index 1273b92162e..9b5fef13548 100644 --- a/src/tools/call-omo-agent/session-creator.ts +++ b/src/tools/call-omo-agent/session-creator.ts @@ -1,7 +1,9 @@ import type { CallOmoAgentArgs } from "./types" import type { PluginInput } from "@opencode-ai/plugin" +import type { DelegatedModelConfig } from "../../shared/model-resolution-types" import { subagentSessions, syncSubagentSessions } from "../../features/claude-code-session-state" -import { log } from "../../shared" +import { log } from "../../shared/logger" +import { buildSubagentSessionCreateBody } from "../../shared/subagent-context-window" export async function createOrGetSession( args: CallOmoAgentArgs, @@ -12,7 +14,8 @@ export async function createOrGetSession( abort: AbortSignal metadata?: (input: { title?: string; metadata?: Record }) => void }, - ctx: PluginInput + ctx: PluginInput, + model?: DelegatedModelConfig, ): Promise<{ sessionID: string; isNew: boolean }> { if (args.session_id) { log(`[call_omo_agent] Using existing session: ${args.session_id}`) @@ -36,10 +39,11 @@ export async function createOrGetSession( const parentDirectory = parentSession?.data?.directory ?? ctx.directory const createResult = await ctx.client.session.create({ - body: { - parentID: toolContext.sessionID, + body: buildSubagentSessionCreateBody({ + parentSessionID: toolContext.sessionID, title: `${args.description} (@${args.subagent_type} subagent)`, - } as Record, + model, + }), query: { directory: parentDirectory, }, diff --git a/src/tools/call-omo-agent/sync-executor.ts b/src/tools/call-omo-agent/sync-executor.ts index 23089d8ea08..63a3d45ebed 100644 --- a/src/tools/call-omo-agent/sync-executor.ts +++ b/src/tools/call-omo-agent/sync-executor.ts @@ -74,7 +74,7 @@ export async function executeSync( let appliedFallbackChain = false try { - const session = await deps.createOrGetSession(args, toolContext, ctx) + const session = await deps.createOrGetSession(args, toolContext, ctx, model) sessionID = session.sessionID createdSessionForExecution = session.isNew subagentSessions.add(sessionID) diff --git a/src/tools/delegate-task/prompt-builder.test.ts b/src/tools/delegate-task/prompt-builder.test.ts index 9c31fdefc27..85ddc85ac94 100644 --- a/src/tools/delegate-task/prompt-builder.test.ts +++ b/src/tools/delegate-task/prompt-builder.test.ts @@ -1,9 +1,11 @@ declare const require: (name: string) => unknown -const { describe, test, expect } = require("bun:test") as { +const { describe, test, expect, afterEach } = require("bun:test") as { describe: (name: string, fn: () => void) => void test: (name: string, fn: () => void) => void + afterEach: (fn: () => void) => void expect: (value: unknown) => { toBe: (expected: unknown) => void + toBeLessThanOrEqual: (expected: number) => void toContain: (expected: string) => void toBeUndefined: () => void toBeDefined: () => void @@ -15,9 +17,18 @@ const { describe, test, expect } = require("bun:test") as { } import { buildSystemContent } from "./prompt-builder" +import { estimateTokenCount } from "./token-limiter" import type { AvailableSkill, AvailableCategory } from "../../agents/dynamic-agent-prompt-builder" +import { + _resetMemCacheForTesting, + writeProviderModelsCache, +} from "../../shared/connected-providers-cache" describe("prompt-builder", () => { + afterEach(() => { + _resetMemCacheForTesting() + }) + describe("buildSystemContent", () => { describe("#given non-plan agent with availableSkills", () => { test("#when availableSkills contains project-level skills #then system content includes available_skills section", () => { @@ -120,6 +131,36 @@ describe("prompt-builder", () => { expect(result).toContain("Custom agent context here") expect(result).toContain("deploy-skill") }) + + test("#when model has a 16k context window #then examples are trimmed before required instructions", () => { + // given + writeProviderModelsCache({ + connected: ["ollama"], + models: { + ollama: [{ id: "qwen2.5:14b", context: 16_384 }], + }, + }) + + const result = buildSystemContent({ + agentName: "sisyphus-junior", + model: { providerID: "ollama", modelID: "qwen2.5:14b" }, + agentsContext: [ + "MANDATORY: Keep this instruction.", + Array.from({ length: 2000 }, () => [ + "Example walkthrough:", + "```typescript", + "task(subagent_type=\"explore\", run_in_background=true)", + "```", + "WRONG: do the slow thing", + ].join("\n")).join("\n\n"), + ].join("\n\n"), + }) + + // then + expect(result).toBeDefined() + expect(result).toContain("MANDATORY: Keep this instruction.") + expect(estimateTokenCount(result ?? "")).toBeLessThanOrEqual(8_192) + }) }) }) }) diff --git a/src/tools/delegate-task/prompt-builder.ts b/src/tools/delegate-task/prompt-builder.ts index 838fac93fd0..295f8c57ff3 100644 --- a/src/tools/delegate-task/prompt-builder.ts +++ b/src/tools/delegate-task/prompt-builder.ts @@ -2,6 +2,7 @@ import type { BuildSystemContentInput } from "./types" import type { AvailableSkill } from "../../agents/dynamic-agent-prompt-builder" import { buildPlanAgentSystemPrepend, isPlanAgent } from "./constants" import { buildSystemContentWithTokenLimit } from "./token-limiter" +import { resolveSubagentPromptTokenBudget } from "../../shared/subagent-context-window" const FREE_OR_LOCAL_PROMPT_TOKEN_LIMIT = 24000 const PLAN_AGENT_PROMPT_BASE = ` @@ -83,6 +84,7 @@ export function buildSystemContent(input: BuildSystemContentInput): string | und : baseAgentsContext const effectiveMaxPromptTokens = maxPromptTokens + ?? resolveSubagentPromptTokenBudget(model) ?? (usesFreeOrLocalModel(model) ? FREE_OR_LOCAL_PROMPT_TOKEN_LIMIT : undefined) return buildSystemContentWithTokenLimit( diff --git a/src/tools/delegate-task/sync-session-creator.test.ts b/src/tools/delegate-task/sync-session-creator.test.ts index 1987afcb218..9059bdc144c 100644 --- a/src/tools/delegate-task/sync-session-creator.test.ts +++ b/src/tools/delegate-task/sync-session-creator.test.ts @@ -1,8 +1,52 @@ import { describe, expect, test } from "bun:test" import { createSyncSession } from "./sync-session-creator" +import { + _resetMemCacheForTesting, + writeProviderModelsCache, +} from "../../shared/connected-providers-cache" describe("createSyncSession", () => { + test("creates detached session for small-context models", async () => { + // given + writeProviderModelsCache({ + connected: ["ollama"], + models: { + ollama: [{ id: "qwen2.5:14b", context: 16_384 }], + }, + }) + + const createCalls: Array> = [] + const client = { + session: { + get: async () => ({ data: { directory: "/parent" } }), + create: async (input: Record) => { + createCalls.push(input) + return { data: { id: "ses_child" } } + }, + }, + } + + // when + await createSyncSession(client as never, { + parentSessionID: "ses_parent", + agentToUse: "explore", + description: "test task", + defaultDirectory: "/fallback", + model: { providerID: "ollama", modelID: "qwen2.5:14b" }, + }) + + // then + expect(createCalls[0]?.body).toEqual({ + title: "test task (@explore subagent)", + permission: [ + { permission: "question", action: "deny", pattern: "*" }, + ], + }) + + _resetMemCacheForTesting() + }) + test("creates child session with question permission denied", async () => { // given const createCalls: Array> = [] @@ -34,5 +78,7 @@ describe("createSyncSession", () => { { permission: "question", action: "deny", pattern: "*" }, ], }) + + _resetMemCacheForTesting() }) }) diff --git a/src/tools/delegate-task/sync-session-creator.ts b/src/tools/delegate-task/sync-session-creator.ts index 7c463db33ab..5b60b6595b5 100644 --- a/src/tools/delegate-task/sync-session-creator.ts +++ b/src/tools/delegate-task/sync-session-creator.ts @@ -1,9 +1,17 @@ import type { OpencodeClient } from "./types" +import type { DelegatedModelConfig } from "../../shared/model-resolution-types" import { QUESTION_DENIED_SESSION_PERMISSION } from "../../shared/question-denied-session-permission" +import { buildSubagentSessionCreateBody } from "../../shared/subagent-context-window" export async function createSyncSession( client: OpencodeClient, - input: { parentSessionID: string; agentToUse: string; description: string; defaultDirectory: string } + input: { + parentSessionID: string + agentToUse: string + description: string + defaultDirectory: string + model?: DelegatedModelConfig + } ): Promise<{ ok: true; sessionID: string; parentDirectory: string } | { ok: false; error: string }> { const parentSession = client.session.get ? await client.session.get({ path: { id: input.parentSessionID } }).catch(() => null) @@ -11,11 +19,12 @@ export async function createSyncSession( const parentDirectory = parentSession?.data?.directory ?? input.defaultDirectory const createResult = await client.session.create({ - body: { - parentID: input.parentSessionID, + body: buildSubagentSessionCreateBody({ + parentSessionID: input.parentSessionID, title: `${input.description} (@${input.agentToUse} subagent)`, permission: QUESTION_DENIED_SESSION_PERMISSION, - } as Record, + model: input.model, + }), query: { directory: parentDirectory, }, diff --git a/src/tools/delegate-task/sync-task.ts b/src/tools/delegate-task/sync-task.ts index 18d99e5004c..17aae1212b7 100644 --- a/src/tools/delegate-task/sync-task.ts +++ b/src/tools/delegate-task/sync-task.ts @@ -67,6 +67,7 @@ export async function executeSyncTask( agentToUse, description: args.description, defaultDirectory: directory, + model: categoryModel, }) if (!createSessionResult.ok) { diff --git a/src/tools/delegate-task/token-limiter.ts b/src/tools/delegate-task/token-limiter.ts index f29162f5a1c..42c479ad376 100644 --- a/src/tools/delegate-task/token-limiter.ts +++ b/src/tools/delegate-task/token-limiter.ts @@ -45,7 +45,71 @@ function reduceSegmentToFitBudget(content: string, overflowTokens: number): stri const currentTokens = estimateTokenCount(content) const nextBudget = Math.max(0, currentTokens - overflowTokens) - return truncateToTokenBudget(content, nextBudget) + return truncateLowPriorityBlocks(content, nextBudget) +} + +function getBlockPriority(block: string): number { + const normalized = block.toLowerCase() + + if ( + normalized.includes("```") + || normalized.includes("example") + || normalized.includes("examples") + || normalized.includes("wrong:") + || normalized.includes("correct:") + ) { + return 0 + } + + if (normalized.includes("optional") || normalized.includes("note:")) { + return 1 + } + + return 2 +} + +function truncateLowPriorityBlocks(content: string, maxTokens: number): string { + if (!content || maxTokens <= 0) { + return "" + } + + if (estimateTokenCount(content) <= maxTokens) { + return content + } + + const blocks = content + .split(/\n\s*\n/) + .map((block) => block.trim()) + .filter((block) => block.length > 0) + + if (blocks.length <= 1) { + return truncateToTokenBudget(content, maxTokens) + } + + const prioritizedBlocks = blocks.map((block, index) => ({ + block, + index, + priority: getBlockPriority(block), + })) + + const kept = [...prioritizedBlocks] + kept.sort((left, right) => left.priority - right.priority || right.index - left.index) + + while (kept.length > 1) { + const current = kept + .slice() + .sort((left, right) => left.index - right.index) + .map((entry) => entry.block) + .join("\n\n") + + if (estimateTokenCount(current) <= maxTokens) { + return current + } + + kept.shift() + } + + return truncateToTokenBudget(kept[0]?.block ?? content, maxTokens) } export function buildSystemContentWithTokenLimit(