Bo’s Blog

Core of Pi - the while loop. The core of the Pi is basically a while loop, in packages/agent/src/agent-loop.ts.

    // Outer loop: continues when queued follow-up messages arrive after agent would stop
    while (true) {
      let hasMoreToolCalls = true;
      let steeringAfterTools: AgentMessage[] | null = null;

      // Inner loop: process tool calls and steering messages
      while (hasMoreToolCalls || pendingMessages.length > 0) {
        if (!firstTurn) {
          stream.push({ type: "turn_start" });
        } else {
          firstTurn = false;
        }

        // Process pending messages (inject before next assistant response)
        if (pendingMessages.length > 0) {
          for (const message of pendingMessages) {
            stream.push({ type: "message_start", message });
            stream.push({ type: "message_end", message });
            currentContext.messages.push(message);
            newMessages.push(message);
          }
          pendingMessages = [];
        }

        // Stream assistant response
        const message = await streamAssistantResponse(currentContext, config, signal, stream, streamFn);
        newMessages.push(message);

        if (message.stopReason === "error" || message.stopReason === "aborted") {
          stream.push({ type: "turn_end", message, toolResults: [] });
          stream.push({ type: "agent_end", messages: newMessages });
          stream.end(newMessages);
          return;
        }

        // Check for tool calls
        const toolCalls = message.content.filter((c) => c.type === "toolCall");
        hasMoreToolCalls = toolCalls.length > 0;

        const toolResults: ToolResultMessage[] = [];
        if (hasMoreToolCalls) {
          const toolExecution = await executeToolCalls(
            currentContext.tools,
            message,
            signal,
            stream,
            config.getSteeringMessages,
          );
          toolResults.push(...toolExecution.toolResults);
          steeringAfterTools = toolExecution.steeringMessages ?? null;

          for (const result of toolResults) {
            currentContext.messages.push(result);
            newMessages.push(result);
          }
        }

        stream.push({ type: "turn_end", message, toolResults });

        // Get steering messages after turn completes
        if (steeringAfterTools && steeringAfterTools.length > 0) {
          pendingMessages = steeringAfterTools;
          steeringAfterTools = null;
        } else {
          pendingMessages = (await config.getSteeringMessages?.()) || [];
        }
      }

      // Agent would stop here. Check for follow-up messages.
      const followUpMessages = (await config.getFollowUpMessages?.()) || [];
      if (followUpMessages.length > 0) {
        // Set as pending so inner loop processes them
        pendingMessages = followUpMessages;
        continue;
      }

      // No more messages, exit
      break;
    }

This loop is conceptually simple:

  1. User sends messages to AI.
  2. AI decides it needs tool calls, executes them, and gets results.
  3. AI checks results; if it needs more tools, repeat.
  4. AI finishes and checks for follow-up messages; continue if present, otherwise stop.