Files
egutierrez 1b499c9b67 fix: resolver system_prompt_file relativo al directorio del config
Antes, el runtime construia la ruta del system prompt como
agents/<agent-id>/<file>, lo cual fallaba para agentes en
agents/_specials/ (como Father Bot). Ahora:

1. config.Load() guarda el directorio del config en ConfigDir
2. llm.go usa ConfigDir para resolver rutas relativas

Esto corrige que Father Bot operara sin su system prompt completo
(369 lineas de instrucciones, pipeline, seguridad) usando solo la
description de una linea como fallback.

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

222 lines
6.9 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"
"github.com/enmanuel/agents/shell/effects"
shelllm "github.com/enmanuel/agents/shell/llm"
)
// runLLM executes the LLM completion loop, including iterative tool-use.
// progress may be nil; when non-nil, its StreamFunc is attached to the request
// for providers that support streaming (claude-code).
func (a *Agent) runLLM(ctx context.Context, msgCtx decision.MessageContext, memKey string, progress *effects.ProgressReporter) (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 the config directory (handles _specials/ and custom locations)
spPath := filepath.Join(a.cfg.ConfigDir, 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
}
// Resolve StreamFunc for providers that support streaming
var streamFn coretypes.StreamFunc
if progress != nil {
streamFn = progress.StreamFunc()
}
// 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,
StreamFunc: streamFn,
}
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)
}
}