Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/GuaiZai233/FrostAgent/llms.txt

Use this file to discover all available pages before exploring further.

The Engine is the central orchestration component of FrostAgent. It owns the agentic loop that repeatedly calls the LLM, inspects the response for tool calls, executes those tools, and feeds the results back into the conversation until the model produces a plain-text final answer or the iteration ceiling is hit. Every other subsystem — the session manager, the tool registry, the LLM provider, the message dispatcher — is reached through the Engine.

Engine Struct Fields

The Engine struct (defined in internal/llm/agent.go) bundles everything the agentic loop needs into one place:
type Engine struct {
    MaxIterations          int
    ToolRegistry           map[string]ToolExecutor
    Provider               core.LLMProvider
    BaseURL                string
    APIKey                 string
    ModelName              string
    SessionManager         *SessionManager
    Dispatcher             core.MessageDispatcher
    SendHook               func(toolResultJSON string)
    StartedAt              time.Time
    Version                string
    TotalMessagesProcessed int64
}
FieldPurpose
MaxIterationsMaximum number of LLM ↔ tool-call rounds before giving up. Set to 5 in main.go.
ToolRegistryMap of tool name → ToolExecutor. Populated at startup from internal/tools.
ProviderThe core.LLMProvider implementation (openai.Client). Receives ChatRequest, returns ChatResponse.
BaseURLThe OpenAI-compatible API endpoint, from UPSTREAM_ENDPOINT.
APIKeyBearer token for the upstream LLM API, from UPSTREAM_API_KEY.
ModelNameModel identifier sent in every ChatRequest, from MODEL_NAME.
SessionManagerManages all active SessionContext objects for per-user history.
DispatcherRoutes OutgoingMessage to the right platform adapter by platform ID.
SendHookOptional callback fired when send_message executes. The adapter sets this to deliver rich messages mid-loop.
StartedAtTimestamp recorded when the Engine is initialised; exposed through BotStatusService.
VersionSemantic version string ("0.1.0"); reported in status and log headers.
TotalMessagesProcessedMonotonically incremented counter (one per runLoop iteration). Useful for observability.
MaxIterations is initialised to 5 in cmd/app/main.go. Raising it allows more complex, multi-step reasoning but also increases worst-case latency and LLM token cost.

Running the Engine

The Engine exposes three entry points. Choose based on whether you need stateless inference, direct history control, or automatic session management.

Run — Single stateless call

Run constructs a minimal two-message conversation (system prompt + user input) and runs the loop once. It is useful for one-shot queries where conversation history is not required.
func (e *Engine) Run(prompt string) string
engine := &llm.Engine{
    MaxIterations: 5,
    Provider:      openai.NewClient(endpoint, apiKey),
    ModelName:     "gpt-4o",
    ToolRegistry:  executorMap,
    SessionManager: llm.NewSessionManager(),
}

answer := engine.Run("What is the capital of France?")
fmt.Println(answer) // "The capital of France is Paris."
The system prompt is read from the SYSTEM_PROMPT environment variable. If the variable is empty, the system message will have an empty content string.

RunMessages — Pass a full message history

RunMessages accepts an already-assembled []ChatMessage slice and feeds it directly into the loop. If the first message is not a system message, a system prompt is prepended automatically.
func (e *Engine) RunMessages(messages []ChatMessage) string
history := []llm.ChatMessage{
    {Role: "system", Content: "You are a helpful assistant."},
    {Role: "user",   Content: "Summarise the last meeting notes."},
    {Role: "assistant", Content: "The meeting covered Q3 targets and budget."},
    {Role: "user",   Content: "What were the action items?"},
}

answer := engine.RunMessages(history)
This is the entry point used by the OneBot adapter, which builds the history from session.Snapshot() and passes it here to keep session locking outside the loop.

RunWithSession — Stateful session-based call

RunWithSession handles the full lifecycle: it loads (or creates) the session, prepends the system prompt for new sessions, appends the user message, runs the loop, trims the history, and writes it back — all under the session mutex.
func (e *Engine) RunWithSession(sessionID string, prompt string) string
// Group chat session
answer := engine.RunWithSession("group:123456789", "What's the weather in Tokyo?")

// Private DM session
answer := engine.RunWithSession("private:987654321", "Remind me what we discussed earlier.")
RunWithSession holds the session lock (session.Lock() / session.Unlock()) for the entire duration of the LLM call. For high-throughput scenarios consider using RunMessages with externally managed locking, as the OneBot adapter does.

The Agentic Loop

runLoop is the private method that all three entry points converge on. It implements the standard ReAct-style observe → think → act cycle:
func (e *Engine) runLoop(ctx context.Context, messages []ChatMessage) string {
    // Build the tool list for this call
    var modelTools []core.Tool
    for _, t := range e.ToolRegistry {
        modelTools = append(modelTools, core.Tool{
            Name:        t.Name(),
            Description: t.Description(),
            Parameters:  t.Parameters(),
        })
    }

    for i := 0; i < e.MaxIterations; i++ {
        e.TotalMessagesProcessed++

        // 1. Call the LLM
        resp, err := e.Provider.Chat(ctx, core.ChatRequest{
            Model:    e.ModelName,
            Messages: convertToCoreMessages(messages),
            Tools:    modelTools,
        })

        // 2. Append the assistant message
        messages = append(messages, responseMsg)

        // 3. No tool calls → final answer
        if len(responseMsg.ToolCalls) == 0 {
            return contentStr
        }

        // 4. Execute each requested tool
        for _, tc := range responseMsg.ToolCalls {
            result, err := tool.Execute(tc.Function.Arguments)

            // 5. Fire SendHook for send_message calls
            if tc.Function.Name == "send_message" && e.SendHook != nil {
                e.SendHook(result)
                result = "消息已发送"
            }

            // 6. Append tool result message
            messages = append(messages, ChatMessage{
                Role:       "tool",
                Content:    result,
                ToolCallID: tc.ID,
            })
        }
    }
    return "达到最大迭代次数,未能得出最终答案"
}
Key behaviours:
  • Tool schema injection — All registered tools are serialised into core.Tool structs and sent with every ChatRequest so the model can choose any of them.
  • Sequential tool execution — If the LLM requests multiple tool calls in a single turn, they are executed sequentially in the order returned.
  • Iteration ceiling — If MaxIterations rounds complete without a plain-text response, the loop returns a fallback string indicating the limit was reached.

SendHook

SendHook is a func(toolResultJSON string) field on the Engine. When the send_message tool fires successfully, the Engine calls SendHook with the raw JSON result before reporting back to the LLM. This lets the platform adapter deliver rich messages to the user mid-loop, without waiting for the model to finish its final turn. In the OneBot adapter, SendHook is configured per-request inside the reply function:
engine.SendHook = func(toolResultJSON string) {
    var toolOutput struct {
        Messages []tools.Msg `json:"messages"`
    }
    if err := json.Unmarshal([]byte(toolResultJSON), &toolOutput); err != nil {
        return
    }
    oneBotSegments := tools.BuildOneBotMessage(toolOutput.Messages)

    botAction := model.OneBotAction{
        Action: action,   // "send_group_msg" or "send_private_msg"
        Params: map[string]interface{}{
            type1:     id,
            "message": oneBotSegments,
        },
        Echo: echo,
    }
    actionBytes, _ := json.Marshal(botAction)
    conn.WriteMessage(websocket.TextMessage, actionBytes)
}

replyText = engine.RunMessages(messages)
engine.SendHook = nil // Clear after the call
After SendHook returns, the Engine substitutes the result with a short confirmation string ("消息已发送") so the LLM knows the delivery succeeded without receiving the full payload again.
Reset SendHook to nil after each RunMessages call (as the adapter does). Leaving a stale hook pointing to a closed WebSocket connection will cause write errors on the next request served by the same Engine instance.

Context Trimming

After RunWithSession finishes, it calls trimMessagesForSession to keep the session history within bounds:
func (e *Engine) trimMessagesForSession(messages []ChatMessage) []ChatMessage {
    maxHistory := e.SessionManager.MaxHistory // 20 by default
    if len(messages) <= maxHistory+1 {
        return messages
    }

    startIdx := len(messages) - maxHistory

    // Walk back past any leading tool messages to preserve tool call chains
    for startIdx > 1 && messages[startIdx].Role == "tool" {
        startIdx--
    }

    trimmed := []ChatMessage{messages[0]} // Always keep system prompt
    trimmed = append(trimmed, messages[startIdx:]...)
    return trimmed
}
The trimmer always preserves the system message at index 0 and then keeps the most recent MaxHistory messages. Crucially, if the trim boundary lands in the middle of a tool-call chain (i.e., a tool role message would appear before its corresponding assistant message), the start index is walked backwards until it sits on a non-tool message. This prevents the OpenAI API error: “tool message must follow an assistant message with tool_calls”.

Build docs developers (and LLMs) love