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
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 messageDir = getMessageDir(task.parentSessionID)
const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
agent = currentMessage?.agent ?? task.parentAgent
const agent = currentMessage?.agent ?? task.parentAgent model = currentMessage?.model?.providerID && currentMessage?.model?.modelID
const model = currentMessage?.model?.providerID && currentMessage?.model?.modelID
? { providerID: currentMessage.model.providerID, modelID: currentMessage.model.modelID } ? { providerID: currentMessage.model.providerID, modelID: currentMessage.model.modelID }
: undefined : 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 {
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 messageDir = getMessageDir(sessionID)
const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
const agent = currentMessage?.agent agent = currentMessage?.agent
const model = currentMessage?.model?.providerID && currentMessage?.model?.modelID model = currentMessage?.model?.providerID && currentMessage?.model?.modelID
? { providerID: currentMessage.model.providerID, modelID: currentMessage.model.modelID } ? { providerID: currentMessage.model.providerID, modelID: currentMessage.model.modelID }
: undefined : 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 })
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 messageDir = getMessageDir(sessionID)
const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
const model = currentMessage?.model?.providerID && currentMessage?.model?.modelID model = currentMessage?.model?.providerID && currentMessage?.model?.modelID
? { providerID: currentMessage.model.providerID, modelID: currentMessage.model.modelID } ? { providerID: currentMessage.model.providerID, modelID: currentMessage.model.modelID }
: undefined : 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
} }
let agentName = resolvedInfo?.agent
let model = resolvedInfo?.model
let tools = resolvedInfo?.tools
if (!agentName || !model) {
const messageDir = getMessageDir(sessionID) const messageDir = getMessageDir(sessionID)
const prevMessage = messageDir ? findNearestMessageWithFields(messageDir) : null 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 {
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 resumeMessageDir = getMessageDir(args.resume)
const resumeMessage = resumeMessageDir ? findNearestMessageWithFields(resumeMessageDir) : null const resumeMessage = resumeMessageDir ? findNearestMessageWithFields(resumeMessageDir) : null
const resumeAgent = resumeMessage?.agent resumeAgent = resumeMessage?.agent
const resumeModel = resumeMessage?.model?.providerID && resumeMessage?.model?.modelID resumeModel = resumeMessage?.model?.providerID && resumeMessage?.model?.modelID
? { providerID: resumeMessage.model.providerID, modelID: resumeMessage.model.modelID } ? { providerID: resumeMessage.model.providerID, modelID: resumeMessage.model.modelID }
: undefined : undefined
}
await client.session.prompt({ await client.session.prompt({
path: { id: args.resume }, path: { id: args.resume },