Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
129e6b6
add client-defined tools and prepareSendMessagesRequest options
whoiskatrin Dec 10, 2025
68071e6
fix client tools execution
whoiskatrin Dec 10, 2025
e6e76cf
minor nits
whoiskatrin Dec 10, 2025
bc2e874
some claude review feedback
whoiskatrin Dec 10, 2025
fca9db3
refactor
whoiskatrin Dec 10, 2025
f99d9b7
Refactor useAgentChat
whoiskatrin Dec 10, 2025
63f9996
more nits
whoiskatrin Dec 10, 2025
3620f91
take a different approach
whoiskatrin Dec 10, 2025
800bbd9
cleanup after the refactor
whoiskatrin Dec 10, 2025
a526cc0
fix param convertion
whoiskatrin Dec 10, 2025
44e9333
improve tool execution handling in useAgentChat
whoiskatrin Dec 10, 2025
6cdec1f
fix request body structure in useAgentChat to include id, messages, a…
whoiskatrin Dec 11, 2025
2e7f062
review comments on types
whoiskatrin Dec 11, 2025
1f62a81
filter broadcast id's out
whoiskatrin Dec 11, 2025
461306e
fix types
whoiskatrin Dec 11, 2025
44e9f40
fix msg duplication
whoiskatrin Dec 11, 2025
7bbbb1b
fix the duplication issue in client tools
whoiskatrin Dec 11, 2025
0faeb1a
fix types
whoiskatrin Dec 11, 2025
365394d
fix types
whoiskatrin Dec 11, 2025
ab2b997
remove example from the git
whoiskatrin Dec 11, 2025
afb31c1
Send tool results to server as source of truth
whoiskatrin Dec 11, 2025
6cae864
remove automatic message sending after tool results
whoiskatrin Dec 12, 2025
8585098
cleanup the tests
whoiskatrin Dec 12, 2025
dc0736b
fix OpenAI item ID stripping to prevent duplicate errors in persisted…
whoiskatrin Dec 12, 2025
939c0fc
minor nits
whoiskatrin Dec 12, 2025
b8026ae
add server-side auto-continuation after tool results for improved UX;…
whoiskatrin Dec 12, 2025
27f8a8e
update useAgentChat to include autoContinueAfterToolResult in message…
whoiskatrin Dec 12, 2025
88ed2e2
fix for non human in the loop tools
whoiskatrin Dec 12, 2025
7d1aede
update the tests
whoiskatrin Dec 12, 2025
2f2edf6
Update slow-jobs-boil.md with new options
whoiskatrin Dec 12, 2025
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
5 changes: 5 additions & 0 deletions .changeset/slow-jobs-boil.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"agents": patch
---

add client-defined tools and prepareSendMessagesRequest options
29 changes: 29 additions & 0 deletions docs/client-tools-continuation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Client Tool Auto-Continuation

## Overview

By default, client-executed tools and server-executed tools behave differently:

- **Server tools**: Execute and continue responding in the same turn
- **Client tools**: Execute, then require a new request to continue

The `autoContinueAfterToolResult` option lets client tools behave like server tools - the assistant can call a tool and immediately follow up with a response in one seamless turn.

## How It Works

```typescript
import { useAgentChat } from "agents/ai-react";

const { messages, addToolResult } = useAgentChat({
agent,
tools: myClientTools,
autoContinueAfterToolResult: true
});
```

When enabled:

1. Client executes the tool and sends the result to the server
2. Server automatically calls `onChatMessage()` to continue
3. The LLM's continuation is merged into the same assistant message
4. User sees a single, seamless response
6 changes: 4 additions & 2 deletions guides/human-in-the-loop/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,9 @@ export default function Chat() {
agent,
experimental_automaticToolResolution: true,
toolsRequiringConfirmation,
tools: clientTools satisfies Record<string, AITool>
tools: clientTools satisfies Record<string, AITool>,
// Enable server auto-continuation after tool results for seamless UX
autoContinueAfterToolResult: true
});

const [input, setInput] = useState("");
Expand Down Expand Up @@ -229,7 +231,7 @@ export default function Chat() {
// 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(input)
? await tool.execute(part.input)
: APPROVAL.YES;
addToolResult({
tool: toolName,
Expand Down
43 changes: 35 additions & 8 deletions guides/human-in-the-loop/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,16 +27,43 @@ export class HumanInTheLoop extends AIChatAgent<Env> {
const lastMessage = this.messages[this.messages.length - 1];

if (hasToolConfirmation(lastMessage)) {
// Process tool confirmations using UI stream
const stream = createUIMessageStream({
execute: async ({ writer }) => {
await processToolCalls(
{ writer, messages: this.messages, tools },
{ getWeatherInformation }
);
// Process tool confirmations - execute the tool and update messages
const updatedMessages = await processToolCalls(
{ messages: this.messages, tools },
{ getWeatherInformation }
);

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

// Now continue with streamText so the LLM can respond to the tool result
const result = streamText({
messages: convertToModelMessages(this.messages),
model: openai("gpt-4o"),
onFinish,
tools,
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
};
}
}
});
return createUIMessageStreamResponse({ stream });
}

// Use streamText directly and return with metadata
Expand Down
28 changes: 23 additions & 5 deletions guides/human-in-the-loop/src/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,14 +45,32 @@ export const tools = {
};

// Export AITool format for client-side use
// AITool uses JSON Schema (not Zod) because it needs to be serialized over the wire.
// Only tools with `execute` need `parameters` - they get extracted and sent to the server.
// Tools without `execute` are server-side only and just need description for display.
export const clientTools: Record<string, AITool> = {
getLocalTime: getLocalTimeTool as AITool,
getLocalTime: {
description: "get the local time for a specified location",
parameters: {
type: "object",
properties: {
location: { type: "string" }
},
required: ["location"]
},
execute: async (input) => {
const { location } = input as { location: string };
console.log(`Getting local time for ${location}`);
await new Promise((res) => setTimeout(res, 2000));
return "10am";
}
},
// Server-side tools: no execute, no parameters needed (schema lives on server)
getWeatherInformation: {
description: getWeatherInformationTool.description,
inputSchema: getWeatherInformationTool.inputSchema
description:
"Get the current weather information for a specific city. Always use this tool when the user asks about weather."
},
getLocalNews: {
description: getLocalNewsTool.description,
inputSchema: getLocalNewsTool.inputSchema
description: "get local news for a specified location"
}
};
38 changes: 10 additions & 28 deletions guides/human-in-the-loop/src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { UIMessage } from "@ai-sdk/react";
import type { UIMessageStreamWriter, ToolSet } from "ai";
import type { ToolSet } from "ai";
import type { z } from "zod";

// Helper type to infer tool arguments from Zod schema
Expand Down Expand Up @@ -76,12 +76,10 @@ export async function processToolCalls<
}
>(
{
writer,
messages,
tools: _tools
}: {
tools: Tools; // used for type inference
writer: UIMessageStreamWriter;
messages: UIMessage[];
},
executeFunctions: {
Expand All @@ -107,7 +105,7 @@ export async function processToolCalls<
return part;
}

let result: string;
let result: string | undefined;

if (output === APPROVAL.YES) {
const toolInstance =
Expand All @@ -120,37 +118,21 @@ export async function processToolCalls<
result = await (
toolInstance as (args: typeof toolInput) => Promise<string>
)(toolInput);

// Stream the result directly using writer
const messageId = crypto.randomUUID();
const textStream = new ReadableStream({
start(controller) {
controller.enqueue({
type: "text-start",
id: messageId
});
controller.enqueue({
type: "text-delta",
id: messageId,
delta: result
});
controller.enqueue({
type: "text-end",
id: messageId
});
controller.close();
}
});

writer.merge(textStream);
} else {
result = "Error: No execute function found on tool";
}
} else if (output === APPROVAL.NO) {
result = "Error: User denied access to tool execution";
}

return part; // Return the original part
// Return updated part with actual tool result (not the confirmation)
if (result !== undefined) {
return {
...part,
output: result
};
}
return part;
}
return part; // Return unprocessed parts
})
Expand Down
Loading
Loading