Skip to content

Commit b43758d

Browse files
committed
feat: add human-in-the-loop middleware for tool approval process
- Integrated human-in-the-loop middleware to handle dangerous tool executions. - Updated AgentRuntime to include new middleware and handle approval decisions. - Modified system prompts to clarify the approval process for dangerous actions. - Enhanced TurnLifecycleService to support resuming turns with approval decisions. - Updated TurnStreamConsumer to manage interrupt events for tool approvals. - Added new types and structures to support approval decisions in turn types. - Implemented agent event handling for tool approval requests and responses. - Created a new ToolApprovalRenderer component for displaying approval messages in the UI. - Updated agent chat manager to handle tool approval requests and submissions. - Enhanced API endpoints to process tool approval decisions and stream responses. - Refactored skills documentation to clarify mutation confirmation requirements.
1 parent 67e14dc commit b43758d

19 files changed

Lines changed: 646 additions & 90 deletions

agent/middleware/apiBasedTools.ts

Lines changed: 16 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ export function createApiBasedToolsMiddleware(
8282
async wrapToolCall(request, handler) {
8383
const startedAt = Date.now();
8484
const toolInput = JSON.stringify(request.toolCall.args ?? {});
85+
const toolCallId = request.toolCall.id as string;
8586
const { adminUser, emit, sequenceDebugSink, userTimeZone } = request.runtime.context as {
8687
adminUser: AdminUser;
8788
emit?: AgentEventEmitter;
@@ -113,7 +114,7 @@ export function createApiBasedToolsMiddleware(
113114
}
114115
const toolCallTracker = createToolCallTracker({
115116
emit: emitToolCall,
116-
toolCallId: request.toolCall.id,
117+
toolCallId,
117118
toolName: request.toolCall.name,
118119
toolInfo,
119120
input: toolArgs,
@@ -125,39 +126,29 @@ export function createApiBasedToolsMiddleware(
125126
);
126127

127128
try {
128-
let result;
129129

130-
if (request.tool) {
131-
result = await handler(request);
132-
} else {
133-
const enabledApiToolNames = getEnabledApiToolNames(request.state.messages);
134-
135-
if (enabledApiToolNames.has(request.toolCall.name)) {
136-
result = await handler({
130+
const result = getEnabledApiToolNames(request.state.messages).has(request.toolCall.name)
131+
? await handler({
137132
...request,
138133
tool: dynamicTools[request.toolCall.name],
139-
});
140-
} else {
141-
result = new ToolMessage({
142-
content: `Tool "${request.toolCall.name}" is not loaded. Call fetch_tool_schema first.`,
143-
tool_call_id: request.toolCall.id ?? "",
144-
name: request.toolCall.name,
145-
status: "error",
146-
});
147-
}
148-
}
134+
})
135+
: await handler(request);
149136

150137
toolCallTracker.finishSuccess(result);
151138
return result;
152139
} catch (error) {
153-
const errorDetails =
154-
error instanceof Error ? error.stack ?? error.message : String(error);
155-
156140
logger.error(
157-
`Tool "${request.toolCall.name}" failed after ${Date.now() - startedAt}ms with input: ${toolInput}\n${errorDetails}`,
141+
`Error calling tool "${request.toolCall.name}": ${error instanceof Error ? error.stack ?? error.message : String(error)}`,
158142
);
159-
toolCallTracker.finishError(error);
160-
throw error;
143+
toolCallTracker.finishError(`Error: ${error instanceof Error ? error.message : String(error)}`);
144+
return new ToolMessage({
145+
name: request.toolCall.name,
146+
tool_call_id: toolCallId,
147+
status: "error",
148+
content: `Error: ${
149+
error instanceof Error ? error.message : String(error)
150+
}`,
151+
})
161152
} finally {
162153
logger.info(
163154
`Tool "${request.toolCall.name}" finished in ${Date.now() - startedAt}ms`,

agent/runtime/AgentRuntime.ts

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,28 @@
11
import type { IAdminForth } from "adminforth";
2-
import { createAgent, summarizationMiddleware } from "langchain";
2+
import { createAgent, summarizationMiddleware, humanInTheLoopMiddleware } from "langchain";
33
import type { BaseCheckpointSaver } from "@langchain/langgraph";
44
import { createApiBasedToolsMiddleware } from "../middleware/apiBasedTools.js";
55
import { createSequenceDebugMiddleware } from "../middleware/sequenceDebug.js";
66
import { createAgentLlmMetricsLogger } from "../simpleAgent.js";
77
import type { AgentToolProvider } from "../tools/AgentToolProvider.js";
88
import type { AgentRuntimeRunInput } from "../turn/turnTypes.js";
99
import { contextSchema, toLangchainAgentContext } from "./AgentContext.js";
10+
import type { ApiBasedTool } from "../../apiBasedTools.js";
11+
12+
function createHumanInTheLoopInterrupts(
13+
apiBasedTools: Record<string, ApiBasedTool>,
14+
): Record<string, { allowedDecisions: ("approve" | "reject" | "edit")[] }> {
15+
return Object.fromEntries(
16+
Object.entries(apiBasedTools)
17+
.filter(([, apiBasedTool]) => apiBasedTool.agent?.isDangerous === true)
18+
.map(([toolName]) => [
19+
toolName,
20+
{
21+
allowedDecisions: ["approve", "reject"],
22+
},
23+
]),
24+
);
25+
}
1026

1127
export type AgentRuntimeOptions = {
1228
name: string;
@@ -29,10 +45,15 @@ export class AgentRuntime {
2945
const sequenceDebugMiddleware = createSequenceDebugMiddleware(
3046
input.observability.sequenceDebugSink,
3147
);
48+
const hitlMiddleware = humanInTheLoopMiddleware({
49+
interruptOn: createHumanInTheLoopInterrupts(apiBasedTools),
50+
descriptionPrefix: "Tool execution pending approval",
51+
});
3252
const middleware = [
3353
apiBasedToolsMiddleware,
3454
...(input.models.modelMiddleware ?? []),
3555
sequenceDebugMiddleware,
56+
hitlMiddleware,
3657
summarizationMiddleware({
3758
model: input.models.summaryModel,
3859
trigger: { tokens: 1024 * 64 },
@@ -49,8 +70,8 @@ export class AgentRuntime {
4970
middleware,
5071
});
5172

52-
return agent.stream({ messages: input.messages } as any, {
53-
streamMode: "messages",
73+
return agent.stream(input.input as any, {
74+
streamMode: ["messages", "updates"],
5475
recursionLimit: 100,
5576
callbacks: [createAgentLlmMetricsLogger()],
5677
signal: input.context.abortSignal,

agent/systemPrompt.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,8 @@ export const DEFAULT_AGENT_SYSTEM_PROMPT = [
2525
"Do not add extra explanations or suggestions unless the user asks.",
2626
"Adapt to the user's tone and style of speaking, mirroring their vibe and wording.",
2727
"if the user speaks casually, you should respond casually too",
28-
"Never mutate data without user confirmation for a clearly described mutation plan.",
29-
"One confirmation may cover one mutation or one explicitly described batch/sequence of related mutations.",
30-
"If the confirmed plan has multiple steps, you may execute the whole confirmed plan without asking again between those steps.",
31-
"If the plan changes, expands, or you want to do anything beyond the confirmed plan, ask for confirmation again.",
32-
"Do not reuse an old confirmation for a new mutation plan.",
28+
"Before calling a dangerous tool, briefly describe the exact action, target, and important changes in chat.",
29+
"Do not ask the user for textual confirmation; dangerous tools are approved by the runtime approval UI.",
3330
].join(" ");
3431

3532
export function appendCustomSystemPrompt(
@@ -124,7 +121,7 @@ export async function buildAgentSystemPrompt(
124121
"If the user wants to fetch records, load fetch_data first. If the user wants analytics or charts, load analyze_data first.",
125122
"Only call fetch_tool_schema for tool names that are explicitly mentioned in a fetched skill and are not already available as base tools.",
126123
"If a fetched skill lists a non-base tool you need, call fetch_tool_schema for it immediately instead of telling the user the tool is unavailable.",
127-
"For example: for record creation load mutate_data, read its tool list, call fetch_tool_schema for create_record, and then use create_record after confirmation.",
124+
"For example: for record creation load mutate_data, read its tool list, call fetch_tool_schema for create_record, describe the planned record, and then use create_record.",
128125
"When fetch_tool_schema succeeds, that tool becomes available on the next step.",
129126
"All admin links must be root-relative and start with '/'.",
130127
"Build record links as '/resource/{resourceId}/show/{primary key}'. Never use bare 'resource/{resourceId}/show/{primary key}' without the leading slash.",

agent/turn/TurnLifecycleService.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import type { AgentSessionStore } from "../../sessionStore.js";
2+
import type { PluginOptions } from "../../types.js";
23
import type { BaseAgentTurnInput } from "./turnTypes.js";
34
import { TurnPersistenceService } from "./TurnPersistenceService.js";
45

56
export class TurnLifecycleService {
67
constructor(
78
private readonly sessionStore: AgentSessionStore,
89
private readonly persistence: TurnPersistenceService,
10+
private readonly options: PluginOptions,
911
) {}
1012

1113
async start(input: BaseAgentTurnInput) {
@@ -19,6 +21,22 @@ export class TurnLifecycleService {
1921
};
2022
}
2123

24+
async resume(input: BaseAgentTurnInput) {
25+
const latestTurn = await this.sessionStore.getLatestTurn(input.sessionId);
26+
27+
if (!latestTurn) {
28+
throw new Error(`No agent turn found for session "${input.sessionId}".`);
29+
}
30+
31+
return {
32+
turnId: latestTurn[this.options.turnResource.idField],
33+
previousUserMessages: await this.sessionStore.getPreviousUserMessages(input.sessionId),
34+
initialResponse: latestTurn[this.options.turnResource.responseField] === "not_finished"
35+
? ""
36+
: String(latestTurn[this.options.turnResource.responseField]),
37+
};
38+
}
39+
2240
async finish(input: {
2341
turnId: string;
2442
responseText: string;

agent/turn/TurnStreamConsumer.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,27 @@ import { VegaLiteStreamBuffer } from "./VegaLiteStreamBuffer.js";
33

44
export class TurnStreamConsumer {
55
async consume(input: {
6-
stream: AsyncIterable<[any, any]>;
6+
stream: AsyncIterable<["messages", [any, any]] | ["updates", Record<string, any>]>;
77
abortSignal?: AbortSignal;
88
emit?: AgentEventEmitter;
9+
onInterrupt?: (interrupt: unknown) => void | Promise<void>;
910
}) {
1011
let fullResponse = "";
1112
const textBuffer = new VegaLiteStreamBuffer();
1213

13-
for await (const rawChunk of input.stream) {
14+
for await (const [mode, chunk] of input.stream) {
1415
if (input.abortSignal?.aborted) {
1516
throw new DOMException("This operation was aborted", "AbortError");
1617
}
1718

18-
const [token, metadata] = rawChunk;
19+
if (mode === "updates") {
20+
if ("__interrupt__" in chunk) {
21+
await input.onInterrupt?.(chunk.__interrupt__);
22+
}
23+
continue;
24+
}
25+
26+
const [token, metadata] = chunk;
1927
const nodeName =
2028
typeof metadata?.langgraph_node === "string"
2129
? metadata.langgraph_node

agent/turn/turnTypes.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { AdminUser, AudioAdapter } from "adminforth";
22
import type { Messages } from "@langchain/langgraph";
3+
import type { Command } from "@langchain/langgraph";
34
import type { AgentChatModel, AgentMiddleware } from "../simpleAgent.js";
45
import type { SequenceDebugCollector } from "../middleware/sequenceDebug.js";
56
import type { PreviousUserMessage } from "../languageDetect.js";
@@ -20,6 +21,7 @@ export type BaseAgentTurnInput = {
2021

2122
export type TextAgentTurnInput = BaseAgentTurnInput & {
2223
emit: AgentEventEmitter;
24+
approvalDecision?: "approve" | "reject";
2325
failureLogMessage?: string;
2426
abortLogMessage?: string;
2527
};
@@ -60,6 +62,11 @@ export type PreparedAgentTurn = {
6062
modeName?: string | null;
6163
context: AgentTurnContext;
6264
observability: AgentTurnObservability;
65+
resume?: {
66+
decision: "approve" | "reject";
67+
interrupts?: { id: string; count: number }[];
68+
};
69+
initialResponse?: string;
6370
};
6471

6572
export type AgentTurnModels = {
@@ -70,13 +77,14 @@ export type AgentTurnModels = {
7077

7178
export type AgentRuntimeRunInput = {
7279
models: AgentTurnModels;
73-
messages: Messages;
80+
input: { messages: Messages } | Command;
7481
context: AgentTurnContext;
7582
observability: AgentTurnObservability;
7683
};
7784

7885
export type RunAndPersistAgentResponseInput = BaseAgentTurnInput & {
7986
emit?: AgentEventEmitter;
87+
approvalDecision?: "approve" | "reject";
8088
failureLogMessage: string;
8189
abortLogMessage: string;
8290
};

agentEvents.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ export type AgentEvent =
2222
phase: "start" | "end";
2323
label: string;
2424
}
25+
| {
26+
type: "interrupt";
27+
sessionId: string;
28+
interrupt: unknown;
29+
}
2530
| {
2631
type: "open-page";
2732
targetPath: string;

0 commit comments

Comments
 (0)