diff --git a/cmd/gateway.go b/cmd/gateway.go index de05d55f..b7c4ffbf 100644 --- a/cmd/gateway.go +++ b/cmd/gateway.go @@ -728,6 +728,7 @@ func runGateway() { heartbeatTool.SetWakeFn(heartbeatTicker.Wake) heartbeatMethods.SetWakeFn(heartbeatTicker.Wake) heartbeatMethods.SetAgentStore(pgStores.Agents) + heartbeatMethods.SetProviderStore(pgStores.Providers) cronHeartbeatWakeFn = func(agentID string) { if id, err := uuid.Parse(agentID); err == nil { heartbeatTicker.Wake(id) diff --git a/internal/agent/loop.go b/internal/agent/loop.go index 5fdbd56a..980e34ce 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -618,10 +618,16 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) toolDefs = filtered } + // Use per-request model override if set (e.g. heartbeat uses cheaper model). + model := l.model + if req.ModelOverride != "" { + model = req.ModelOverride + } + chatReq := providers.ChatRequest{ Messages: messages, Tools: toolDefs, - Model: l.model, + Model: model, Options: map[string]any{ providers.OptMaxTokens: l.effectiveMaxTokens(), providers.OptTemperature: 0.7, diff --git a/internal/agent/loop_types.go b/internal/agent/loop_types.go index 8a587a26..3e11588c 100644 --- a/internal/agent/loop_types.go +++ b/internal/agent/loop_types.go @@ -370,6 +370,7 @@ type RunRequest struct { TraceName string // override trace name (default: "chat ") TraceTags []string // additional tags for the trace (e.g. "cron") MaxIterations int // per-request override (0 = use agent default, must be lower) + ModelOverride string // per-request model override (heartbeat uses cheaper model) LightContext bool // skip loading context files (only inject ExtraSystemPrompt) // Run classification diff --git a/internal/gateway/methods/heartbeat.go b/internal/gateway/methods/heartbeat.go index b677b5ea..3f828ddd 100644 --- a/internal/gateway/methods/heartbeat.go +++ b/internal/gateway/methods/heartbeat.go @@ -19,10 +19,11 @@ import ( // HeartbeatMethods handles heartbeat.get/set/toggle/test/logs/checklist RPC methods. type HeartbeatMethods struct { - hbStore store.HeartbeatStore - agentStore store.AgentStore - eventBus bus.EventPublisher - wakeFn func(uuid.UUID) // triggers immediate heartbeat run + hbStore store.HeartbeatStore + agentStore store.AgentStore + providerStore store.ProviderStore + eventBus bus.EventPublisher + wakeFn func(uuid.UUID) // triggers immediate heartbeat run } func NewHeartbeatMethods(hb store.HeartbeatStore, eventBus bus.EventPublisher) *HeartbeatMethods { @@ -34,6 +35,11 @@ func (m *HeartbeatMethods) SetAgentStore(as store.AgentStore) { m.agentStore = as } +// SetProviderStore sets the provider store for resolving provider names to UUIDs. +func (m *HeartbeatMethods) SetProviderStore(ps store.ProviderStore) { + m.providerStore = ps +} + // SetWakeFn sets the function called when "heartbeat.test" triggers an immediate run. func (m *HeartbeatMethods) SetWakeFn(fn func(uuid.UUID)) { m.wakeFn = fn @@ -89,6 +95,7 @@ func (m *HeartbeatMethods) handleSet(ctx context.Context, client *gateway.Client Enabled *bool `json:"enabled"` IntervalSec *int `json:"intervalSec"` Prompt *string `json:"prompt"` + ProviderName *string `json:"providerName"` Model *string `json:"model"` IsolatedSession *bool `json:"isolatedSession"` LightContext *bool `json:"lightContext"` @@ -143,8 +150,24 @@ func (m *HeartbeatMethods) handleSet(ctx context.Context, client *gateway.Client if params.Prompt != nil { hb.Prompt = params.Prompt } + if params.ProviderName != nil { + if *params.ProviderName == "" { + hb.ProviderID = nil // clear override + } else if m.providerStore != nil { + prov, err := m.providerStore.GetProviderByName(ctx, *params.ProviderName) + if err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "provider not found: "+*params.ProviderName)) + return + } + hb.ProviderID = &prov.ID + } + } if params.Model != nil { - hb.Model = params.Model + if *params.Model == "" { + hb.Model = nil // clear override + } else { + hb.Model = params.Model + } } if params.IsolatedSession != nil { hb.IsolatedSession = *params.IsolatedSession diff --git a/internal/heartbeat/ticker.go b/internal/heartbeat/ticker.go index 66d3277e..f79c65bd 100644 --- a/internal/heartbeat/ticker.go +++ b/internal/heartbeat/ticker.go @@ -220,6 +220,12 @@ func (t *Ticker) runOne(ctx context.Context, hb store.AgentHeartbeat) { var result *agent.RunResult maxAttempts := hb.MaxRetries + 1 + // Model override: use heartbeat-specific model if configured. + var modelOverride string + if hb.Model != nil && *hb.Model != "" { + modelOverride = *hb.Model + } + for attempt := range maxAttempts { outCh := t.runAgent(ctx, agent.RunRequest{ SessionKey: sessionKey, @@ -229,6 +235,7 @@ func (t *Ticker) runOne(ctx context.Context, hb store.AgentHeartbeat) { RunID: fmt.Sprintf("heartbeat:%s", agentIDStr), Stream: false, ExtraSystemPrompt: extraSystem, + ModelOverride: modelOverride, LightContext: hb.LightContext, TraceName: fmt.Sprintf("Heartbeat [%s]", agentKey), TraceTags: []string{"heartbeat"}, diff --git a/internal/store/pg/heartbeat.go b/internal/store/pg/heartbeat.go index cc20c18d..7386260d 100644 --- a/internal/store/pg/heartbeat.go +++ b/internal/store/pg/heartbeat.go @@ -199,6 +199,9 @@ func (s *PGHeartbeatStore) UpdateState(ctx context.Context, id uuid.UUID, state id, state.NextRunAt, state.LastRunAt, state.LastStatus, state.LastError, state.RunCount, state.SuppressCount, ) + if err == nil { + s.InvalidateCache() // ensure ListDue picks up new next_run_at immediately + } return err } diff --git a/ui/web/src/i18n/locales/en/agents.json b/ui/web/src/i18n/locales/en/agents.json index 4e52b218..f05921d1 100644 --- a/ui/web/src/i18n/locales/en/agents.json +++ b/ui/web/src/i18n/locales/en/agents.json @@ -438,6 +438,8 @@ "activeHoursStart": "Active From (HH:MM)", "activeHoursEnd": "Active Until (HH:MM)", "timezone": "Timezone", + "sectionModel": "Model", + "modelHint": "Override provider/model for heartbeat runs. Leave empty to use agent defaults.", "sectionDelivery": "Delivery", "channel": "Channel", "channelPlaceholder": "Select channel", diff --git a/ui/web/src/i18n/locales/vi/agents.json b/ui/web/src/i18n/locales/vi/agents.json index 6d9a1cf6..27af4c68 100644 --- a/ui/web/src/i18n/locales/vi/agents.json +++ b/ui/web/src/i18n/locales/vi/agents.json @@ -438,6 +438,8 @@ "activeHoursStart": "Bắt đầu (HH:MM)", "activeHoursEnd": "Kết thúc (HH:MM)", "timezone": "Múi giờ", + "sectionModel": "Model", + "modelHint": "Ghi đè provider/model cho heartbeat. Để trống để dùng mặc định của agent.", "sectionDelivery": "Gửi kết quả", "channel": "Kênh", "channelPlaceholder": "Chọn kênh", diff --git a/ui/web/src/i18n/locales/zh/agents.json b/ui/web/src/i18n/locales/zh/agents.json index b25335f6..ff99b235 100644 --- a/ui/web/src/i18n/locales/zh/agents.json +++ b/ui/web/src/i18n/locales/zh/agents.json @@ -438,6 +438,8 @@ "activeHoursStart": "开始时间(HH:MM)", "activeHoursEnd": "结束时间(HH:MM)", "timezone": "时区", + "sectionModel": "模型", + "modelHint": "覆盖心跳运行的提供者/模型。留空使用代理默认值。", "sectionDelivery": "推送设置", "channel": "渠道", "channelPlaceholder": "选择渠道", diff --git a/ui/web/src/pages/agents/agent-detail/agent-detail-page.tsx b/ui/web/src/pages/agents/agent-detail/agent-detail-page.tsx index 5b2f6dfb..b41ebf84 100644 --- a/ui/web/src/pages/agents/agent-detail/agent-detail-page.tsx +++ b/ui/web/src/pages/agents/agent-detail/agent-detail-page.tsx @@ -128,6 +128,8 @@ export function AgentDetailPage({ agentId, onBack }: AgentDetailPageProps) { setChecklist={hb.setChecklist} fetchTargets={hb.fetchTargets} refresh={hb.refresh} + agentProvider={agent?.provider} + agentModel={agent?.model} /> )} diff --git a/ui/web/src/pages/agents/agent-detail/heartbeat-config-dialog.tsx b/ui/web/src/pages/agents/agent-detail/heartbeat-config-dialog.tsx index 45bb2ccf..5756f723 100644 --- a/ui/web/src/pages/agents/agent-detail/heartbeat-config-dialog.tsx +++ b/ui/web/src/pages/agents/agent-detail/heartbeat-config-dialog.tsx @@ -1,5 +1,5 @@ -import { useState, useEffect, useCallback } from "react"; -import { Play, Loader2, ChevronDown, Heart, Clock, Send, FileText } from "lucide-react"; +import { useState, useEffect, useCallback, useMemo } from "react"; +import { Play, Loader2, ChevronDown, Heart, Clock, Send, FileText, Cpu } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Dialog, DialogContent, DialogHeader, DialogTitle, @@ -15,7 +15,9 @@ import { } from "@/components/ui/select"; import { useMinLoading } from "@/hooks/use-min-loading"; import { useChannels } from "@/pages/channels/hooks/use-channels"; +import { useProviders } from "@/pages/providers/hooks/use-providers"; import { useUiStore } from "@/stores/use-ui-store"; +import { ProviderModelSelect } from "@/components/shared/provider-model-select"; import type { HeartbeatConfig, DeliveryTarget } from "@/pages/agents/hooks/use-agent-heartbeat"; interface HeartbeatConfigDialogProps { @@ -23,19 +25,25 @@ interface HeartbeatConfigDialogProps { onOpenChange: (open: boolean) => void; config: HeartbeatConfig | null; saving: boolean; - update: (params: Partial) => Promise; + update: (params: Partial & { providerName?: string }) => Promise; test: () => Promise; getChecklist: () => Promise; setChecklist: (content: string) => Promise; fetchTargets: () => Promise; refresh: () => Promise; + /** Agent's current provider name (used as default for heartbeat). */ + agentProvider?: string; + /** Agent's current model (used as default for heartbeat). */ + agentModel?: string; } export function HeartbeatConfigDialog({ open, onOpenChange, config, saving, update, test, getChecklist, setChecklist, fetchTargets, refresh, + agentProvider, agentModel, }: HeartbeatConfigDialogProps) { const { t } = useTranslation("agents"); const { channels: availableChannels } = useChannels(); + const { providers } = useProviders(); const channelNames = Object.keys(availableChannels); const userTz = useUiStore((s) => s.timezone); const browserTz = Intl.DateTimeFormat().resolvedOptions().timeZone; @@ -53,6 +61,8 @@ export function HeartbeatConfigDialog({ const [timezone, setTimezone] = useState(""); const [channel, setChannel] = useState(""); const [chatId, setChatId] = useState(""); + const [hbProvider, setHbProvider] = useState(""); + const [hbModel, setHbModel] = useState(""); const [checklist, setChecklistState] = useState(""); const [originalChecklist, setOriginalChecklist] = useState(""); const [checklistLoading, setChecklistLoading] = useState(false); @@ -73,6 +83,13 @@ export function HeartbeatConfigDialog({ } }, [getChecklist]); + // Map providerId UUID → provider name for the select component. + const providerNameById = useMemo(() => { + const map: Record = {}; + for (const p of providers) map[p.id] = p.name; + return map; + }, [providers]); + // Sync form state only when dialog opens (false→true). useEffect(() => { if (!open) return; @@ -88,9 +105,14 @@ export function HeartbeatConfigDialog({ setTimezone(config.timezone || defaultTz); setChannel(config.channel ?? ""); setChatId(config.chatId ?? ""); + // Map stored providerId UUID → name for the select + setHbProvider(config.providerId ? (providerNameById[config.providerId] ?? "") : ""); + setHbModel(config.model ?? ""); } else { // First-time setup defaults setTimezone(defaultTz); + setHbProvider(""); + setHbModel(""); } setShowAdvanced(false); loadChecklist(); @@ -117,6 +139,8 @@ export function HeartbeatConfigDialog({ timezone: timezone || undefined, channel: channel || undefined, chatId: chatId || undefined, + model: hbModel || undefined, + providerName: hbProvider || undefined, }); if (checklist !== originalChecklist) { await setChecklist(checklist); @@ -164,6 +188,27 @@ export function HeartbeatConfigDialog({ + {/* ── Provider / Model override ── */} +
+
+ +

+ {t("heartbeat.sectionModel")} +

+
+

{t("heartbeat.modelHint")}

+ +
+ {/* ── Delivery — WHERE it sends ── */}
diff --git a/ui/web/src/pages/agents/hooks/use-agent-heartbeat.ts b/ui/web/src/pages/agents/hooks/use-agent-heartbeat.ts index d66f2583..497d0e99 100644 --- a/ui/web/src/pages/agents/hooks/use-agent-heartbeat.ts +++ b/ui/web/src/pages/agents/hooks/use-agent-heartbeat.ts @@ -11,6 +11,8 @@ export interface HeartbeatConfig { intervalSec: number; ackMaxChars: number; maxRetries: number; + providerId?: string; + model?: string; isolatedSession: boolean; lightContext: boolean; activeHoursStart?: string;