Files
letta-code/src/agent/approval-execution.ts
2025-11-23 09:53:27 -08:00

154 lines
4.9 KiB
TypeScript

// src/agent/approval-execution.ts
// Shared logic for executing approval batches (used by both interactive and headless modes)
import type {
ApprovalCreate,
ToolReturn,
} from "@letta-ai/letta-client/resources/agents/messages";
import type { ToolReturnMessage } from "@letta-ai/letta-client/resources/tools";
import type { ApprovalRequest } from "../cli/helpers/stream";
import { executeTool } from "../tools/manager";
export type ApprovalDecision =
| { type: "approve"; approval: ApprovalRequest }
| { type: "deny"; approval: ApprovalRequest; reason: string };
// Align result type with the SDK's expected union for approvals payloads
export type ApprovalResult = ToolReturn | ApprovalCreate.ApprovalReturn;
/**
* Execute a batch of approval decisions and format results for the backend.
*
* This function handles:
* - Executing approved tools (with error handling)
* - Formatting denials
* - Combining all results into a single batch
*
* Used by both interactive (App.tsx) and headless (headless.ts) modes.
*
* @param decisions - Array of approve/deny decisions for each tool
* @param onChunk - Optional callback to update UI with tool results (for interactive mode)
* @returns Array of formatted results ready to send to backend
*/
export async function executeApprovalBatch(
decisions: ApprovalDecision[],
onChunk?: (chunk: ToolReturnMessage) => void,
options?: { abortSignal?: AbortSignal },
): Promise<ApprovalResult[]> {
const results: ApprovalResult[] = [];
for (const decision of decisions) {
// If aborted before starting this decision, record an interrupted result
if (options?.abortSignal?.aborted) {
// Emit an interrupted chunk for visibility if callback provided
if (onChunk) {
onChunk({
message_type: "tool_return_message",
id: "dummy",
date: new Date().toISOString(),
tool_call_id: decision.approval.toolCallId,
tool_return: "User interrupted tool execution",
status: "error",
});
}
results.push({
type: "tool",
tool_call_id: decision.approval.toolCallId,
tool_return: "User interrupted tool execution",
status: "error",
});
continue;
}
if (decision.type === "approve") {
// Execute the approved tool
try {
const parsedArgs =
typeof decision.approval.toolArgs === "string"
? JSON.parse(decision.approval.toolArgs)
: decision.approval.toolArgs || {};
const toolResult = await executeTool(
decision.approval.toolName,
parsedArgs,
{ signal: options?.abortSignal },
);
// Update UI if callback provided (interactive mode)
if (onChunk) {
onChunk({
message_type: "tool_return_message",
id: "dummy",
date: new Date().toISOString(),
tool_call_id: decision.approval.toolCallId,
tool_return: toolResult.toolReturn,
status: toolResult.status,
stdout: toolResult.stdout,
stderr: toolResult.stderr,
});
}
results.push({
type: "tool",
tool_call_id: decision.approval.toolCallId,
tool_return: toolResult.toolReturn,
status: toolResult.status,
stdout: toolResult.stdout,
stderr: toolResult.stderr,
});
} catch (e) {
const isAbortError =
e instanceof Error &&
(e.name === "AbortError" ||
e.message === "The operation was aborted");
const errorMessage = isAbortError
? "User interrupted tool execution"
: `Error executing tool: ${String(e)}`;
// Still need to send error result to backend for this tool
// Update UI if callback provided
if (onChunk) {
onChunk({
message_type: "tool_return_message",
id: "dummy",
date: new Date().toISOString(),
tool_call_id: decision.approval.toolCallId,
tool_return: errorMessage,
status: "error",
});
}
results.push({
type: "tool",
tool_call_id: decision.approval.toolCallId,
tool_return: errorMessage,
status: "error",
});
}
} else {
// Format denial for backend
// Update UI if callback provided
if (onChunk) {
onChunk({
message_type: "tool_return_message",
id: "dummy",
date: new Date().toISOString(),
tool_call_id: decision.approval.toolCallId,
tool_return: `Error: request to call tool denied. User reason: ${decision.reason}`,
status: "error",
});
}
results.push({
type: "approval",
tool_call_id: decision.approval.toolCallId,
approve: false,
reason: decision.reason,
});
}
}
return results;
}