bd0c8c0dd3
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>
212 lines
6.5 KiB
Go
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)
|
|
}
|
|
}
|