fix(prompt-gate): block prompts into pending tool turns
This commit is contained in:
@@ -7,6 +7,7 @@ import {
|
||||
|
||||
export const DEFAULT_PROMPT_ASYNC_POST_DISPATCH_HOLD_MS = 250
|
||||
export const DEFAULT_PROMPT_DISPATCH_TIMEOUT_MS = 30_000
|
||||
export const DEFAULT_PROMPT_GATE_MESSAGES_FETCH_TIMEOUT_MS = 5_000
|
||||
|
||||
type PromptAsyncInput = {
|
||||
path?: { id?: string }
|
||||
@@ -16,9 +17,15 @@ type PromptAsyncInput = {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
type PromptMessagesQuery = {
|
||||
directory: string
|
||||
limit?: number
|
||||
}
|
||||
|
||||
type PromptAsyncClient<TInput> = {
|
||||
session?: {
|
||||
status?: () => Promise<unknown>
|
||||
messages?: (input: { path: { id: string }; query: PromptMessagesQuery }) => Promise<unknown>
|
||||
promptAsync?: (input: TInput) => Promise<unknown>
|
||||
}
|
||||
}
|
||||
@@ -26,6 +33,7 @@ type PromptAsyncClient<TInput> = {
|
||||
type PromptClient<TInput> = {
|
||||
session?: {
|
||||
status?: () => Promise<unknown>
|
||||
messages?: (input: { path: { id: string }; query: PromptMessagesQuery }) => Promise<unknown>
|
||||
prompt?: (input: TInput) => Promise<unknown>
|
||||
}
|
||||
}
|
||||
@@ -40,6 +48,8 @@ type PromptAsyncReservation = {
|
||||
declare function setTimeout(callback: () => void, delay?: number): ReturnType<typeof globalThis.setTimeout>
|
||||
declare function clearTimeout(timeout: ReturnType<typeof globalThis.setTimeout>): void
|
||||
|
||||
let promptGateMessagesFetchTimeoutMsForTesting: number | undefined
|
||||
|
||||
export type PromptAsyncGateResult =
|
||||
| { status: "dispatched"; response: unknown }
|
||||
| { status: "active" }
|
||||
@@ -54,6 +64,14 @@ type PromptAsyncReservationReleaseOptions = {
|
||||
|
||||
const promptAsyncReservations = new Map<string, PromptAsyncReservation>()
|
||||
|
||||
export function _setPromptGateMessagesFetchTimeoutMsForTesting(value: number | undefined): void {
|
||||
promptGateMessagesFetchTimeoutMsForTesting = value
|
||||
}
|
||||
|
||||
function getPromptGateMessagesFetchTimeoutMs(): number {
|
||||
return promptGateMessagesFetchTimeoutMsForTesting ?? DEFAULT_PROMPT_GATE_MESSAGES_FETCH_TIMEOUT_MS
|
||||
}
|
||||
|
||||
function pruneExpiredReservations(now = Date.now()): void {
|
||||
for (const [sessionID, reservation] of promptAsyncReservations) {
|
||||
if (typeof reservation.expiresAt === "number" && reservation.expiresAt <= now) {
|
||||
@@ -119,9 +137,120 @@ async function withDispatchTimeout<T>(
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null
|
||||
}
|
||||
|
||||
function getPromptQuery(input: unknown): PromptMessagesQuery {
|
||||
if (!isRecord(input)) {
|
||||
return { directory: "" }
|
||||
}
|
||||
const query = input.query
|
||||
if (!isRecord(query)) {
|
||||
return { directory: "" }
|
||||
}
|
||||
|
||||
const promptQuery: PromptMessagesQuery = { directory: "" }
|
||||
if (typeof query.directory === "string") {
|
||||
promptQuery.directory = query.directory
|
||||
}
|
||||
if (typeof query.limit === "number") {
|
||||
promptQuery.limit = query.limit
|
||||
}
|
||||
return promptQuery
|
||||
}
|
||||
|
||||
function getMessagesData(response: unknown): unknown[] {
|
||||
if (isRecord(response) && Array.isArray(response.data)) {
|
||||
return response.data
|
||||
}
|
||||
return Array.isArray(response) ? response : []
|
||||
}
|
||||
|
||||
function messageRole(message: unknown): string | undefined {
|
||||
if (!isRecord(message)) {
|
||||
return undefined
|
||||
}
|
||||
const info = message.info
|
||||
if (isRecord(info) && typeof info.role === "string") {
|
||||
return info.role
|
||||
}
|
||||
return typeof message.role === "string" ? message.role : undefined
|
||||
}
|
||||
|
||||
function partIsWaitingOnTool(part: unknown): boolean {
|
||||
if (!isRecord(part)) {
|
||||
return false
|
||||
}
|
||||
if (part.type !== "tool" && part.type !== "tool_use") {
|
||||
return false
|
||||
}
|
||||
|
||||
const state = part.state
|
||||
if (!isRecord(state)) {
|
||||
return false
|
||||
}
|
||||
return state.status === "pending" || state.status === "running"
|
||||
}
|
||||
|
||||
function latestAssistantTurnIsWaitingOnTools(messages: unknown[]): boolean {
|
||||
for (let index = messages.length - 1; index >= 0; index--) {
|
||||
const message = messages[index]
|
||||
const role = messageRole(message)
|
||||
if (role === "assistant") {
|
||||
if (!isRecord(message) || !Array.isArray(message.parts)) {
|
||||
return false
|
||||
}
|
||||
return message.parts.some(partIsWaitingOnTool)
|
||||
}
|
||||
if (role === "user") {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async function sessionLatestAssistantIsWaitingOnTools<TInput>(args: {
|
||||
client: { session?: { messages?: (input: { path: { id: string }; query: PromptMessagesQuery }) => Promise<unknown> } }
|
||||
sessionID: string
|
||||
input: TInput
|
||||
sessionName: "promptAsync" | "prompt"
|
||||
source: string
|
||||
timeoutMs: number
|
||||
}): Promise<boolean> {
|
||||
const messages = args.client.session?.messages
|
||||
if (typeof messages !== "function") {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await withDispatchTimeout(
|
||||
messages({
|
||||
path: { id: args.sessionID },
|
||||
query: getPromptQuery(args.input),
|
||||
}),
|
||||
args.timeoutMs,
|
||||
`[prompt-async-gate] ${args.sessionName} session.messages`,
|
||||
)
|
||||
return latestAssistantTurnIsWaitingOnTools(getMessagesData(response))
|
||||
} catch (error) {
|
||||
log("[prompt-async-gate] latest assistant tool-state check failed", {
|
||||
sessionID: args.sessionID,
|
||||
source: args.source,
|
||||
error: String(error),
|
||||
})
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function dispatchAfterSessionIdle<TInput>(args: {
|
||||
sessionName: "promptAsync" | "prompt"
|
||||
client: { session?: { status?: () => Promise<unknown> } }
|
||||
client: {
|
||||
session?: {
|
||||
status?: () => Promise<unknown>
|
||||
messages?: (input: { path: { id: string }; query: PromptMessagesQuery }) => Promise<unknown>
|
||||
}
|
||||
}
|
||||
sessionID: string
|
||||
input: TInput
|
||||
source: string
|
||||
@@ -129,6 +258,7 @@ async function dispatchAfterSessionIdle<TInput>(args: {
|
||||
postDispatchHoldMs: number
|
||||
dispatchTimeoutMs: number
|
||||
checkStatus: boolean
|
||||
checkToolState: boolean
|
||||
dispatch: (input: TInput) => Promise<unknown>
|
||||
}): Promise<PromptAsyncGateResult> {
|
||||
const {
|
||||
@@ -141,6 +271,7 @@ async function dispatchAfterSessionIdle<TInput>(args: {
|
||||
postDispatchHoldMs,
|
||||
dispatchTimeoutMs,
|
||||
checkStatus,
|
||||
checkToolState,
|
||||
dispatch,
|
||||
} = args
|
||||
|
||||
@@ -186,6 +317,25 @@ async function dispatchAfterSessionIdle<TInput>(args: {
|
||||
return { status: "active" }
|
||||
}
|
||||
|
||||
if (
|
||||
checkToolState
|
||||
&& typeof client.session?.messages === "function"
|
||||
&& await sessionLatestAssistantIsWaitingOnTools({
|
||||
client,
|
||||
sessionID,
|
||||
input,
|
||||
sessionName,
|
||||
source,
|
||||
timeoutMs: Math.min(dispatchTimeoutMs, getPromptGateMessagesFetchTimeoutMs()),
|
||||
})
|
||||
) {
|
||||
log(`[prompt-async-gate] ${sessionName} skipped because latest assistant is waiting on tools`, {
|
||||
sessionID,
|
||||
source,
|
||||
})
|
||||
return { status: "active" }
|
||||
}
|
||||
|
||||
log(`[prompt-async-gate] ${sessionName} dispatching`, { sessionID, source })
|
||||
dispatchAttempted = true
|
||||
const response = await withDispatchTimeout(
|
||||
@@ -219,6 +369,7 @@ export async function promptAsyncAfterSessionIdle<TInput = PromptAsyncInput>(arg
|
||||
postDispatchHoldMs?: number
|
||||
dispatchTimeoutMs?: number
|
||||
checkStatus?: boolean
|
||||
checkToolState?: boolean
|
||||
}): Promise<PromptAsyncGateResult> {
|
||||
const {
|
||||
client,
|
||||
@@ -247,6 +398,7 @@ export async function promptAsyncAfterSessionIdle<TInput = PromptAsyncInput>(arg
|
||||
postDispatchHoldMs,
|
||||
dispatchTimeoutMs,
|
||||
checkStatus: args.checkStatus !== false,
|
||||
checkToolState: args.checkToolState !== false,
|
||||
dispatch: (dispatchInput) => dispatchPromptAsync(dispatchInput),
|
||||
})
|
||||
}
|
||||
@@ -260,6 +412,7 @@ export async function promptAfterSessionIdle<TInput = PromptAsyncInput>(args: {
|
||||
postDispatchHoldMs?: number
|
||||
dispatchTimeoutMs?: number
|
||||
checkStatus?: boolean
|
||||
checkToolState?: boolean
|
||||
}): Promise<PromptAsyncGateResult> {
|
||||
const {
|
||||
client,
|
||||
@@ -288,12 +441,14 @@ export async function promptAfterSessionIdle<TInput = PromptAsyncInput>(args: {
|
||||
postDispatchHoldMs,
|
||||
dispatchTimeoutMs,
|
||||
checkStatus: args.checkStatus !== false,
|
||||
checkToolState: args.checkToolState !== false,
|
||||
dispatch: (dispatchInput) => dispatchPrompt(dispatchInput),
|
||||
})
|
||||
}
|
||||
|
||||
export function releaseAllPromptAsyncReservationsForTesting(): void {
|
||||
promptAsyncReservations.clear()
|
||||
promptGateMessagesFetchTimeoutMsForTesting = undefined
|
||||
}
|
||||
|
||||
export function releasePromptAsyncReservation(
|
||||
|
||||
Reference in New Issue
Block a user