loop

package
v0.408.920045543 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: May 20, 2026 License: Apache-2.0 Imports: 14 Imported by: 0

README

Loop Package

The loop package provides the core agentic conversation loop for Percy, handling LLM interactions, tool execution, and message recording.

Features

  • LLM Integration: Works with any LLM service implementing the llm.Service interface
  • Predictable Testing: Includes a PredictableService for deterministic testing
  • Tool Execution: Automatically executes tools called by the LLM
  • Message Recording: Records all conversation messages via a configurable function
  • Usage Tracking: Tracks token usage and costs across all LLM calls
  • Context Cancellation: Gracefully handles context cancellation
  • Thread Safety: All methods are safe for concurrent use

Basic Usage

// Create tools (using claudetool package or custom tools)
tools := []*llm.Tool{bashTool, patchTool, thinkTool}

// Define message recording function (typically saves to the database)
recordMessage := func(ctx context.Context, message llm.Message, usage llm.Usage) error {
    return messageService.Create(ctx, db.CreateMessageParams{
        ConversationID: conversationID,
        Type:            getMessageType(message.Role),
        LLMData:         message,
        UsageData:       usage,
    })
}

// Create loop with explicit LLM configuration
agentLoop := loop.NewLoop(loop.Config{
    LLM:           &ant.Service{APIKey: apiKey},
    History:       history, // existing conversation history
    Tools:         tools,
    RecordMessage: recordMessage,
    Logger:        logger,
    System:        systemPrompt, // []llm.SystemContent
})

// Queue user messages for the current turn
agentLoop.QueueUserMessage(llm.UserStringMessage("Hello, please help me with something"))

// Run the conversation turn
ctx := context.Background()
if err := agentLoop.ProcessOneTurn(ctx); err != nil {
    log.Fatalf("conversation failed: %v", err)
}

Testing with PredictableService

The PredictableService records requests and returns deterministic responses that are convenient for tests:

service := loop.NewPredictableService()

testLoop := loop.NewLoop(loop.Config{
    LLM:           service,
    RecordMessage: func(context.Context, llm.Message, llm.Usage) error { return nil },
})

testLoop.QueueUserMessage(llm.UserStringMessage("hello"))
if err := testLoop.ProcessOneTurn(context.Background()); err != nil {
    t.Fatalf("loop failed: %v", err)
}

last := service.GetLastRequest()
require.NotNil(t, last)

Documentation

Index

Examples

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type Config

type Config struct {
	LLM              llm.Service
	History          []llm.Message
	Tools            []*llm.Tool
	RecordMessage    MessageRecordFunc
	Logger           *slog.Logger
	System           []llm.SystemContent
	WorkingDir       string // working directory for tools
	OnGitStateChange GitStateChangeFunc
	// ActiveToolsFn returns the tools to send to the LLM for the current turn.
	// If nil, all Tools are sent. This is used for deferred tool loading where
	// only a subset of tools are active initially.
	ActiveToolsFn func() []*llm.Tool
	// GetWorkingDir returns the current working directory for tools.
	// If set, this is called at end of turn to check for git state changes.
	// If nil, Config.WorkingDir is used as a static value.
	GetWorkingDir func() string
}

Config contains all configuration needed to create a Loop.

type GitStateChangeFunc

type GitStateChangeFunc func(ctx context.Context, state *gitstate.GitState)

GitStateChangeFunc is called when the git state changes at the end of a turn. This is used to record user-visible notifications about git changes.

type Loop

type Loop struct {
	// contains filtered or unexported fields
}

Loop manages a conversation turn with an LLM including tool execution and message recording. Notably, when the turn ends, the "Loop" is over. TODO: maybe rename to Turn?

Example
package main

import (
	"context"
	"encoding/json"
	"fmt"
	"time"

	"github.com/tgruben-circuit/percy/llm"
	"github.com/tgruben-circuit/percy/loop"
)

func main() {
	// Create a simple tool
	testTool := &llm.Tool{
		Name:        "greet",
		Description: "Greets the user with a friendly message",
		InputSchema: llm.MustSchema(`{"type": "object", "properties": {"name": {"type": "string"}}}`),
		Run: func(ctx context.Context, input json.RawMessage) llm.ToolOut {
			var req struct {
				Name string `json:"name"`
			}
			if err := json.Unmarshal(input, &req); err != nil {
				return llm.ErrorToolOut(err)
			}
			return llm.ToolOut{
				LLMContent: llm.TextContent(fmt.Sprintf("Hello, %s! Nice to meet you.", req.Name)),
			}
		},
	}

	// Message recording function (in real usage, this would save to database)
	recordMessage := func(ctx context.Context, message llm.Message, usage llm.Usage) error {
		roleStr := "user"
		if message.Role == llm.MessageRoleAssistant {
			roleStr = "assistant"
		}
		fmt.Printf("Recorded %s message with %d content items\n", roleStr, len(message.Content))
		return nil
	}

	// Create a loop with initial history
	initialHistory := []llm.Message{
		{
			Role: llm.MessageRoleUser,
			Content: []llm.Content{
				{Type: llm.ContentTypeText, Text: "Hello, I'm Alice"},
			},
		},
	}

	// Set up a predictable service for this example
	service := loop.NewPredictableService()
	myLoop := loop.NewLoop(loop.Config{
		LLM:           service,
		History:       initialHistory,
		Tools:         []*llm.Tool{testTool},
		RecordMessage: recordMessage,
	})

	// Queue a user message that triggers a simple response
	myLoop.QueueUserMessage(llm.Message{
		Role:    llm.MessageRoleUser,
		Content: []llm.Content{{Type: llm.ContentTypeText, Text: "hello"}},
	})

	// Run the loop for a short time
	ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
	defer cancel()

	_ = myLoop.Go(ctx)

	// Check usage
	usage := myLoop.GetUsage()
	fmt.Printf("Total usage: %s\n", usage.String())

}
Output:
Recorded assistant message with 1 content items
Total usage: in: 31, out: 3

func NewLoop

func NewLoop(config Config) *Loop

NewLoop creates a new Loop instance with the provided configuration

func (*Loop) GetHistory

func (l *Loop) GetHistory() []llm.Message

GetHistory returns a copy of the current conversation history

func (*Loop) GetUsage

func (l *Loop) GetUsage() llm.Usage

GetUsage returns the total usage accumulated by this loop

func (*Loop) Go

func (l *Loop) Go(ctx context.Context) error

Go runs the conversation loop until the context is canceled

func (*Loop) ProcessOneTurn

func (l *Loop) ProcessOneTurn(ctx context.Context) error

ProcessOneTurn processes queued messages through one complete turn (user message + assistant response) It stops after the assistant responds, regardless of whether tools were called

func (*Loop) QueueUserMessage

func (l *Loop) QueueUserMessage(message llm.Message)

QueueUserMessage adds a user message to the queue to be processed

type MessageRecordFunc

type MessageRecordFunc func(ctx context.Context, message llm.Message, usage llm.Usage) error

MessageRecordFunc is called to record new messages to persistent storage.

type PredictableService

type PredictableService struct {

	// AlwaysMaxTokens makes every response return StopReasonMaxTokens for testing truncation retries.
	AlwaysMaxTokens bool
	// contains filtered or unexported fields
}

PredictableService is an LLM service that returns predictable responses for testing.

To add new test patterns, update the Do() method directly by adding cases to the switch statement or new prefix checks. Do not extend or wrap this service - modify it in place. Available patterns include:

  • "echo: <text>" - echoes the text back
  • "bash: <command>" - triggers bash tool with command
  • "think: <thoughts>" - returns response with extended thinking content
  • "subagent: <slug> <prompt>" - triggers subagent tool
  • "change_dir: <path>" - triggers change_dir tool
  • "delay: <seconds>" - delays response by specified seconds
  • See Do() method for complete list of supported patterns

func NewPredictableService

func NewPredictableService() *PredictableService

NewPredictableService creates a new predictable LLM service

func (*PredictableService) ClearRequests

func (s *PredictableService) ClearRequests()

ClearRequests clears the request history

func (*PredictableService) Do

Do processes a request and returns a predictable response based on the input text

func (*PredictableService) GetLastRequest

func (s *PredictableService) GetLastRequest() *llm.Request

GetLastRequest returns the most recent request, or nil if none

func (*PredictableService) GetRecentRequests

func (s *PredictableService) GetRecentRequests() []*llm.Request

GetRecentRequests returns the recent requests made to this service

func (*PredictableService) MaxImageDimension

func (s *PredictableService) MaxImageDimension() int

MaxImageDimension returns the maximum allowed image dimension.

func (*PredictableService) TokenContextWindow

func (s *PredictableService) TokenContextWindow() int

TokenContextWindow returns the maximum token context window size

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL