Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 31 additions & 61 deletions guides/human-in-the-loop/src/app.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
import type { UIMessage as Message } from "ai";
import { getToolName, isToolUIPart } from "ai";
import { clientTools } from "./tools";
import { APPROVAL, toolsRequiringConfirmation } from "./utils";
import "./styles.css";
import { useAgentChat, type AITool } from "agents/ai-react";
import { useAgent } from "agents/react";
import { useChat, type PendingToolCall } from "agents/react";
import { useCallback, useEffect, useRef, useState } from "react";

export default function Chat() {
Expand All @@ -18,11 +16,9 @@ export default function Chat() {
}, []);

useEffect(() => {
// Set initial theme
document.documentElement.setAttribute("data-theme", theme);
}, [theme]);

// Scroll to bottom on mount
useEffect(() => {
scrollToBottom();
}, [scrollToBottom]);
Expand All @@ -33,17 +29,17 @@ export default function Chat() {
document.documentElement.setAttribute("data-theme", newTheme);
};

const agent = useAgent({
agent: "human-in-the-loop"
});

const { messages, sendMessage, addToolResult, clearHistory } = useAgentChat({
agent,
experimental_automaticToolResolution: true,
toolsRequiringConfirmation,
tools: clientTools satisfies Record<string, AITool>,
// Enable server auto-continuation after tool results for seamless UX
autoContinueAfterToolResult: true
const {
messages,
sendMessage,
pendingToolCalls,
approve,
deny,
clearHistory,
sessionId
} = useChat({
agent: "human-in-the-loop",
tools: clientTools
});

const [input, setInput] = useState("");
Expand All @@ -55,7 +51,6 @@ export default function Chat() {
const startTime = Date.now();
sendMessage({ role: "user", parts: [{ type: "text", text: input }] });
setInput("");
// Simulate response time tracking
setTimeout(() => {
setLastResponseTime(Date.now() - startTime);
}, 1000);
Expand All @@ -68,19 +63,11 @@ export default function Chat() {
setInput(e.target.value);
};

// Scroll to bottom when messages change
useEffect(() => {
messages.length > 0 && scrollToBottom();
}, [messages, scrollToBottom]);

// Tools requiring confirmation are auto-detected by useAgentChat from tools object
// Tools without execute function need confirmation (getWeatherInformation)
// Tools with execute function are automatic (getLocalTime)
const pendingToolCallConfirmation = messages.some((m: Message) =>
m.parts?.some(
(part) => isToolUIPart(part) && part.state === "input-available"
)
);
const hasPendingToolCalls = pendingToolCalls.length > 0;

return (
<>
Expand All @@ -95,19 +82,18 @@ export default function Chat() {
<div className="theme-switch-handle" />
</button>
<button type="button" onClick={clearHistory} className="clear-history">
🗑️ Clear History
Clear History
</button>
<button
type="button"
onClick={() => setShowMetadata(!showMetadata)}
className="clear-history"
style={{ marginLeft: "10px" }}
>
{showMetadata ? "📊 Hide" : "📊 Show"} Metadata
{showMetadata ? "Hide" : "Show"} Metadata
</button>
</div>

{/* Metadata Display Panel */}
{showMetadata && (
<div
style={{
Expand All @@ -120,7 +106,7 @@ export default function Chat() {
}}
>
<h3 style={{ margin: "0 0 10px 0", color: "var(--text-primary)" }}>
📊 Response Metadata
Response Metadata
</h3>
<div
style={{
Expand All @@ -145,10 +131,10 @@ export default function Chat() {
{Object.keys(clientTools).length}
</div>
<div>
<strong>Human-in-Loop:</strong> Enabled
<strong>Human-in-Loop:</strong> Enabled
</div>
<div>
<strong>Session ID:</strong> {agent.id || "Active"}
<strong>Session ID:</strong> {sessionId || "Connecting..."}
</div>
{lastResponseTime && (
<div>
Expand Down Expand Up @@ -190,7 +176,7 @@ export default function Chat() {
const toolCallId = part.toolCallId;
const toolName = getToolName(part);

// Show tool results for automatic tools
// Show tool results
if (part.state === "output-available") {
return (
<div key={toolCallId} className="tool-invocation">
Expand All @@ -203,18 +189,23 @@ export default function Chat() {
);
}

// render confirmation tool (client-side tool with user interaction)
// Show pending tool calls
if (part.state === "input-available") {
const tool = clientTools[toolName];
// Don't show confirmation UI for server-executed tools
if (!toolsRequiringConfirmation.includes(toolName)) {
// Check if this tool is in pendingToolCalls
const pending = pendingToolCalls.find(
(p: PendingToolCall) => p.toolCallId === toolCallId
);

// Not in pending = auto-executing
if (!pending) {
return (
<div key={toolCallId} className="tool-invocation">
<span className="dynamic-info">{toolName}</span>{" "}
executing...
</div>
);
}

return (
<div key={toolCallId} className="tool-invocation">
Run <span className="dynamic-info">{toolName}</span>{" "}
Expand All @@ -226,35 +217,14 @@ export default function Chat() {
<button
type="button"
className="button-approve"
onClick={async () => {
// If it's a client-side tool requiring approval
// we execute it and set the result, otherwise we
// set the approval and let the server handle it
const output = tool.execute
? await tool.execute(part.input)
: APPROVAL.YES;
addToolResult({
tool: toolName,
output,
toolCallId
});
}}
onClick={() => approve(toolCallId)}
>
Yes
</button>
<button
type="button"
className="button-reject"
onClick={() => {
const output = tool.execute
? "User declined to run tool"
: APPROVAL.NO;
addToolResult({
tool: toolName,
output,
toolCallId
});
}}
onClick={() => deny(toolCallId)}
>
No
</button>
Expand All @@ -274,7 +244,7 @@ export default function Chat() {

<form onSubmit={handleSubmit}>
<input
disabled={pendingToolCallConfirmation}
disabled={hasPendingToolCalls}
className="chat-input"
value={input}
placeholder="Say something..."
Expand Down
100 changes: 51 additions & 49 deletions guides/human-in-the-loop/src/server.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,26 @@
/**
* Human-in-the-Loop Chat Agent
*
* This agent demonstrates tool confirmation workflow:
* 1. User sends a message
* 2. LLM decides to call a tool
* 3. Client shows confirmation UI (via useChat hook)
* 4. User approves/denies
* 5. Server executes tool (if server-side) or receives result (if client-side)
* 6. LLM responds with tool result
*/

import { openai } from "@ai-sdk/openai";
import { routeAgentRequest } from "agents";
import { AIChatAgent } from "agents/ai-chat-agent";
import {
convertToModelMessages,
createUIMessageStream,
createUIMessageStreamResponse,
type StreamTextOnFinishCallback,
streamText,
stepCountIs
} from "ai";
import { tools } from "./tools";
import {
processToolCalls,
hasToolConfirmation,
getWeatherInformation
} from "./utils";
import { serverTools, getWeatherInformation } from "./tools";
import { processToolCalls, hasToolConfirmation } from "./utils";

type Env = {
OPENAI_API_KEY: string;
Expand All @@ -23,77 +29,73 @@ type Env = {
export class HumanInTheLoop extends AIChatAgent<Env> {
async onChatMessage(onFinish: StreamTextOnFinishCallback<{}>) {
const startTime = Date.now();

const lastMessage = this.messages[this.messages.length - 1];

// Check if this is a tool confirmation response
if (hasToolConfirmation(lastMessage)) {
// Process tool confirmations - execute the tool and update messages
// Process the confirmation - execute server-side tools if approved
const updatedMessages = await processToolCalls(
{ messages: this.messages, tools },
{ getWeatherInformation }
{ messages: this.messages, tools: serverTools },
{ getWeatherInformation } // Server-side tool implementations
);

// Update the agent's messages with the actual tool results
// This replaces "Yes, confirmed." with the actual tool output
// Update and persist messages with tool results
this.messages = updatedMessages;
await this.persistMessages(this.messages);

// Now continue with streamText so the LLM can respond to the tool result
// Continue the conversation so LLM can respond to the tool result
const result = streamText({
messages: convertToModelMessages(this.messages),
model: openai("gpt-4o"),
onFinish,
tools,
tools: serverTools,
stopWhen: stepCountIs(5)
});

return result.toUIMessageStreamResponse({
messageMetadata: ({ part }) => {
if (part.type === "start") {
return {
model: "gpt-4o",
createdAt: Date.now(),
messageCount: this.messages.length
};
}
if (part.type === "finish") {
return {
responseTime: Date.now() - startTime,
totalTokens: part.totalUsage?.totalTokens
};
}
}
messageMetadata: this.createMetadata(startTime)
});
}

// Use streamText directly and return with metadata
// Normal message - let LLM respond (may trigger tool calls)
const result = streamText({
messages: convertToModelMessages(this.messages),
model: openai("gpt-4o"),
onFinish,
tools,
tools: serverTools,
stopWhen: stepCountIs(5)
});

return result.toUIMessageStreamResponse({
messageMetadata: ({ part }) => {
// This is optional, purely for demo purposes in this example
if (part.type === "start") {
return {
model: "gpt-4o",
createdAt: Date.now(),
messageCount: this.messages.length
};
}
if (part.type === "finish") {
return {
responseTime: Date.now() - startTime,
totalTokens: part.totalUsage?.totalTokens
};
}
}
messageMetadata: this.createMetadata(startTime)
});
}

/**
* Creates metadata callback for stream responses.
* This is optional - purely for demo purposes.
*/
private createMetadata(startTime: number) {
return ({
part
}: {
part: { type: string; totalUsage?: { totalTokens?: number } };
}) => {
if (part.type === "start") {
return {
model: "gpt-4o",
createdAt: Date.now(),
messageCount: this.messages.length
};
}
if (part.type === "finish") {
return {
responseTime: Date.now() - startTime,
totalTokens: part.totalUsage?.totalTokens
};
}
};
}
}

export default {
Expand Down
Loading
Loading