Merge pull request #2007 from code-yeongyu/fix/1901-ralph-loop-fresh-context
feat(ralph-loop): add strategy option for fresh context per iteration
This commit is contained in:
commit
5adbbad277
@ -3343,11 +3343,20 @@
|
|||||||
},
|
},
|
||||||
"state_dir": {
|
"state_dir": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
},
|
||||||
|
"default_strategy": {
|
||||||
|
"default": "continue",
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"reset",
|
||||||
|
"continue"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"enabled",
|
"enabled",
|
||||||
"default_max_iterations"
|
"default_max_iterations",
|
||||||
|
"default_strategy"
|
||||||
],
|
],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
|
|||||||
@ -7,6 +7,7 @@ export const RalphLoopConfigSchema = z.object({
|
|||||||
default_max_iterations: z.number().min(1).max(1000).default(100),
|
default_max_iterations: z.number().min(1).max(1000).default(100),
|
||||||
/** Custom state file directory relative to project root (default: .opencode/) */
|
/** Custom state file directory relative to project root (default: .opencode/) */
|
||||||
state_dir: z.string().optional(),
|
state_dir: z.string().optional(),
|
||||||
|
default_strategy: z.enum(["reset", "continue"]).default("continue"),
|
||||||
})
|
})
|
||||||
|
|
||||||
export type RalphLoopConfig = z.infer<typeof RalphLoopConfigSchema>
|
export type RalphLoopConfig = z.infer<typeof RalphLoopConfigSchema>
|
||||||
|
|||||||
@ -28,7 +28,7 @@ ${RALPH_LOOP_TEMPLATE}
|
|||||||
<user-task>
|
<user-task>
|
||||||
$ARGUMENTS
|
$ARGUMENTS
|
||||||
</user-task>`,
|
</user-task>`,
|
||||||
argumentHint: '"task description" [--completion-promise=TEXT] [--max-iterations=N]',
|
argumentHint: '"task description" [--completion-promise=TEXT] [--max-iterations=N] [--strategy=reset|continue]',
|
||||||
},
|
},
|
||||||
"ulw-loop": {
|
"ulw-loop": {
|
||||||
description: "(builtin) Start ultrawork loop - continues until completion with ultrawork mode",
|
description: "(builtin) Start ultrawork loop - continues until completion with ultrawork mode",
|
||||||
@ -39,7 +39,7 @@ ${RALPH_LOOP_TEMPLATE}
|
|||||||
<user-task>
|
<user-task>
|
||||||
$ARGUMENTS
|
$ARGUMENTS
|
||||||
</user-task>`,
|
</user-task>`,
|
||||||
argumentHint: '"task description" [--completion-promise=TEXT] [--max-iterations=N]',
|
argumentHint: '"task description" [--completion-promise=TEXT] [--max-iterations=N] [--strategy=reset|continue]',
|
||||||
},
|
},
|
||||||
"cancel-ralph": {
|
"cancel-ralph": {
|
||||||
description: "(builtin) Cancel active Ralph Loop",
|
description: "(builtin) Cancel active Ralph Loop",
|
||||||
|
|||||||
@ -24,7 +24,7 @@ export const RALPH_LOOP_TEMPLATE = `You are starting a Ralph Loop - a self-refer
|
|||||||
## Your Task
|
## Your Task
|
||||||
|
|
||||||
Parse the arguments below and begin working on the task. The format is:
|
Parse the arguments below and begin working on the task. The format is:
|
||||||
\`"task description" [--completion-promise=TEXT] [--max-iterations=N]\`
|
\`"task description" [--completion-promise=TEXT] [--max-iterations=N] [--strategy=reset|continue]\`
|
||||||
|
|
||||||
Default completion promise is "DONE" and default max iterations is 100.`
|
Default completion promise is "DONE" and default max iterations is 100.`
|
||||||
|
|
||||||
|
|||||||
@ -32,6 +32,7 @@ export { createNoSisyphusGptHook } from "./no-sisyphus-gpt";
|
|||||||
export { createNoHephaestusNonGptHook } from "./no-hephaestus-non-gpt";
|
export { createNoHephaestusNonGptHook } from "./no-hephaestus-non-gpt";
|
||||||
export { createAutoSlashCommandHook } from "./auto-slash-command";
|
export { createAutoSlashCommandHook } from "./auto-slash-command";
|
||||||
export { createEditErrorRecoveryHook } from "./edit-error-recovery";
|
export { createEditErrorRecoveryHook } from "./edit-error-recovery";
|
||||||
|
|
||||||
export { createPrometheusMdOnlyHook } from "./prometheus-md-only";
|
export { createPrometheusMdOnlyHook } from "./prometheus-md-only";
|
||||||
export { createSisyphusJuniorNotepadHook } from "./sisyphus-junior-notepad";
|
export { createSisyphusJuniorNotepadHook } from "./sisyphus-junior-notepad";
|
||||||
export { createTaskResumeInfoHook } from "./task-resume-info";
|
export { createTaskResumeInfoHook } from "./task-resume-info";
|
||||||
@ -48,6 +49,6 @@ export { createTasksTodowriteDisablerHook } from "./tasks-todowrite-disabler";
|
|||||||
export { createRuntimeFallbackHook, type RuntimeFallbackHook, type RuntimeFallbackOptions } from "./runtime-fallback";
|
export { createRuntimeFallbackHook, type RuntimeFallbackHook, type RuntimeFallbackOptions } from "./runtime-fallback";
|
||||||
export { createWriteExistingFileGuardHook } from "./write-existing-file-guard";
|
export { createWriteExistingFileGuardHook } from "./write-existing-file-guard";
|
||||||
export { createHashlineReadEnhancerHook } from "./hashline-read-enhancer";
|
export { createHashlineReadEnhancerHook } from "./hashline-read-enhancer";
|
||||||
|
export { createHashlineEditDiffEnhancerHook } from "./hashline-edit-diff-enhancer";
|
||||||
export { createBeastModeSystemHook, BEAST_MODE_SYSTEM_PROMPT } from "./beast-mode-system";
|
export { createBeastModeSystemHook, BEAST_MODE_SYSTEM_PROMPT } from "./beast-mode-system";
|
||||||
export { createHashlineEditDiffEnhancerHook } from "./hashline-edit-diff-enhancer"
|
|
||||||
export { createJsonErrorRecoveryHook, JSON_ERROR_TOOL_EXCLUDE_LIST, JSON_ERROR_PATTERNS, JSON_ERROR_REMINDER } from "./json-error-recovery";
|
export { createJsonErrorRecoveryHook, JSON_ERROR_TOOL_EXCLUDE_LIST, JSON_ERROR_PATTERNS, JSON_ERROR_REMINDER } from "./json-error-recovery";
|
||||||
|
|||||||
30
src/hooks/ralph-loop/command-arguments.ts
Normal file
30
src/hooks/ralph-loop/command-arguments.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
export type RalphLoopStrategy = "reset" | "continue"
|
||||||
|
|
||||||
|
export type ParsedRalphLoopArguments = {
|
||||||
|
prompt: string
|
||||||
|
maxIterations?: number
|
||||||
|
completionPromise?: string
|
||||||
|
strategy?: RalphLoopStrategy
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_PROMPT = "Complete the task as instructed"
|
||||||
|
|
||||||
|
export function parseRalphLoopArguments(rawArguments: string): ParsedRalphLoopArguments {
|
||||||
|
const taskMatch = rawArguments.match(/^(["'])(.+?)\1/)
|
||||||
|
const promptCandidate = taskMatch?.[2] ?? (rawArguments.startsWith("--") ? "" : rawArguments.split(/\s+--/)[0]?.trim() ?? "")
|
||||||
|
const prompt = promptCandidate || DEFAULT_PROMPT
|
||||||
|
|
||||||
|
const maxIterationMatch = rawArguments.match(/--max-iterations=(\d+)/i)
|
||||||
|
const completionPromiseQuoted = rawArguments.match(/--completion-promise=(["'])(.+?)\1/i)
|
||||||
|
const completionPromiseUnquoted = rawArguments.match(/--completion-promise=([^\s"']+)/i)
|
||||||
|
const completionPromise = completionPromiseQuoted?.[2] ?? completionPromiseUnquoted?.[1]
|
||||||
|
const strategyMatch = rawArguments.match(/--strategy=(reset|continue)/i)
|
||||||
|
const strategyValue = strategyMatch?.[1]?.toLowerCase()
|
||||||
|
|
||||||
|
return {
|
||||||
|
prompt,
|
||||||
|
maxIterations: maxIterationMatch ? Number.parseInt(maxIterationMatch[1], 10) : undefined,
|
||||||
|
completionPromise,
|
||||||
|
strategy: strategyValue === "reset" || strategyValue === "continue" ? strategyValue : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -19,16 +19,23 @@ type MessageInfo = {
|
|||||||
|
|
||||||
export async function injectContinuationPrompt(
|
export async function injectContinuationPrompt(
|
||||||
ctx: PluginInput,
|
ctx: PluginInput,
|
||||||
options: { sessionID: string; prompt: string; directory: string; apiTimeoutMs: number },
|
options: {
|
||||||
|
sessionID: string
|
||||||
|
prompt: string
|
||||||
|
directory: string
|
||||||
|
apiTimeoutMs: number
|
||||||
|
inheritFromSessionID?: string
|
||||||
|
},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
let agent: string | undefined
|
let agent: string | undefined
|
||||||
let model: { providerID: string; modelID: string } | undefined
|
let model: { providerID: string; modelID: string } | undefined
|
||||||
let tools: Record<string, boolean | "allow" | "deny" | "ask"> | undefined
|
let tools: Record<string, boolean | "allow" | "deny" | "ask"> | undefined
|
||||||
|
const sourceSessionID = options.inheritFromSessionID ?? options.sessionID
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const messagesResp = await withTimeout(
|
const messagesResp = await withTimeout(
|
||||||
ctx.client.session.messages({
|
ctx.client.session.messages({
|
||||||
path: { id: options.sessionID },
|
path: { id: sourceSessionID },
|
||||||
}),
|
}),
|
||||||
options.apiTimeoutMs,
|
options.apiTimeoutMs,
|
||||||
)
|
)
|
||||||
@ -47,7 +54,7 @@ export async function injectContinuationPrompt(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
const messageDir = getMessageDir(options.sessionID)
|
const messageDir = getMessageDir(sourceSessionID)
|
||||||
const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
|
const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
|
||||||
agent = currentMessage?.agent
|
agent = currentMessage?.agent
|
||||||
model =
|
model =
|
||||||
@ -60,7 +67,7 @@ export async function injectContinuationPrompt(
|
|||||||
tools = currentMessage?.tools
|
tools = currentMessage?.tools
|
||||||
}
|
}
|
||||||
|
|
||||||
const inheritedTools = resolveInheritedPromptTools(options.sessionID, tools)
|
const inheritedTools = resolveInheritedPromptTools(sourceSessionID, tools)
|
||||||
|
|
||||||
await ctx.client.session.promptAsync({
|
await ctx.client.session.promptAsync({
|
||||||
path: { id: options.sessionID },
|
path: { id: options.sessionID },
|
||||||
|
|||||||
@ -6,12 +6,14 @@ import { tmpdir } from "node:os"
|
|||||||
import { createRalphLoopHook } from "./index"
|
import { createRalphLoopHook } from "./index"
|
||||||
import { readState, writeState, clearState } from "./storage"
|
import { readState, writeState, clearState } from "./storage"
|
||||||
import type { RalphLoopState } from "./types"
|
import type { RalphLoopState } from "./types"
|
||||||
|
import { parseRalphLoopArguments } from "./command-arguments"
|
||||||
|
|
||||||
describe("ralph-loop", () => {
|
describe("ralph-loop", () => {
|
||||||
const TEST_DIR = join(tmpdir(), "ralph-loop-test-" + Date.now())
|
const TEST_DIR = join(tmpdir(), "ralph-loop-test-" + Date.now())
|
||||||
let promptCalls: Array<{ sessionID: string; text: string }>
|
let promptCalls: Array<{ sessionID: string; text: string }>
|
||||||
let toastCalls: Array<{ title: string; message: string; variant: string }>
|
let toastCalls: Array<{ title: string; message: string; variant: string }>
|
||||||
let messagesCalls: Array<{ sessionID: string }>
|
let messagesCalls: Array<{ sessionID: string }>
|
||||||
|
let createSessionCalls: Array<{ parentID?: string; title?: string; directory?: string }>
|
||||||
let mockSessionMessages: Array<{ info?: { role?: string }; parts?: Array<{ type: string; text?: string }> }>
|
let mockSessionMessages: Array<{ info?: { role?: string }; parts?: Array<{ type: string; text?: string }> }>
|
||||||
let mockMessagesApiResponseShape: "data" | "array"
|
let mockMessagesApiResponseShape: "data" | "array"
|
||||||
|
|
||||||
@ -37,6 +39,17 @@ describe("ralph-loop", () => {
|
|||||||
messagesCalls.push({ sessionID: opts.path.id })
|
messagesCalls.push({ sessionID: opts.path.id })
|
||||||
return mockMessagesApiResponseShape === "array" ? mockSessionMessages : { data: mockSessionMessages }
|
return mockMessagesApiResponseShape === "array" ? mockSessionMessages : { data: mockSessionMessages }
|
||||||
},
|
},
|
||||||
|
create: async (opts: {
|
||||||
|
body: { parentID?: string; title?: string }
|
||||||
|
query?: { directory?: string }
|
||||||
|
}) => {
|
||||||
|
createSessionCalls.push({
|
||||||
|
parentID: opts.body.parentID,
|
||||||
|
title: opts.body.title,
|
||||||
|
directory: opts.query?.directory,
|
||||||
|
})
|
||||||
|
return { data: { id: `new-session-${createSessionCalls.length}` } }
|
||||||
|
},
|
||||||
},
|
},
|
||||||
tui: {
|
tui: {
|
||||||
showToast: async (opts: { body: { title: string; message: string; variant: string } }) => {
|
showToast: async (opts: { body: { title: string; message: string; variant: string } }) => {
|
||||||
@ -57,6 +70,7 @@ describe("ralph-loop", () => {
|
|||||||
promptCalls = []
|
promptCalls = []
|
||||||
toastCalls = []
|
toastCalls = []
|
||||||
messagesCalls = []
|
messagesCalls = []
|
||||||
|
createSessionCalls = []
|
||||||
mockSessionMessages = []
|
mockSessionMessages = []
|
||||||
mockMessagesApiResponseShape = "data"
|
mockMessagesApiResponseShape = "data"
|
||||||
|
|
||||||
@ -123,6 +137,26 @@ describe("ralph-loop", () => {
|
|||||||
expect(readResult?.ultrawork).toBe(true)
|
expect(readResult?.ultrawork).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("should store and read strategy field", () => {
|
||||||
|
// given - a state object with strategy
|
||||||
|
const state: RalphLoopState = {
|
||||||
|
active: true,
|
||||||
|
iteration: 1,
|
||||||
|
max_iterations: 50,
|
||||||
|
completion_promise: "DONE",
|
||||||
|
started_at: "2025-12-30T01:00:00Z",
|
||||||
|
prompt: "Build a REST API",
|
||||||
|
strategy: "reset",
|
||||||
|
}
|
||||||
|
|
||||||
|
// when - write and read state
|
||||||
|
writeState(TEST_DIR, state)
|
||||||
|
const readResult = readState(TEST_DIR)
|
||||||
|
|
||||||
|
// then - strategy should be preserved
|
||||||
|
expect(readResult?.strategy).toBe("reset")
|
||||||
|
})
|
||||||
|
|
||||||
test("should return null for non-existent state", () => {
|
test("should return null for non-existent state", () => {
|
||||||
// given - no state file exists
|
// given - no state file exists
|
||||||
// when - read state
|
// when - read state
|
||||||
@ -173,6 +207,32 @@ describe("ralph-loop", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("command arguments", () => {
|
||||||
|
test("should parse --strategy=reset flag", () => {
|
||||||
|
// given - ralph-loop command arguments with reset strategy
|
||||||
|
const rawArguments = '"Build feature X" --strategy=reset --max-iterations=12'
|
||||||
|
|
||||||
|
// when - parse command arguments
|
||||||
|
const parsedArguments = parseRalphLoopArguments(rawArguments)
|
||||||
|
|
||||||
|
// then - strategy should be parsed as reset
|
||||||
|
expect(parsedArguments.strategy).toBe("reset")
|
||||||
|
expect(parsedArguments.prompt).toBe("Build feature X")
|
||||||
|
expect(parsedArguments.maxIterations).toBe(12)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should parse --strategy=continue flag", () => {
|
||||||
|
// given - ralph-loop command arguments with continue strategy
|
||||||
|
const rawArguments = '"Build feature X" --strategy=continue'
|
||||||
|
|
||||||
|
// when - parse command arguments
|
||||||
|
const parsedArguments = parseRalphLoopArguments(rawArguments)
|
||||||
|
|
||||||
|
// then - strategy should be parsed as continue
|
||||||
|
expect(parsedArguments.strategy).toBe("continue")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe("hook", () => {
|
describe("hook", () => {
|
||||||
test("should start loop and write state", () => {
|
test("should start loop and write state", () => {
|
||||||
// given - hook instance
|
// given - hook instance
|
||||||
@ -445,6 +505,38 @@ describe("ralph-loop", () => {
|
|||||||
expect(state?.max_iterations).toBe(200)
|
expect(state?.max_iterations).toBe(200)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("should default strategy to continue when not specified", () => {
|
||||||
|
// given - hook with no strategy option
|
||||||
|
const hook = createRalphLoopHook(createMockPluginInput())
|
||||||
|
|
||||||
|
// when - start loop without strategy
|
||||||
|
hook.startLoop("session-123", "Test task")
|
||||||
|
|
||||||
|
// then - strategy should default to continue
|
||||||
|
const state = hook.getState()
|
||||||
|
expect(state?.strategy).toBe("continue")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should create new session for reset strategy", async () => {
|
||||||
|
// given - hook with reset strategy
|
||||||
|
const hook = createRalphLoopHook(createMockPluginInput())
|
||||||
|
hook.startLoop("session-123", "Build a feature", { strategy: "reset" })
|
||||||
|
|
||||||
|
// when - session goes idle
|
||||||
|
await hook.event({
|
||||||
|
event: {
|
||||||
|
type: "session.idle",
|
||||||
|
properties: { sessionID: "session-123" },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// then - new session should be created and continuation injected there
|
||||||
|
expect(createSessionCalls.length).toBe(1)
|
||||||
|
expect(promptCalls.length).toBe(1)
|
||||||
|
expect(promptCalls[0].sessionID).toBe("new-session-1")
|
||||||
|
expect(hook.getState()?.session_id).toBe("new-session-1")
|
||||||
|
})
|
||||||
|
|
||||||
test("should not inject when no loop is active", async () => {
|
test("should not inject when no loop is active", async () => {
|
||||||
// given - no active loop
|
// given - no active loop
|
||||||
const hook = createRalphLoopHook(createMockPluginInput())
|
const hook = createRalphLoopHook(createMockPluginInput())
|
||||||
|
|||||||
63
src/hooks/ralph-loop/iteration-continuation.ts
Normal file
63
src/hooks/ralph-loop/iteration-continuation.ts
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
|
import type { RalphLoopState } from "./types"
|
||||||
|
import { log } from "../../shared/logger"
|
||||||
|
import { HOOK_NAME } from "./constants"
|
||||||
|
import { buildContinuationPrompt } from "./continuation-prompt-builder"
|
||||||
|
import { injectContinuationPrompt } from "./continuation-prompt-injector"
|
||||||
|
import { createIterationSession, selectSessionInTui } from "./session-reset-strategy"
|
||||||
|
|
||||||
|
type ContinuationOptions = {
|
||||||
|
directory: string
|
||||||
|
apiTimeoutMs: number
|
||||||
|
previousSessionID: string
|
||||||
|
loopState: {
|
||||||
|
setSessionID: (sessionID: string) => RalphLoopState | null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function continueIteration(
|
||||||
|
ctx: PluginInput,
|
||||||
|
state: RalphLoopState,
|
||||||
|
options: ContinuationOptions,
|
||||||
|
): Promise<void> {
|
||||||
|
const strategy = state.strategy ?? "continue"
|
||||||
|
const continuationPrompt = buildContinuationPrompt(state)
|
||||||
|
|
||||||
|
if (strategy === "reset") {
|
||||||
|
const newSessionID = await createIterationSession(
|
||||||
|
ctx,
|
||||||
|
options.previousSessionID,
|
||||||
|
options.directory,
|
||||||
|
)
|
||||||
|
if (!newSessionID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const boundState = options.loopState.setSessionID(newSessionID)
|
||||||
|
if (!boundState) {
|
||||||
|
log(`[${HOOK_NAME}] Failed to bind loop state to new session`, {
|
||||||
|
previousSessionID: options.previousSessionID,
|
||||||
|
newSessionID,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await injectContinuationPrompt(ctx, {
|
||||||
|
sessionID: newSessionID,
|
||||||
|
inheritFromSessionID: options.previousSessionID,
|
||||||
|
prompt: continuationPrompt,
|
||||||
|
directory: options.directory,
|
||||||
|
apiTimeoutMs: options.apiTimeoutMs,
|
||||||
|
})
|
||||||
|
|
||||||
|
await selectSessionInTui(ctx.client, newSessionID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await injectContinuationPrompt(ctx, {
|
||||||
|
sessionID: options.previousSessionID,
|
||||||
|
prompt: continuationPrompt,
|
||||||
|
directory: options.directory,
|
||||||
|
apiTimeoutMs: options.apiTimeoutMs,
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -24,6 +24,7 @@ export function createLoopStateController(options: {
|
|||||||
maxIterations?: number
|
maxIterations?: number
|
||||||
completionPromise?: string
|
completionPromise?: string
|
||||||
ultrawork?: boolean
|
ultrawork?: boolean
|
||||||
|
strategy?: "reset" | "continue"
|
||||||
},
|
},
|
||||||
): boolean {
|
): boolean {
|
||||||
const state: RalphLoopState = {
|
const state: RalphLoopState = {
|
||||||
@ -37,6 +38,7 @@ export function createLoopStateController(options: {
|
|||||||
loopOptions?.completionPromise ??
|
loopOptions?.completionPromise ??
|
||||||
DEFAULT_COMPLETION_PROMISE,
|
DEFAULT_COMPLETION_PROMISE,
|
||||||
ultrawork: loopOptions?.ultrawork,
|
ultrawork: loopOptions?.ultrawork,
|
||||||
|
strategy: loopOptions?.strategy ?? config?.default_strategy ?? "continue",
|
||||||
started_at: new Date().toISOString(),
|
started_at: new Date().toISOString(),
|
||||||
prompt,
|
prompt,
|
||||||
session_id: sessionID,
|
session_id: sessionID,
|
||||||
@ -77,5 +79,19 @@ export function createLoopStateController(options: {
|
|||||||
incrementIteration(): RalphLoopState | null {
|
incrementIteration(): RalphLoopState | null {
|
||||||
return incrementIteration(directory, stateDir)
|
return incrementIteration(directory, stateDir)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setSessionID(sessionID: string): RalphLoopState | null {
|
||||||
|
const state = readState(directory, stateDir)
|
||||||
|
if (!state) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
state.session_id = sessionID
|
||||||
|
if (!writeState(directory, state, stateDir)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return state
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,15 +6,19 @@ import {
|
|||||||
detectCompletionInSessionMessages,
|
detectCompletionInSessionMessages,
|
||||||
detectCompletionInTranscript,
|
detectCompletionInTranscript,
|
||||||
} from "./completion-promise-detector"
|
} from "./completion-promise-detector"
|
||||||
import { buildContinuationPrompt } from "./continuation-prompt-builder"
|
import { continueIteration } from "./iteration-continuation"
|
||||||
import { injectContinuationPrompt } from "./continuation-prompt-injector"
|
|
||||||
|
|
||||||
type SessionRecovery = {
|
type SessionRecovery = {
|
||||||
isRecovering: (sessionID: string) => boolean
|
isRecovering: (sessionID: string) => boolean
|
||||||
markRecovering: (sessionID: string) => void
|
markRecovering: (sessionID: string) => void
|
||||||
clear: (sessionID: string) => void
|
clear: (sessionID: string) => void
|
||||||
}
|
}
|
||||||
type LoopStateController = { getState: () => RalphLoopState | null; clear: () => boolean; incrementIteration: () => RalphLoopState | null }
|
type LoopStateController = {
|
||||||
|
getState: () => RalphLoopState | null
|
||||||
|
clear: () => boolean
|
||||||
|
incrementIteration: () => RalphLoopState | null
|
||||||
|
setSessionID: (sessionID: string) => RalphLoopState | null
|
||||||
|
}
|
||||||
type RalphLoopEventHandlerOptions = { directory: string; apiTimeoutMs: number; getTranscriptPath: (sessionID: string) => string | undefined; checkSessionExists?: RalphLoopOptions["checkSessionExists"]; sessionRecovery: SessionRecovery; loopState: LoopStateController }
|
type RalphLoopEventHandlerOptions = { directory: string; apiTimeoutMs: number; getTranscriptPath: (sessionID: string) => string | undefined; checkSessionExists?: RalphLoopOptions["checkSessionExists"]; sessionRecovery: SessionRecovery; loopState: LoopStateController }
|
||||||
|
|
||||||
export function createRalphLoopEventHandler(
|
export function createRalphLoopEventHandler(
|
||||||
@ -128,11 +132,11 @@ export function createRalphLoopEventHandler(
|
|||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await injectContinuationPrompt(ctx, {
|
await continueIteration(ctx, newState, {
|
||||||
sessionID,
|
previousSessionID: sessionID,
|
||||||
prompt: buildContinuationPrompt(newState),
|
|
||||||
directory: options.directory,
|
directory: options.directory,
|
||||||
apiTimeoutMs: options.apiTimeoutMs,
|
apiTimeoutMs: options.apiTimeoutMs,
|
||||||
|
loopState: options.loopState,
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log(`[${HOOK_NAME}] Failed to inject continuation`, {
|
log(`[${HOOK_NAME}] Failed to inject continuation`, {
|
||||||
|
|||||||
@ -10,7 +10,12 @@ export interface RalphLoopHook {
|
|||||||
startLoop: (
|
startLoop: (
|
||||||
sessionID: string,
|
sessionID: string,
|
||||||
prompt: string,
|
prompt: string,
|
||||||
options?: { maxIterations?: number; completionPromise?: string; ultrawork?: boolean }
|
options?: {
|
||||||
|
maxIterations?: number
|
||||||
|
completionPromise?: string
|
||||||
|
ultrawork?: boolean
|
||||||
|
strategy?: "reset" | "continue"
|
||||||
|
}
|
||||||
) => boolean
|
) => boolean
|
||||||
cancelLoop: (sessionID: string) => boolean
|
cancelLoop: (sessionID: string) => boolean
|
||||||
getState: () => RalphLoopState | null
|
getState: () => RalphLoopState | null
|
||||||
|
|||||||
69
src/hooks/ralph-loop/session-reset-strategy.ts
Normal file
69
src/hooks/ralph-loop/session-reset-strategy.ts
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
|
import { isRecord } from "../../shared/record-type-guard"
|
||||||
|
import { log } from "../../shared/logger"
|
||||||
|
|
||||||
|
export async function createIterationSession(
|
||||||
|
ctx: PluginInput,
|
||||||
|
parentSessionID: string,
|
||||||
|
directory: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const createResult = await ctx.client.session.create({
|
||||||
|
body: {
|
||||||
|
parentID: parentSessionID,
|
||||||
|
title: "Ralph Loop Iteration",
|
||||||
|
},
|
||||||
|
query: { directory },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (createResult.error || !createResult.data?.id) {
|
||||||
|
log("[ralph-loop] Failed to create iteration session", {
|
||||||
|
parentSessionID,
|
||||||
|
error: String(createResult.error ?? "No session ID returned"),
|
||||||
|
})
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return createResult.data.id
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function selectSessionInTui(
|
||||||
|
client: PluginInput["client"],
|
||||||
|
sessionID: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const selectSession = getSelectSessionApi(client)
|
||||||
|
if (!selectSession) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await selectSession({ body: { sessionID } })
|
||||||
|
return true
|
||||||
|
} catch (error: unknown) {
|
||||||
|
log("[ralph-loop] Failed to select session in TUI", {
|
||||||
|
sessionID,
|
||||||
|
error: String(error),
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type SelectSessionApi = (args: { body: { sessionID: string } }) => Promise<unknown>
|
||||||
|
|
||||||
|
function getSelectSessionApi(client: unknown): SelectSessionApi | null {
|
||||||
|
if (!isRecord(client)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientRecord = client
|
||||||
|
const tuiValue = clientRecord.tui
|
||||||
|
if (!isRecord(tuiValue)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectSessionValue = tuiValue.selectSession
|
||||||
|
if (typeof selectSessionValue !== "function") {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (selectSessionValue as Function).bind(tuiValue) as SelectSessionApi
|
||||||
|
}
|
||||||
@ -49,6 +49,7 @@ export function readState(directory: string, customPath?: string): RalphLoopStat
|
|||||||
prompt: body.trim(),
|
prompt: body.trim(),
|
||||||
session_id: data.session_id ? stripQuotes(data.session_id) : undefined,
|
session_id: data.session_id ? stripQuotes(data.session_id) : undefined,
|
||||||
ultrawork: data.ultrawork === true || data.ultrawork === "true" ? true : undefined,
|
ultrawork: data.ultrawork === true || data.ultrawork === "true" ? true : undefined,
|
||||||
|
strategy: data.strategy === "reset" || data.strategy === "continue" ? data.strategy : undefined,
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null
|
||||||
@ -70,13 +71,14 @@ export function writeState(
|
|||||||
|
|
||||||
const sessionIdLine = state.session_id ? `session_id: "${state.session_id}"\n` : ""
|
const sessionIdLine = state.session_id ? `session_id: "${state.session_id}"\n` : ""
|
||||||
const ultraworkLine = state.ultrawork !== undefined ? `ultrawork: ${state.ultrawork}\n` : ""
|
const ultraworkLine = state.ultrawork !== undefined ? `ultrawork: ${state.ultrawork}\n` : ""
|
||||||
|
const strategyLine = state.strategy ? `strategy: "${state.strategy}"\n` : ""
|
||||||
const content = `---
|
const content = `---
|
||||||
active: ${state.active}
|
active: ${state.active}
|
||||||
iteration: ${state.iteration}
|
iteration: ${state.iteration}
|
||||||
max_iterations: ${state.max_iterations}
|
max_iterations: ${state.max_iterations}
|
||||||
completion_promise: "${state.completion_promise}"
|
completion_promise: "${state.completion_promise}"
|
||||||
started_at: "${state.started_at}"
|
started_at: "${state.started_at}"
|
||||||
${sessionIdLine}${ultraworkLine}---
|
${sessionIdLine}${ultraworkLine}${strategyLine}---
|
||||||
${state.prompt}
|
${state.prompt}
|
||||||
`
|
`
|
||||||
|
|
||||||
|
|||||||
@ -9,6 +9,7 @@ export interface RalphLoopState {
|
|||||||
prompt: string
|
prompt: string
|
||||||
session_id?: string
|
session_id?: string
|
||||||
ultrawork?: boolean
|
ultrawork?: boolean
|
||||||
|
strategy?: "reset" | "continue"
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RalphLoopOptions {
|
export interface RalphLoopOptions {
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { hasConnectedProvidersCache } from "../shared"
|
|||||||
import { setSessionModel } from "../shared/session-model-state"
|
import { setSessionModel } from "../shared/session-model-state"
|
||||||
import { setSessionAgent } from "../features/claude-code-session-state"
|
import { setSessionAgent } from "../features/claude-code-session-state"
|
||||||
import { applyUltraworkModelOverrideOnMessage } from "./ultrawork-model-override"
|
import { applyUltraworkModelOverrideOnMessage } from "./ultrawork-model-override"
|
||||||
|
import { parseRalphLoopArguments } from "../hooks/ralph-loop/command-arguments"
|
||||||
|
|
||||||
import type { CreatedHooks } from "../create-hooks"
|
import type { CreatedHooks } from "../create-hooks"
|
||||||
|
|
||||||
@ -119,20 +120,12 @@ export function createChatMessageHandler(args: {
|
|||||||
if (isRalphLoopTemplate) {
|
if (isRalphLoopTemplate) {
|
||||||
const taskMatch = promptText.match(/<user-task>\s*([\s\S]*?)\s*<\/user-task>/i)
|
const taskMatch = promptText.match(/<user-task>\s*([\s\S]*?)\s*<\/user-task>/i)
|
||||||
const rawTask = taskMatch?.[1]?.trim() || ""
|
const rawTask = taskMatch?.[1]?.trim() || ""
|
||||||
const quotedMatch = rawTask.match(/^["'](.+?)["']/)
|
const parsedArguments = parseRalphLoopArguments(rawTask)
|
||||||
const prompt =
|
|
||||||
quotedMatch?.[1] ||
|
|
||||||
rawTask.split(/\s+--/)[0]?.trim() ||
|
|
||||||
"Complete the task as instructed"
|
|
||||||
|
|
||||||
const maxIterMatch = rawTask.match(/--max-iterations=(\d+)/i)
|
hooks.ralphLoop.startLoop(input.sessionID, parsedArguments.prompt, {
|
||||||
const promiseMatch = rawTask.match(
|
maxIterations: parsedArguments.maxIterations,
|
||||||
/--completion-promise=["']?([^"'\s]+)["']?/i,
|
completionPromise: parsedArguments.completionPromise,
|
||||||
)
|
strategy: parsedArguments.strategy,
|
||||||
|
|
||||||
hooks.ralphLoop.startLoop(input.sessionID, prompt, {
|
|
||||||
maxIterations: maxIterMatch ? parseInt(maxIterMatch[1], 10) : undefined,
|
|
||||||
completionPromise: promiseMatch?.[1],
|
|
||||||
})
|
})
|
||||||
} else if (isCancelRalphTemplate) {
|
} else if (isCancelRalphTemplate) {
|
||||||
hooks.ralphLoop.cancelLoop(input.sessionID)
|
hooks.ralphLoop.cancelLoop(input.sessionID)
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { getMainSessionID } from "../features/claude-code-session-state"
|
|||||||
import { clearBoulderState } from "../features/boulder-state"
|
import { clearBoulderState } from "../features/boulder-state"
|
||||||
import { log } from "../shared"
|
import { log } from "../shared"
|
||||||
import { resolveSessionAgent } from "./session-agent-resolver"
|
import { resolveSessionAgent } from "./session-agent-resolver"
|
||||||
|
import { parseRalphLoopArguments } from "../hooks/ralph-loop/command-arguments"
|
||||||
|
|
||||||
import type { CreatedHooks } from "../create-hooks"
|
import type { CreatedHooks } from "../create-hooks"
|
||||||
|
|
||||||
@ -50,36 +51,24 @@ export function createToolExecuteBeforeHandler(args: {
|
|||||||
|
|
||||||
if (command === "ralph-loop" && sessionID) {
|
if (command === "ralph-loop" && sessionID) {
|
||||||
const rawArgs = rawName?.replace(/^\/?(ralph-loop)\s*/i, "") || ""
|
const rawArgs = rawName?.replace(/^\/?(ralph-loop)\s*/i, "") || ""
|
||||||
const taskMatch = rawArgs.match(/^["'](.+?)["']/)
|
const parsedArguments = parseRalphLoopArguments(rawArgs)
|
||||||
const prompt =
|
|
||||||
taskMatch?.[1] ||
|
|
||||||
rawArgs.split(/\s+--/)[0]?.trim() ||
|
|
||||||
"Complete the task as instructed"
|
|
||||||
|
|
||||||
const maxIterMatch = rawArgs.match(/--max-iterations=(\d+)/i)
|
hooks.ralphLoop.startLoop(sessionID, parsedArguments.prompt, {
|
||||||
const promiseMatch = rawArgs.match(/--completion-promise=["']?([^"'\s]+)["']?/i)
|
maxIterations: parsedArguments.maxIterations,
|
||||||
|
completionPromise: parsedArguments.completionPromise,
|
||||||
hooks.ralphLoop.startLoop(sessionID, prompt, {
|
strategy: parsedArguments.strategy,
|
||||||
maxIterations: maxIterMatch ? parseInt(maxIterMatch[1], 10) : undefined,
|
|
||||||
completionPromise: promiseMatch?.[1],
|
|
||||||
})
|
})
|
||||||
} else if (command === "cancel-ralph" && sessionID) {
|
} else if (command === "cancel-ralph" && sessionID) {
|
||||||
hooks.ralphLoop.cancelLoop(sessionID)
|
hooks.ralphLoop.cancelLoop(sessionID)
|
||||||
} else if (command === "ulw-loop" && sessionID) {
|
} else if (command === "ulw-loop" && sessionID) {
|
||||||
const rawArgs = rawName?.replace(/^\/?(ulw-loop)\s*/i, "") || ""
|
const rawArgs = rawName?.replace(/^\/?(ulw-loop)\s*/i, "") || ""
|
||||||
const taskMatch = rawArgs.match(/^["'](.+?)["']/)
|
const parsedArguments = parseRalphLoopArguments(rawArgs)
|
||||||
const prompt =
|
|
||||||
taskMatch?.[1] ||
|
|
||||||
rawArgs.split(/\s+--/)[0]?.trim() ||
|
|
||||||
"Complete the task as instructed"
|
|
||||||
|
|
||||||
const maxIterMatch = rawArgs.match(/--max-iterations=(\d+)/i)
|
hooks.ralphLoop.startLoop(sessionID, parsedArguments.prompt, {
|
||||||
const promiseMatch = rawArgs.match(/--completion-promise=["']?([^"'\s]+)["']?/i)
|
|
||||||
|
|
||||||
hooks.ralphLoop.startLoop(sessionID, prompt, {
|
|
||||||
ultrawork: true,
|
ultrawork: true,
|
||||||
maxIterations: maxIterMatch ? parseInt(maxIterMatch[1], 10) : undefined,
|
maxIterations: parsedArguments.maxIterations,
|
||||||
completionPromise: promiseMatch?.[1],
|
completionPromise: parsedArguments.completionPromise,
|
||||||
|
strategy: parsedArguments.strategy,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user