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>` </system-reminder>`
} }
// Dynamically lookup the parent session's current message context let agent: string | undefined = task.parentAgent
// This ensures we use the CURRENT model/agent, not the stale one from task creation time let model: { providerID: string; modelID: string } | undefined
const messageDir = getMessageDir(task.parentSessionID)
const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
const agent = currentMessage?.agent ?? task.parentAgent try {
const model = currentMessage?.model?.providerID && currentMessage?.model?.modelID const messagesResp = await this.client.session.messages({ path: { id: task.parentSessionID } })
? { providerID: currentMessage.model.providerID, modelID: currentMessage.model.modelID } const messages = (messagesResp.data ?? []) as Array<{
: undefined 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:", { log("[background-agent] notifyParentSession context:", {
taskId: task.id, taskId: task.id,
messageDir: !!messageDir,
currentAgent: currentMessage?.agent,
currentModel: currentMessage?.model,
resolvedAgent: agent, resolvedAgent: agent,
resolvedModel: model, resolvedModel: model,
}) })

View File

@ -1,4 +1,4 @@
export { injectHookMessage, findNearestMessageWithFields, findFirstMessageWithAgent } from "./injector" export { injectHookMessage, findNearestMessageWithFields, findFirstMessageWithAgent } from "./injector"
export type { StoredMessage } 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" export { MESSAGE_STORAGE } from "./constants"

View File

@ -315,12 +315,30 @@ export function createRalphLoopHook(
.catch(() => {}) .catch(() => {})
try { try {
const messageDir = getMessageDir(sessionID) let agent: string | undefined
const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null let model: { providerID: string; modelID: string } | undefined
const agent = currentMessage?.agent
const model = currentMessage?.model?.providerID && currentMessage?.model?.modelID try {
? { providerID: currentMessage.model.providerID, modelID: currentMessage.model.modelID } const messagesResp = await ctx.client.session.messages({ path: { id: sessionID } })
: undefined 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({ await ctx.client.session.prompt({
path: { id: sessionID }, path: { id: sessionID },

View File

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

View File

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

View File

@ -279,12 +279,30 @@ Use \`background_output\` with task_id="${task.id}" to check progress.`
}) })
try { try {
const resumeMessageDir = getMessageDir(args.resume) let resumeAgent: string | undefined
const resumeMessage = resumeMessageDir ? findNearestMessageWithFields(resumeMessageDir) : null let resumeModel: { providerID: string; modelID: string } | undefined
const resumeAgent = resumeMessage?.agent
const resumeModel = resumeMessage?.model?.providerID && resumeMessage?.model?.modelID try {
? { providerID: resumeMessage.model.providerID, modelID: resumeMessage.model.modelID } const messagesResp = await client.session.messages({ path: { id: args.resume } })
: undefined 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({ await client.session.prompt({
path: { id: args.resume }, path: { id: args.resume },