Files
agents_and_robots/devagents/llm.go
T
egutierrez bd0c8c0dd3 refactor: mover runtime Go de agents/ a devagents/
agents/ ahora solo contiene carpetas de agentes (config, reglas, prompts).
El runtime (Agent, Robot, Runner, registry, handler, commands, llm, memory)
vive en devagents/ como package devagents.

Cambios:
- git mv agents/*.go → devagents/*.go
- package agents → package devagents en todos los archivos movidos
- Actualizar imports en agents/*/agent.go, cmd/launcher/, dev-scripts/
- Actualizar docs: CLAUDE.md, rules/, docs/e2ee.md, issues pendientes

Build y tests pasan sin errores.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 21:19:25 +00:00

212 lines
6.5 KiB
Go

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 <em>%s</em>", 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)
}
}