Building Agentic Loops
Introduction: From Manual Steps to Automated Loops
In the previous lesson, you built a tool execution pattern where you made two separate API calls to complete a calculation task. This manual approach helped you understand context flow, but it is not practical for production systems where tasks might require many steps. In this lesson, you will build an agentic loop that automates this process, implementing Factor 8 (Own Your Control Flow) by giving your code full control over execution, and Factor 9 (Compact Errors into Context Window) by feeding structured failure information back to the model. By the end, you will have an agent that handles multi-step tasks like "What is 15 + 27? Then multiply the result by 3" without any manual intervention.
Setting Up Loop Control Variables
Before building the loop, you need to establish control variables that manage execution boundaries and track progress. These variables implement Factor 8 by putting your code in charge of when the loop continues and when it stops.
These control variables work together to manage your agent's execution lifecycle:
- The
maxStepsconstant acts as a safety boundary that prevents runaway execution, which is critical for production systems where you need predictable resource usage and cost control. - The
stepcounter tracks how many iterations have occurred, helping with debugging and observability. - The
doneflag serves as your completion signal that gets set totruewhen the model callsfinal_answer. - The
finalAnswervariable is typed asstring | null, storing the result once it is provided or remainingnullif the agent never completes.
The maxSteps boundary is particularly important because it ensures your agent cannot consume unlimited API calls or compute resources. The combination of done and step gives you both logical completion (when the task finishes) and physical completion (when you hit resource limits), allowing your system to handle both successful completions and edge cases gracefully.
Building the While Loop Condition
The core agent loop uses a while condition that combines your completion flag and safety boundary to determine when execution should continue.
The condition !done && step < maxSteps ensures that the loop stops either when the agent signals completion or when you hit your safety limit. At the start of each iteration, you increment the step counter and use console.log with a template literal to print a separator, making the execution flow visible during development.
Making API Calls Inside the Loop
Inside the loop, you make the same API call that you learned in previous lessons, passing the current context, which contains the complete conversation history. Because the OpenAI SDK call is asynchronous, you must use the await keyword to wait for the response before proceeding.
The client is an instance of new OpenAI() created before the loop begins. The model sees the full history at each iteration and decides what to do next based on what has already happened. The tool_choice: "required" property ensures the model must call a tool rather than responding conversationally, maintaining the structured agent behavior you established in earlier lessons.
Processing Function Calls in the Response
You iterate through the response.output items using TypeScript's type narrowing to isolate function calls and extract the necessary information.
Instead of checking for the presence of a function_call, you use an early-exit pattern: if item.type is not "function_call", you call continue to skip to the next item. After the guard, TypeScript has narrowed the type, and you can safely access item.name and item.arguments. You parse the arguments string into an object using JSON.parse, cast it to Record<string, unknown> since argument shapes vary per tool, and then use JSON.stringify(args) when logging so the output matches a consistent, readable format.
Recording the Function Call to Context
Before executing the function, you add the function call to your context so the model can see its own decisions in future iterations.
Because context is typed as ContextItem[], TypeScript will enforce that the object you push matches one of the valid union members. Here, the shape with type: "function_call" satisfies that constraint. Recording the function call maintains the conversation history and ensures the model has complete information on the next iteration. The call_id is particularly important because it links this call to its eventual result, which you will add to the context after execution.
Dispatching to Functions with Switch Statements
You use a switch statement wrapped in a try block to execute the corresponding TypeScript function based on item.name.
You declare result and output before the switch so they are in scope for the context push that follows. The switch dispatches on item.name, and each case accesses arguments explicitly from the parsed args object, using as number to cast them since the Record<string, unknown> type requires explicit narrowing before passing values to typed function parameters. The final_answer case is special: it reads args.answer, assigns it to finalAnswer with a type guard to satisfy the string | null type, and sets done = true to signal loop termination rather than performing a calculation. For calculation functions like add and multiply, you call the functions directly and serialize their results using JSON.stringify. The default branch handles unexpected tool names by constructing a structured error message, preventing crashes if the model requests a non-existent tool.
Implementing Error Handling with Try-Catch
The catch clause catches any error that occurs during tool execution and converts it into structured feedback that the model can understand.
In TypeScript, caught values are typed as unknown, so you cannot directly access .message without first checking the type. The expression error instanceof Error ? error.message : String(error) safely extracts a human-readable message regardless of what was thrown. Instead of letting errors crash your program, you convert them into a JSON object with an error field using JSON.stringify. This structured format gives the model consistent, parseable information about what went wrong. When this error output is added to the context, the model sees the failure in its next iteration and can potentially retry with different arguments or call a different tool. This error feedback mechanism implements Factor 9 by turning failures into recoverable situations rather than fatal crashes.
Adding Function Results to Context
After executing the function (successfully or with an error), you log the result and push the output back into the context.
The output variable is always a JSON string at this point, produced by JSON.stringify(...) in either the successful switch branch or the catch block. Pushing a function_call_output item completes the execution cycle by giving the model feedback about what happened when its tool was executed. The call_id links this output to the original function call, maintaining the conversation structure. This feedback enables the model to make informed decisions about what to do next based on the complete history of actions and results.
Handling Final Answer Completion
When the model calls final_answer, you need to exit the item processing loop immediately since the agent's work is complete.
This check prevents any additional processing in the current iteration after handling final_answer. The outer while loop will naturally terminate on its next condition check since done is now true.
Viewing the Complete Loop Structure
Now that you have built each piece of the loop individually, it is helpful to see the complete structure in one place. The following code shows the entire while loop, including all the sections you built: the API call, function call processing, context recording, tool execution with error handling, result recording, and the completion break.
This complete loop structure automatically handles multi-step tasks without any manual intervention between steps. The context array, typed as ContextItem[], flows naturally from one iteration to the next, with each function call and result being pushed so the model can see the complete history at every step.
Observing Step-by-Step Execution
One possible run for "What is 15 + 27? Then multiply the result by 3" looks like the trace below. The model may group calls differently or choose final_answer earlier; the step count is not a fixed expected result.
Because arguments are logged with JSON.stringify(args), they appear in quoted JSON format rather than a plain object representation. Each step shows the function being called with its arguments, followed by the result that gets pushed into the context. In this example, the model uses 42 from the addition as an input to multiply. Other valid call groupings can produce the same answer. The execution continues until the model calls final_answer in step 3, setting the done flag that will cause the loop to terminate on its next condition check.
Adding Post-Loop Summary and Boundary Detection
After the loop completes, you add two final checks that provide completion information and detect abnormal termination scenarios.
The first if statement detects whether you exited the loop due to hitting maxSteps, rather than the model signaling completion with final_answer. This is important for identifying cases where the agent could not complete the task within expected boundaries. The unconditional console.log displays the step count regardless of how the loop terminated. The final if (finalAnswer) check only displays the answer if one was actually provided. Since finalAnswer is typed as string | null, this check is both a runtime guard and a deliberate narrowing: a null value means the agent never reached a final answer, so printing nothing is the correct behavior.
For the illustrative three-step run shown above, these post-loop checks produce the following summary output:
If the loop had terminated due to maxSteps instead, you would see the "Reached maximum steps" message before the completion summary, and the "Final answer" line would not appear since finalAnswer would still be null.
Summary: Owning Your Control Flow
You have now implemented a complete agentic loop that automates multi-step task execution through explicit control flow management. By defining typed loop control variables and using try/catch blocks for error handling, you have implemented Factor 8 to ensure your code owns the execution boundaries and Factor 9 to turn failures into structured feedback. The pattern that you have learned generalizes beyond mathematical calculations to any multi-step agentic task: maintain an explicit typed context, loop until completion or boundaries are reached, execute tools with error handling, and feed all results back to the model.
