Files
goclaw/cmd/gateway_cron.go
T
viettranx c5b886048e fix(cron): inject delivery context into cron job system prompt
Cron jobs ran in isolated sessions with no context about who requested
them or where to deliver responses. The agent would misroute responses
(e.g., using team_message instead of replying to the original chat).

- Inject ExtraSystemPrompt with job name, requester ID, and delivery
  target so the agent knows to produce content directly
- Pass client.UserID() in Web UI cron.create instead of empty string
2026-03-11 07:26:43 +07:00

117 lines
4.0 KiB
Go

package cmd
import (
"context"
"fmt"
"strings"
"github.com/nextlevelbuilder/goclaw/internal/agent"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/channels"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/scheduler"
"github.com/nextlevelbuilder/goclaw/internal/sessions"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// makeCronJobHandler creates a cron job handler that routes through the scheduler's cron lane.
// This ensures per-session concurrency control (same job can't run concurrently)
// and integration with /stop, /stopall commands.
func makeCronJobHandler(sched *scheduler.Scheduler, msgBus *bus.MessageBus, cfg *config.Config, channelMgr *channels.Manager) func(job *store.CronJob) (*store.CronJobResult, error) {
return func(job *store.CronJob) (*store.CronJobResult, error) {
agentID := job.AgentID
if agentID == "" {
agentID = cfg.ResolveDefaultAgentID()
} else {
agentID = config.NormalizeAgentID(agentID)
}
sessionKey := sessions.BuildCronSessionKey(agentID, job.ID)
channel := job.Payload.Channel
if channel == "" {
channel = "cron"
}
// Infer peer kind from the stored session metadata (group chats need it
// so that tools like message can route correctly via group APIs).
peerKind := resolveCronPeerKind(job)
// Resolve channel type for system prompt context.
channelType := resolveChannelType(channelMgr, channel)
// Build cron context so the agent knows delivery target and requester.
var extraPrompt string
if job.Payload.Deliver && job.Payload.Channel != "" && job.Payload.To != "" {
extraPrompt = fmt.Sprintf(
"[Cron Job]\nThis is scheduled job \"%s\" (ID: %s).\n"+
"Requester: user %s on channel \"%s\" (chat %s).\n"+
"Your response will be automatically delivered to that chat — just produce the content directly.",
job.Name, job.ID, job.UserID, job.Payload.Channel, job.Payload.To,
)
} else {
extraPrompt = fmt.Sprintf(
"[Cron Job]\nThis is scheduled job \"%s\" (ID: %s), created by user %s.\n"+
"Delivery is not configured — respond normally.",
job.Name, job.ID, job.UserID,
)
}
// Schedule through cron lane — scheduler handles agent resolution and concurrency
outCh := sched.Schedule(context.Background(), scheduler.LaneCron, agent.RunRequest{
SessionKey: sessionKey,
Message: job.Payload.Message,
Channel: channel,
ChannelType: channelType,
ChatID: job.Payload.To,
PeerKind: peerKind,
UserID: job.UserID,
RunID: fmt.Sprintf("cron:%s", job.ID),
Stream: false,
ExtraSystemPrompt: extraPrompt,
TraceName: fmt.Sprintf("Cron [%s] - %s", job.Name, agentID),
TraceTags: []string{"cron"},
})
// Block until the scheduled run completes
outcome := <-outCh
if outcome.Err != nil {
return nil, outcome.Err
}
result := outcome.Result
// If job wants delivery to a channel, send the agent response to the target chat.
if job.Payload.Deliver && job.Payload.Channel != "" && job.Payload.To != "" {
outMsg := bus.OutboundMessage{
Channel: job.Payload.Channel,
ChatID: job.Payload.To,
Content: result.Content,
}
if peerKind == "group" {
outMsg.Metadata = map[string]string{"group_id": job.Payload.To}
}
appendMediaToOutbound(&outMsg, result.Media)
msgBus.PublishOutbound(outMsg)
}
cronResult := &store.CronJobResult{
Content: result.Content,
}
if result.Usage != nil {
cronResult.InputTokens = result.Usage.PromptTokens
cronResult.OutputTokens = result.Usage.CompletionTokens
}
return cronResult, nil
}
}
// resolveCronPeerKind infers peer kind from the cron job's user ID.
// Group cron jobs have userID prefixed with "group:" (set during job creation).
func resolveCronPeerKind(job *store.CronJob) string {
if strings.HasPrefix(job.UserID, "group:") {
return "group"
}
return ""
}