feat: implementar shell/cron — scheduler autónomo para bots
Nuevo paquete shell/cron con dos archivos:
shell/cron/scheduler.go — Scheduler struct con método Start(ctx) que:
- Registra todas las entradas de config.ScheduleCfg como jobs de robfig/cron
- Omite schedules sin output_room o sin action.kind (warn en log)
- Bloquea hasta que ctx sea cancelado, luego detiene el cron limpiamente
- Recibe MatrixSender, CompleteFunc y *slog.Logger como dependencias (sin importar agents/)
shell/cron/actions.go — ejecutores para fase 1:
- send_message: resuelve contenido desde Message (inline) o Template (archivo .md),
luego llama a matrix.SendMarkdown
- llm_prompt: resuelve prompt desde Prompt o Template, llama al LLM y envía
la respuesta al room configurado; no-op silencioso si no hay LLM
resolveContent() prioriza texto inline sobre ruta de archivo, lo que permite
tanto mensajes cortos en YAML como prompts largos en archivos .md separados.
Fase 2 (run_tool) y fase 3 (inter-bot) quedan pendientes según el issue.
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
package cron
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/enmanuel/agents/internal/config"
|
||||
coretypes "github.com/enmanuel/agents/pkg/llm"
|
||||
)
|
||||
|
||||
const actionKindSendMessage = "send_message"
|
||||
const actionKindLLMPrompt = "llm_prompt"
|
||||
|
||||
// handler is a function that fires when a schedule triggers.
|
||||
type handler func(ctx context.Context, room string)
|
||||
|
||||
// buildHandler returns the handler for a schedule, or nil for unsupported kinds.
|
||||
func (s *Scheduler) buildHandler(sc config.ScheduleCfg) handler {
|
||||
switch sc.Action.Kind {
|
||||
case actionKindSendMessage:
|
||||
return s.sendMessageHandler(sc)
|
||||
case actionKindLLMPrompt:
|
||||
return s.llmPromptHandler(sc)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// sendMessageHandler returns a handler that sends a static message to a Matrix room.
|
||||
// The message content is resolved in priority order: Message > Template file.
|
||||
func (s *Scheduler) sendMessageHandler(sc config.ScheduleCfg) handler {
|
||||
return func(ctx context.Context, room string) {
|
||||
content, err := resolveContent(sc.Action.Message, sc.Action.Template)
|
||||
if err != nil {
|
||||
s.logger.Error("send_message: failed to resolve content",
|
||||
"name", sc.Name, "err", err)
|
||||
return
|
||||
}
|
||||
if content == "" {
|
||||
s.logger.Warn("send_message: empty content, skipping", "name", sc.Name)
|
||||
return
|
||||
}
|
||||
|
||||
s.logger.Info("cron_fire", "name", sc.Name, "kind", actionKindSendMessage, "room", room)
|
||||
if err := s.matrix.SendMarkdown(ctx, room, content); err != nil {
|
||||
s.logger.Error("send_message: matrix send failed",
|
||||
"name", sc.Name, "room", room, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// llmPromptHandler returns a handler that calls the LLM with a prompt and sends
|
||||
// the response to a Matrix room.
|
||||
func (s *Scheduler) llmPromptHandler(sc config.ScheduleCfg) handler {
|
||||
return func(ctx context.Context, room string) {
|
||||
if s.llm == nil {
|
||||
s.logger.Warn("llm_prompt: no LLM configured, skipping", "name", sc.Name)
|
||||
return
|
||||
}
|
||||
|
||||
prompt, err := resolveContent(sc.Action.Prompt, sc.Action.Template)
|
||||
if err != nil {
|
||||
s.logger.Error("llm_prompt: failed to resolve prompt",
|
||||
"name", sc.Name, "err", err)
|
||||
return
|
||||
}
|
||||
if prompt == "" {
|
||||
s.logger.Warn("llm_prompt: empty prompt, skipping", "name", sc.Name)
|
||||
return
|
||||
}
|
||||
|
||||
s.logger.Info("cron_fire", "name", sc.Name, "kind", actionKindLLMPrompt, "room", room)
|
||||
|
||||
req := coretypes.CompletionRequest{
|
||||
Model: s.model,
|
||||
Messages: []coretypes.Message{
|
||||
{Role: coretypes.RoleUser, Content: prompt},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := s.llm(ctx, req)
|
||||
if err != nil {
|
||||
s.logger.Error("llm_prompt: LLM call failed",
|
||||
"name", sc.Name, "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
content := strings.TrimSpace(resp.Content)
|
||||
if content == "" {
|
||||
s.logger.Warn("llm_prompt: LLM returned empty response", "name", sc.Name)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.matrix.SendMarkdown(ctx, room, content); err != nil {
|
||||
s.logger.Error("llm_prompt: matrix send failed",
|
||||
"name", sc.Name, "room", room, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// resolveContent returns the inline text if non-empty, otherwise reads the file at templatePath.
|
||||
func resolveContent(inline, templatePath string) (string, error) {
|
||||
if inline != "" {
|
||||
return inline, nil
|
||||
}
|
||||
if templatePath == "" {
|
||||
return "", nil
|
||||
}
|
||||
data, err := os.ReadFile(templatePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("reading template %q: %w", templatePath, err)
|
||||
}
|
||||
return strings.TrimSpace(string(data)), nil
|
||||
}
|
||||
Reference in New Issue
Block a user