feat(heartbeat): provider/model override + fix cache invalidation

- Add ProviderModelSelect to heartbeat config dialog (allowEmpty, verify button)
- Backend: accept providerName in HEARTBEAT.SET, resolve to UUID via GetProviderByName
- Add ModelOverride to RunRequest, used by Loop when set (cheaper model for heartbeat)
- Ticker passes heartbeat model override to agent RunRequest
- Fix: InvalidateCache after UpdateState so ListDue picks up new next_run_at immediately
- i18n: add sectionModel/modelHint keys (en/vi/zh)
This commit is contained in:
viettranx committed 2026-03-18 23:02:48 +07:00
1 parent 74c9fdd66b
commit 5b349db7eb
12 files changed
+105 -9

No files matched your search

+1
View File
@@ -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)
+7 -1
View File
@@ -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,
+1
View File
@@ -370,6 +370,7 @@ type RunRequest struct {
TraceName string // override trace name (default: "chat <agentID>")
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
+28 -5
View File
@@ -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
+7
View File
@@ -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"},
+3
View File
@@ -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
}
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -438,6 +438,8 @@
"activeHoursStart": "开始时间(HH:MM)",
"activeHoursEnd": "结束时间(HH:MM)",
"timezone": "时区",
"sectionModel": "模型",
"modelHint": "覆盖心跳运行的提供者/模型。留空使用代理默认值。",
"sectionDelivery": "推送设置",
"channel": "渠道",
"channelPlaceholder": "选择渠道",
@@ -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}
/>
)}
@@ -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<HeartbeatConfig>) => Promise<void>;
update: (params: Partial<HeartbeatConfig> & { providerName?: string }) => Promise<void>;
test: () => Promise<void>;
getChecklist: () => Promise<string>;
setChecklist: (content: string) => Promise<void>;
fetchTargets: () => Promise<DeliveryTarget[]>;
refresh: () => Promise<void>;
/** 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<string, string> = {};
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({
</div>
</div>
{/* ── Provider / Model override ── */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<Cpu className="h-3.5 w-3.5 text-violet-500" />
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{t("heartbeat.sectionModel")}
</h4>
</div>
<p className="text-xs text-muted-foreground">{t("heartbeat.modelHint")}</p>
<ProviderModelSelect
provider={hbProvider}
onProviderChange={setHbProvider}
model={hbModel}
onModelChange={setHbModel}
allowEmpty
showVerify={!!(hbProvider && hbModel)}
providerPlaceholder={agentProvider ? `(${agentProvider})` : "(agent default)"}
modelPlaceholder={agentModel ? `(${agentModel})` : "(agent default)"}
/>
</div>
{/* ── Delivery — WHERE it sends ── */}
<div className="space-y-2">
<div className="flex items-center gap-2">
@@ -11,6 +11,8 @@ export interface HeartbeatConfig {
intervalSec: number;
ackMaxChars: number;
maxRetries: number;
providerId?: string;
model?: string;
isolatedSession: boolean;
lightContext: boolean;
activeHoursStart?: string;