feat(hooks): capture reasoning and assistant messages in hooks (#719)
This commit is contained in:
@@ -2669,6 +2669,17 @@ export default function App({
|
||||
lastDequeuedMessageRef.current = null; // Clear - message was processed successfully
|
||||
lastSentInputRef.current = null; // Clear - no recovery needed
|
||||
|
||||
// Get last assistant message and reasoning for Stop hook
|
||||
const lastAssistant = Array.from(
|
||||
buffersRef.current.byId.values(),
|
||||
).findLast((item) => item.kind === "assistant" && "text" in item);
|
||||
const assistantMessage =
|
||||
lastAssistant && "text" in lastAssistant
|
||||
? lastAssistant.text
|
||||
: undefined;
|
||||
const precedingReasoning = buffersRef.current.lastReasoning;
|
||||
buffersRef.current.lastReasoning = undefined; // Clear after use
|
||||
|
||||
// Run Stop hooks - if blocked/errored, continue the conversation with feedback
|
||||
const stopHookResult = await runStopHooks(
|
||||
stopReasonToHandle,
|
||||
@@ -2676,6 +2687,9 @@ export default function App({
|
||||
Array.from(buffersRef.current.byId.values()).filter(
|
||||
(item) => item.kind === "tool_call",
|
||||
).length,
|
||||
undefined, // workingDirectory (uses default)
|
||||
precedingReasoning,
|
||||
assistantMessage,
|
||||
);
|
||||
|
||||
// If hook blocked (exit 2), inject stderr feedback and continue conversation
|
||||
|
||||
@@ -181,6 +181,8 @@ export type Buffers = {
|
||||
interrupted?: boolean; // Track if stream was interrupted by user (skip stale refreshes)
|
||||
commitGeneration?: number; // Incremented when resuming from error to invalidate pending refreshes
|
||||
abortGeneration?: number; // Incremented on each interrupt to detect cancellation across async boundaries
|
||||
lastReasoning?: string; // Track last reasoning content for hooks (PostToolUse, Stop)
|
||||
lastAssistantMessage?: string; // Track last assistant message for hooks (PostToolUse)
|
||||
usage: {
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
@@ -243,6 +245,15 @@ function markAsFinished(b: Buffers, id: string) {
|
||||
const updatedLine = { ...line, phase: "finished" as const };
|
||||
b.byId.set(id, updatedLine);
|
||||
// console.log(`[MARK_FINISHED] Successfully marked ${id} as finished`);
|
||||
|
||||
// Track last reasoning content for hooks (PostToolUse and Stop will include it)
|
||||
if (line.kind === "reasoning" && "text" in line && line.text) {
|
||||
b.lastReasoning = line.text;
|
||||
}
|
||||
// Track last assistant message for hooks (PostToolUse will include it)
|
||||
if (line.kind === "assistant" && "text" in line && line.text) {
|
||||
b.lastAssistantMessage = line.text;
|
||||
}
|
||||
} else {
|
||||
// console.log(`[MARK_FINISHED] Did NOT mark ${id} as finished (conditions not met)`);
|
||||
}
|
||||
@@ -717,6 +728,12 @@ export function onChunk(b: Buffers, chunk: LettaStreamingResponse) {
|
||||
// Args parsing failed
|
||||
}
|
||||
|
||||
// Get and clear preceding reasoning/message for hook
|
||||
const precedingReasoning = b.lastReasoning;
|
||||
const precedingAssistantMessage = b.lastAssistantMessage;
|
||||
b.lastReasoning = undefined;
|
||||
b.lastAssistantMessage = undefined;
|
||||
|
||||
runPostToolUseHooks(
|
||||
serverToolInfo.toolName,
|
||||
parsedArgs,
|
||||
@@ -727,6 +744,8 @@ export function onChunk(b: Buffers, chunk: LettaStreamingResponse) {
|
||||
toolCallId,
|
||||
undefined,
|
||||
b.agentId,
|
||||
precedingReasoning,
|
||||
precedingAssistantMessage,
|
||||
).catch(() => {});
|
||||
|
||||
b.serverToolCalls.delete(toolCallId);
|
||||
|
||||
@@ -72,6 +72,8 @@ export async function runPostToolUseHooks(
|
||||
toolCallId?: string,
|
||||
workingDirectory: string = process.cwd(),
|
||||
agentId?: string,
|
||||
precedingReasoning?: string,
|
||||
precedingAssistantMessage?: string,
|
||||
): Promise<HookExecutionResult> {
|
||||
const hooks = await getHooksForEvent(
|
||||
"PostToolUse",
|
||||
@@ -90,6 +92,8 @@ export async function runPostToolUseHooks(
|
||||
tool_call_id: toolCallId,
|
||||
tool_result: toolResult,
|
||||
agent_id: agentId,
|
||||
preceding_reasoning: precedingReasoning,
|
||||
preceding_assistant_message: precedingAssistantMessage,
|
||||
};
|
||||
|
||||
// Run in parallel since PostToolUse cannot block
|
||||
@@ -202,6 +206,8 @@ export async function runStopHooks(
|
||||
messageCount?: number,
|
||||
toolCallCount?: number,
|
||||
workingDirectory: string = process.cwd(),
|
||||
precedingReasoning?: string,
|
||||
assistantMessage?: string,
|
||||
): Promise<HookExecutionResult> {
|
||||
const hooks = await getHooksForEvent("Stop", undefined, workingDirectory);
|
||||
if (hooks.length === 0) {
|
||||
@@ -214,6 +220,8 @@ export async function runStopHooks(
|
||||
stop_reason: stopReason,
|
||||
message_count: messageCount,
|
||||
tool_call_count: toolCallCount,
|
||||
preceding_reasoning: precedingReasoning,
|
||||
assistant_message: assistantMessage,
|
||||
};
|
||||
|
||||
// Run sequentially - Stop can block
|
||||
|
||||
@@ -186,6 +186,10 @@ export interface PostToolUseHookInput extends HookInputBase {
|
||||
};
|
||||
/** Agent ID (for server-side tools like memory) */
|
||||
agent_id?: string;
|
||||
/** Reasoning/thinking content that preceded this tool call */
|
||||
preceding_reasoning?: string;
|
||||
/** Assistant message content that preceded this tool call */
|
||||
preceding_assistant_message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -247,6 +251,10 @@ export interface StopHookInput extends HookInputBase {
|
||||
message_count?: number;
|
||||
/** Number of tool calls in the turn */
|
||||
tool_call_count?: number;
|
||||
/** Reasoning/thinking content that preceded the final response */
|
||||
preceding_reasoning?: string;
|
||||
/** The assistant's final message content */
|
||||
assistant_message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -242,6 +242,37 @@ describe.skipIf(isWindows)("Hooks Integration Tests", () => {
|
||||
// Allow headroom for CI runners (especially macOS ARM) which can be slow
|
||||
expect(duration).toBeLessThan(400); // Parallel should be ~100ms
|
||||
});
|
||||
|
||||
test("includes preceding_reasoning and preceding_assistant_message when provided", async () => {
|
||||
createHooksConfig({
|
||||
PostToolUse: [
|
||||
{
|
||||
matcher: "*",
|
||||
hooks: [{ type: "command", command: "cat" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await runPostToolUseHooks(
|
||||
"Bash",
|
||||
{ command: "ls" },
|
||||
{ status: "success", output: "file.txt" },
|
||||
"tool-123",
|
||||
tempDir,
|
||||
"agent-456",
|
||||
"I should list the files to see what's there...",
|
||||
"Let me check what files are in this directory.",
|
||||
);
|
||||
|
||||
const parsed = JSON.parse(result.results[0]?.stdout || "{}");
|
||||
expect(parsed.preceding_reasoning).toBe(
|
||||
"I should list the files to see what's there...",
|
||||
);
|
||||
expect(parsed.preceding_assistant_message).toBe(
|
||||
"Let me check what files are in this directory.",
|
||||
);
|
||||
expect(parsed.agent_id).toBe("agent-456");
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
@@ -506,6 +537,30 @@ describe.skipIf(isWindows)("Hooks Integration Tests", () => {
|
||||
expect(parsed.message_count).toBe(10);
|
||||
expect(parsed.tool_call_count).toBe(7);
|
||||
});
|
||||
|
||||
test("includes preceding_reasoning and assistant_message when provided", async () => {
|
||||
createHooksConfig({
|
||||
Stop: [
|
||||
{
|
||||
matcher: "*",
|
||||
hooks: [{ type: "command", command: "cat" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await runStopHooks(
|
||||
"end_turn",
|
||||
5,
|
||||
2,
|
||||
tempDir,
|
||||
"Let me think about this...",
|
||||
"Here is my response.",
|
||||
);
|
||||
|
||||
const parsed = JSON.parse(result.results[0]?.stdout || "{}");
|
||||
expect(parsed.preceding_reasoning).toBe("Let me think about this...");
|
||||
expect(parsed.assistant_message).toBe("Here is my response.");
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -955,6 +955,7 @@ export async function executeTool(
|
||||
);
|
||||
|
||||
// Run PostToolUse hooks (async, non-blocking)
|
||||
// Note: preceding_reasoning/assistant_message not available here - tracked in accumulator for server tools
|
||||
runPostToolUseHooks(
|
||||
internalName,
|
||||
args as Record<string, unknown>,
|
||||
@@ -963,6 +964,10 @@ export async function executeTool(
|
||||
output: getDisplayableToolReturn(flattenedResponse),
|
||||
},
|
||||
options?.toolCallId,
|
||||
undefined, // workingDirectory
|
||||
undefined, // agentId
|
||||
undefined, // precedingReasoning - not available in tool manager context
|
||||
undefined, // precedingAssistantMessage - not available in tool manager context
|
||||
).catch(() => {
|
||||
// Silently ignore hook errors - don't affect tool execution
|
||||
});
|
||||
@@ -1009,6 +1014,10 @@ export async function executeTool(
|
||||
args as Record<string, unknown>,
|
||||
{ status: "error", output: errorMessage },
|
||||
options?.toolCallId,
|
||||
undefined, // workingDirectory
|
||||
undefined, // agentId
|
||||
undefined, // precedingReasoning - not available in tool manager context
|
||||
undefined, // precedingAssistantMessage - not available in tool manager context
|
||||
).catch(() => {
|
||||
// Silently ignore hook errors
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user