Add Generative UI auto-present opt-in with budget and cooldown
ci / vet-and-test (pull_request) Successful in 1m4s
ci / vet-and-test (pull_request) Successful in 1m4s
Why: Known-boundary follow-up: owner opt-in auto page suggestions on semantic task events without self-trigger from surface revisions. Evidence: go test ./internal/genui/ ./internal/runmgr/ ./internal/settings/ ./internal/app/ Impact: Default-off settings key; material/plan/todo/document hooks nudge present. Refs: docs/proposals/2026-10-12-generative-ui-auto-trigger.md Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -546,6 +546,19 @@ func Run(opt Options) error {
|
||||
row, err := surfaceSvc.Activation(context.Background(), owner, genui.PluginID)
|
||||
return err == nil && row.Enabled
|
||||
})
|
||||
// 自动建议页面 opt-in:settings ∧ activation;预算/冷却在 genui.Auto。
|
||||
mgr.SetGenUIAuto(genui.NewAuto(
|
||||
func(owner string) bool {
|
||||
row, err := surfaceSvc.Activation(context.Background(), owner, genui.PluginID)
|
||||
return err == nil && row.Enabled
|
||||
},
|
||||
func(owner string) bool {
|
||||
return setSvc.Effective(owner, "").GetBool(genui.SettingAutoPresent, false)
|
||||
},
|
||||
func(owner, kind, detail string) {
|
||||
_ = st.AppendAuditEvent(store.AuditEvent{Owner: owner, Action: kind, Evidence: detail})
|
||||
},
|
||||
))
|
||||
|
||||
bus.SetPendingStore(st)
|
||||
pluginHost.OnCommands = bus.RegisterPluginCommands
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package genui
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AutoBudgetPerHour 是每任务(conversation)滑动一小时内允许的自动建议次数。
|
||||
const AutoBudgetPerHour = 3
|
||||
|
||||
// AutoCooldown 是同会话两次自动触发的最小间隔。
|
||||
const AutoCooldown = 60 * time.Second
|
||||
|
||||
// SettingAutoPresent 是 owner 级 opt-in 设置键(默认 false;与 activation 分离)。
|
||||
const SettingAutoPresent = "plugins.generative-ui.autoPresent"
|
||||
|
||||
// AutoReminder 是注入 system-reminder 的建议文案(只引导 present,禁止改任务/点动作)。
|
||||
const AutoReminder = "任务语义事件已发生(阶段或材料/工件完成)。若 generative-ui 已启用,请调用一次 generative-ui.present 生成或修订建议页,按当前任务阶段组织信息。不要点击页面 actions,不要改写 plan/todo/任务完成状态;任务事实只能来自真实任务记录。"
|
||||
|
||||
// ActivationLookup 查询 owner 的 Surface activation(generative-ui)。
|
||||
type ActivationLookup func(owner string) (enabled bool)
|
||||
|
||||
// SettingsLookup 查询 owner 的 autoPresent opt-in。
|
||||
type SettingsLookup func(owner string) (enabled bool)
|
||||
|
||||
// Auditor 写审计事件(耗尽等);nil = 跳过。
|
||||
type Auditor func(owner, kind, detail string)
|
||||
|
||||
// Auto 是自动触发闸门:opt-in ∧ activation ∧ 预算 ∧ 冷却。
|
||||
// 触发源由 runmgr 显式调用;present/surface/action 路径不得调用。
|
||||
type Auto struct {
|
||||
activation ActivationLookup
|
||||
settings SettingsLookup
|
||||
audit Auditor
|
||||
now func() time.Time
|
||||
|
||||
mu sync.Mutex
|
||||
budget map[string][]time.Time // convID → 触发时刻
|
||||
last map[string]time.Time // convID → 上次成功放行
|
||||
}
|
||||
|
||||
// NewAuto 构造闸门(lookup 不得为 nil)。
|
||||
func NewAuto(activation ActivationLookup, settings SettingsLookup, audit Auditor) *Auto {
|
||||
return &Auto{
|
||||
activation: activation,
|
||||
settings: settings,
|
||||
audit: audit,
|
||||
now: func() time.Time { return time.Now().UTC() },
|
||||
budget: map[string][]time.Time{},
|
||||
last: map[string]time.Time{},
|
||||
}
|
||||
}
|
||||
|
||||
// Try 尝试放行一次自动建议。ok=false 时 skip 含原因(供测试);ok=true 时调用方应注入 AutoReminder。
|
||||
func (a *Auto) Try(owner, convID, reason string) (ok bool, skip string) {
|
||||
if a == nil || a.activation == nil || a.settings == nil {
|
||||
return false, "unconfigured"
|
||||
}
|
||||
if owner == "" || convID == "" {
|
||||
return false, "missing_ids"
|
||||
}
|
||||
if !a.settings(owner) {
|
||||
return false, "opt_in_off"
|
||||
}
|
||||
if !a.activation(owner) {
|
||||
return false, "activation_off"
|
||||
}
|
||||
|
||||
now := a.now()
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
if t, hit := a.last[convID]; hit && now.Sub(t) < AutoCooldown {
|
||||
return false, "cooldown"
|
||||
}
|
||||
|
||||
cutoff := now.Add(-time.Hour)
|
||||
kept := a.budget[convID][:0]
|
||||
for _, t := range a.budget[convID] {
|
||||
if t.After(cutoff) {
|
||||
kept = append(kept, t)
|
||||
}
|
||||
}
|
||||
a.budget[convID] = kept
|
||||
if len(kept) >= AutoBudgetPerHour {
|
||||
if a.audit != nil {
|
||||
a.audit(owner, "genui.auto_present_exhausted", "conversation="+convID+" reason="+reason)
|
||||
}
|
||||
return false, "budget"
|
||||
}
|
||||
|
||||
a.budget[convID] = append(kept, now)
|
||||
a.last[convID] = now
|
||||
return true, ""
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package genui
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAutoDefaultOff(t *testing.T) {
|
||||
a := NewAuto(func(string) bool { return true }, func(string) bool { return false }, nil)
|
||||
if ok, skip := a.Try("alice", "c1", "plan"); ok || skip != "opt_in_off" {
|
||||
t.Fatalf("want opt_in_off, got ok=%v skip=%q", ok, skip)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoRequiresActivation(t *testing.T) {
|
||||
a := NewAuto(func(string) bool { return false }, func(string) bool { return true }, nil)
|
||||
if ok, skip := a.Try("alice", "c1", "plan"); ok || skip != "activation_off" {
|
||||
t.Fatalf("want activation_off, got ok=%v skip=%q", ok, skip)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoBudgetAndCooldown(t *testing.T) {
|
||||
var exhausted int
|
||||
now := time.Unix(1_700_000_000, 0).UTC()
|
||||
a := NewAuto(func(string) bool { return true }, func(string) bool { return true },
|
||||
func(owner, kind, detail string) {
|
||||
if kind == "genui.auto_present_exhausted" {
|
||||
exhausted++
|
||||
}
|
||||
})
|
||||
a.now = func() time.Time { return now }
|
||||
|
||||
for i := 0; i < AutoBudgetPerHour; i++ {
|
||||
ok, skip := a.Try("alice", "c1", "plan")
|
||||
if !ok {
|
||||
t.Fatalf("shot %d should pass, skip=%q", i, skip)
|
||||
}
|
||||
now = now.Add(AutoCooldown + time.Second)
|
||||
}
|
||||
if ok, skip := a.Try("alice", "c1", "plan"); ok || skip != "budget" || exhausted != 1 {
|
||||
t.Fatalf("want budget exhausted, ok=%v skip=%q exhausted=%d", ok, skip, exhausted)
|
||||
}
|
||||
|
||||
// 冷却:同一时刻第二次拒绝
|
||||
a2 := NewAuto(func(string) bool { return true }, func(string) bool { return true }, nil)
|
||||
a2.now = func() time.Time { return now }
|
||||
if ok, _ := a2.Try("alice", "c2", "art"); !ok {
|
||||
t.Fatal("first should pass")
|
||||
}
|
||||
if ok, skip := a2.Try("alice", "c2", "art"); ok || skip != "cooldown" {
|
||||
t.Fatalf("want cooldown, got ok=%v skip=%q", ok, skip)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package runmgr
|
||||
|
||||
import (
|
||||
"agent-system-backend/internal/genui"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// SetGenUIAuto 注入自动建议闸门(组合根装配;nil 关闭)。
|
||||
func (m *Manager) SetGenUIAuto(a GenUIAutoTrigger) { m.genuiAuto = a }
|
||||
|
||||
func genUIAutoReminderText() string { return genui.AutoReminder }
|
||||
|
||||
// maybeGenUIAuto:语义事件钩子入口。会话忙碌时只追加 system 消息供下一轮消费;
|
||||
// 空闲时启动短 notice-run 让模型有机会立即 present。
|
||||
func (m *Manager) maybeGenUIAuto(owner, convID, reason string) {
|
||||
if m == nil || m.genuiAuto == nil {
|
||||
return
|
||||
}
|
||||
ok, _ := m.genuiAuto.Try(owner, convID, reason)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
go m.deliverGenUIAutoReminder(owner, convID, reason)
|
||||
}
|
||||
|
||||
func (m *Manager) deliverGenUIAutoReminder(owner, convID, reason string) {
|
||||
text := "<system-reminder>\n" + genUIAutoReminderText() + "\n(trigger=" + reason + ")\n</system-reminder>"
|
||||
msg := core.Message{
|
||||
ConversationID: convID, Role: core.RoleSystem,
|
||||
Parts: []core.Part{{Type: core.PartText, Text: text}},
|
||||
}
|
||||
if err := m.st.AppendMessage(&msg); err != nil {
|
||||
return
|
||||
}
|
||||
m.convMu.Lock()
|
||||
busy := m.convActive[convID]
|
||||
m.convMu.Unlock()
|
||||
if busy {
|
||||
return
|
||||
}
|
||||
conv, err := m.st.GetConversation(owner, convID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if err := m.claimConversation(convID); err != nil {
|
||||
return
|
||||
}
|
||||
if _, err := m.startClaimedRun(conv, msg, nil, nil, nil); err != nil {
|
||||
m.releaseConversation(convID)
|
||||
}
|
||||
}
|
||||
|
||||
func planStageChanged(prev, next core.PlanSnapshot) bool {
|
||||
pm := map[string]string{}
|
||||
for _, s := range prev.Steps {
|
||||
pm[s.ID] = string(s.Status)
|
||||
}
|
||||
for _, s := range next.Steps {
|
||||
if pm[s.ID] != string(s.Status) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// 新增步骤也视为阶段组织变化
|
||||
if len(next.Steps) > len(prev.Steps) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func todoStageChanged(prev, next core.TodoList) bool {
|
||||
pm := map[string]string{}
|
||||
for _, t := range prev.Items {
|
||||
pm[t.ID] = string(t.Status)
|
||||
}
|
||||
for _, t := range next.Items {
|
||||
if pm[t.ID] != string(t.Status) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if len(next.Items) > len(prev.Items) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -96,6 +96,15 @@ type Manager struct {
|
||||
|
||||
allowMu sync.Mutex
|
||||
sessionAllow map[string]map[string]bool
|
||||
|
||||
// genuiAuto:Generative UI 自动建议闸门(nil = 未接线;默认关闭行为)。
|
||||
// 仅由任务阶段/材料工件语义事件调用;present/surface/action 不得接入。
|
||||
genuiAuto GenUIAutoTrigger
|
||||
}
|
||||
|
||||
// GenUIAutoTrigger 自动建议页面闸门(由 genui.Auto 实现)。
|
||||
type GenUIAutoTrigger interface {
|
||||
Try(owner, convID, reason string) (ok bool, skip string)
|
||||
}
|
||||
|
||||
const maxBackgroundChildren = 3
|
||||
@@ -677,11 +686,15 @@ func (c *runCtx) Subhint() string { return c.subHint }
|
||||
func (c *runCtx) Submodel() string { return c.subModel }
|
||||
|
||||
func (c *runCtx) UpdatePlan(p core.PlanSnapshot) error {
|
||||
prev, _ := c.mgr.st.GetPlan(c.run.ConversationID)
|
||||
p.PlanID = c.run.ConversationID
|
||||
if err := c.mgr.st.SavePlan(c.run.ConversationID, p); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := c.mgr.emit(c.run.ConversationID, c.run.ID, core.EvPlanUpdate, core.PlanUpdatePayload{Plan: p})
|
||||
if err == nil && planStageChanged(prev, p) {
|
||||
c.mgr.maybeGenUIAuto(c.run.Owner, c.run.ConversationID, "plan_stage")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -704,11 +717,15 @@ func (c *runCtx) Plan() (core.PlanSnapshot, bool) {
|
||||
}
|
||||
|
||||
func (c *runCtx) UpdateTodos(l core.TodoList) error {
|
||||
prev, _ := c.mgr.st.GetTodos(c.run.ConversationID)
|
||||
l.ConversationID = c.run.ConversationID
|
||||
if err := c.mgr.st.SaveTodos(c.run.ConversationID, l); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := c.mgr.emit(c.run.ConversationID, c.run.ID, core.EvTodoUpdate, core.TodoUpdatePayload{List: l})
|
||||
if err == nil && todoStageChanged(prev, l) {
|
||||
c.mgr.maybeGenUIAuto(c.run.Owner, c.run.ConversationID, "todo_stage")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -832,6 +849,9 @@ func (c *runCtx) WriteArtifact(name, kind, mime string, content []byte) (core.Ar
|
||||
if _, err := c.mgr.emit(c.run.ConversationID, c.run.ID, core.EvArtifactCreated, core.ArtifactPayload{Artifact: art}); err != nil {
|
||||
return art, err
|
||||
}
|
||||
if kind == "document" {
|
||||
c.mgr.maybeGenUIAuto(c.run.Owner, c.run.ConversationID, "artifact_document")
|
||||
}
|
||||
// 工件文本进检索索引。二进制 Office/PDF 先经 ingest 抽出纯文本,抽到才入库;
|
||||
// 抽不到则跳过,避免把 ZIP 字节当文本索引。
|
||||
if kind == "document" && len(content) < 200000 {
|
||||
@@ -2151,6 +2171,13 @@ func (m *Manager) startNoticeRun(convID string, items []bgNotice) error {
|
||||
}
|
||||
if materialSeen {
|
||||
b.WriteString("\n这批材料的系统整理已完成。处理方式:\n1) 先用不超过 5 行汇报整理结果要点(来自上面的摘要)。\n2) 然后立即调用 confirm_choice 工具发起交付物选择卡:问题如「基于整理结果,接下来生成什么?」;选项取整理结果中的「建议后续处理」(3-4 项,description 写一句材料依据,最贴合的一项设 recommended),允许自由输入。\n3) 用户答复后按工具结果指引,用 delegate_task 的 background=true 启动对应后台任务。\n不要重复派发材料整理任务。\n")
|
||||
if m.genuiAuto != nil {
|
||||
if ok, _ := m.genuiAuto.Try(owner, convID, "material_done"); ok {
|
||||
b.WriteString("\n")
|
||||
b.WriteString(genUIAutoReminderText())
|
||||
b.WriteString("\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
b.WriteString("</system-reminder>")
|
||||
msg := core.Message{
|
||||
|
||||
@@ -65,7 +65,12 @@ func SystemDefaults() map[string]any {
|
||||
"accentColor": "", // 空 = 主题默认主色;#RRGGBB 或 RRGGBB
|
||||
"templateArtifactId": "", // 非空 = 该工件作为默认模板,落到 inputs/_default-template*
|
||||
},
|
||||
"plugins": map[string]any{"dir": "./plugins"},
|
||||
"plugins": map[string]any{
|
||||
"dir": "./plugins",
|
||||
"generative-ui": map[string]any{
|
||||
"autoPresent": false, // 自动建议页面:默认关;需同时开启 Surface activation
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,6 +131,7 @@ func Schema() []core.SettingDesc {
|
||||
{Key: "deliverables.accentColor", Group: "deliverables", Label: "交付物强调色", Type: core.SettingString, Default: "", Effect: core.EffectNewRun, Description: "6 位十六进制,如 1B3A4B。空 = 使用样式自带主色"},
|
||||
{Key: "deliverables.templateArtifactId", Group: "deliverables", Label: "默认模板工件 ID", Type: core.SettingString, Default: "", Effect: core.EffectNewRun, Description: "填写已上传模板的工件 ID。每次运行会复制到会话 inputs/_default-template"},
|
||||
{Key: "plugins.dir", Group: "plugins", Label: "插件目录", Type: core.SettingString, Default: "./plugins", Effect: core.EffectRestart},
|
||||
{Key: "plugins.generative-ui.autoPresent", Group: "plugins", Label: "Generative UI 自动建议页面", Type: core.SettingBool, Default: false, Effect: core.EffectHot, Description: "开启后在任务阶段变化或材料/工件完成时建议调用 generative-ui.present(默认关)。须同时启用该插件的 Surface activation;有预算与冷却,不会因页面修订自激"},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user