Manual Agent Loop

Learn how to create your own agentic loop with full control over tool execution

4 min readnodeagentView source

When you need complete control over the agentic loop and tool execution, you can manage the agent flow yourself rather than using prepareStep and stopWhen. This approach gives you full flexibility over when and how tools are executed, message history management, and loop termination conditions. This pattern is useful when you want to: - Implement custom logic between tool calls - Handle tool execution errors in specific ways - Add custom logging or monitoring - Integrate with external systems during the loop - Have complete control over the conversation history

Run it locally

$ npm install ai

Example

import { ModelMessage, streamText, tool } from 'ai-toolkit';

import 'dotenv/config';

import z from 'zod';

const getWeather = async ({ location }: { location: string }) => {

return The weather in ${location} is ${Math.floor(Math.random() * 100)} degrees.;

};

const messages: ModelMessage[] = [

{

role: 'user',

content: 'Get the weather in New York and San Francisco',

},

];

async function main() {

while (true) {

const result = streamText({

model: 'openai/gpt-4o',

messages,

tools: {

getWeather: tool({

description: 'Get the current weather in a given location',

inputSchema: z.object({

location: z.string(),

}),

}),

// add more tools here, omitting the execute function so you handle it yourself

},

});

// Stream the response (only necessary for providing updates to the user)

for await (const chunk of result.fullStream) {

if (chunk.type === 'text-delta') {

process.stdout.write(chunk.text);

}

if (chunk.type === 'tool-call') {

console.log('\\nCalling tool:', chunk.toolName);

}

}

// Add LLM generated messages to the message history

const responseMessages = (await result.response).messages;

messages.push(...responseMessages);

const finishReason = await result.finishReason;

if (finishReason === 'tool-calls') {

const toolCalls = await result.toolCalls;

// Handle all tool call execution here

for (const toolCall of toolCalls) {

if (toolCall.toolName === 'getWeather') {

const toolOutput = await getWeather(toolCall.input);

messages.push({

role: 'tool',

content: [

{

toolName: toolCall.toolName,

toolCallId: toolCall.toolCallId,

type: 'tool-result',

output: { type: 'text', value: toolOutput }, // update depending on the tool's output format

},

],

});

}

// Handle other tool calls

}

} else {

// Exit the loop when the model doesn't request to use any more tools

console.log('\\n\\nFinal message history:');

console.dir(messages, { depth: null });

break;

}

}

}

main().catch(console.error);

ts
  1. 1import { ModelMessage, streamText, tool } from 'ai-toolkit';
  2. 2import 'dotenv/config';
  3. 3import z from 'zod';
  4. 4const getWeather = async ({ location }: { location: string }) => {
  5. 5 return `The weather in ${location} is ${Math.floor(Math.random() * 100)} degrees.`;
  6. 6};
  7. 7const messages: ModelMessage[] = [
  8. 8 {
  9. 9 role: 'user',
  10. 10 content: 'Get the weather in New York and San Francisco',
  11. 11 },
  12. 12];
  13. 13async function main() {
  14. 14 while (true) {
  15. 15 const result = streamText({
  16. 16 model: 'openai/gpt-4o',
  17. 17 messages,
  18. 18 tools: {
  19. 19 getWeather: tool({
  20. 20 description: 'Get the current weather in a given location',
  21. 21 inputSchema: z.object({
  22. 22 location: z.string(),
  23. 23 }),
  24. 24 }),
  25. 25 // add more tools here, omitting the execute function so you handle it yourself
  26. 26 },
  27. 27 });
  28. 28 // Stream the response (only necessary for providing updates to the user)
  29. 29 for await (const chunk of result.fullStream) {
  30. 30 if (chunk.type === 'text-delta') {
  31. 31 process.stdout.write(chunk.text);
  32. 32 }
  33. 33 if (chunk.type === 'tool-call') {
  34. 34 console.log('\\nCalling tool:', chunk.toolName);
  35. 35 }
  36. 36 }
  37. 37 // Add LLM generated messages to the message history
  38. 38 const responseMessages = (await result.response).messages;
  39. 39 messages.push(...responseMessages);
  40. 40 const finishReason = await result.finishReason;
  41. 41 if (finishReason === 'tool-calls') {
  42. 42 const toolCalls = await result.toolCalls;
  43. 43 // Handle all tool call execution here
  44. 44 for (const toolCall of toolCalls) {
  45. 45 if (toolCall.toolName === 'getWeather') {
  46. 46 const toolOutput = await getWeather(toolCall.input);
  47. 47 messages.push({
  48. 48 role: 'tool',
  49. 49 content: [
  50. 50 {
  51. 51 toolName: toolCall.toolName,
  52. 52 toolCallId: toolCall.toolCallId,
  53. 53 type: 'tool-result',
  54. 54 output: { type: 'text', value: toolOutput }, // update depending on the tool's output format
  55. 55 },
  56. 56 ],
  57. 57 });
  58. 58 }
  59. 59 // Handle other tool calls
  60. 60 }
  61. 61 } else {
  62. 62 // Exit the loop when the model doesn't request to use any more tools
  63. 63 console.log('\\n\\nFinal message history:');
  64. 64 console.dir(messages, { depth: null });
  65. 65 break;
  66. 66 }
  67. 67 }
  68. 68}
  69. 69main().catch(console.error);

Key Concepts

### Message Management

The example maintains a messages array that tracks the entire conversation history. After each model response, the generated messages are added to this history:

const responseMessages = (await result.response).messages;

messages.push(...responseMessages);

### Tool Execution Control

Tool execution is handled manually in the loop. When the model requests tool calls, you process each one individually:

if (finishReason === 'tool-calls') {

const toolCalls = await result.toolCalls;

for (const toolCall of toolCalls) {

if (toolCall.toolName === 'getWeather') {

const toolOutput = await getWeather(toolCall.input);

// Add tool result to message history

messages.push({

role: 'tool',

content: [

{

toolName: toolCall.toolName,

toolCallId: toolCall.toolCallId,

type: 'tool-result',

output: { type: 'text', value: toolOutput },

},

],

});

}

}

}

### Loop Termination

The loop continues until the model stops requesting tool calls. You can customize this logic to implement your own termination conditions:

if (finishReason === 'tool-calls') {

// Continue the loop

} else {

// Exit the loop

break;

}

ts
  1. 1const responseMessages = (await result.response).messages;
  2. 2messages.push(...responseMessages);
ts
  1. 1if (finishReason === 'tool-calls') {
  2. 2 const toolCalls = await result.toolCalls;
  3. 3 for (const toolCall of toolCalls) {
  4. 4 if (toolCall.toolName === 'getWeather') {
  5. 5 const toolOutput = await getWeather(toolCall.input);
  6. 6 // Add tool result to message history
  7. 7 messages.push({
  8. 8 role: 'tool',
  9. 9 content: [
  10. 10 {
  11. 11 toolName: toolCall.toolName,
  12. 12 toolCallId: toolCall.toolCallId,
  13. 13 type: 'tool-result',
  14. 14 output: { type: 'text', value: toolOutput },
  15. 15 },
  16. 16 ],
  17. 17 });
  18. 18 }
  19. 19 }
  20. 20}
ts
  1. 1if (finishReason === 'tool-calls') {
  2. 2 // Continue the loop
  3. 3} else {
  4. 4 // Exit the loop
  5. 5 break;
  6. 6}

Extending This Example

### Custom Loop Control

Implement maximum iterations or time limits:

let iterations = 0;

const MAX_ITERATIONS = 10;

while (iterations < MAX_ITERATIONS) {

iterations++;

// ... rest of the loop

}

### Parallel Tool Execution

Execute multiple tools in parallel for better performance:

const toolPromises = toolCalls.map(async toolCall => {

if (toolCall.toolName === 'getWeather') {

const toolOutput = await getWeather(toolCall.input);

return {

role: 'tool' as const,

content: [

{

toolName: toolCall.toolName,

toolCallId: toolCall.toolCallId,

type: 'tool-result' as const,

output: { type: 'text' as const, value: toolOutput },

},

],

};

}

// Handle other tools

});

const toolResults = await Promise.all(toolPromises);

messages.push(...toolResults.filter(Boolean));

This manual approach gives you complete control over the agentic loop while still leveraging the AI TOOLKIT's powerful streaming and tool calling capabilities.

ts
  1. 1let iterations = 0;
  2. 2const MAX_ITERATIONS = 10;
  3. 3while (iterations < MAX_ITERATIONS) {
  4. 4 iterations++;
  5. 5 // ... rest of the loop
  6. 6}
ts
  1. 1const toolPromises = toolCalls.map(async toolCall => {
  2. 2 if (toolCall.toolName === 'getWeather') {
  3. 3 const toolOutput = await getWeather(toolCall.input);
  4. 4 return {
  5. 5 role: 'tool' as const,
  6. 6 content: [
  7. 7 {
  8. 8 toolName: toolCall.toolName,
  9. 9 toolCallId: toolCall.toolCallId,
  10. 10 type: 'tool-result' as const,
  11. 11 output: { type: 'text' as const, value: toolOutput },
  12. 12 },
  13. 13 ],
  14. 14 };
  15. 15 }
  16. 16 // Handle other tools
  17. 17});
  18. 18const toolResults = await Promise.all(toolPromises);
  19. 19messages.push(...toolResults.filter(Boolean));