package devagents import ( "context" "fmt" "log/slog" "os" "path/filepath" "github.com/enmanuel/agents/internal/config" "github.com/enmanuel/agents/pkg/command" "github.com/enmanuel/agents/pkg/decision" coretypes "github.com/enmanuel/agents/pkg/llm" "github.com/enmanuel/agents/pkg/personality" "github.com/enmanuel/agents/shell/audit" shelllm "github.com/enmanuel/agents/shell/llm" ) // runLLM executes the LLM completion loop, including iterative tool-use. func (a *Agent) runLLM(ctx context.Context, msgCtx decision.MessageContext, memKey string) (string, error) { a.logger.Debug("calling LLM", "model", a.cfg.LLM.Primary.Model, "provider", a.cfg.LLM.Primary.Provider, ) // Load system prompt from file if configured, else use description systemPrompt := a.cfg.Agent.Description if spFile := a.cfg.LLM.Reasoning.SystemPromptFile; spFile != "" { // Resolve path relative to agent directory spPath := filepath.Join("agents", a.cfg.Agent.ID, spFile) if data, err := os.ReadFile(spPath); err == nil { systemPrompt = string(data) } else { a.logger.Warn("failed to load system_prompt_file, using description", "path", spPath, "err", err) } } // Concatenate personality prompt block personalityBlock := personality.BuildPersonalityPrompt(a.personality) if personalityBlock != "" { systemPrompt = systemPrompt + "\n\n" + personalityBlock } // Build messages: conversation history from window (includes current user msg) messages := a.getWindowMessages(memKey) if len(messages) == 0 { // Fallback if memory is disabled: just the current message messages = []coretypes.Message{ {Role: coretypes.RoleUser, Content: msgCtx.Content}, } } // Build tool specs for the LLM if tool_use is enabled var llmTools []coretypes.ToolSpec if a.cfg.LLM.ToolUse.Enabled && a.toolReg.Len() > 0 { llmTools = a.toolReg.ToLLMSpecs() a.logger.Debug("tools available for LLM", "count", len(llmTools)) } maxIter := a.cfg.LLM.ToolUse.MaxIterations if maxIter <= 0 { maxIter = defaultMaxToolIterations } // Tool-use loop: call LLM → execute tools → feed results back → repeat for i := 0; i < maxIter; i++ { req := coretypes.CompletionRequest{ Model: a.cfg.LLM.Primary.Model, MaxTokens: a.cfg.LLM.Primary.MaxTokens, Temperature: a.cfg.LLM.Primary.Temperature, SystemPrompt: systemPrompt, Messages: messages, Tools: llmTools, } resp, err := a.llm(ctx, req) if err != nil { a.logger.Error("LLM call failed", "model", req.Model, "err", err) // Audit: llm_error a.emitAudit(audit.Event{ AgentID: a.cfg.Agent.ID, EventType: audit.EventLLMError, Detail: fmt.Sprintf("provider=%s model=%s error=%s", a.cfg.LLM.Primary.Provider, req.Model, err), }) return "", err } a.logger.Debug("LLM responded", "content_len", len(resp.Content), "tool_calls", len(resp.ToolCalls), "finish_reason", resp.FinishReason, ) // Audit: llm_request a.emitAudit(audit.Event{ AgentID: a.cfg.Agent.ID, EventType: audit.EventLLMRequest, Detail: fmt.Sprintf("provider=%s model=%s content_len=%d tool_calls=%d", a.cfg.LLM.Primary.Provider, req.Model, len(resp.Content), len(resp.ToolCalls)), }) // No tool calls — return the text response if len(resp.ToolCalls) == 0 { return resp.Content, nil } // Append assistant message with tool calls to conversation messages = append(messages, coretypes.Message{ Role: coretypes.RoleAssistant, Content: resp.Content, ToolCalls: resp.ToolCalls, }) // Execute each tool and append results for _, tc := range resp.ToolCalls { a.logger.Info("executing tool", "tool", tc.Name, "call_id", tc.ID, ) // RBAC check for tool execution if !a.acl.CanDo(msgCtx.SenderID, "tool:"+tc.Name) { a.logger.Info("tool_denied", "tool", tc.Name, "sender", msgCtx.SenderID) messages = append(messages, coretypes.Message{ Role: coretypes.RoleTool, Content: "error: permission denied for tool " + tc.Name, ToolCallID: tc.ID, }) continue } // Notify the room that a tool is being called (respect thread context) toolNotice := fmt.Sprintf("\U0001f528 %s", tc.Name) if err := a.sendReply(ctx, msgCtx.RoomID, msgCtx.EventID, msgCtx.ThreadID, toolNotice); err != nil { a.logger.Warn("failed to send tool call notice", "tool", tc.Name, "err", err) } result := a.toolReg.ExecuteForRoom(ctx, tc.Name, tc.Arguments, msgCtx.RoomID) output := result.Output if result.Err != nil { output = fmt.Sprintf("error: %s", result.Err) a.logger.Warn("tool execution error", "tool", tc.Name, "err", result.Err, ) } else { a.logger.Debug("tool executed", "tool", tc.Name, "output_len", len(output), ) } messages = append(messages, coretypes.Message{ Role: coretypes.RoleTool, Content: output, ToolCallID: tc.ID, }) } } // Max iterations reached — return whatever we have a.logger.Warn("tool-use loop reached max iterations", "max", maxIter) return "I've reached the maximum number of tool iterations. Here's what I found so far.", nil } // initLLM creates the LLM client function with optional fallback. // Returns nil when no provider is configured (command-only bot). func initLLM(cfg *config.AgentConfig, logger *slog.Logger) (coretypes.CompleteFunc, error) { if cfg.LLM.Primary.Provider == "" { logger.Info("no LLM configured, running as command-only bot") return nil, nil } llmLog := logger.With("component", "llm") primaryLLM, err := shelllm.FromConfig(cfg.LLM.Primary, llmLog) if err != nil { return nil, fmt.Errorf("primary LLM: %w", err) } llmFunc := primaryLLM if cfg.LLM.Fallback.Provider != "" { fallbackLLM, err := shelllm.FromConfig(cfg.LLM.Fallback, llmLog) if err != nil { logger.Warn("fallback LLM config error", "err", err) } else { llmFunc = shelllm.WithFallback(primaryLLM, fallbackLLM, cfg.LLM.Fallback, llmLog) } } return llmFunc, nil } // loadPromptCommands scans the project-root prompts/ directory and loads all .md files. func (a *Agent) loadPromptCommands() { prompts, err := command.LoadPromptCommands("prompts") if err != nil { a.logger.Warn("failed to load prompt-commands", "err", err) return } a.promptCmds = make(map[string]string, len(prompts)) for _, p := range prompts { a.promptCmds[p.Name] = p.Content } if len(a.promptCmds) > 0 { names := make([]string, 0, len(a.promptCmds)) for n := range a.promptCmds { names = append(names, n) } a.logger.Info("prompt-commands loaded", "count", len(a.promptCmds), "names", names) } }