Skip to content
Merged
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
2 changes: 2 additions & 0 deletions guides/human-in-the-loop/.dev.vars.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
OPENAI_API_KEY=<your-openai-api-key>

134 changes: 134 additions & 0 deletions guides/human-in-the-loop/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
## Human in the Loop with Cloudflare Agents

[Work in Progress, ignore for now]

This example demonstrates how to implement human-in-the-loop functionality using Cloudflare Agents, allowing AI agents to request human approval before executing certain actions. This pattern is crucial for scenarios where human oversight and confirmation are required before taking important actions.

### Overview

The implementation showcases:

- AI agents that can request human approval for specific actions
- Real-time communication between agents and humans using WebSocket connections
- Persistent state management across agent lifecycles
- Tool-based architecture for extensible agent capabilities

### Key Components

1. **Agent Definition**

```ts
import { Agent } from "@cloudflare/agents";

export class HumanInTheLoopAgent extends Agent {
// Tool registry with approval requirements
tools = {
getWeatherInformation: {
description: "Show the weather in a given city to the user",
requiresApproval: true,
parameters: { city: "string" },
},
getLocalTime: {
description: "Get the local time for a specified location",
requiresApproval: false,
parameters: { location: "string" },
},
};

async onMessage(connection, message) {
// Handle incoming messages and tool calls
if (message.type === "tool-call") {
const tool = this.tools[message.toolName];

if (tool.requiresApproval) {
// Request human approval
await this.requestApproval(connection, message);
} else {
// Execute tool directly
await this.executeTool(message);
}
}
}

async requestApproval(connection, toolCall) {
// Update state to reflect pending approval
this.setState({
...this.state,
pendingApprovals: [
...(this.state.pendingApprovals || []),
{ toolCall, connection },
],
});
}
}
```

2. **React Client Integration**

```tsx
import { useAgent } from "@cloudflare/agents/react";

function Chat() {
const agent = useAgent({
agent: "human-in-the-loop-agent",
onStateUpdate: (state) => {
// Handle state updates, including pending approvals
setPendingApprovals(state.pendingApprovals);
},
});

// Render chat interface with approval UI
return (
<div>
{/* Chat messages */}
{pendingApprovals.map((approval) => (
<ApprovalRequest
key={approval.id}
approval={approval}
onApprove={() => agent.send({ type: "approve", id: approval.id })}
onReject={() => agent.send({ type: "reject", id: approval.id })}
/>
))}
</div>
);
}
```

### Features

- **Persistent State**: Agent state persists across sessions using Cloudflare's durable storage
- **Real-time Updates**: WebSocket connections ensure immediate updates for approval requests
- **Tool Registry**: Flexible tool system with configurable approval requirements
- **Type Safety**: Full TypeScript support for tool definitions and parameters

### Getting Started

1. Configure your `wrangler.toml`:

```toml
[[durable_objects]]
binding = "HumanInTheLoopAgent"
class_name = "HumanInTheLoopAgent"
```

2. Deploy your agent:

```bash
wrangler deploy
```

3. Connect from your frontend using the React hooks provided by `@cloudflare/agents/react`

### Best Practices

- Define clear approval workflows for sensitive operations
- Implement timeouts for approval requests
- Provide detailed context in approval requests
- Handle connection drops and reconnections gracefully
- Log all approval decisions for audit trails

### Learn More

- [Cloudflare Agents Documentation](https://developers.cloudflare.com/agents/)
- [Building AI Agents with Human Oversight](https://developers.cloudflare.com/agents/patterns/human-in-the-loop/)
- [State Management in Cloudflare Agents](https://developers.cloudflare.com/agents/state-management/)
10 changes: 10 additions & 0 deletions guides/human-in-the-loop/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<!DOCTYPE html>
<html>
<head>
<title>Human in the Loop</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/client.tsx"></script>
</body>
</html>
14 changes: 14 additions & 0 deletions guides/human-in-the-loop/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"name": "@cloudflare/agents-human-in-the-loop",
"version": "0.0.0",
"description": "Human in the Loop",
"private": true,
"type": "module",
"scripts": {
"start": "vite dev",
"deploy": "rm -rf dist && vite build && wrangler deploy"
},
"keywords": [],
"author": "",
"license": "ISC"
}
123 changes: 123 additions & 0 deletions guides/human-in-the-loop/src/app.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { type Message, useChat } from "@ai-sdk/react";
import { APPROVAL, getToolsRequiringConfirmation } from "./utils";
import { tools } from "./tools";
import "./styles.css";
import { useEffect, useState } from "react";
import { useAgent } from "@cloudflare/agents/react";

export default function Chat() {
const [theme, setTheme] = useState<"dark" | "light">("dark");

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

const toggleTheme = () => {
const newTheme = theme === "dark" ? "light" : "dark";
setTheme(newTheme);
document.documentElement.setAttribute("data-theme", newTheme);
};

const { messages, input, handleInputChange, handleSubmit, addToolResult } =
useChat({
api: "/api/use-chat-human-in-the-loop",
maxSteps: 5,
});

const toolsRequiringConfirmation = getToolsRequiringConfirmation(tools);

const pendingToolCallConfirmation = messages.some((m: Message) =>
m.parts?.some(
(part) =>
part.type === "tool-invocation" &&
part.toolInvocation.state === "call" &&
toolsRequiringConfirmation.includes(part.toolInvocation.toolName)
)
);

return (
<>
<button onClick={toggleTheme} className="theme-toggle">
{theme === "dark" ? "🌞 Light Mode" : "🌙 Dark Mode"}
</button>

<div className="chat-container">
{messages?.map((m: Message) => (
<div key={m.id} className="message">
<strong>{`${m.role}: `}</strong>
{m.parts?.map((part, i) => {
switch (part.type) {
case "text":
return (
<div key={i} className="message-content">
{part.text}
</div>
);
case "tool-invocation":
const toolInvocation = part.toolInvocation;
const toolCallId = toolInvocation.toolCallId;

// render confirmation tool (client-side tool with user interaction)
if (
toolsRequiringConfirmation.includes(
toolInvocation.toolName
) &&
toolInvocation.state === "call"
) {
return (
<div key={toolCallId} className="tool-invocation">
Run{" "}
<span className="dynamic-info">
{toolInvocation.toolName}
</span>{" "}
with args:{" "}
<span className="dynamic-info">
{JSON.stringify(toolInvocation.args)}
</span>
<div className="button-container">
<button
className="button-approve"
onClick={() =>
addToolResult({
toolCallId,
result: APPROVAL.YES,
})
}
>
Yes
</button>
<button
className="button-reject"
onClick={() =>
addToolResult({
toolCallId,
result: APPROVAL.NO,
})
}
>
No
</button>
</div>
</div>
);
}
}
})}
<br />
</div>
))}

<form onSubmit={handleSubmit}>
<input
disabled={pendingToolCallConfirmation}
className="chat-input"
value={input}
placeholder="Say something..."
onChange={handleInputChange}
/>
</form>
</div>
</>
);
}
7 changes: 7 additions & 0 deletions guides/human-in-the-loop/src/client.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import "./styles.css";
import { createRoot } from "react-dom/client";
import App from "./app";

const root = createRoot(document.getElementById("root")!);

root.render(<App />);
60 changes: 60 additions & 0 deletions guides/human-in-the-loop/src/server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { createOpenAI } from "@ai-sdk/openai";
import { createDataStreamResponse, type Message, streamText } from "ai";
import { processToolCalls } from "./utils";
import { tools } from "./tools";
// import { Agent } from "@cloudflare/agents";

// Allow streaming responses up to 30 seconds
export const maxDuration = 30;

type Env = {
OPENAI_API_KEY: string;
};

export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
const url = new URL(request.url);
switch (`${request.method} ${url.pathname}`) {
case "POST /api/use-chat-human-in-the-loop": {
const { messages }: { messages: Message[] } = await request.json();

return createDataStreamResponse({
execute: async (dataStream) => {
// Utility function to handle tools that require human confirmation
// Checks for confirmation in last message and then runs associated tool
const processedMessages = await processToolCalls(
{
messages,
dataStream,
tools,
},
{
// type-safe object for tools without an execute function
getWeatherInformation: async ({ city }) => {
const conditions = ["sunny", "cloudy", "rainy", "snowy"];
return `The weather in ${city} is ${
conditions[Math.floor(Math.random() * conditions.length)]
}.`;
},
}
);

const openai = createOpenAI({
apiKey: env.OPENAI_API_KEY,
});

const result = streamText({
model: openai("gpt-4o"),
messages: processedMessages,
tools,
});

result.mergeIntoDataStream(dataStream);
},
});
}
default:
return new Response("Not found", { status: 404 });
}
},
} satisfies ExportedHandler<Env>;
Loading