1b499c9b67
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>
222 lines
6.9 KiB
Go
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)
|
|
}
|
|
}
|