fix(hooks): use API instead of filesystem to resolve model info for session.prompt

Previously, continuation hooks (todo-continuation, boulder-continuation, ralph-loop)
and background tasks resolved model info from filesystem cache, which could be stale
or missing. This caused session.prompt to fallback to default model (Sonnet) instead
of using the originally configured model (e.g., Opus).

Now all session.prompt calls first try API (session.messages) to get current model
info, with filesystem as fallback if API fails.

Affected files:
- todo-continuation-enforcer.ts
- sisyphus-orchestrator/index.ts
- ralph-loop/index.ts
- background-agent/manager.ts
- sisyphus-task/tools.ts
- hook-message-injector/index.ts (export ToolPermission type)
This commit is contained in:
justsisyphus 2026-01-16 02:33:44 +09:00
parent f658544cd6
commit c9f762f980
6 changed files with 150 additions and 55 deletions

View File

@ -675,21 +675,33 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
</system-reminder>`
}
// Dynamically lookup the parent session's current message context
// This ensures we use the CURRENT model/agent, not the stale one from task creation time
const messageDir = getMessageDir(task.parentSessionID)
const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
let agent: string | undefined = task.parentAgent
let model: { providerID: string; modelID: string } | undefined
const agent = currentMessage?.agent ?? task.parentAgent
const model = currentMessage?.model?.providerID && currentMessage?.model?.modelID
? { providerID: currentMessage.model.providerID, modelID: currentMessage.model.modelID }
: undefined
try {
const messagesResp = await this.client.session.messages({ path: { id: task.parentSessionID } })
const messages = (messagesResp.data ?? []) as Array<{
info?: { agent?: string; model?: { providerID: string; modelID: string } }
}>
for (let i = messages.length - 1; i >= 0; i--) {
const info = messages[i].info
if (info?.agent || info?.model) {
agent = info.agent ?? task.parentAgent
model = info.model
break
}
}
} catch {
const messageDir = getMessageDir(task.parentSessionID)
const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
agent = currentMessage?.agent ?? task.parentAgent
model = currentMessage?.model?.providerID && currentMessage?.model?.modelID
? { providerID: currentMessage.model.providerID, modelID: currentMessage.model.modelID }
: undefined
}
log("[background-agent] notifyParentSession context:", {
taskId: task.id,
messageDir: !!messageDir,
currentAgent: currentMessage?.agent,
currentModel: currentMessage?.model,
resolvedAgent: agent,
resolvedModel: model,
})

View File

@ -1,4 +1,4 @@
export { injectHookMessage, findNearestMessageWithFields, findFirstMessageWithAgent } from "./injector"
export type { StoredMessage } from "./injector"
export type { MessageMeta, OriginalMessageContext, TextPart } from "./types"
export type { MessageMeta, OriginalMessageContext, TextPart, ToolPermission } from "./types"
export { MESSAGE_STORAGE } from "./constants"

View File

@ -315,12 +315,30 @@ export function createRalphLoopHook(
.catch(() => {})
try {
const messageDir = getMessageDir(sessionID)
const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
const agent = currentMessage?.agent
const model = currentMessage?.model?.providerID && currentMessage?.model?.modelID
? { providerID: currentMessage.model.providerID, modelID: currentMessage.model.modelID }
: undefined
let agent: string | undefined
let model: { providerID: string; modelID: string } | undefined
try {
const messagesResp = await ctx.client.session.messages({ path: { id: sessionID } })
const messages = (messagesResp.data ?? []) as Array<{
info?: { agent?: string; model?: { providerID: string; modelID: string } }
}>
for (let i = messages.length - 1; i >= 0; i--) {
const info = messages[i].info
if (info?.agent || info?.model) {
agent = info.agent
model = info.model
break
}
}
} catch {
const messageDir = getMessageDir(sessionID)
const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
agent = currentMessage?.agent
model = currentMessage?.model?.providerID && currentMessage?.model?.modelID
? { providerID: currentMessage.model.providerID, modelID: currentMessage.model.modelID }
: undefined
}
await ctx.client.session.prompt({
path: { id: sessionID },

View File

@ -436,11 +436,26 @@ export function createSisyphusOrchestratorHook(
try {
log(`[${HOOK_NAME}] Injecting boulder continuation`, { sessionID, planName, remaining })
const messageDir = getMessageDir(sessionID)
const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
const model = currentMessage?.model?.providerID && currentMessage?.model?.modelID
? { providerID: currentMessage.model.providerID, modelID: currentMessage.model.modelID }
: undefined
let model: { providerID: string; modelID: string } | undefined
try {
const messagesResp = await ctx.client.session.messages({ path: { id: sessionID } })
const messages = (messagesResp.data ?? []) as Array<{
info?: { model?: { providerID: string; modelID: string } }
}>
for (let i = messages.length - 1; i >= 0; i--) {
const msgModel = messages[i].info?.model
if (msgModel?.providerID && msgModel?.modelID) {
model = { providerID: msgModel.providerID, modelID: msgModel.modelID }
break
}
}
} catch {
const messageDir = getMessageDir(sessionID)
const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
model = currentMessage?.model?.providerID && currentMessage?.model?.modelID
? { providerID: currentMessage.model.providerID, modelID: currentMessage.model.modelID }
: undefined
}
await ctx.client.session.prompt({
path: { id: sessionID },

View File

@ -6,6 +6,7 @@ import { getMainSessionID, subagentSessions } from "../features/claude-code-sess
import {
findNearestMessageWithFields,
MESSAGE_STORAGE,
type ToolPermission,
} from "../features/hook-message-injector"
import { log } from "../shared/logger"
@ -151,7 +152,18 @@ export function createTodoContinuationEnforcer(
}).catch(() => {})
}
async function injectContinuation(sessionID: string, incompleteCount: number, total: number): Promise<void> {
interface ResolvedMessageInfo {
agent?: string
model?: { providerID: string; modelID: string }
tools?: Record<string, ToolPermission>
}
async function injectContinuation(
sessionID: string,
incompleteCount: number,
total: number,
resolvedInfo?: ResolvedMessageInfo
): Promise<void> {
const state = sessions.get(sessionID)
if (state?.isRecovering) {
@ -159,8 +171,6 @@ export function createTodoContinuationEnforcer(
return
}
const hasRunningBgTasks = backgroundManager
? backgroundManager.getTasksByParentSession(sessionID).some(t => t.status === "running")
: false
@ -185,38 +195,44 @@ export function createTodoContinuationEnforcer(
return
}
const messageDir = getMessageDir(sessionID)
const prevMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
let agentName = resolvedInfo?.agent
let model = resolvedInfo?.model
let tools = resolvedInfo?.tools
if (!agentName || !model) {
const messageDir = getMessageDir(sessionID)
const prevMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
agentName = agentName ?? prevMessage?.agent
model = model ?? (prevMessage?.model?.providerID && prevMessage?.model?.modelID
? { providerID: prevMessage.model.providerID, modelID: prevMessage.model.modelID }
: undefined)
tools = tools ?? prevMessage?.tools
}
const agentName = prevMessage?.agent
if (agentName && skipAgents.includes(agentName)) {
log(`[${HOOK_NAME}] Skipped: agent in skipAgents list`, { sessionID, agent: agentName })
return
}
const editPermission = prevMessage?.tools?.edit
const writePermission = prevMessage?.tools?.write
const hasWritePermission = !prevMessage?.tools ||
const editPermission = tools?.edit
const writePermission = tools?.write
const hasWritePermission = !tools ||
((editPermission !== false && editPermission !== "deny") &&
(writePermission !== false && writePermission !== "deny"))
if (!hasWritePermission) {
log(`[${HOOK_NAME}] Skipped: agent lacks write permission`, { sessionID, agent: prevMessage?.agent })
log(`[${HOOK_NAME}] Skipped: agent lacks write permission`, { sessionID, agent: agentName })
return
}
const prompt = `${CONTINUATION_PROMPT}\n\n[Status: ${todos.length - freshIncompleteCount}/${todos.length} completed, ${freshIncompleteCount} remaining]`
const model = prevMessage?.model?.providerID && prevMessage?.model?.modelID
? { providerID: prevMessage.model.providerID, modelID: prevMessage.model.modelID }
: undefined
try {
log(`[${HOOK_NAME}] Injecting continuation`, { sessionID, agent: prevMessage?.agent, model, incompleteCount: freshIncompleteCount })
log(`[${HOOK_NAME}] Injecting continuation`, { sessionID, agent: agentName, model, incompleteCount: freshIncompleteCount })
await ctx.client.session.prompt({
path: { id: sessionID },
body: {
agent: prevMessage?.agent,
agent: agentName,
...(model !== undefined ? { model } : {}),
parts: [{ type: "text", text: prompt }],
},
@ -229,7 +245,12 @@ export function createTodoContinuationEnforcer(
}
}
function startCountdown(sessionID: string, incompleteCount: number, total: number): void {
function startCountdown(
sessionID: string,
incompleteCount: number,
total: number,
resolvedInfo?: ResolvedMessageInfo
): void {
const state = getState(sessionID)
cancelCountdown(sessionID)
@ -246,7 +267,7 @@ export function createTodoContinuationEnforcer(
state.countdownTimer = setTimeout(() => {
cancelCountdown(sessionID)
injectContinuation(sessionID, incompleteCount, total)
injectContinuation(sessionID, incompleteCount, total, resolvedInfo)
}, COUNTDOWN_SECONDS * 1000)
log(`[${HOOK_NAME}] Countdown started`, { sessionID, seconds: COUNTDOWN_SECONDS, incompleteCount })
@ -350,15 +371,26 @@ export function createTodoContinuationEnforcer(
return
}
let agentName: string | undefined
let resolvedInfo: ResolvedMessageInfo | undefined
try {
const messagesResp = await ctx.client.session.messages({
path: { id: sessionID },
})
const messages = (messagesResp.data ?? []) as Array<{ info?: { agent?: string } }>
const messages = (messagesResp.data ?? []) as Array<{
info?: {
agent?: string
model?: { providerID: string; modelID: string }
tools?: Record<string, ToolPermission>
}
}>
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].info?.agent) {
agentName = messages[i].info?.agent
const info = messages[i].info
if (info?.agent || info?.model) {
resolvedInfo = {
agent: info.agent,
model: info.model,
tools: info.tools,
}
break
}
}
@ -366,13 +398,13 @@ export function createTodoContinuationEnforcer(
log(`[${HOOK_NAME}] Failed to fetch messages for agent check`, { sessionID, error: String(err) })
}
log(`[${HOOK_NAME}] Agent check`, { sessionID, agentName, skipAgents })
if (agentName && skipAgents.includes(agentName)) {
log(`[${HOOK_NAME}] Skipped: agent in skipAgents list`, { sessionID, agent: agentName })
log(`[${HOOK_NAME}] Agent check`, { sessionID, agentName: resolvedInfo?.agent, skipAgents })
if (resolvedInfo?.agent && skipAgents.includes(resolvedInfo.agent)) {
log(`[${HOOK_NAME}] Skipped: agent in skipAgents list`, { sessionID, agent: resolvedInfo.agent })
return
}
startCountdown(sessionID, incompleteCount, todos.length)
startCountdown(sessionID, incompleteCount, todos.length, resolvedInfo)
return
}

View File

@ -279,12 +279,30 @@ Use \`background_output\` with task_id="${task.id}" to check progress.`
})
try {
const resumeMessageDir = getMessageDir(args.resume)
const resumeMessage = resumeMessageDir ? findNearestMessageWithFields(resumeMessageDir) : null
const resumeAgent = resumeMessage?.agent
const resumeModel = resumeMessage?.model?.providerID && resumeMessage?.model?.modelID
? { providerID: resumeMessage.model.providerID, modelID: resumeMessage.model.modelID }
: undefined
let resumeAgent: string | undefined
let resumeModel: { providerID: string; modelID: string } | undefined
try {
const messagesResp = await client.session.messages({ path: { id: args.resume } })
const messages = (messagesResp.data ?? []) as Array<{
info?: { agent?: string; model?: { providerID: string; modelID: string } }
}>
for (let i = messages.length - 1; i >= 0; i--) {
const info = messages[i].info
if (info?.agent || info?.model) {
resumeAgent = info.agent
resumeModel = info.model
break
}
}
} catch {
const resumeMessageDir = getMessageDir(args.resume)
const resumeMessage = resumeMessageDir ? findNearestMessageWithFields(resumeMessageDir) : null
resumeAgent = resumeMessage?.agent
resumeModel = resumeMessage?.model?.providerID && resumeMessage?.model?.modelID
? { providerID: resumeMessage.model.providerID, modelID: resumeMessage.model.modelID }
: undefined
}
await client.session.prompt({
path: { id: args.resume },