feat(channels): add sidecar delivery behavior overrides

Closes #144.

- Retire user-facing Tool Status Messages and deterministic tool-status channel text.
- Add sidecar-generated Quick Acknowledgement and Intermediate Replies with provider/model/timeout/token/char caps.
- Resolve delivery behavior as Channel > Agent > Workspace with agent overrides in other_config.delivery_behavior.
- Preserve legacy block_reply defaults for Intermediate Replies.
This commit is contained in:
Duy /zuey/ authored and GitHub committed 2026-06-09 22:24:24 +07:00
1 parent 23b18fa6cd
commit fdba1c2138
46 files changed
+1960 -201

No files matched your search

+19 -9
View File
@@ -6,6 +6,11 @@ All notable changes to GoClaw are documented here. For full documentation, see [
### Added
- **Behavior UX sidecar delivery overrides** — Adds sidecar-generated Quick
Acknowledgement and Intermediate Replies with provider/model, timeout, token,
and char caps. Effective config resolves Channel > Agent > Workspace, with
agent overrides stored in `other_config.delivery_behavior`.
- **Built-in skill `workspace-organizing`** — closes #71. Discipline skill that
teaches agents to keep personal, team, and delegate workspaces tidy.
Enforces a purpose-based folder convention with two modes: flat
@@ -40,10 +45,12 @@ All notable changes to GoClaw are documented here. For full documentation, see [
### Changed
- **Config Behavior explanations** — Adds purpose tooltips for Tool Status
Messages, Intermediate Replies, and Quick Acknowledgement so admins can
distinguish debug progress, natural intermediate updates, and receipt-only
acknowledgement behavior in the web UI.
- **Behavior UX simplification** — Retires user-facing Tool Status Messages and
deterministic tool-status channel text. Show Reasoning remains separate for
debugging/testing, while Quick Acknowledgement and Intermediate Replies are
delivery-only sidecar messages. Legacy `block_reply` config remains readable
as an inherited Intermediate Replies default but is no longer exposed as a
separate Web UI control.
- **ChatGPT Subscription (OAuth)** — default model and backend-owned model catalog
now prefer `gpt-5.5`, with reasoning metadata and context-window defaults updated
@@ -51,11 +58,14 @@ All notable changes to GoClaw are documented here. For full documentation, see [
### Fixed
- **Intermediate Replies are model-generated** — Tool-call progress no longer
appends fixed "I'll use ..." text or emits synthetic fallback bubbles when the
model returns tool calls without assistant text. The system prompt now nudges
the model to write any short progress sentence naturally in the user's
language without mentioning internal tool names.
- **Quick Acknowledgement generated mode** — Generated acknowledgements now use
the sidecar delivery generator instead of always falling back to fixed
templates. Sidecar failures stay non-blocking and fall back to templates.
- **Intermediate Replies are sidecar-generated** — Tool-call progress no longer
appends fixed "I'll use ..." text or relies on main-pipeline assistant content.
Visible progress is generated from bounded delivery metadata and is kept out
of session history.
- **Multi-attachment messages no longer trigger N agent replies (#63).**
Three coalescing surfaces hardened so a single user action produces ONE
+3 -1
View File
@@ -15,6 +15,7 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/channels"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/scheduler"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/tools"
@@ -26,7 +27,7 @@ import (
// and routes them through the scheduler/agent loop, then publishes the response back.
// Also handles subagent announcements: routes them through the parent agent's session
// (matching TS subagent-announce.ts pattern) so the agent can reformulate for the user.
func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents *agent.Router, cfg *config.Config, sched *scheduler.Scheduler, channelMgr *channels.Manager, teamStore store.TeamStore, quotaChecker *channels.QuotaChecker, sessStore store.SessionStore, agentStore store.AgentStore, contactCollector *store.ContactCollector, postTurn tools.PostTurnProcessor, subagentMgr *tools.SubagentManager, usageCapSvc *usagecaps.Service) {
func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents *agent.Router, cfg *config.Config, sched *scheduler.Scheduler, channelMgr *channels.Manager, teamStore store.TeamStore, quotaChecker *channels.QuotaChecker, sessStore store.SessionStore, agentStore store.AgentStore, contactCollector *store.ContactCollector, postTurn tools.PostTurnProcessor, subagentMgr *tools.SubagentManager, usageCapSvc *usagecaps.Service, providerReg *providers.Registry) {
slog.Info("inbound message consumer started")
// Inbound message deduplication (matching TS src/infra/dedupe.ts + inbound-dedupe.ts).
@@ -59,6 +60,7 @@ func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents
ContactCollector: contactCollector,
SubagentMgr: subagentMgr,
UsageCaps: usageCapSvc,
ProviderReg: providerReg,
GetAnnounceMu: getAnnounceMu,
}
+2
View File
@@ -7,6 +7,7 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/channels"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/scheduler"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/tools"
@@ -30,6 +31,7 @@ type ConsumerDeps struct {
TaskRunSessions sync.Map
SubagentMgr *tools.SubagentManager
UsageCaps *usagecaps.Service
ProviderReg *providers.Registry
BgWg sync.WaitGroup
GetAnnounceMu func(string) *sync.Mutex
}
+60 -3
View File
@@ -242,14 +242,17 @@ func processNormalMessage(
if lk := msg.Metadata["local_key"]; lk != "" {
chatIDForRun = lk
}
blockReply := deps.ChannelMgr != nil && deps.ChannelMgr.ResolveBlockReply(msg.Channel, deps.Cfg.Gateway.BlockReply)
chatBehavior := channels.ResolvedChatBehavior{}
if deps.ChannelMgr != nil {
chatBehavior = deps.ChannelMgr.ResolveChatBehavior(msg.Channel, deps.Cfg.Gateway.ChatBehavior)
workspaceBehavior := channels.ChatBehaviorConfigWithIntermediateDefault(deps.Cfg.Gateway.ChatBehavior, deps.Cfg.Gateway.BlockReply)
agentBehavior := channels.ParseAgentDeliveryBehaviorConfig(agentLoop.OtherConfig())
chatBehavior = deps.ChannelMgr.ResolveChatBehaviorWithAgent(msg.Channel, workspaceBehavior, agentBehavior)
}
blockReply := deps.ChannelMgr != nil && chatBehavior.IntermediateReplies.Enabled
deliveryRuntime := buildDeliveryRuntime(ctx, deps, agentLoop, chatBehavior, msg, peerKind, resolveChannelType(deps.ChannelMgr, msg.Channel), agentID)
toolStatus := deps.Cfg.Gateway.ToolStatus == nil || *deps.Cfg.Gateway.ToolStatus // default true
if deps.ChannelMgr != nil {
deps.ChannelMgr.RegisterRunWithBehavior(runID, msg.Channel, chatIDForRun, messageID, outMeta, msg.TenantID, channelStream, blockReply, toolStatus, chatBehavior, reasoningDelivery)
deps.ChannelMgr.RegisterRunWithDelivery(runID, msg.Channel, chatIDForRun, messageID, outMeta, msg.TenantID, channelStream, blockReply, toolStatus, chatBehavior, deliveryRuntime, reasoningDelivery)
}
// Group-aware system prompt: help the LLM adapt tone and behavior for group chats.
@@ -608,6 +611,60 @@ func processNormalMessage(
}(agentID, msg.Channel, msg.ChatID, sessionKey, runID, peerKind, msg.Content, outMeta, blockReply, chatBehavior, channelStream, ptd, msg.TenantID, agentLoop.UUID(), agentLoop.OtherConfig())
}
func buildDeliveryRuntime(ctx context.Context, deps *ConsumerDeps, agentLoop agent.Agent, behavior channels.ResolvedChatBehavior, msg bus.InboundMessage, peerKind, channelType, agentKey string) channels.DeliveryRuntime {
locale := msg.Metadata["locale"]
if locale == "" {
locale = "auto"
}
runtime := channels.DeliveryRuntime{
Locale: locale,
Inbound: msg.Content,
PeerKind: peerKind,
Channel: channelType,
AgentName: agentKey,
}
if behavior.Enabled && behavior.QuickAck.Enabled {
switch behavior.QuickAck.Mode {
case channels.QuickAckModeLLMGenerated, channels.QuickAckModeSidecar, "":
runtime.QuickAckGenerator = buildDeliveryGenerator(ctx, deps, agentLoop, msg.TenantID, behavior.QuickAck.Provider, behavior.QuickAck.Model)
}
}
if behavior.Enabled && behavior.IntermediateReplies.Enabled {
switch behavior.IntermediateReplies.Mode {
case channels.IntermediateModeSidecar, channels.QuickAckModeLLMGenerated, "":
runtime.ProgressGenerator = buildDeliveryGenerator(ctx, deps, agentLoop, msg.TenantID, behavior.IntermediateReplies.Provider, behavior.IntermediateReplies.Model)
}
}
return runtime
}
func buildDeliveryGenerator(ctx context.Context, deps *ConsumerDeps, agentLoop agent.Agent, tenantID uuid.UUID, providerName, model string) channels.DeliveryMessageGenerator {
provider := agentLoop.Provider()
resolvedProviderName := agentLoop.ProviderName()
if providerName != "" && deps.ProviderReg != nil {
if p, err := deps.ProviderReg.Get(ctx, providerName); err == nil {
provider = p
resolvedProviderName = providerName
} else {
slog.Warn("channel delivery: configured provider unavailable, falling back to agent provider", "provider", providerName, "error", err)
}
}
if provider == nil {
return nil
}
if model == "" {
model = agentLoop.Model()
}
return channels.ProviderDeliveryMessageGenerator{
Provider: provider,
ProviderName: resolvedProviderName,
Model: model,
UsageCaps: deps.UsageCaps,
TenantID: tenantID,
AgentID: agentLoop.UUID(),
}
}
// isSafeBitrixEntityToken validates a webhook-sourced Bitrix entity token before
// it is interpolated into the agent system prompt. Rejects empty, oversized, or
// control-character payloads to prevent prompt-injection from a crafted portal
+1 -1
View File
@@ -147,7 +147,7 @@ func (d *gatewayDeps) runLifecycle(
d.channelMgr.SetContactCollector(contactCollector)
}
go consumeInboundMessages(ctx, d.msgBus, d.agentRouter, d.cfg, deps.sched, d.channelMgr, deps.consumerTeamStore, deps.quotaChecker, d.pgStores.Sessions, d.pgStores.Agents, contactCollector, deps.postTurn, deps.subagentMgr, d.usageCapSvc)
go consumeInboundMessages(ctx, d.msgBus, d.agentRouter, d.cfg, deps.sched, d.channelMgr, deps.consumerTeamStore, deps.quotaChecker, d.pgStores.Sessions, d.pgStores.Agents, contactCollector, deps.postTurn, deps.subagentMgr, d.usageCapSvc, d.providerRegistry)
// Webhook callback worker — delivers async webhook_calls rows to receiver callback_url.
// Runs in both editions: Standard (PG, concurrency=4) and Lite (SQLite, concurrency=1).
+27 -9
View File
@@ -73,19 +73,37 @@ Normal channel messages pass through the shared inbound debouncer before agent e
### Human-like Delivery
`gateway.chat_behavior` controls optional channel-only delivery polish:
`gateway.chat_behavior` controls optional channel-only delivery polish. Delivery
text is not added to session history or the main provider messages.
- `quick_ack.mode = "llm_generated"` uses the existing main-turn `block.reply` event as the natural progress response for non-streaming channel runs. It does not make a separate LLM call.
- `quick_ack.templates` are fallback messages. In generated mode, the first template is sent only if no generated `block.reply` arrives before `quick_ack.min_delay_ms`.
- `quick_ack.mode = "fixed_template"` preserves the older fixed-template acknowledgement behavior.
- `quick_ack.mode = "off"` disables chat-behavior quick acknowledgement. When explicit `gateway.block_reply` is on, the first generic pre-tool `block.reply` is treated as the immediate acknowledgement and suppressed; model-generated tool progress still delivers before tool execution because it is part of Intermediate Replies.
- If a model requests tools without assistant text, the pipeline does not emit a synthetic Intermediate Reply. This avoids repetitive server templates; visible progress messages come from the LLM and should match the user's language.
- `final_split` splits long final text replies into a bounded number of paragraph messages.
- Per-channel `chat_behavior` overrides inherit the gateway config unless a field is explicitly set.
- `quick_ack` sends one short receipt for non-streaming channel runs.
`mode="sidecar_generated"` uses a bounded sidecar LLM call;
`mode="llm_generated"` is kept as a backward-compatible alias;
`mode="fixed_template"` sends the first template; `mode="off"` disables it.
- `quick_ack.provider/model` can choose a cheaper sidecar provider/model. If
unset, the delivery generator falls back to the agent provider/model. Timeout,
max token, and max char limits bound the call; templates are fallback output on
errors, timeouts, or empty sidecar text.
- `intermediate_replies` sends sidecar-generated progress during tool phases. It
is independent from `quick_ack`: either feature can be enabled or disabled
alone.
- Sidecar progress uses bounded delivery metadata only: message preview, locale,
channel/peer, agent label, and tool phase. It does not receive session
history, tool arguments, tool output, tool schemas, memory, or system prompts.
- Deterministic Tool Status Messages no longer emit channel text. Platform
reactions and explicit Show Reasoning delivery remain separate behavior.
- `final_split` splits long final text replies into a bounded number of
paragraph messages.
- Override order is Channel > Agent > Workspace. Agent overrides live in
`agents.other_config.delivery_behavior`; channel overrides continue under
`chat_behavior`.
- Legacy `gateway.block_reply` and channel `block_reply` values are still read
as inherited `intermediate_replies.enabled` defaults when the newer
`chat_behavior.intermediate_replies.enabled` field is unset.
Splitting is intentionally conservative. Replies containing fenced code, tables, lists, quotes, JSON/XML-ish blocks, or URL-only paragraphs remain a single message. Media replies and streaming deliveries are not split.
Progress messages are not added to session history by this behavior. Existing run timeline handling for `block.reply` remains unchanged.
Progress messages are not added to session history by this behavior. Existing run timeline handling for explicit `block.reply` remains unchanged.
### Reasoning Delivery
+26
View File
@@ -4,6 +4,32 @@ Significant changes, features, and fixes in reverse chronological order.
---
## 2026-06-09
### Behavior UX sidecar delivery overrides (issue #144)
**Changes**
- Removed user-facing Tool Status Messages from Behavior settings and disabled
deterministic tool-status channel text.
- Added sidecar-generated Quick Acknowledgement and Intermediate Replies with
optional provider/model, timeout, token, and char caps.
- Added Channel > Agent > Workspace delivery-behavior resolution. Agent
overrides use `other_config.delivery_behavior` without a schema migration.
- Kept legacy `block_reply` readable as a default for Intermediate Replies while
removing its separate Web UI controls.
- Kept Show Reasoning as a separate debug/testing feature.
**Tests**
- Added config resolution coverage for Channel > Agent > Workspace and Quick
Ack independent from Intermediate Replies.
- Added channel event coverage for sidecar quick ack, sidecar tool progress, and
retired tool-status messages.
- Added Web UI schema coverage for sidecar delivery override fields.
---
## 2026-06-01
### Telegram Show Reasoning delivery modes (issues #132, #133)
+161 -7
View File
@@ -1,40 +1,67 @@
package channels
import (
"encoding/json"
"strings"
"time"
"github.com/nextlevelbuilder/goclaw/internal/config"
)
const (
defaultQuickAckDelayMs = 1000
defaultDeliveryTimeout = 2500
defaultQuickAckTokens = 40
defaultQuickAckChars = 120
defaultProgressTokens = 60
defaultProgressChars = 180
defaultFinalSplitMin = 1200
defaultFinalSplitMax = 3
defaultFinalSplitDelay = 500
defaultAckTemplate = "Got it. Working on it..."
QuickAckModeLLMGenerated = "llm_generated"
QuickAckModeSidecar = "sidecar_generated"
QuickAckModeFixedTemplate = "fixed_template"
QuickAckModeOff = "off"
IntermediateModeSidecar = "sidecar_generated"
IntermediateModeOff = "off"
QuickAckSourceGenerated = "generated"
QuickAckSourceTemplate = "template"
QuickAckSourceOff = "off"
)
type ResolvedChatBehavior struct {
Enabled bool
QuickAck ResolvedQuickAckConfig
FinalSplit ResolvedFinalSplitConfig
Enabled bool
IntermediateReplies ResolvedIntermediateRepliesConfig
QuickAck ResolvedQuickAckConfig
FinalSplit ResolvedFinalSplitConfig
}
type ResolvedQuickAckConfig struct {
Enabled bool
Mode string
MinDelayMs int
Provider string
Model string
Timeout time.Duration
MaxTokens int
MaxChars int
Templates []string
}
type ResolvedIntermediateRepliesConfig struct {
Enabled bool
Mode string
Provider string
Model string
Timeout time.Duration
MaxTokens int
MaxChars int
}
type ResolvedFinalSplitConfig struct {
Enabled bool
MinChars int
@@ -67,10 +94,23 @@ type SplitPreview struct {
}
func ResolveChatBehavior(global, override *config.ChatBehaviorConfig) ResolvedChatBehavior {
return ResolveChatBehaviorWithAgent(global, nil, override)
}
func ResolveChatBehaviorWithAgent(global, agentOverride, channelOverride *config.ChatBehaviorConfig) ResolvedChatBehavior {
resolved := ResolvedChatBehavior{
IntermediateReplies: ResolvedIntermediateRepliesConfig{
Mode: IntermediateModeSidecar,
Timeout: time.Duration(defaultDeliveryTimeout) * time.Millisecond,
MaxTokens: defaultProgressTokens,
MaxChars: defaultProgressChars,
},
QuickAck: ResolvedQuickAckConfig{
Mode: QuickAckModeLLMGenerated,
MinDelayMs: defaultQuickAckDelayMs,
Timeout: time.Duration(defaultDeliveryTimeout) * time.Millisecond,
MaxTokens: defaultQuickAckTokens,
MaxChars: defaultQuickAckChars,
Templates: []string{defaultAckTemplate},
},
FinalSplit: ResolvedFinalSplitConfig{
@@ -80,8 +120,10 @@ func ResolveChatBehavior(global, override *config.ChatBehaviorConfig) ResolvedCh
},
}
applyChatBehavior(&resolved, global)
applyChatBehavior(&resolved, override)
applyChatBehavior(&resolved, agentOverride)
applyChatBehavior(&resolved, channelOverride)
if !resolved.Enabled {
resolved.IntermediateReplies.Enabled = false
resolved.QuickAck.Enabled = false
resolved.FinalSplit.Enabled = false
}
@@ -91,6 +133,60 @@ func ResolveChatBehavior(global, override *config.ChatBehaviorConfig) ResolvedCh
return resolved
}
func ParseAgentDeliveryBehaviorConfig(raw json.RawMessage) *config.ChatBehaviorConfig {
if len(raw) == 0 {
return nil
}
var envelope struct {
DeliveryBehavior *config.ChatBehaviorConfig `json:"delivery_behavior"`
}
if err := json.Unmarshal(raw, &envelope); err != nil {
return nil
}
return envelope.DeliveryBehavior
}
func ChatBehaviorConfigWithIntermediateDefault(src *config.ChatBehaviorConfig, enabled *bool) *config.ChatBehaviorConfig {
if enabled == nil {
return src
}
clone := cloneChatBehaviorConfig(src)
if clone == nil {
clone = &config.ChatBehaviorConfig{}
}
if *enabled && clone.Enabled == nil {
clone.Enabled = enabled
}
if clone.IntermediateReplies == nil {
clone.IntermediateReplies = &config.IntermediateRepliesConfig{}
}
if clone.IntermediateReplies.Enabled == nil {
clone.IntermediateReplies.Enabled = enabled
}
return clone
}
func cloneChatBehaviorConfig(src *config.ChatBehaviorConfig) *config.ChatBehaviorConfig {
if src == nil {
return nil
}
clone := *src
if src.IntermediateReplies != nil {
v := *src.IntermediateReplies
clone.IntermediateReplies = &v
}
if src.QuickAck != nil {
v := *src.QuickAck
v.Templates = append([]string(nil), src.QuickAck.Templates...)
clone.QuickAck = &v
}
if src.FinalSplit != nil {
v := *src.FinalSplit
clone.FinalSplit = &v
}
return &clone
}
func applyChatBehavior(dst *ResolvedChatBehavior, src *config.ChatBehaviorConfig) {
if src == nil {
return
@@ -98,6 +194,29 @@ func applyChatBehavior(dst *ResolvedChatBehavior, src *config.ChatBehaviorConfig
if src.Enabled != nil {
dst.Enabled = *src.Enabled
}
if src.IntermediateReplies != nil {
if src.IntermediateReplies.Enabled != nil {
dst.IntermediateReplies.Enabled = *src.IntermediateReplies.Enabled
}
if src.IntermediateReplies.Mode != nil {
dst.IntermediateReplies.Mode = normalizeIntermediateMode(*src.IntermediateReplies.Mode)
}
if src.IntermediateReplies.Provider != "" {
dst.IntermediateReplies.Provider = strings.TrimSpace(src.IntermediateReplies.Provider)
}
if src.IntermediateReplies.Model != "" {
dst.IntermediateReplies.Model = strings.TrimSpace(src.IntermediateReplies.Model)
}
if src.IntermediateReplies.TimeoutMs != nil {
dst.IntermediateReplies.Timeout = time.Duration(max(0, *src.IntermediateReplies.TimeoutMs)) * time.Millisecond
}
if src.IntermediateReplies.MaxTokens != nil {
dst.IntermediateReplies.MaxTokens = max(1, *src.IntermediateReplies.MaxTokens)
}
if src.IntermediateReplies.MaxChars != nil {
dst.IntermediateReplies.MaxChars = max(1, *src.IntermediateReplies.MaxChars)
}
}
if src.QuickAck != nil {
if src.QuickAck.Enabled != nil {
dst.QuickAck.Enabled = *src.QuickAck.Enabled
@@ -108,6 +227,21 @@ func applyChatBehavior(dst *ResolvedChatBehavior, src *config.ChatBehaviorConfig
if src.QuickAck.MinDelayMs != nil {
dst.QuickAck.MinDelayMs = max(0, *src.QuickAck.MinDelayMs)
}
if src.QuickAck.Provider != "" {
dst.QuickAck.Provider = strings.TrimSpace(src.QuickAck.Provider)
}
if src.QuickAck.Model != "" {
dst.QuickAck.Model = strings.TrimSpace(src.QuickAck.Model)
}
if src.QuickAck.TimeoutMs != nil {
dst.QuickAck.Timeout = time.Duration(max(0, *src.QuickAck.TimeoutMs)) * time.Millisecond
}
if src.QuickAck.MaxTokens != nil {
dst.QuickAck.MaxTokens = max(1, *src.QuickAck.MaxTokens)
}
if src.QuickAck.MaxChars != nil {
dst.QuickAck.MaxChars = max(1, *src.QuickAck.MaxChars)
}
if len(src.QuickAck.Templates) > 0 {
dst.QuickAck.Templates = cleanTemplates(src.QuickAck.Templates)
}
@@ -160,7 +294,7 @@ func ShouldSendQuickAck(behavior ResolvedChatBehavior, streaming bool) bool {
return false
}
switch effectiveQuickAckMode(behavior.QuickAck.Mode) {
case QuickAckModeLLMGenerated, QuickAckModeFixedTemplate:
case QuickAckModeLLMGenerated, QuickAckModeSidecar, QuickAckModeFixedTemplate:
return true
default:
return false
@@ -169,9 +303,9 @@ func ShouldSendQuickAck(behavior ResolvedChatBehavior, streaming bool) bool {
func ShouldDeliverGeneratedProgress(behavior ResolvedChatBehavior, streaming bool) bool {
return behavior.Enabled &&
behavior.QuickAck.Enabled &&
behavior.IntermediateReplies.Enabled &&
!streaming &&
effectiveQuickAckMode(behavior.QuickAck.Mode) == QuickAckModeLLMGenerated
effectiveIntermediateMode(behavior.IntermediateReplies.Mode) == IntermediateModeSidecar
}
func ShouldSuppressInitialBlockReply(behavior ResolvedChatBehavior, streaming bool) bool {
@@ -183,6 +317,8 @@ func ShouldSuppressInitialBlockReply(behavior ResolvedChatBehavior, streaming bo
func normalizeQuickAckMode(mode string) string {
switch strings.TrimSpace(mode) {
case QuickAckModeLLMGenerated, QuickAckModeSidecar:
return strings.TrimSpace(mode)
case QuickAckModeFixedTemplate:
return QuickAckModeFixedTemplate
case QuickAckModeOff:
@@ -192,6 +328,17 @@ func normalizeQuickAckMode(mode string) string {
}
}
func normalizeIntermediateMode(mode string) string {
switch strings.TrimSpace(mode) {
case QuickAckModeLLMGenerated, IntermediateModeSidecar:
return IntermediateModeSidecar
case IntermediateModeOff:
return IntermediateModeOff
default:
return IntermediateModeSidecar
}
}
func effectiveQuickAckMode(mode string) string {
if strings.TrimSpace(mode) == "" {
return QuickAckModeLLMGenerated
@@ -199,6 +346,13 @@ func effectiveQuickAckMode(mode string) string {
return normalizeQuickAckMode(mode)
}
func effectiveIntermediateMode(mode string) string {
if strings.TrimSpace(mode) == "" {
return IntermediateModeSidecar
}
return normalizeIntermediateMode(mode)
}
func buildAckPreview(behavior ResolvedChatBehavior, streaming, hasToolCalls bool) AckPreview {
mode := effectiveQuickAckMode(behavior.QuickAck.Mode)
preview := AckPreview{Mode: mode}
+72 -5
View File
@@ -62,7 +62,7 @@ func TestUnregisterRun_CancelsPendingQuickAck(t *testing.T) {
QuickAck: ResolvedQuickAckConfig{
Enabled: true,
Mode: QuickAckModeFixedTemplate,
MinDelayMs: 50,
MinDelayMs: 500,
Templates: []string{"On it."},
},
})
@@ -106,10 +106,16 @@ func TestCancelQuickAck_BlocksInFlightSend(t *testing.T) {
func TestHandleAgentEvent_GeneratedProgressCancelsFallback(t *testing.T) {
behavior := ResolvedChatBehavior{
Enabled: true,
IntermediateReplies: ResolvedIntermediateRepliesConfig{
Enabled: true,
Mode: IntermediateModeSidecar,
MaxTokens: 20,
MaxChars: 120,
},
QuickAck: ResolvedQuickAckConfig{
Enabled: true,
Mode: QuickAckModeLLMGenerated,
MinDelayMs: 50,
MinDelayMs: 500,
Templates: []string{"Fallback."},
},
}
@@ -117,10 +123,12 @@ func TestHandleAgentEvent_GeneratedProgressCancelsFallback(t *testing.T) {
mb := bus.New()
mgr := NewManager(mb)
mgr.RegisterChannel("test", &chatBehaviorTestChannel{name: "test"})
mgr.RegisterRunWithBehavior("run-1", "test", "chat-1", "msg-1", map[string]string{"local_key": "chat-1/topic"}, uuid.Nil, false, false, true, behavior)
mgr.RegisterRunWithDelivery("run-1", "test", "chat-1", "msg-1", map[string]string{"local_key": "chat-1/topic"}, uuid.Nil, false, false, true, behavior, DeliveryRuntime{
ProgressGenerator: fakeDeliveryGenerator{content: "Mình đang kiểm tra tiếp."},
})
mgr.HandleAgentEvent(protocol.AgentEventRunStarted, "run-1", nil)
mgr.HandleAgentEvent(protocol.AgentEventBlockReply, "run-1", map[string]string{"content": "I will check that now."})
mgr.HandleAgentEvent(protocol.AgentEventToolCall, "run-1", map[string]string{"name": "skill_search"})
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
@@ -128,7 +136,7 @@ func TestHandleAgentEvent_GeneratedProgressCancelsFallback(t *testing.T) {
if !ok {
t.Fatal("expected generated progress outbound message")
}
if got.Content != "I will check that now." || got.Metadata["local_key"] != "chat-1/topic" {
if got.Content != "Mình đang kiểm tra tiếp." || got.Metadata["local_key"] != "chat-1/topic" {
t.Fatalf("generated progress outbound = %+v, want generated content and routing metadata", got)
}
@@ -139,6 +147,21 @@ func TestHandleAgentEvent_GeneratedProgressCancelsFallback(t *testing.T) {
}
}
func TestHandleAgentEvent_ToolStatusMessagesRetired(t *testing.T) {
mb := bus.New()
mgr := NewManager(mb)
mgr.RegisterChannel("test", &chatBehaviorTestChannel{name: "test"})
mgr.RegisterRunWithBehavior("run-1", "test", "chat-1", "msg-1", nil, uuid.Nil, false, false, true, ResolvedChatBehavior{})
mgr.HandleAgentEvent(protocol.AgentEventToolCall, "run-1", map[string]string{"name": "skill_search"})
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Millisecond)
defer cancel()
if got, ok := mb.SubscribeOutbound(ctx); ok {
t.Fatalf("tool status emitted outbound message: %+v", got)
}
}
func TestHandleAgentEvent_GeneratedModeFallsBackWithoutBlockReply(t *testing.T) {
behavior := ResolvedChatBehavior{
Enabled: true,
@@ -168,6 +191,41 @@ func TestHandleAgentEvent_GeneratedModeFallsBackWithoutBlockReply(t *testing.T)
}
}
func TestHandleAgentEvent_GeneratedQuickAckUsesSidecarGenerator(t *testing.T) {
behavior := ResolvedChatBehavior{
Enabled: true,
QuickAck: ResolvedQuickAckConfig{
Enabled: true,
Mode: QuickAckModeLLMGenerated,
MinDelayMs: 0,
MaxTokens: 20,
MaxChars: 80,
Templates: []string{"Fallback."},
},
}
mb := bus.New()
mgr := NewManager(mb)
mgr.RegisterChannel("test", &chatBehaviorTestChannel{name: "test"})
mgr.RegisterRunWithDelivery("run-1", "test", "chat-1", "msg-1", nil, uuid.Nil, false, false, true, behavior, DeliveryRuntime{
QuickAckGenerator: fakeDeliveryGenerator{content: "Mình nhận rồi, để mình xử lý."},
Inbound: "kiểm tra giúp tôi",
Locale: "vi",
})
mgr.HandleAgentEvent(protocol.AgentEventRunStarted, "run-1", nil)
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
got, ok := mb.SubscribeOutbound(ctx)
if !ok {
t.Fatal("expected generated quick acknowledgement")
}
if got.Content != "Mình nhận rồi, để mình xử lý." {
t.Fatalf("quick ack content = %q, want generated content", got.Content)
}
}
func TestHandleAgentEvent_FixedQuickAckPreservesInitialExplicitBlockReply(t *testing.T) {
behavior := ResolvedChatBehavior{
Enabled: true,
@@ -321,3 +379,12 @@ func TestHandleAgentEvent_ToolAnnouncementBypassesInitialQuickAckSuppression(t *
})
}
}
type fakeDeliveryGenerator struct {
content string
err error
}
func (g fakeDeliveryGenerator) GenerateDeliveryMessage(context.Context, DeliveryMessageRequest) (string, error) {
return g.content, g.err
}
+108 -4
View File
@@ -63,8 +63,8 @@ func TestResolveChatBehavior_DefaultQuickAckModeIsLLMGenerated(t *testing.T) {
if got.QuickAck.Mode != QuickAckModeLLMGenerated {
t.Fatalf("QuickAck.Mode = %q, want %q", got.QuickAck.Mode, QuickAckModeLLMGenerated)
}
if !ShouldDeliverGeneratedProgress(got, false) {
t.Fatal("generated progress disabled for non-streaming llm_generated mode")
if ShouldDeliverGeneratedProgress(got, false) {
t.Fatal("generated progress coupled to quick ack; want independent intermediate_replies gate")
}
if !ShouldSendQuickAck(got, false) {
t.Fatal("fallback quick ack disabled for non-streaming llm_generated mode")
@@ -74,6 +74,108 @@ func TestResolveChatBehavior_DefaultQuickAckModeIsLLMGenerated(t *testing.T) {
}
}
func TestResolveChatBehavior_IntermediateRepliesIndependentFromQuickAck(t *testing.T) {
mode := IntermediateModeSidecar
global := &config.ChatBehaviorConfig{
Enabled: new(true),
IntermediateReplies: &config.IntermediateRepliesConfig{
Enabled: new(true),
Mode: &mode,
},
QuickAck: &config.QuickAckConfig{Enabled: new(false), Templates: []string{"Fallback."}},
}
got := ResolveChatBehavior(global, nil)
if !ShouldDeliverGeneratedProgress(got, false) {
t.Fatal("intermediate replies disabled when quick ack is off")
}
if ShouldSendQuickAck(got, false) {
t.Fatal("quick ack enabled despite explicit false")
}
}
func TestResolveChatBehaviorWithAgent_ChannelBeatsAgentBeatsWorkspace(t *testing.T) {
global := &config.ChatBehaviorConfig{
Enabled: new(true),
IntermediateReplies: &config.IntermediateRepliesConfig{
Enabled: new(false),
Provider: "workspace-provider",
},
QuickAck: &config.QuickAckConfig{Enabled: new(false), Provider: "workspace-provider"},
}
agentOverride := &config.ChatBehaviorConfig{
IntermediateReplies: &config.IntermediateRepliesConfig{
Enabled: new(true),
Provider: "agent-provider",
},
QuickAck: &config.QuickAckConfig{Enabled: new(true), Provider: "agent-provider"},
}
channelOverride := &config.ChatBehaviorConfig{
QuickAck: &config.QuickAckConfig{Enabled: new(false), Provider: "channel-provider"},
}
got := ResolveChatBehaviorWithAgent(global, agentOverride, channelOverride)
if !got.IntermediateReplies.Enabled || got.IntermediateReplies.Provider != "agent-provider" {
t.Fatalf("intermediate = %+v, want agent override", got.IntermediateReplies)
}
if got.QuickAck.Enabled || got.QuickAck.Provider != "channel-provider" {
t.Fatalf("quick ack = %+v, want channel override", got.QuickAck)
}
}
func TestChatBehaviorConfigWithIntermediateDefault_UsesLegacyBlockReplyOnlyWhenUnset(t *testing.T) {
legacyEnabled := true
explicitDisabled := false
base := &config.ChatBehaviorConfig{
IntermediateReplies: &config.IntermediateRepliesConfig{Enabled: &explicitDisabled},
}
got := ChatBehaviorConfigWithIntermediateDefault(base, &legacyEnabled)
if got.IntermediateReplies == nil || got.IntermediateReplies.Enabled == nil || *got.IntermediateReplies.Enabled {
t.Fatalf("intermediate enabled = %#v, want explicit false to win", got.IntermediateReplies)
}
if base.IntermediateReplies.Enabled == nil || *base.IntermediateReplies.Enabled {
t.Fatalf("mutated source config = %#v", base.IntermediateReplies)
}
got = ChatBehaviorConfigWithIntermediateDefault(nil, &legacyEnabled)
if got == nil || got.IntermediateReplies == nil || got.IntermediateReplies.Enabled == nil || !*got.IntermediateReplies.Enabled {
t.Fatalf("legacy default not applied: %#v", got)
}
if got.Enabled == nil || !*got.Enabled {
t.Fatalf("legacy block_reply=true did not enable chat behavior: %#v", got)
}
}
func TestResolveChatBehaviorWithAgent_ChannelBlockReplySeedsIntermediateDefault(t *testing.T) {
global := &config.ChatBehaviorConfig{Enabled: new(true)}
globalBlockReply := true
channelBlockReply := false
mgr := NewManager(bus.New())
mgr.RegisterChannel("test", &chatBehaviorTestChannel{name: "test", blockReply: &channelBlockReply})
got := mgr.ResolveChatBehaviorWithAgent("test", ChatBehaviorConfigWithIntermediateDefault(global, &globalBlockReply), nil)
if got.IntermediateReplies.Enabled {
t.Fatalf("intermediate enabled = true, want legacy channel block_reply=false to win")
}
}
func TestParseAgentDeliveryBehaviorConfig(t *testing.T) {
raw := []byte(`{"unrelated":true,"delivery_behavior":{"enabled":true,"quick_ack":{"enabled":true,"provider":"groq"},"intermediate_replies":{"enabled":false}}}`)
got := ParseAgentDeliveryBehaviorConfig(raw)
if got == nil || got.QuickAck == nil || got.QuickAck.Provider != "groq" {
t.Fatalf("agent delivery behavior = %#v, want quick ack provider", got)
}
if got.IntermediateReplies == nil || got.IntermediateReplies.Enabled == nil || *got.IntermediateReplies.Enabled {
t.Fatalf("intermediate override = %#v, want enabled=false", got.IntermediateReplies)
}
}
func TestResolveChatBehavior_QuickAckModeOffKeepsFallbackDisabled(t *testing.T) {
mode := QuickAckModeOff
global := &config.ChatBehaviorConfig{
@@ -232,8 +334,9 @@ func TestManagerResolveChatBehavior_UsesChannelOverride(t *testing.T) {
}
type chatBehaviorTestChannel struct {
name string
behavior *config.ChatBehaviorConfig
name string
behavior *config.ChatBehaviorConfig
blockReply *bool
}
func (c *chatBehaviorTestChannel) Name() string { return c.name }
@@ -244,3 +347,4 @@ func (c *chatBehaviorTestChannel) Send(context.Context, bus.OutboundMessage) err
func (c *chatBehaviorTestChannel) IsRunning() bool { return true }
func (c *chatBehaviorTestChannel) IsAllowed(string) bool { return true }
func (c *chatBehaviorTestChannel) ChatBehaviorConfig() *config.ChatBehaviorConfig { return c.behavior }
func (c *chatBehaviorTestChannel) BlockReplyEnabled() *bool { return c.blockReply }
@@ -0,0 +1,154 @@
package channels
import (
"context"
"strconv"
"strings"
"time"
"unicode/utf8"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/store"
usagecaps "github.com/nextlevelbuilder/goclaw/internal/usage/caps"
)
const (
DeliveryPurposeQuickAck = "quick_ack"
DeliveryPurposeProgress = "intermediate_progress"
)
type DeliveryMessageGenerator interface {
GenerateDeliveryMessage(ctx context.Context, req DeliveryMessageRequest) (string, error)
}
type DeliveryMessageRequest struct {
Purpose string
UserMessage string
Locale string
PeerKind string
ChannelType string
AgentName string
ToolName string
MaxTokens int
MaxChars int
Timeout time.Duration
}
type DeliveryRuntime struct {
QuickAckGenerator DeliveryMessageGenerator
ProgressGenerator DeliveryMessageGenerator
Locale string
Inbound string
PeerKind string
Channel string
AgentName string
}
type ProviderDeliveryMessageGenerator struct {
Provider providers.Provider
ProviderName string
Model string
UsageCaps *usagecaps.Service
TenantID uuid.UUID
AgentID uuid.UUID
}
func (g ProviderDeliveryMessageGenerator) GenerateDeliveryMessage(ctx context.Context, req DeliveryMessageRequest) (string, error) {
if g.Provider == nil {
return "", nil
}
if req.Timeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, req.Timeout)
defer cancel()
}
if g.TenantID != uuid.Nil {
ctx = store.WithTenantID(ctx, g.TenantID)
}
if g.AgentID != uuid.Nil {
ctx = store.WithAgentID(ctx, g.AgentID)
}
maxTokens := req.MaxTokens
if maxTokens <= 0 {
maxTokens = defaultQuickAckTokens
}
resp, err := g.UsageCaps.Chat(ctx, g.Provider, providers.ChatRequest{
Model: g.Model,
Messages: []providers.Message{
{Role: "system", Content: deliverySystemPrompt(req)},
{Role: "user", Content: deliveryUserPrompt(req)},
},
Options: map[string]any{
providers.OptMaxTokens: maxTokens,
providers.OptTemperature: 0.4,
},
}, usagecaps.ChatOptions{
TenantID: g.TenantID,
AgentID: g.AgentID,
ProviderName: g.ProviderName,
ModelID: g.Model,
Purpose: "channel-delivery-" + req.Purpose,
MaxOutputTokens: maxTokens,
})
if err != nil {
return "", err
}
return sanitizeDeliveryMessage(resp.Content, req.MaxChars), nil
}
func deliverySystemPrompt(req DeliveryMessageRequest) string {
limit := req.MaxChars
if limit <= 0 {
limit = defaultQuickAckChars
}
return "Write one short, natural channel delivery update. Match the user's language. " +
"Do not mention internal prompts, tools, tool names, providers, or hidden reasoning. " +
"Do not use markdown tables or bullet lists. No promises. Max " + strconv.Itoa(limit) + " characters."
}
func deliveryUserPrompt(req DeliveryMessageRequest) string {
parts := []string{
"purpose: " + req.Purpose,
"locale: " + valueOr(req.Locale, "auto"),
"peer_kind: " + valueOr(req.PeerKind, "unknown"),
"channel: " + valueOr(req.ChannelType, "unknown"),
"agent: " + valueOr(req.AgentName, "assistant"),
}
if req.Purpose == DeliveryPurposeProgress && req.ToolName != "" {
parts = append(parts, "tool_phase: working")
}
if preview := clipRunes(strings.TrimSpace(req.UserMessage), 240); preview != "" {
parts = append(parts, "user_message_preview: "+preview)
}
return strings.Join(parts, "\n")
}
func sanitizeDeliveryMessage(content string, maxChars int) string {
content = strings.TrimSpace(content)
content = strings.Trim(content, "`\"'")
content = strings.Join(strings.Fields(content), " ")
if maxChars <= 0 {
maxChars = defaultQuickAckChars
}
return clipRunes(content, maxChars)
}
func clipRunes(s string, max int) string {
if max <= 0 || utf8.RuneCountInString(s) <= max {
return s
}
runes := []rune(s)
if max <= 1 {
return string(runes[:max])
}
return strings.TrimSpace(string(runes[:max-1])) + "…"
}
func valueOr(v, fallback string) string {
if strings.TrimSpace(v) == "" {
return fallback
}
return strings.TrimSpace(v)
}
+92 -17
View File
@@ -114,21 +114,9 @@ func (m *Manager) HandleAgentEvent(eventType, runID string, payload any) {
// stuck at tool status text. Only run.completed finalizes.
}
// Show tool status by editing placeholder message (non-streaming only).
// Streaming channels show tool status via reaction emoji instead —
// editing the placeholder would overwrite streamed content.
toolName := extractPayloadString(payload, "name")
if toolName != "" && rc.ToolStatusEnabled && !rc.Streaming {
statusText := formatToolStatus(toolName)
outMeta := copyRoutingMeta(rc.Metadata)
outMeta["placeholder_update"] = "true"
m.bus.PublishOutbound(bus.OutboundMessage{
Channel: rc.ChannelName,
ChatID: rc.ChatID,
Content: statusText,
Metadata: outMeta,
TenantID: rc.TenantID,
})
if toolName != "" && ShouldDeliverGeneratedProgress(rc.ChatBehavior, rc.Streaming) {
go m.sendIntermediateProgress(rc, toolName)
}
case protocol.ChatEventChunk:
// Accumulate chunk deltas into full text.
@@ -307,6 +295,13 @@ func (m *Manager) HandleAgentEvent(eventType, runID string, payload any) {
}
}
if eventType == protocol.AgentEventToolCall {
toolName := extractPayloadString(payload, "name")
if toolName != "" && ShouldDeliverGeneratedProgress(rc.ChatBehavior, rc.Streaming) {
go m.sendIntermediateProgress(rc, toolName)
}
}
if !rc.Streaming && rc.ReasoningDelivery.BubbleDelivery {
switch eventType {
case protocol.ChatEventThinking:
@@ -347,12 +342,11 @@ func (m *Manager) HandleAgentEvent(eventType, runID string, payload any) {
if streaming {
return // streaming already delivered via chunks
}
generatedProgress := ShouldDeliverGeneratedProgress(chatBehavior, streaming)
if !blockReplyEnabled && !generatedProgress {
if !blockReplyEnabled {
return
}
isToolAnnouncement := source == protocol.BlockReplySourceToolAnnouncement
if isInitialBlockReply && !isToolAnnouncement && blockReplyEnabled && !generatedProgress && ShouldSuppressInitialBlockReply(chatBehavior, streaming) {
if isInitialBlockReply && !isToolAnnouncement && blockReplyEnabled && ShouldSuppressInitialBlockReply(chatBehavior, streaming) {
return
}
@@ -475,10 +469,26 @@ func (m *Manager) sendQuickAck(rc *RunContext) {
return
}
content := rc.ChatBehavior.QuickAck.Templates[0]
mode := effectiveQuickAckMode(rc.ChatBehavior.QuickAck.Mode)
generator := rc.Delivery.QuickAckGenerator
request := rc.deliveryRequestLocked(DeliveryPurposeQuickAck, "")
rc.ackSent = true
rc.ackTimer = nil
rc.mu.Unlock()
if generator != nil && (mode == QuickAckModeLLMGenerated || mode == QuickAckModeSidecar) {
if generated, err := generator.GenerateDeliveryMessage(context.Background(), request); err == nil && generated != "" {
content = generated
}
}
rc.mu.Lock()
if rc.ackCancelled || rc.blockReplySent {
rc.mu.Unlock()
return
}
rc.mu.Unlock()
m.bus.PublishOutbound(bus.OutboundMessage{
Channel: rc.ChannelName,
ChatID: rc.ChatID,
@@ -488,6 +498,71 @@ func (m *Manager) sendQuickAck(rc *RunContext) {
})
}
func (m *Manager) sendIntermediateProgress(rc *RunContext, toolName string) {
rc.mu.Lock()
if rc.ackCancelled || rc.blockReplySent || !ShouldDeliverGeneratedProgress(rc.ChatBehavior, rc.Streaming) {
rc.mu.Unlock()
return
}
generator := rc.Delivery.ProgressGenerator
request := rc.deliveryRequestLocked(DeliveryPurposeProgress, toolName)
rc.mu.Unlock()
if generator == nil {
return
}
content, err := generator.GenerateDeliveryMessage(context.Background(), request)
if err != nil || content == "" {
return
}
rc.mu.Lock()
if rc.ackCancelled || rc.blockReplySent {
rc.mu.Unlock()
return
}
rc.ackCancelled = true
if rc.ackTimer != nil {
rc.ackTimer.Stop()
rc.ackTimer = nil
}
rc.blockReplySent = true
rc.interimDelivered++
rc.lastInterimReply = content
rc.mu.Unlock()
m.bus.PublishOutbound(bus.OutboundMessage{
Channel: rc.ChannelName,
ChatID: rc.ChatID,
Content: content,
Metadata: copyRoutingMeta(rc.Metadata),
TenantID: rc.TenantID,
})
}
func (rc *RunContext) deliveryRequestLocked(purpose, toolName string) DeliveryMessageRequest {
maxTokens := rc.ChatBehavior.QuickAck.MaxTokens
maxChars := rc.ChatBehavior.QuickAck.MaxChars
timeout := rc.ChatBehavior.QuickAck.Timeout
if purpose == DeliveryPurposeProgress {
maxTokens = rc.ChatBehavior.IntermediateReplies.MaxTokens
maxChars = rc.ChatBehavior.IntermediateReplies.MaxChars
timeout = rc.ChatBehavior.IntermediateReplies.Timeout
}
return DeliveryMessageRequest{
Purpose: purpose,
UserMessage: rc.Delivery.Inbound,
Locale: rc.Delivery.Locale,
PeerKind: rc.Delivery.PeerKind,
ChannelType: rc.Delivery.Channel,
AgentName: rc.Delivery.AgentName,
ToolName: toolName,
MaxTokens: maxTokens,
MaxChars: maxChars,
Timeout: timeout,
}
}
// extractPayloadString extracts a string field from a payload (map[string]string or map[string]interface{}).
func extractPayloadString(payload any, key string) string {
switch p := payload.(type) {
+1
View File
@@ -38,6 +38,7 @@ type RunContext struct {
BlockReplyEnabled bool // whether block.reply delivery is enabled for this run (resolved at RegisterRun time)
ToolStatusEnabled bool // whether tool name shows in streaming preview during tool execution
ChatBehavior ResolvedChatBehavior
Delivery DeliveryRuntime
ReasoningDelivery ResolvedReasoningDelivery
mu sync.Mutex
ackTimer *time.Timer
+15 -1
View File
@@ -17,6 +17,10 @@ func (m *Manager) RegisterRun(runID, channelName, chatID, messageID string, meta
// RegisterRunWithBehavior associates a run ID with channel context and
// resolved delivery behavior so event handlers do not read mutable config mid-run.
func (m *Manager) RegisterRunWithBehavior(runID, channelName, chatID, messageID string, metadata map[string]string, tenantID uuid.UUID, streaming, blockReply, toolStatus bool, chatBehavior ResolvedChatBehavior, reasoningDelivery ...ResolvedReasoningDelivery) {
m.RegisterRunWithDelivery(runID, channelName, chatID, messageID, metadata, tenantID, streaming, blockReply, toolStatus, chatBehavior, DeliveryRuntime{}, reasoningDelivery...)
}
func (m *Manager) RegisterRunWithDelivery(runID, channelName, chatID, messageID string, metadata map[string]string, tenantID uuid.UUID, streaming, blockReply, toolStatus bool, chatBehavior ResolvedChatBehavior, deliveryRuntime DeliveryRuntime, reasoningDelivery ...ResolvedReasoningDelivery) {
delivery := ResolveReasoningDelivery("", nil)
if len(reasoningDelivery) > 0 {
delivery = reasoningDelivery[0]
@@ -31,6 +35,7 @@ func (m *Manager) RegisterRunWithBehavior(runID, channelName, chatID, messageID
BlockReplyEnabled: blockReply,
ToolStatusEnabled: toolStatus,
ChatBehavior: chatBehavior,
Delivery: deliveryRuntime,
ReasoningDelivery: delivery,
})
}
@@ -94,16 +99,25 @@ func (m *Manager) ResolveBlockReply(channelName string, globalDefault *bool) boo
// ResolveChatBehavior checks per-channel override, then falls back to gateway config.
func (m *Manager) ResolveChatBehavior(channelName string, globalDefault *config.ChatBehaviorConfig) ResolvedChatBehavior {
return m.ResolveChatBehaviorWithAgent(channelName, globalDefault, nil)
}
func (m *Manager) ResolveChatBehaviorWithAgent(channelName string, globalDefault, agentOverride *config.ChatBehaviorConfig) ResolvedChatBehavior {
var override *config.ChatBehaviorConfig
var channelBlockReply *bool
m.mu.RLock()
ch, exists := m.channels[channelName]
m.mu.RUnlock()
if exists {
if bc, ok := ch.(BlockReplyChannel); ok {
channelBlockReply = bc.BlockReplyEnabled()
}
if bc, ok := ch.(ChatBehaviorChannel); ok {
override = bc.ChatBehaviorConfig()
}
}
return ResolveChatBehavior(globalDefault, override)
override = ChatBehaviorConfigWithIntermediateDefault(override, channelBlockReply)
return ResolveChatBehaviorWithAgent(globalDefault, agentOverride, override)
}
func (m *Manager) ResolveReasoningDelivery(channelName string) ResolvedReasoningDelivery {
+20 -3
View File
@@ -3,9 +3,10 @@ package config
// ChatBehaviorConfig controls optional human-like channel delivery behavior.
// Pointer fields allow per-channel overrides to inherit gateway defaults.
type ChatBehaviorConfig struct {
Enabled *bool `json:"enabled,omitempty"`
QuickAck *QuickAckConfig `json:"quick_ack,omitempty"`
FinalSplit *FinalSplitConfig `json:"final_split,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
IntermediateReplies *IntermediateRepliesConfig `json:"intermediate_replies,omitempty"`
QuickAck *QuickAckConfig `json:"quick_ack,omitempty"`
FinalSplit *FinalSplitConfig `json:"final_split,omitempty"`
}
// QuickAckConfig controls one short acknowledgement before longer non-streaming runs.
@@ -13,9 +14,25 @@ type QuickAckConfig struct {
Enabled *bool `json:"enabled,omitempty"`
Mode *string `json:"mode,omitempty"`
MinDelayMs *int `json:"min_delay_ms,omitempty"`
Provider string `json:"provider,omitempty"`
Model string `json:"model,omitempty"`
TimeoutMs *int `json:"timeout_ms,omitempty"`
MaxTokens *int `json:"max_tokens,omitempty"`
MaxChars *int `json:"max_chars,omitempty"`
Templates []string `json:"templates,omitempty"`
}
// IntermediateRepliesConfig controls delivery-only progress messages during tool phases.
type IntermediateRepliesConfig struct {
Enabled *bool `json:"enabled,omitempty"`
Mode *string `json:"mode,omitempty"`
Provider string `json:"provider,omitempty"`
Model string `json:"model,omitempty"`
TimeoutMs *int `json:"timeout_ms,omitempty"`
MaxTokens *int `json:"max_tokens,omitempty"`
MaxChars *int `json:"max_chars,omitempty"`
}
// FinalSplitConfig controls semantic splitting of final channel replies.
type FinalSplitConfig struct {
Enabled *bool `json:"enabled,omitempty"`
@@ -0,0 +1,87 @@
---
phase: 1
title: "Characterization and Contract Tests"
status: completed
priority: P1
effort: "0.5d"
dependencies: []
---
# Phase 1: Characterization and Contract Tests
## Overview
Add failing tests before changing runtime behavior. This phase locks the current
surfaces and proves the target contract: no tool-status messages, deterministic
override order, and no context pollution from delivery-only messages.
## Requirements
- Functional: characterize existing Workspace and Channel behavior resolution.
- Functional: define expected Agent override behavior before implementation.
- Functional: prove Quick Ack and Intermediate Replies resolve independently.
- Functional: prove Quick Ack and Intermediate Replies are delivery events, not
persisted session messages or main-provider messages.
- Functional: characterize the current Quick Ack bug: `llm_generated` mode does
not call an LLM today and only falls back to template delivery.
- Non-functional: tests must be deterministic and must not call real providers.
## Architecture
Use package-level unit tests around existing resolver/event boundaries:
`internal/channels/chat_behavior.go`, `internal/channels/events.go`, and
`cmd/gateway_consumer_normal.go`. Add fake provider/generator interfaces only
inside tests until the implementation phase introduces the real seam. Include a
fake tenant-aware provider registry because configured sidecar provider names
cannot be exercised through the current `ConsumerDeps` shape.
## Related Code Files
- Modify: `internal/channels/chat_behavior_test.go`
- Modify: `internal/channels/chat_behavior_events_test.go`
- Create: `cmd/gateway_consumer_behavior_test.go`
- Modify: `ui/web/src/pages/channels/channel-schemas.test.ts`
- Modify: `ui/web/src/pages/config/sections/behavior-chat-card.tsx` tests if a
local test file already exists; otherwise defer UI assertions to phase 5.
## Implementation Steps
1. Add resolver tests for `Channel > Agent > Workspace` with all three layers
setting conflicting Quick Ack and Intermediate Replies values.
2. Add a test proving unset Agent behavior inherits Workspace and Channel still
wins when both Agent and Channel are set.
3. Add tests proving Quick Ack can be disabled while Intermediate Replies stay
enabled, and the reverse.
4. Add a characterization test proving current `llm_generated` Quick Ack does
not call a provider, so phase 3 must add the sidecar path rather than only
changing labels.
5. Add event tests proving a sidecar-generated Quick Ack publishes exactly one
outbound message and cancels on sidecar or final delivery.
6. Add a test proving sidecar Quick Ack failure or timeout falls back to the
configured template without blocking the main run.
7. Add a history/context test around the main inbound path proving visible
ack/progress text does not enter `SessionStore` history or either the main
provider request messages.
8. Add a provider-resolution test proving explicit delivery provider/model beats
the agent provider/model and falls back when unset.
9. Add a tool-status retirement test proving tool calls no longer emit
placeholder `formatToolStatus()` messages.
10. Add UI schema and form-serializer tests proving channel schemas include
Quick Ack and Intermediate Reply override fields, preserve nested
provider/model/timeout values, and do not expose Tool Status.
## Success Criteria
- [x] Tests fail for Agent override support before phase 2.
- [x] Tests fail for sidecar Quick Ack before phase 3.
- [x] Tests fail for sidecar Intermediate Replies before phase 3.
- [x] Tests fail when Quick Ack and Intermediate Replies are accidentally
coupled through the same `quick_ack.enabled` gate.
- [x] Tests fail for tool-status message retirement before phase 4.
- [x] No test requires a live LLM provider, network, or zuey access.
## Risk Assessment
The main risk is writing tests against invented helper APIs. Keep tests anchored
to existing public functions first, then introduce small interfaces only where
the implementation needs them.
@@ -0,0 +1,119 @@
---
phase: 2
title: "Config Resolution and Storage Contract"
status: completed
priority: P1
effort: "0.75d"
dependencies: [1]
---
# Phase 2: Config Resolution and Storage Contract
## Overview
Define the durable config contract for Workspace, Agent, and Channel behavior.
Keep existing channel and workspace keys backward-compatible while adding a
typed agent override envelope that can live in `agents.other_config`.
## Requirements
- Functional: resolve Intermediate Replies and Quick Ack independently by
`Channel > Agent > Workspace`.
- Functional: support provider/model settings for generated Quick Ack and
generated Intermediate Replies.
- Functional: keep existing `gateway.block_reply`, `gateway.chat_behavior`,
channel `block_reply`, and channel `chat_behavior` readable.
- Non-functional: avoid DB migration unless tests prove `other_config` is not
enough.
## Architecture
Add a small delivery behavior resolver instead of widening unrelated channel
code. Do not hang Intermediate Replies under `quick_ack`: current
`ShouldDeliverGeneratedProgress()` is quick-ack-gated, so the new contract must
make the two features independent. Recommended agent `other_config` contract:
```json
{
"delivery_behavior": {
"intermediate_replies": {
"enabled": true,
"mode": "sidecar_generated",
"provider": "groq",
"model": "llama-3.1-8b-instant",
"timeout_ms": 2500,
"max_tokens": 60,
"max_chars": 180
},
"quick_ack": {
"enabled": true,
"mode": "sidecar_generated",
"provider": "groq",
"model": "llama-3.1-8b-instant",
"min_delay_ms": 1000,
"timeout_ms": 2500,
"max_tokens": 40,
"max_chars": 120,
"templates": ["Got it. Working on it..."]
}
}
}
```
Backward compatibility mapping:
- `gateway.block_reply` and channel `block_reply` become the inherited
`intermediate_replies.enabled` default.
- `gateway.chat_behavior.quick_ack` and channel `chat_behavior.quick_ack`
become the inherited `quick_ack` default.
- `gateway.chat_behavior.final_split` remains final-answer splitting and should
not be bundled into the new Agent override unless implementation tests require
it.
Agent DB rows should parse this from `AgentData.OtherConfig`. Config-file agents
may use `AgentSpec.DeliveryBehavior` only if the config loader path needs
first-class support.
## Related Code Files
- Modify: `internal/config/config_channels.go`
- Modify: `internal/config/config.go`
- Modify: `internal/channels/chat_behavior.go`
- Modify: `internal/channels/runs.go`
- Modify: `internal/store/agent_store.go`
- Modify: `cmd/gateway_consumer_normal.go`
- Modify: `docs/05-channels-messaging.md`
## Implementation Steps
1. Add `DeliveryModeSidecarGenerated` for generated delivery, while keeping
legacy quick-ack modes `llm_generated`, `fixed_template`, and `off` readable.
2. Add `IntermediateRepliesConfig` and `QuickAckConfig` provider/model/timeout
fields or a shared nested generator object; choose the smaller shape during
implementation.
3. Add `DeliveryBehaviorConfig` or equivalent resolver input for
`IntermediateReplies + QuickAck`.
4. Add `ParseDeliveryBehaviorConfig()` on `AgentData` for
`other_config.delivery_behavior`.
5. Add resolver function that accepts workspace, agent, and channel configs and
applies them in that exact order.
6. Update channel manager APIs so callers can pass the already-resolved result
instead of letting channel manager know only workspace/channel.
7. Remove/generated-progress coupling to `quick_ack.enabled`; tests must prove
the two feature toggles are independent.
8. Document backward compatibility and the new agent override JSON shape.
## Success Criteria
- [x] Unit tests prove Channel > Agent > Workspace for both `block_reply` and
`quick_ack`.
- [x] Unit tests prove Intermediate Replies can be on while Quick Ack is off.
- [x] Missing agent config has no behavior change.
- [x] Existing channel `chat_behavior` configs still parse and resolve.
- [x] Invalid/unknown Quick Ack mode normalizes to a safe default.
- [x] No migration is added unless an implementation blocker is documented.
## Risk Assessment
Risk: adding a generic config type can sprawl into final split, reasoning, or
other channel settings. Keep this plan scoped to Intermediate Replies and Quick
Ack; final split can keep existing behavior unless a regression test forces it.
@@ -0,0 +1,111 @@
---
phase: 3
title: "Sidecar Delivery Message Generator"
status: completed
priority: P1
effort: "1d"
dependencies: [2]
---
# Phase 3: Sidecar Delivery Message Generator
## Overview
Implement a sidecar LLM generator for Quick Acknowledgement and Intermediate
Replies. It must be cheap, bounded, language-aware, and completely separate
from the main session context.
## Requirements
- Functional: generated delivery text can use configured provider/model,
falling back to agent provider/model when unset.
- Functional: output follows the user's language and is natural, not tool-name
based.
- Functional: timeout/error falls back to configured template.
- Non-functional: max output tokens, timeout, prompt size, and final chars are
bounded.
- Non-functional: no tools, no session history, no main RunRequest.
## Architecture
Use an injected generator interface in channel runtime:
```go
type DeliveryMessageGenerator interface {
GenerateDeliveryMessage(ctx context.Context, req DeliveryMessageRequest) (string, error)
}
```
The implementation can live outside provider-agnostic channel logic and call
`providers.Provider.Chat` directly with a tiny prompt, similar to
`agent.ClassifyIntentWithUsageCaps`. The request should include only: user
message preview, locale, peer kind, channel type, agent display name, and max
length, and purpose (`quick_ack` or `intermediate_progress`). For intermediate
progress, include only bounded tool-phase metadata such as tool category/count
or elapsed time; do not include raw tool arguments or tool output. Do not include
session history, tool schemas, system prompt, or memory.
Provider resolution must be explicit:
- Add a small provider resolver dependency to the consumer/channel seam. Current
`ConsumerDeps` has no provider registry, while `providers.Registry.Get(ctx,
name)` already supports tenant-aware lookup.
- Resolve sidecar provider/model once at run registration:
configured provider/model > agent provider/model.
- Store the resolved provider handle/model or a tiny generator closure on the
run snapshot; channel event handling should not perform mutable config lookup.
## Related Code Files
- Create: `internal/channels/delivery_message_generator.go` or
`internal/agent/delivery_message_generator.go`
- Modify: `internal/channels/events.go`
- Modify: `internal/channels/manager.go`
- Modify: `cmd/gateway_consumer_normal.go`
- Modify: `cmd/gateway_consumer_deps.go`
- Modify: `cmd/gateway_consumer.go`
- Modify: `cmd/gateway_lifecycle.go`
- Modify: `internal/agent/intent_classify.go` only if sharing tiny-call helper
code is cleaner than duplicating request boilerplate.
- Modify: tracing/usage caps integration only if existing provider calls require
accounting.
## Implementation Steps
1. Add a fake-generator test seam first; do not import provider registry into
tests that only need channel behavior.
2. Implement sidecar prompts with strict output contracts:
one short acknowledgement/progress update, same language as user, no raw tool
names, no promises, no markdown table, no more than configured chars.
3. Thread provider registry into `ConsumerDeps` from `gatewayRuntime`, then
resolve sidecar provider/model at run registration from delivery config:
explicit provider/model > agent provider/model.
4. Call generator from `scheduleQuickAck` after `min_delay_ms`; guard with the
same cancellation and `blockReplySent` checks as current template ack.
5. On error, timeout, empty output, or unsafe output, send the first configured
template when templates are present.
6. For tool-call progress, generate sidecar text from the delivery request and
publish it as delivery-only content. Do not use `resp.Content` from
`ThinkStage` as the sidecar message because that content is appended to main
run messages for tool-call continuity.
7. Record logs/tracing metadata as delivery purpose, but do not create a chat
session message.
8. Keep legacy `llm_generated` behavior as an alias or fallback only if needed
for existing configs; the UI should prefer `sidecar_generated`.
## Success Criteria
- [x] Sidecar generated ack sends before the main run finishes when delay
elapses.
- [x] Sidecar intermediate progress sends on bounded tool phases.
- [x] Sidecar delivery uses configured provider/model in tests.
- [x] Sidecar provider resolution uses tenant-aware registry lookup when a
provider is configured, and uses agent provider/model when unset.
- [x] Sidecar failure falls back or suppresses without failing the user request.
- [x] No sidecar prompt contains session history or tool schemas.
- [x] No sidecar prompt contains raw tool arguments or tool output.
- [x] Generated text is sanitized and max-length bounded before outbound send.
## Risk Assessment
Risk: sidecar ack increases cost and provider pressure. Mitigate with disabled
default behavior, low token cap, short timeout, and template fallback.
@@ -0,0 +1,94 @@
---
phase: 4
title: "Runtime Delivery and Context Isolation"
status: completed
priority: P1
effort: "1d"
dependencies: [2, 3]
---
# Phase 4: Runtime Delivery and Context Isolation
## Overview
Wire the new resolved behavior into channel runs, retire deterministic tool
status messages, and prove delivery-only messages cannot pollute main context.
## Requirements
- Functional: apply resolved behavior before `RegisterRunWithBehavior`.
- Functional: Intermediate Replies and Quick Ack obey Channel > Agent >
Workspace.
- Functional: Tool Status Messages no longer emit channel text.
- Functional: Intermediate Reply sidecar delivery does not reuse main
`block.reply` `resp.Content`.
- Non-functional: no new goroutine leaks; timers cancel on terminal events.
## Architecture
The main inbound path already resolves channel streaming and registers run state
in `cmd/gateway_consumer_normal.go:204` and `cmd/gateway_consumer_normal.go:245`.
Extend that point to parse agent override from `agentLoop.OtherConfig()` and
build a single resolved delivery behavior snapshot for the run. Also thread the
tenant-aware provider registry into `ConsumerDeps`; the current dependency bag
cannot resolve a configured sidecar provider name.
Keep outbound event handling in `internal/channels/events.go`, but add a
delivery event path that is separate from main-pipeline `block.reply` content.
Do not push delivery-only text into `pipeline.RunState.Messages` or
`sessions.Manager`.
## Related Code Files
- Modify: `cmd/gateway_consumer_normal.go`
- Modify: `cmd/gateway_consumer_deps.go`
- Modify: `cmd/gateway_consumer.go`
- Modify: `cmd/gateway_lifecycle.go`
- Modify: `internal/channels/runs.go`
- Modify: `internal/channels/events.go`
- Modify: `internal/channels/chat_behavior_events_test.go`
- Modify: `internal/pipeline/think_stage.go` only if adding a sidecar-trigger
event requires a clearer separation from legacy `block.reply`.
## Implementation Steps
1. Parse agent delivery override from `agentLoop.OtherConfig()` before
run registration.
2. Replace current `ResolveBlockReply(channel, gateway)` and
`ResolveChatBehavior(channel, gateway)` usage with a single resolver that
includes agent override.
3. Resolve sidecar provider/model with tenant context before run registration
and pass the resolved generator metadata into `RegisterRunWithBehavior`.
4. Replace generated intermediate delivery that depends on `block.reply`
`resp.Content` with a sidecar delivery trigger on bounded tool phases.
5. Keep legacy explicit `block_reply` compatible when users rely on main LLM
progress text, but default/generated Intermediate Replies should use
sidecar delivery only.
6. Retire tool-status message output by removing or hard-disabling the
`rc.ToolStatusEnabled && !rc.Streaming` publish path.
7. Keep reaction statuses for channels that support reactions; these are not
Tool Status Messages and are less intrusive.
8. Ensure terminal events cancel Quick Ack timers and sidecar generation cannot
publish after run unregister.
9. Add context isolation tests proving visible ack/intermediate outbound text
does not change session history or provider request messages.
## Success Criteria
- [x] Channel-level false disables Agent/Workspace-enabled Quick Ack.
- [x] Agent-level true enables behavior when Workspace is disabled and Channel
inherits.
- [x] Workspace still controls defaults when Agent and Channel inherit.
- [x] Quick Ack disabled does not disable Intermediate Replies, and Intermediate
Replies disabled does not disable Quick Ack.
- [x] Sidecar Intermediate Reply content is not equal to `resp.Content` from
tool-call assistant messages unless a fake generator intentionally returns
that value.
- [x] Tool calls no longer publish deterministic status text.
- [x] Existing Show Reasoning bubble behavior still works.
- [x] Existing final answer dedup for block replies still works.
## Risk Assessment
Risk: removing tool-status messages might also remove useful placeholder cleanup.
Verify empty final outbound and stream finalization still clean placeholders.
@@ -0,0 +1,98 @@
---
phase: 5
title: "Web UI Behavior Simplification"
status: completed
priority: P2
effort: "1d"
dependencies: [2, 3, 4]
---
# Phase 5: Web UI Behavior Simplification
## Overview
Update Web UI so users see one coherent Behavior model: Workspace defaults,
Agent overrides, and Channel overrides. Remove Tool Status Messages from
Config > Behavior.
## Requirements
- Functional: Config > Behavior no longer shows Tool Status Messages.
- Functional: Config > Behavior still controls Workspace Intermediate Replies
and Quick Ack defaults.
- Functional: Agent advanced settings expose inherit/custom override for
Intermediate Replies and Quick Ack.
- Functional: Channel settings expose the same override fields and provider/model
options for Quick Ack and Intermediate Replies.
- Non-functional: all user-facing strings have en/vi/zh translations.
## Architecture
Use existing UI patterns:
- Workspace behavior card: `ui/web/src/pages/config/sections/behavior-*.tsx`.
- Agent advanced dialog: `ui/web/src/pages/agents/agent-detail/`.
- Channel config schema: `ui/web/src/pages/channels/channel-schemas.ts`.
- Existing channel schema already has `chat_behavior.quick_ack.*` override fields
but lacks provider/model controls and still describes generated mode as
"main LLM block reply".
- Channel create/edit forms flatten nested keys, delete `"inherit"`, and coerce
string booleans in `channel-instance-form-dialog.tsx`; new nested delivery
fields need serializer tests, not schema-only tests.
## Related Code Files
- Modify: `ui/web/src/pages/config/sections/behavior-ux-card.tsx`
- Modify: `ui/web/src/pages/config/sections/behavior-section.tsx`
- Modify: `ui/web/src/pages/config/sections/behavior-chat-card.tsx`
- Modify: `ui/web/src/components/layout/system-settings-modal.tsx`
- Modify: `ui/web/src/pages/agents/agent-detail/agent-advanced-dialog.tsx`
- Modify: `ui/web/src/pages/agents/agent-detail/agent-advanced-state-utils.ts`
- Modify: `ui/web/src/types/agent.ts`
- Modify: `ui/web/src/pages/channels/channel-schemas.ts`
- Modify: `ui/web/src/pages/channels/channel-instance-form-dialog.tsx` tests or
extracted serializer helpers if implementation extracts them.
- Modify: `ui/web/src/i18n/locales/{en,vi,zh}/config.json`
- Modify: `ui/web/src/i18n/locales/{en,vi,zh}/agents.json`
- Modify: `ui/web/src/i18n/locales/{en,vi,zh}/channels.json`
- Inspect: `ui/desktop/frontend/src/components/channels/channel-schemas.ts`
## Implementation Steps
1. Remove `tool_status` from `BehaviorUxCard` UI state and save payload.
2. Remove or hide `gateway.tool_status` from any other user-facing config
surface, including `system-settings-modal.tsx`.
3. Keep backend accepting `gateway.tool_status` silently for backward
compatibility; do not expose it in Web UI.
4. Add Quick Ack and Intermediate Reply provider/model fields to Workspace
behavior card with provider selectors where available.
5. Add Agent "Delivery Behavior" section with Inherit/Custom mode and controls
for Intermediate Replies plus Quick Ack.
6. Persist Agent overrides into `other_config.delivery_behavior` without
overwriting unrelated keys.
7. Extend channel schema fields with provider/model/timeout/max token settings
for Quick Ack and Intermediate Replies.
8. Update preview copy so generated delivery is described as sidecar-generated,
not "main LLM block reply".
9. Add or update UI tests for schema normalization, flatten/unflatten payloads,
select coercion, and `other_config` merge behavior.
10. Scout desktop channel schema. Either add matching fields or document why
desktop is out of scope for Standard channel settings.
## Success Criteria
- [x] Tool Status Messages label no longer appears in Config > Behavior.
- [x] `gateway.tool_status` is not exposed in any user-facing Web UI settings
surface.
- [x] Agent UI can save inherit/custom behavior override without overwriting
unrelated `other_config` keys.
- [x] Channel UI can save provider/model Quick Ack and Intermediate Reply
overrides with correct nested JSON.
- [x] Workspace UI can save provider/model Quick Ack and Intermediate Reply
defaults.
- [x] en/vi/zh locale keys are complete.
- [x] Mobile layout keeps inputs at `text-base md:text-sm` where applicable.
## Risk Assessment
Risk: agent advanced payloads can overwrite unrelated `other_config`. Reuse the
existing merge pattern used by inbound debounce and prompt settings; test it.
@@ -0,0 +1,78 @@
---
phase: 6
title: "Validation and Zuey Beta Handoff"
status: in_progress
priority: P1
effort: "0.5d"
dependencies: [1, 2, 3, 4, 5]
---
# Phase 6: Validation and Zuey Beta Handoff
## Overview
Run the local and CI validation needed before shipping to `dev`, then verify the
automatic beta release and zuey deploy.
## Requirements
- Functional: all backend and web checks relevant to channel behavior pass.
- Functional: beta workflow completes after merge to `dev`.
- Functional: zuey public health is verified after beta deploy.
- Non-functional: do not expose zuey secrets in logs, docs, PR, or chat.
## Architecture
Use the repo's normal beta path: PR to `dev`, merge, `Dev CI and Beta Release`
creates the beta tag and deploys zuey. Use JSON polling for late workflow jobs
because `gh run watch` is noisy in this workflow family.
## Related Code Files
- Modify: `CHANGELOG.md`
- Modify: `docs/05-channels-messaging.md`
- Modify: `docs/project-changelog.md`
- Verify: `.github/workflows/dev-beta-release.yaml`
- Verify: `scripts/ci/dev-beta-release-workflow.test.mjs`
## Implementation Steps
1. Run targeted Go tests:
`go test ./internal/channels ./internal/config ./cmd ./internal/agent ./internal/usage/caps`.
2. Run SQLite-tag coverage for shared behavior:
`go test -tags sqliteonly ./internal/channels ./internal/config ./cmd`.
3. Run broad compile checks:
`go build ./...`, `go build -tags sqliteonly ./...`, `go vet ./...`.
4. Run web checks:
`cd ui/web && pnpm test -- --run && pnpm build`.
5. If desktop schema changed, run:
`cd ui/desktop/frontend && pnpm build`.
6. Update changelog/docs with the exact behavior contract and override order.
7. Ship beta via PR to `dev`, then verify:
`gh run view <run-id> --repo digitopvn/goclaw --json status,conclusion,jobs`.
8. Verify zuey after deploy:
`/opt/goclaw/current/goclaw version`, `systemctl is-active goclaw`,
local `/health`, and public `https://goclaw.zuey.me/health`.
9. Smoke-test a channel with Workspace default, Agent override, and Channel
override to prove effective behavior.
10. Smoke-test explicit sidecar provider/model by choosing a cheap provider and
verifying logs/traces show sidecar delivery purpose without adding delivery
text to session history.
## Success Criteria
- [x] Local targeted tests and builds pass.
- [x] Web build passes.
- [ ] PR checks pass.
- [ ] Beta workflow conclusion is `success`.
- [ ] New beta release tag is visible.
- [ ] Zuey service is active and health returns `{"status":"ok","protocol":3}`.
- [ ] Manual smoke confirms sidecar ack is visible but absent from session
history.
- [ ] Manual smoke confirms sidecar intermediate reply is visible but absent from
session history and does not match main assistant tool-call content.
## Risk Assessment
Risk: zuey deploy can be healthy before Docker tail jobs finish. Do not report
beta shipped until the whole workflow has `conclusion=success`.
@@ -0,0 +1,156 @@
---
title: "Agent Behavior UX Overrides and Sidecar Delivery"
description: >-
TDD plan to remove Tool Status Messages, add sidecar-generated delivery
updates, and support Channel > Agent > Workspace behavior overrides.
status: in_progress
priority: P1
branch: "codex/agent-behavior-ux-overrides"
tags: [channels, agents, chat-behavior, quick-ack, tdd, zuey]
blockedBy: []
blocks: []
created: "2026-06-09T10:14:07.569Z"
createdBy: "ck:plan"
source: skill
---
# Agent Behavior UX Overrides and Sidecar Delivery
## Overview
Simplify agent/channel Behavior UX after the Show Reasoning work: remove Tool
Status Messages, generate delivery updates through an optional cheap sidecar
provider/model, and add agent-level overrides.
Product decisions:
- Keep Show Reasoning separate for debugging/testing.
- Retire Tool Status Messages as a message feature because it overlaps with
Intermediate Replies and deterministic tool-name text reads robotic.
- Quick Acknowledgement and Intermediate Replies are channel delivery events
only; visible progress text must not be added to main session context.
- Delivery updates may use a separate fast/cheap provider and model.
- Override order is Channel > Agent > Workspace (`Config > Behavior`).
- Prefer no DB migration: store agent overrides in `agents.other_config`.
Verified current facts:
- `ChatBehaviorConfig` and `QuickAckConfig` exist in
`internal/config/config_channels.go:3`; workspace `gateway.block_reply`,
`gateway.chat_behavior`, and `gateway.tool_status` are at
`internal/config/config_channels.go:407`.
- Quick Ack currently schedules from channel events in
`internal/channels/events.go:42`; Intermediate Replies publish outbound
`block.reply` bubbles in `internal/channels/events.go:330`.
- Channel registration currently resolves Channel > Workspace only at
`cmd/gateway_consumer_normal.go:245`.
- `RunRequest` already has provider/model override fields in
`internal/agent/loop_types.go:621`, useful precedent for cheap side calls.
- `ConsumerDeps` currently has no provider registry/store in
`cmd/gateway_consumer_deps.go:18`; configured sidecar provider names need a
new dependency path from `gatewayRuntime.providerRegistry`.
- Current `llm_generated` Quick Ack does not call an LLM: `sendQuickAck()` sends
the first template from `internal/channels/events.go:471`, while
`ShouldDeliverGeneratedProgress()` is only a block.reply gate in
`internal/channels/chat_behavior.go:170`.
- Current intermediate replies are main-pipeline `block.reply` events:
`ThinkStage` appends the assistant tool-call message to context at
`internal/pipeline/think_stage.go:136` before emitting block.reply at
`internal/pipeline/think_stage.go:147`.
## Phases
| Phase | Name | Status |
|-------|------|--------|
| 1 | [Characterization and Contract Tests](./phase-01-characterization-and-contract-tests.md) | Done |
| 2 | [Config Resolution and Storage Contract](./phase-02-config-resolution-and-storage-contract.md) | Done |
| 3 | [Sidecar Delivery Message Generator](./phase-03-sidecar-acknowledgement-generator.md) | Done |
| 4 | [Runtime Delivery and Context Isolation](./phase-04-runtime-delivery-and-context-isolation.md) | Done |
| 5 | [Web UI Behavior Simplification](./phase-05-web-ui-behavior-simplification.md) | Done |
| 6 | [Validation and Zuey Beta Handoff](./phase-06-validation-and-zuey-beta-handoff.md) | In Progress |
## Dependencies
- Completed plan: `../260529-1210-human-like-channel-chat-behavior/plan.md`
- Completed plan: `../260531-1555-issue-118-llm-generated-channel-progress/plan.md`
- Completed plan: `../260601-2004-show-reasoning-always-bubbles/plan.md`
- Runtime files: `internal/config/config_channels.go`, `internal/channels/{chat_behavior,events,runs}.go`, `cmd/gateway_consumer_normal.go`
- Agent/UI files: `internal/store/agent_store.go`, `internal/config/config.go`,
`ui/web/src/pages/{agents,channels,config}/`, `ui/web/src/i18n/locales/`
## Acceptance Criteria
- [x] Tool Status Messages is no longer shown in `Config > Behavior`.
- [x] Runtime no longer emits deterministic tool-status channel messages.
- [x] Quick Ack and Intermediate Replies support sidecar provider/model output.
- [x] Configured sidecar provider names resolve through the tenant-aware provider
registry, with agent provider/model fallback.
- [x] Sidecar output is sent only as channel outbound events.
- [x] Visible delivery text is never appended to session history or main LLM context.
- [x] Channel overrides beat Agent overrides; Agent overrides beat Workspace.
- [x] Existing channel overrides keep backward compatibility.
- [x] Web UI exposes clear Workspace, Agent, and Channel behavior controls.
- [ ] Validation includes local tests/build and zuey beta deploy verification.
## Red Team Review
### Session - 2026-06-09
**Findings:** 7 (6 accepted, 1 rejected)
**Severity breakdown:** 0 Critical, 4 High, 3 Medium
| # | Finding | Severity | Disposition | Applied To |
|---|---------|----------|-------------|------------|
| 1 | Configured sidecar provider cannot resolve from current consumer deps | High | Accept | Phases 1, 3, 4, 6 |
| 2 | Intermediate Replies are still main-pipeline block.reply content | High | Accept | Phases 1, 2, 3, 4 |
| 3 | Quick Ack root cause is not characterized | High | Accept | Phases 1, 3 |
| 4 | Intermediate Replies and Quick Ack are still conflated through quick_ack gating | High | Accept | Phases 1, 2, 4, 5 |
| 5 | UI schema changes can save wrong nested shapes without serializer tests | Medium | Accept | Phases 1, 5 |
| 6 | Tool Status remains in another config UI surface | Medium | Accept | Phase 5 |
| 7 | `agentLoop.OtherConfig()` is fabricated | Medium | Reject | None |
#### Accepted Finding Details
1. **Configured sidecar provider cannot resolve from current consumer deps.**
`ConsumerDeps` exposes `UsageCaps` but not `ProviderReg` or `ProviderStore`
(`cmd/gateway_consumer_deps.go:18`), and `consumeInboundMessages` builds deps
without the registry (`cmd/gateway_consumer.go:48`). Yet provider registry
already supports tenant-aware lookup (`internal/providers/registry.go:119`).
The plan now requires threading `gatewayRuntime.providerRegistry` into the
consumer and tests proving explicit provider/model is actually used.
2. **Intermediate Replies are still main-pipeline block.reply content.**
`ThinkStage` appends assistant tool-call content into run messages
(`internal/pipeline/think_stage.go:136`) before emitting block.reply
(`internal/pipeline/think_stage.go:147`). The plan now requires sidecar
intermediate delivery to be generated from delivery metadata/tool phase only,
not from `resp.Content`.
3. **Quick Ack root cause is not characterized.** Current `sendQuickAck()` always
sends a template (`internal/channels/events.go:471`); `llm_generated` only
affects preview/progress gates (`internal/channels/chat_behavior.go:170`).
Phase 1 now locks this as a failing characterization test.
4. **Intermediate Replies and Quick Ack are conflated.**
`ShouldDeliverGeneratedProgress()` requires `QuickAck.Enabled`
(`internal/channels/chat_behavior.go:170`), so disabling ack also disables
generated progress. The plan now separates `quick_ack` from
`intermediate_replies`.
5. **UI schema changes need serializer tests.** Channel form coercion deletes
`"inherit"` and maps string booleans in `coerceSelects()`
(`ui/web/src/pages/channels/channel-instance-form-dialog.tsx:137`), while the
schema currently stores nested keys like `chat_behavior.quick_ack.mode`
(`ui/web/src/pages/channels/channel-schemas.ts:37`). Phase 5 now tests
flatten/unflatten and coercion for provider/model/timeout fields.
6. **Tool Status remains in another config UI surface.**
`system-settings-modal.tsx` still reads and saves `gateway.tool_status`
(`ui/web/src/components/layout/system-settings-modal.tsx:77` and `:135`).
Phase 5 now removes/hides all user-facing Tool Status controls, not only
`BehaviorUxCard`.
#### Rejected Finding Details
7. **`agentLoop.OtherConfig()` is fabricated.** Rejected: the method exists on
the `Agent` interface (`internal/agent/types.go:17`) and `Loop`
(`internal/agent/loop_tracing.go:36`).
### Whole-Plan Consistency Sweep
- Files reread: `plan.md`, `phase-01-*`, `phase-02-*`, `phase-03-*`,
`phase-04-*`, `phase-05-*`, `phase-06-*`.
- Decision deltas checked: 6.
- Reconciled stale references: 9.
- Unresolved contradictions: 0.
@@ -31,8 +31,6 @@ export interface InitState {
embModel: string;
embMaxChunkLen: string;
embChunkOverlap: string;
toolStatus: boolean;
blockReply: boolean;
intentClassify: boolean;
compProvider: string;
compModel: string;
@@ -54,7 +52,7 @@ export interface InitState {
export const DEFAULTS: InitState = {
embProvider: "", embModel: "",
embMaxChunkLen: "", embChunkOverlap: "",
toolStatus: true, blockReply: false, intentClassify: true,
intentClassify: true,
compProvider: "", compModel: "",
compThreshold: "", compKeepRecent: "", compMaxTokens: "",
kgProvider: "", kgModel: "", kgMinConfidence: "0.75",
@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Settings2, Loader2, Save, AlertTriangle, Info, ExternalLink, Network, Cog } from "lucide-react";
import { Settings2, Loader2, Save, AlertTriangle, Info, ExternalLink, Network, Cog, Brain } from "lucide-react";
import { Link } from "react-router";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
@@ -18,7 +18,6 @@ import { EMBEDDING_MODELS, DEFAULT_EMBEDDING_MODELS, DEFAULTS, parseBool, type I
import { SystemSettingsEmbeddingCard } from "./system-settings-embedding-card";
import { SystemSettingsCompactionCard } from "./system-settings-compaction-card";
import { SystemSettingsSkillsCard } from "./system-settings-skills-card";
import { Eye, MessageSquareText, Brain } from "lucide-react";
interface SystemSettingsModalProps {
open: boolean;
@@ -42,8 +41,6 @@ export function SystemSettingsModal({ open, onOpenChange }: SystemSettingsModalP
const { verifyEmbedding, embVerifying, embResult, resetEmb } = useProviderVerify();
// UX Behavior
const [toolStatus, setToolStatus] = useState(true);
const [blockReply, setBlockReply] = useState(false);
const [intentClassify, setIntentClassify] = useState(true);
// Compaction
@@ -74,7 +71,6 @@ export function SystemSettingsModal({ open, onOpenChange }: SystemSettingsModalP
const s: InitState = {
embProvider: configs["embedding.provider"] ?? "", embModel: configs["embedding.model"] ?? "",
embMaxChunkLen: configs["embedding.max_chunk_len"] ?? "", embChunkOverlap: configs["embedding.chunk_overlap"] ?? "",
toolStatus: parseBool(configs["gateway.tool_status"], true), blockReply: parseBool(configs["gateway.block_reply"], false),
intentClassify: parseBool(configs["gateway.intent_classify"], true),
compProvider: configs["compaction.provider"] ?? "", compModel: configs["compaction.model"] ?? "",
compThreshold: configs["compaction.threshold"] ?? "", compKeepRecent: configs["compaction.keep_recent"] ?? "",
@@ -90,7 +86,7 @@ export function SystemSettingsModal({ open, onOpenChange }: SystemSettingsModalP
};
setInit(s);
setEmbProvider(s.embProvider); setEmbModel(s.embModel); setEmbMaxChunkLen(s.embMaxChunkLen); setEmbChunkOverlap(s.embChunkOverlap);
setToolStatus(s.toolStatus); setBlockReply(s.blockReply); setIntentClassify(s.intentClassify);
setIntentClassify(s.intentClassify);
setCompProvider(s.compProvider); setCompModel(s.compModel); setCompThreshold(s.compThreshold); setCompKeepRecent(s.compKeepRecent); setCompMaxTokens(s.compMaxTokens);
setKgProvider(s.kgProvider); setKgModel(s.kgModel); setKgMinConfidence(s.kgMinConfidence);
setBgProvider(s.bgProvider); setBgModel(s.bgModel);
@@ -132,8 +128,6 @@ export function SystemSettingsModal({ open, onOpenChange }: SystemSettingsModalP
if (embModel !== init.embModel) updates["embedding.model"] = embModel;
if (embMaxChunkLen !== init.embMaxChunkLen) updates["embedding.max_chunk_len"] = embMaxChunkLen;
if (embChunkOverlap !== init.embChunkOverlap) updates["embedding.chunk_overlap"] = embChunkOverlap;
if (toolStatus !== init.toolStatus) updates["gateway.tool_status"] = String(toolStatus);
if (blockReply !== init.blockReply) updates["gateway.block_reply"] = String(blockReply);
if (intentClassify !== init.intentClassify) updates["gateway.intent_classify"] = String(intentClassify);
if (compProvider !== init.compProvider) updates["compaction.provider"] = compProvider;
if (compModel !== init.compModel) updates["compaction.model"] = compModel;
@@ -160,8 +154,6 @@ export function SystemSettingsModal({ open, onOpenChange }: SystemSettingsModalP
};
const uxItems: FeatureSwitchItem[] = [
{ icon: Eye, iconClass: "text-blue-500", label: t("ux.toolStatus"), hint: t("ux.toolStatusHint"), checked: toolStatus, onCheckedChange: setToolStatus, infoWhenOn: t("ux.toolStatusInfo"), infoClass: "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-800 dark:bg-blue-950/30 dark:text-blue-300" },
{ icon: MessageSquareText, iconClass: "text-emerald-500", label: t("ux.blockReply"), hint: t("ux.blockReplyHint"), checked: blockReply, onCheckedChange: setBlockReply, infoWhenOn: t("ux.blockReplyInfo"), infoClass: "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-800 dark:bg-emerald-950/30 dark:text-emerald-300" },
{ icon: Brain, iconClass: "text-orange-500", label: t("ux.intentClassify"), hint: t("ux.intentClassifyHint"), checked: intentClassify, onCheckedChange: setIntentClassify, infoWhenOn: t("ux.intentClassifyInfo"), infoClass: "border-orange-200 bg-orange-50 text-orange-700 dark:border-orange-800 dark:bg-orange-950/30 dark:text-orange-300" },
];
+14
View File
@@ -1121,6 +1121,20 @@
"changeFile": "Change File",
"startImport": "Start Import"
},
"delivery": {
"title": "Delivery Behavior",
"description": "Agent-level override for Quick Acknowledgement and Intermediate Replies. Channel settings still win over this.",
"inherit": "Inherit",
"custom": "Custom",
"quickAck": "Quick acknowledgement",
"intermediate": "Intermediate replies",
"provider": "Provider",
"providerPlaceholder": "Use agent provider",
"model": "Model",
"modelPlaceholder": "Use agent model",
"quickAckDelay": "Ack delay (ms)",
"timeoutMs": "Timeout (ms)"
},
"toast": {
"created": "Agent created",
"createFailed": "Failed to create agent",
-9
View File
@@ -346,10 +346,6 @@
"media_max_mb": { "label": "Max Media Size (MB)" },
"link_preview": { "label": "Link Preview" },
"allow_from": { "label": "Allowed Users" },
"block_reply": {
"label": "Block Reply",
"help": "Deliver tool announcements and intermediate text around tool iterations"
},
"domain": { "label": "Domain" },
"connection_mode": {
"label": "Connection Mode",
@@ -410,11 +406,6 @@
}
},
"fieldOptions": {
"block_reply": {
"inherit": "Inherit from gateway",
"true": "Enabled",
"false": "Disabled"
},
"dm_policy": {
"pairing": "Pairing (require code)",
"open": "Open (accept all)",
+17 -11
View File
@@ -35,8 +35,6 @@
"gateway.allowedOrigins": "Allowed Origins",
"gateway.allowedOriginsTip": "CORS allowed origins for the HTTP API. Use * to allow all origins.",
"gateway.allowedOriginsPlaceholder": "https://yourdomain.com",
"gateway.toolStatus": "Tool Status Messages",
"gateway.blockReply": "Intermediate Replies",
"gateway.maxMessageChars": "Max Message Chars",
"gateway.maxMessageCharsTip": "Maximum allowed character count per inbound message. Messages exceeding this limit are rejected.",
"gateway.rateLimitRpm": "Rate Limit (RPM)",
@@ -122,27 +120,35 @@
"behavior.uxTitle": "UX Behavior",
"behavior.uxDescription": "High-impact toggles that change how the gateway interacts with users",
"behavior.toolStatusHint": "Show real-time tool execution status messages to users during agent runs.",
"behavior.toolStatusPurpose": "Purpose: show deterministic technical progress from tool calls, such as reading files, running commands, or calling APIs. Useful for debugging and verbose channel feedback; may overlap with reasoning and intermediate replies.",
"behavior.toolStatusInfo": "Users will see live updates as the agent reads files, runs commands, and calls APIs.",
"behavior.blockReplyHint": "Send tool announcements and partial assistant text to channels before or during tool calls.",
"behavior.blockReplyPurpose": "Purpose: let the assistant send natural intermediate progress before the final answer, especially around tool work. Useful on non-streaming channels that need a more human delivery rhythm.",
"behavior.blockReplyInfo": "Intermediate text is delivered before or during tool execution, not just the final response.",
"behavior.intentClassifyHint": "Classify user intent before routing to reduce unnecessary agent invocations.",
"behavior.intentClassifyInfo": "Agent will only be invoked when the classifier detects actionable intent.",
"behavior.chatTitle": "Human-like Channel Delivery",
"behavior.chatDescription": "Quick acknowledgement and safe final reply splitting for non-streaming channels",
"behavior.chatDescription": "Sidecar acknowledgement, intermediate progress, and safe final reply splitting for non-streaming channels",
"behavior.chatEnabled": "Enable channel delivery behavior",
"behavior.chatEnabledHint": "Applies globally, with optional per-channel overrides.",
"behavior.quickAck": "Quick acknowledgement",
"behavior.quickAckHint": "Prefer the main LLM's block reply; use templates only as fallback.",
"behavior.quickAckHint": "Use a sidecar provider/model for a short receipt; templates are fallback only.",
"behavior.quickAckPurpose": "Purpose: send a short receipt so users know their message was accepted before longer work starts. It is an acknowledgement, not reasoning or tool progress.",
"behavior.quickAckMode": "Mode",
"behavior.quickAckMode.sidecar_generated": "Sidecar-generated with fallback",
"behavior.quickAckMode.llm_generated": "LLM-generated with fallback",
"behavior.quickAckMode.fixed_template": "Fixed template",
"behavior.quickAckMode.off": "Off",
"behavior.quickAckDelay": "Fallback delay (ms)",
"behavior.quickAckTemplates": "Fallback templates",
"behavior.intermediate": "Intermediate replies",
"behavior.intermediateHint": "Send natural progress updates during tool phases without adding them to session history.",
"behavior.intermediatePurpose": "Purpose: show users that work is moving during longer tool-heavy runs. These are delivery-only sidecar messages, separate from Show Reasoning and not part of the agent context.",
"behavior.intermediateMode": "Mode",
"behavior.intermediateMode.sidecar_generated": "Sidecar-generated",
"behavior.intermediateMode.off": "Off",
"behavior.provider": "Provider",
"behavior.providerPlaceholder": "Use agent provider",
"behavior.model": "Model",
"behavior.modelPlaceholder": "Use agent model",
"behavior.timeoutMs": "Timeout (ms)",
"behavior.maxTokens": "Max tokens",
"behavior.maxChars": "Max chars",
"behavior.finalSplit": "Final reply splitting",
"behavior.finalSplitHint": "Split long final replies into safe paragraph messages.",
"behavior.finalSplitMinChars": "Min chars",
@@ -150,7 +156,7 @@
"behavior.finalSplitDelay": "Delay (ms)",
"behavior.preview": "Preview",
"behavior.previewAck": "Ack",
"behavior.previewGeneratedAck": "Generated by main LLM; fallback: {{fallback}}",
"behavior.previewGeneratedAck": "Generated by sidecar delivery model; fallback: {{fallback}}",
"behavior.previewNoAck": "No acknowledgement for this sample.",
"behavior.previewParts": "{{count}} final message part",
"behavior.previewParts_plural": "{{count}} final message parts",
@@ -30,12 +30,6 @@
"ux": {
"title": "UX Behavior",
"description": "Control how the gateway displays agent activity to users.",
"toolStatus": "Show tool name during execution",
"toolStatusHint": "Display the active tool name while the agent is running.",
"toolStatusInfo": "Tool names are shown in streaming preview while the agent runs.",
"blockReply": "Show intermediate text around tool calls",
"blockReplyHint": "Send tool announcements and partial assistant text before or while tools run.",
"blockReplyInfo": "Intermediate text is delivered to users before or during tool execution.",
"intentClassify": "Intent classification",
"intentClassifyHint": "Classify user intent before routing to the agent.",
"intentClassifyInfo": "Messages are classified to optimize agent routing and behavior."
+14
View File
@@ -1106,6 +1106,20 @@
"changeFile": "Đổi file",
"startImport": "Bắt đầu nhập"
},
"delivery": {
"title": "Hành vi gửi tin",
"description": "Ghi đè cấp Agent cho Xác nhận nhanh và Phản hồi trung gian. Cấu hình Channel vẫn có ưu tiên cao hơn.",
"inherit": "Kế thừa",
"custom": "Tuỳ chỉnh",
"quickAck": "Xác nhận nhanh",
"intermediate": "Phản hồi trung gian",
"provider": "Provider",
"providerPlaceholder": "Dùng provider của agent",
"model": "Model",
"modelPlaceholder": "Dùng model của agent",
"quickAckDelay": "Độ trễ ack (ms)",
"timeoutMs": "Timeout (ms)"
},
"toast": {
"created": "Đã tạo agent",
"createFailed": "Không thể tạo agent",
-6
View File
@@ -309,7 +309,6 @@
"media_max_mb": { "label": "Kích thước media tối đa (MB)" },
"link_preview": { "label": "Xem trước liên kết" },
"allow_from": { "label": "Người dùng được phép" },
"block_reply": { "label": "Phản hồi khối", "help": "Gửi thông báo dùng công cụ và văn bản trung gian quanh các vòng gọi công cụ" },
"domain": { "label": "Tên miền" },
"connection_mode": { "label": "Chế độ kết nối", "help": "WebSocket không cần IP công khai — chỉ kết nối ra ngoài" },
"webhook_port": { "label": "Cổng webhook", "help": "0 = chia sẻ cổng gateway chính (khuyến nghị)" },
@@ -337,11 +336,6 @@
}
},
"fieldOptions": {
"block_reply": {
"inherit": "Kế thừa từ gateway",
"true": "Bật",
"false": "Tắt"
},
"dm_policy": {
"pairing": "Ghép nối (yêu cầu mã)",
"open": "Mở (chấp nhận tất cả)",
+17 -11
View File
@@ -35,8 +35,6 @@
"gateway.allowedOrigins": "Nguồn gốc được phép",
"gateway.allowedOriginsTip": "Nguồn gốc CORS được phép cho HTTP API. Dùng * để cho phép mọi nguồn gốc.",
"gateway.allowedOriginsPlaceholder": "https://yourdomain.com",
"gateway.toolStatus": "Thông báo trạng thái công cụ",
"gateway.blockReply": "Phản hồi trung gian",
"gateway.maxMessageChars": "Ký tự tối đa mỗi tin",
"gateway.maxMessageCharsTip": "Số ký tự tối đa được phép trong mỗi tin nhắn đến. Tin nhắn vượt quá giới hạn sẽ bị từ chối.",
"gateway.rateLimitRpm": "Giới hạn tốc độ (RPM)",
@@ -122,27 +120,35 @@
"behavior.uxTitle": "Hành vi giao diện",
"behavior.uxDescription": "Các công tắc có tác động lớn thay đổi cách gateway tương tác với người dùng",
"behavior.toolStatusHint": "Hiện thông báo trạng thái thực thi công cụ theo thời gian thực cho người dùng trong quá trình chạy agent.",
"behavior.toolStatusPurpose": "Mục đích: hiển thị tiến độ kỹ thuật xác định từ tool call, ví dụ đọc tệp, chạy lệnh hoặc gọi API. Hữu ích cho debug và phản hồi kênh dạng verbose; có thể chồng lên reasoning và phản hồi trung gian.",
"behavior.toolStatusInfo": "Người dùng sẽ thấy cập nhật trực tiếp khi agent đọc tệp, chạy lệnh và gọi API.",
"behavior.blockReplyHint": "Gửi thông báo dùng công cụ và văn bản phụ trợ cho kênh trước hoặc trong khi gọi công cụ.",
"behavior.blockReplyPurpose": "Mục đích: cho assistant gửi cập nhật tiến độ tự nhiên trước câu trả lời cuối, nhất là quanh các bước dùng tool. Hữu ích cho kênh không streaming cần nhịp phản hồi giống người hơn.",
"behavior.blockReplyInfo": "Văn bản trung gian được gửi cho người dùng trước hoặc trong quá trình thực thi công cụ.",
"behavior.intentClassifyHint": "Phân loại ý định người dùng trước khi định tuyến để giảm các lần gọi agent không cần thiết.",
"behavior.intentClassifyInfo": "Agent chỉ được gọi khi bộ phân loại phát hiện ý định có thể thực hiện.",
"behavior.chatTitle": "Gửi tin nhắn giống người hơn",
"behavior.chatDescription": "Xác nhận nhanh và tách phản hồi cuối an toàn cho kênh không streaming",
"behavior.chatDescription": "Xác nhận sidecar, tiến độ trung gian và tách phản hồi cuối an toàn cho kênh không streaming",
"behavior.chatEnabled": "Bật hành vi gửi tin theo kênh",
"behavior.chatEnabledHint": "Áp dụng toàn cục, có thể ghi đè theo từng kênh.",
"behavior.quickAck": "Xác nhận nhanh",
"behavior.quickAckHint": "Ưu tiên block reply từ LLM chính; chỉ dùng mẫu tin làm fallback.",
"behavior.quickAckHint": "Dùng provider/model sidecar để gửi xác nhận ngắn; mẫu tin chỉ là fallback.",
"behavior.quickAckPurpose": "Mục đích: gửi xác nhận ngắn để người dùng biết tin nhắn đã được nhận trước khi tác vụ dài bắt đầu. Đây là xác nhận, không phải reasoning hay tiến độ tool.",
"behavior.quickAckMode": "Chế độ",
"behavior.quickAckMode.sidecar_generated": "Sidecar sinh, có fallback",
"behavior.quickAckMode.llm_generated": "LLM sinh, có fallback",
"behavior.quickAckMode.fixed_template": "Mẫu cố định",
"behavior.quickAckMode.off": "Tắt",
"behavior.quickAckDelay": "Độ trễ fallback (ms)",
"behavior.quickAckTemplates": "Mẫu fallback",
"behavior.intermediate": "Phản hồi trung gian",
"behavior.intermediateHint": "Gửi cập nhật tiến độ tự nhiên trong giai đoạn chạy tool mà không thêm vào lịch sử session.",
"behavior.intermediatePurpose": "Mục đích: cho người dùng thấy tác vụ vẫn đang chạy trong các lượt xử lý lâu hoặc dùng nhiều tool. Đây là tin nhắn delivery-only từ sidecar, tách biệt với Show Reasoning và không nằm trong context của agent.",
"behavior.intermediateMode": "Chế độ",
"behavior.intermediateMode.sidecar_generated": "Sidecar sinh",
"behavior.intermediateMode.off": "Tắt",
"behavior.provider": "Provider",
"behavior.providerPlaceholder": "Dùng provider của agent",
"behavior.model": "Model",
"behavior.modelPlaceholder": "Dùng model của agent",
"behavior.timeoutMs": "Timeout (ms)",
"behavior.maxTokens": "Token tối đa",
"behavior.maxChars": "Ký tự tối đa",
"behavior.finalSplit": "Tách phản hồi cuối",
"behavior.finalSplitHint": "Tách phản hồi cuối dài thành các đoạn tin nhắn an toàn.",
"behavior.finalSplitMinChars": "Ký tự tối thiểu",
@@ -150,7 +156,7 @@
"behavior.finalSplitDelay": "Độ trễ (ms)",
"behavior.preview": "Xem trước",
"behavior.previewAck": "Xác nhận",
"behavior.previewGeneratedAck": "LLM chính sinh; fallback: {{fallback}}",
"behavior.previewGeneratedAck": "Model delivery sidecar sinh; fallback: {{fallback}}",
"behavior.previewNoAck": "Mẫu này không gửi xác nhận.",
"behavior.previewParts": "{{count}} phần phản hồi cuối",
"behavior.pendingCompactionTitle": "Nén tin nhắn chờ",
@@ -30,12 +30,6 @@
"ux": {
"title": "Hành vi UX",
"description": "Điều khiển cách gateway hiển thị hoạt động của agent cho người dùng.",
"toolStatus": "Hiện tên công cụ khi thực thi",
"toolStatusHint": "Hiển thị tên công cụ đang chạy khi agent hoạt động.",
"toolStatusInfo": "Tên công cụ được hiện khi agent đang chạy.",
"blockReply": "Hiện văn bản trung gian quanh lúc gọi công cụ",
"blockReplyHint": "Gửi thông báo dùng công cụ và nội dung phụ trợ trước hoặc trong khi chạy công cụ.",
"blockReplyInfo": "Văn bản trung gian được gửi cho người dùng trước hoặc trong khi thực thi công cụ.",
"intentClassify": "Phân loại ý định",
"intentClassifyHint": "Phân loại ý định người dùng trước khi chuyển tới agent.",
"intentClassifyInfo": "Tin nhắn được phân loại để tối ưu định tuyến agent."
+14
View File
@@ -1106,6 +1106,20 @@
"changeFile": "更换文件",
"startImport": "开始导入"
},
"delivery": {
"title": "投递行为",
"description": "Agent 级 Quick Acknowledgement 和 Intermediate Replies 覆盖。Channel 设置仍然优先。",
"inherit": "继承",
"custom": "自定义",
"quickAck": "快速确认",
"intermediate": "中间回复",
"provider": "Provider",
"providerPlaceholder": "使用 agent provider",
"model": "Model",
"modelPlaceholder": "使用 agent model",
"quickAckDelay": "确认延迟(毫秒)",
"timeoutMs": "超时(毫秒)"
},
"toast": {
"created": "代理已创建",
"createFailed": "创建代理失败",
-6
View File
@@ -309,7 +309,6 @@
"media_max_mb": { "label": "最大媒体大小 (MB)" },
"link_preview": { "label": "链接预览" },
"allow_from": { "label": "允许的用户" },
"block_reply": { "label": "分块回复", "help": "在工具迭代前后发送工具公告和中间文本" },
"domain": { "label": "域名" },
"connection_mode": { "label": "连接模式", "help": "WebSocket 无需公网 IP — 仅需出站连接" },
"webhook_port": { "label": "Webhook 端口", "help": "0 = 共享主网关端口(推荐)" },
@@ -337,11 +336,6 @@
}
},
"fieldOptions": {
"block_reply": {
"inherit": "继承网关设置",
"true": "启用",
"false": "禁用"
},
"dm_policy": {
"pairing": "配对(需要验证码)",
"open": "开放(接受所有)",
+17 -11
View File
@@ -35,8 +35,6 @@
"gateway.allowedOrigins": "允许的来源",
"gateway.allowedOriginsTip": "HTTP API 的 CORS 允许来源。使用 * 允许所有来源。",
"gateway.allowedOriginsPlaceholder": "https://yourdomain.com",
"gateway.toolStatus": "工具状态消息",
"gateway.blockReply": "中间回复",
"gateway.maxMessageChars": "最大消息字符数",
"gateway.maxMessageCharsTip": "每条入站消息允许的最大字符数,超出限制的消息将被拒绝。",
"gateway.rateLimitRpm": "速率限制(RPM)",
@@ -122,27 +120,35 @@
"behavior.uxTitle": "界面行为",
"behavior.uxDescription": "影响网关与用户交互方式的高影响开关",
"behavior.toolStatusHint": "在 Agent 运行期间向用户显示实时工具执行状态消息。",
"behavior.toolStatusPurpose": "用途:显示来自工具调用的确定性技术进度,例如读取文件、运行命令或调用 API。适合调试和更详细的频道反馈;可能与 reasoning 和中间回复重叠。",
"behavior.toolStatusInfo": "当 Agent 读取文件、运行命令和调用 API 时,用户将看到实时更新。",
"behavior.blockReplyHint": "在工具调用前或调用过程中向频道发送工具公告和部分助手文本。",
"behavior.blockReplyPurpose": "用途:让助手在最终答案前发送更自然的中间进度,尤其是在工具执行前后。适合需要更拟人化反馈的非流式频道。",
"behavior.blockReplyInfo": "工具执行前或执行期间向用户发送中间文本,而非仅发送最终响应。",
"behavior.intentClassifyHint": "在路由之前对用户意图进行分类,以减少不必要的 Agent 调用。",
"behavior.intentClassifyInfo": "仅当分类器检测到可执行意图时才调用 Agent。",
"behavior.chatTitle": "拟人化频道投递",
"behavior.chatDescription": "为非流式频道提供快速确认和安全的最终回复拆分",
"behavior.chatDescription": "为非流式频道提供 sidecar 确认、中间进度和安全的最终回复拆分",
"behavior.chatEnabled": "启用频道投递行为",
"behavior.chatEnabledHint": "全局生效,可按频道覆盖。",
"behavior.quickAck": "快速确认",
"behavior.quickAckHint": "优先使用主 LLM 的 block reply;模板仅作为回退。",
"behavior.quickAckHint": "使用 sidecar provider/model 发送简短确认;模板仅作为回退。",
"behavior.quickAckPurpose": "用途:在较长任务开始前发送简短确认,让用户知道消息已被接收。这是确认,不是 reasoning 或工具进度。",
"behavior.quickAckMode": "模式",
"behavior.quickAckMode.sidecar_generated": "Sidecar 生成并带回退",
"behavior.quickAckMode.llm_generated": "LLM 生成并带回退",
"behavior.quickAckMode.fixed_template": "固定模板",
"behavior.quickAckMode.off": "关闭",
"behavior.quickAckDelay": "回退延迟(毫秒)",
"behavior.quickAckTemplates": "回退模板",
"behavior.intermediate": "中间回复",
"behavior.intermediateHint": "在工具阶段发送自然的进度更新,且不写入会话历史。",
"behavior.intermediatePurpose": "用途:让用户在较长或工具密集的运行中看到任务仍在推进。这些是 sidecar delivery-only 消息,独立于 Show Reasoning,不进入 agent context。",
"behavior.intermediateMode": "模式",
"behavior.intermediateMode.sidecar_generated": "Sidecar 生成",
"behavior.intermediateMode.off": "关闭",
"behavior.provider": "Provider",
"behavior.providerPlaceholder": "使用 agent provider",
"behavior.model": "Model",
"behavior.modelPlaceholder": "使用 agent model",
"behavior.timeoutMs": "超时(毫秒)",
"behavior.maxTokens": "最大 token",
"behavior.maxChars": "最大字符数",
"behavior.finalSplit": "最终回复拆分",
"behavior.finalSplitHint": "将较长最终回复拆成安全的段落消息。",
"behavior.finalSplitMinChars": "最小字符数",
@@ -150,7 +156,7 @@
"behavior.finalSplitDelay": "延迟(毫秒)",
"behavior.preview": "预览",
"behavior.previewAck": "确认",
"behavior.previewGeneratedAck": "由主 LLM 生成;回退:{{fallback}}",
"behavior.previewGeneratedAck": "由 sidecar delivery model 生成;回退:{{fallback}}",
"behavior.previewNoAck": "此示例不会发送确认。",
"behavior.previewParts": "{{count}} 个最终回复片段",
"behavior.pendingCompactionTitle": "待处理消息压缩",
@@ -30,12 +30,6 @@
"ux": {
"title": "UX行为",
"description": "控制网关如何向用户展示Agent活动。",
"toolStatus": "执行时显示工具名称",
"toolStatusHint": "Agent运行时显示当前工具名称。",
"toolStatusInfo": "Agent运行时在流式预览中显示工具名称。",
"blockReply": "工具调用前后显示中间文本",
"blockReplyHint": "在工具运行前或运行时发送工具公告和部分助手文本。",
"blockReplyInfo": "工具执行前或执行期间向用户发送中间文本。",
"intentClassify": "意图分类",
"intentClassifyHint": "在路由到Agent之前分类用户意图。",
"intentClassifyInfo": "消息被分类以优化Agent路由和行为。"
@@ -2,13 +2,17 @@ import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Save, Settings, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import {
Dialog, DialogContent, DialogHeader, DialogTitle,
} from "@/components/ui/dialog";
import { ConfigGroupHeader } from "@/components/shared/config-group-header";
import type {
AgentData, ChatGPTOAuthRoutingConfig, CompactionConfig, ContextPruningConfig,
ModelFallbackConfig, ReasoningOverrideMode,
AgentData, ChatGPTOAuthRoutingConfig, CompactionConfig, ContextPruningConfig,
DeliveryBehaviorConfig, ModelFallbackConfig, ReasoningOverrideMode,
SandboxConfig, WorkspaceSharingConfig,
} from "@/types/agent";
import {
@@ -57,6 +61,8 @@ export function AgentAdvancedDialog({ open, onOpenChange, agent, onUpdate }: Age
const [chatgptRouting, setChatgptRouting] = useState<ChatGPTOAuthRoutingConfig>(init.chatgptRouting);
const [modelFallback, setModelFallback] = useState<ModelFallbackConfig>(init.modelFallback);
const [comp, setComp] = useState<CompactionConfig>(init.comp);
const [deliveryBehaviorMode, setDeliveryBehaviorMode] = useState(init.deliveryBehaviorMode);
const [deliveryBehavior, setDeliveryBehavior] = useState<DeliveryBehaviorConfig>(init.deliveryBehavior);
const [inboundDebounceMode, setInboundDebounceMode] = useState(init.inboundDebounceMode);
const [inboundDebounceMs, setInboundDebounceMs] = useState(init.inboundDebounceMs);
const [pruneEnabled, setPruneEnabled] = useState(init.pruneEnabled);
@@ -78,6 +84,8 @@ export function AgentAdvancedDialog({ open, onOpenChange, agent, onUpdate }: Age
setModelFallback(s.modelFallback);
setWsSharing(s.wsSharing);
setComp(s.comp);
setDeliveryBehaviorMode(s.deliveryBehaviorMode);
setDeliveryBehavior(s.deliveryBehavior);
setInboundDebounceMode(s.inboundDebounceMode);
setInboundDebounceMs(s.inboundDebounceMs);
setPruneEnabled(s.pruneEnabled);
@@ -133,6 +141,8 @@ export function AgentAdvancedDialog({ open, onOpenChange, agent, onUpdate }: Age
modelFallback,
wsSharing,
comp,
deliveryBehaviorMode,
deliveryBehavior,
inboundDebounceMode,
inboundDebounceMs,
pruneEnabled,
@@ -228,6 +238,13 @@ export function AgentAdvancedDialog({ open, onOpenChange, agent, onUpdate }: Age
onChange={setModelFallback}
/>
<DeliveryBehaviorSection
value={deliveryBehavior}
mode={deliveryBehaviorMode}
onModeChange={setDeliveryBehaviorMode}
onChange={setDeliveryBehavior}
/>
{/* Performance */}
<ConfigGroupHeader
title={t("configGroups.performance")}
@@ -270,3 +287,98 @@ export function AgentAdvancedDialog({ open, onOpenChange, agent, onUpdate }: Age
</Dialog>
);
}
function DeliveryBehaviorSection({
value,
mode,
onModeChange,
onChange,
}: {
value: DeliveryBehaviorConfig;
mode: "inherit" | "custom";
onModeChange: (mode: "inherit" | "custom") => void;
onChange: (value: DeliveryBehaviorConfig) => void;
}) {
const { t } = useTranslation("agents");
const disabled = mode === "inherit";
const patch = (updates: DeliveryBehaviorConfig) => onChange({ ...value, ...updates });
const patchQuick = (updates: NonNullable<DeliveryBehaviorConfig["quick_ack"]>) =>
patch({ quick_ack: { ...(value.quick_ack ?? {}), ...updates } });
const patchIntermediate = (updates: NonNullable<DeliveryBehaviorConfig["intermediate_replies"]>) =>
patch({ intermediate_replies: { ...(value.intermediate_replies ?? {}), ...updates } });
return (
<div className="space-y-3 rounded-md border p-4">
<div className="flex items-start justify-between gap-4">
<div>
<h3 className="text-sm font-medium">{t("delivery.title")}</h3>
<p className="text-xs text-muted-foreground">{t("delivery.description")}</p>
</div>
<Select value={mode} onValueChange={(next) => onModeChange(next as "inherit" | "custom")}>
<SelectTrigger className="w-32 text-base md:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="inherit">{t("delivery.inherit")}</SelectItem>
<SelectItem value="custom">{t("delivery.custom")}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-3">
<div className="flex items-center justify-between gap-3">
<Label>{t("delivery.quickAck")}</Label>
<Switch checked={value.quick_ack?.enabled ?? true} disabled={disabled} onCheckedChange={(enabled) => patchQuick({ enabled })} />
</div>
<DeliveryProviderFields
disabled={disabled}
provider={value.quick_ack?.provider ?? ""}
model={value.quick_ack?.model ?? ""}
onProvider={(provider) => patchQuick({ provider })}
onModel={(model) => patchQuick({ model })}
/>
<NumberField label={t("delivery.quickAckDelay")} value={value.quick_ack?.min_delay_ms ?? 1000} disabled={disabled} onChange={(min_delay_ms) => patchQuick({ min_delay_ms })} />
</div>
<div className="space-y-3">
<div className="flex items-center justify-between gap-3">
<Label>{t("delivery.intermediate")}</Label>
<Switch checked={value.intermediate_replies?.enabled ?? false} disabled={disabled} onCheckedChange={(enabled) => patchIntermediate({ enabled })} />
</div>
<DeliveryProviderFields
disabled={disabled}
provider={value.intermediate_replies?.provider ?? ""}
model={value.intermediate_replies?.model ?? ""}
onProvider={(provider) => patchIntermediate({ provider })}
onModel={(model) => patchIntermediate({ model })}
/>
<NumberField label={t("delivery.timeoutMs")} value={value.intermediate_replies?.timeout_ms ?? 2500} disabled={disabled} onChange={(timeout_ms) => patchIntermediate({ timeout_ms })} />
</div>
</div>
</div>
);
}
function DeliveryProviderFields({ disabled, provider, model, onProvider, onModel }: { disabled: boolean; provider: string; model: string; onProvider: (value: string) => void; onModel: (value: string) => void }) {
const { t } = useTranslation("agents");
return (
<div className="grid gap-3 sm:grid-cols-2">
<div className="grid gap-1.5">
<Label>{t("delivery.provider")}</Label>
<Input className="text-base md:text-sm" value={provider} disabled={disabled} placeholder={t("delivery.providerPlaceholder")} onChange={(e) => onProvider(e.target.value)} />
</div>
<div className="grid gap-1.5">
<Label>{t("delivery.model")}</Label>
<Input className="text-base md:text-sm" value={model} disabled={disabled} placeholder={t("delivery.modelPlaceholder")} onChange={(e) => onModel(e.target.value)} />
</div>
</div>
);
}
function NumberField({ label, value, disabled, onChange }: { label: string; value: number; disabled: boolean; onChange: (value: number) => void }) {
return (
<div className="grid gap-1.5">
<Label>{label}</Label>
<Input className="text-base md:text-sm" type="number" min={0} value={value} disabled={disabled} onChange={(e) => onChange(Number(e.target.value))} />
</div>
);
}
@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import type { AgentData, DeliveryBehaviorConfig } from "@/types/agent";
import { buildAdvancedUpdatePayload } from "./agent-advanced-state-utils";
function buildPayload(deliveryBehaviorMode: "inherit" | "custom", deliveryBehavior: DeliveryBehaviorConfig) {
return buildAdvancedUpdatePayload({
agent: {
id: "agent-1",
agent_key: "support",
provider: "openai",
model: "gpt-4.1-mini",
other_config: {
keep_me: true,
delivery_behavior: {
quick_ack: { enabled: false },
},
},
} as unknown as AgentData,
currentProvider: undefined,
providersLoading: false,
providerModelsLoading: false,
expertReasoningAvailable: false,
reasoningMode: "inherit",
reasoningEffort: "off",
reasoningExpert: false,
reasoningFallback: "downgrade",
thinkingLevel: "off",
chatgptRouting: {},
modelFallback: { enabled: false, strategy: "priority_order", candidates: [] },
wsSharing: {},
comp: {},
deliveryBehaviorMode,
deliveryBehavior,
inboundDebounceMode: "inherit",
inboundDebounceMs: 0,
pruneEnabled: false,
prune: {},
sbEnabled: false,
sb: {},
});
}
describe("agent advanced delivery behavior payload", () => {
it("saves custom delivery behavior without overwriting unrelated other_config", () => {
const deliveryBehavior: DeliveryBehaviorConfig = {
enabled: true,
intermediate_replies: { enabled: true, mode: "sidecar_generated", provider: "groq" },
quick_ack: { enabled: true, mode: "sidecar_generated", provider: "groq" },
};
const payload = buildPayload("custom", deliveryBehavior);
expect(payload.other_config).toMatchObject({
keep_me: true,
delivery_behavior: deliveryBehavior,
});
});
it("removes only delivery_behavior when inheriting", () => {
const payload = buildPayload("inherit", {});
expect(payload.other_config).toEqual({ keep_me: true });
});
});
@@ -3,6 +3,7 @@ import type {
ChatGPTOAuthRoutingConfig,
CompactionConfig,
ContextPruningConfig,
DeliveryBehaviorConfig,
InboundDebounceOverrideMode,
ModelFallbackConfig,
ReasoningOverrideMode,
@@ -34,6 +35,8 @@ export interface AdvancedDialogState {
modelFallback: ModelFallbackConfig;
wsSharing: WorkspaceSharingConfig;
comp: CompactionConfig;
deliveryBehaviorMode: "inherit" | "custom";
deliveryBehavior: DeliveryBehaviorConfig;
inboundDebounceMode: InboundDebounceOverrideMode;
inboundDebounceMs: number;
pruneEnabled: boolean;
@@ -80,6 +83,9 @@ export function deriveState(
const draftRouting = buildDraftRouting(routing);
const otherConfig = (agent.other_config ?? {}) as Record<string, unknown>;
const rawInboundDebounceMs = otherConfig.inbound_debounce_ms;
const rawDeliveryBehavior = isPlainObject(otherConfig.delivery_behavior)
? otherConfig.delivery_behavior as DeliveryBehaviorConfig
: undefined;
const inboundDebounceMs =
typeof rawInboundDebounceMs === "number" && Number.isFinite(rawInboundDebounceMs)
? Math.max(0, Math.trunc(rawInboundDebounceMs))
@@ -109,6 +115,12 @@ export function deriveState(
{}
) as WorkspaceSharingConfig,
comp: agent.compaction_config ?? {},
deliveryBehaviorMode: rawDeliveryBehavior ? "custom" : "inherit",
deliveryBehavior: rawDeliveryBehavior ?? {
enabled: true,
intermediate_replies: { enabled: false, mode: "sidecar_generated", timeout_ms: 2500, max_tokens: 60, max_chars: 180 },
quick_ack: { enabled: true, mode: "sidecar_generated", min_delay_ms: 1000, timeout_ms: 2500, max_tokens: 40, max_chars: 120 },
},
inboundDebounceMode: inboundDebounceMs === undefined ? "inherit" : "custom",
inboundDebounceMs: inboundDebounceMs ?? 0,
pruneEnabled: agent.context_pruning?.mode === "cache-ttl",
@@ -133,6 +145,8 @@ export interface BuildAdvancedUpdatePayloadParams {
modelFallback: ModelFallbackConfig;
wsSharing: WorkspaceSharingConfig;
comp: CompactionConfig;
deliveryBehaviorMode: "inherit" | "custom";
deliveryBehavior: DeliveryBehaviorConfig;
inboundDebounceMode: InboundDebounceOverrideMode;
inboundDebounceMs: number;
pruneEnabled: boolean;
@@ -148,7 +162,7 @@ export function buildAdvancedUpdatePayload(
agent, currentProvider, providersLoading, providerModelsLoading,
expertReasoningAvailable, reasoningMode, reasoningEffort, reasoningExpert,
reasoningFallback, thinkingLevel, chatgptRouting, wsSharing,
modelFallback, comp, inboundDebounceMode, inboundDebounceMs,
modelFallback, comp, deliveryBehaviorMode, deliveryBehavior, inboundDebounceMode, inboundDebounceMs,
pruneEnabled, prune, sbEnabled, sb,
} = params;
@@ -175,6 +189,11 @@ export function buildAdvancedUpdatePayload(
inboundDebounceMode,
inboundDebounceMs,
);
updates.other_config = buildOtherConfigWithDeliveryBehavior(
updates.other_config,
deliveryBehaviorMode,
deliveryBehavior,
);
// Build reasoning_config and thinking_level as top-level fields
if (reasoningMode === "inherit") {
@@ -211,6 +230,20 @@ export function buildAdvancedUpdatePayload(
return updates;
}
function buildOtherConfigWithDeliveryBehavior(
base: unknown,
mode: "inherit" | "custom",
value: DeliveryBehaviorConfig,
): Record<string, unknown> | null {
const bag = isPlainObject(base) ? { ...base } : {};
if (mode === "inherit") {
delete bag.delivery_behavior;
} else {
bag.delivery_behavior = value;
}
return Object.keys(bag).length > 0 ? bag : null;
}
function buildOtherConfigWithInboundDebounce(
base: unknown,
mode: InboundDebounceOverrideMode,
@@ -26,7 +26,7 @@ const ESSENTIAL_CONFIG_KEYS = new Set(["dm_policy", "group_policy", "require_men
const NETWORK_KEYS = new Set(["api_server", "proxy", "domain", "connection_mode", "webhook_port", "webhook_path", "webhook_url"]);
const LIMITS_KEYS = new Set(["history_limit", "media_max_mb", "text_chunk_limit"]);
const STREAMING_KEYS = new Set(["dm_stream", "group_stream", "draft_transport", "reasoning_delivery", "native_stream", "debounce_delay", "thread_ttl"]);
const BEHAVIOR_KEYS = new Set(["reaction_level", "link_preview", "block_reply", "render_mode", "topic_session_mode"]);
const BEHAVIOR_KEYS = new Set(["reaction_level", "link_preview", "render_mode", "topic_session_mode"]);
const ACCESS_KEYS = new Set(["allow_from", "group_allow_from"]);
function getAdvancedFields(channelType: string) {
@@ -36,7 +36,7 @@ function getAdvancedFields(channelType: string) {
network: advanced.filter((f) => NETWORK_KEYS.has(f.key)),
limits: advanced.filter((f) => LIMITS_KEYS.has(f.key)),
streaming: advanced.filter((f) => STREAMING_KEYS.has(f.key)),
behavior: advanced.filter((f) => BEHAVIOR_KEYS.has(f.key)),
behavior: advanced.filter((f) => BEHAVIOR_KEYS.has(f.key) || f.key.startsWith("chat_behavior.")),
access: advanced.filter((f) => ACCESS_KEYS.has(f.key)),
};
}
@@ -22,6 +22,18 @@ describe("telegram configSchema", () => {
reasoning_delivery: "always_bubbles",
});
});
it("exposes independent sidecar delivery overrides", () => {
const keys = new Set(telegramConfig.map((field) => field.key));
expect(keys.has("chat_behavior.intermediate_replies.enabled")).toBe(true);
expect(keys.has("chat_behavior.intermediate_replies.provider")).toBe(true);
expect(keys.has("chat_behavior.intermediate_replies.model")).toBe(true);
expect(keys.has("chat_behavior.quick_ack.provider")).toBe(true);
expect(keys.has("chat_behavior.quick_ack.model")).toBe(true);
const quickMode = telegramConfig.find((field) => field.key === "chat_behavior.quick_ack.mode")!;
expect(quickMode.help).not.toMatch(/main LLM block reply/i);
expect(quickMode.options!.map((option) => option.value)).toContain("sidecar_generated");
});
});
describe("pancake configSchema", () => {
+19 -16
View File
@@ -21,7 +21,7 @@ export interface FieldDef {
// --- Shared option lists ---
const blockReplyOptions = [
const inheritToggleOptions = [
{ value: "inherit", label: "Inherit from gateway" },
{ value: "true", label: "Enabled" },
{ value: "false", label: "Disabled" },
@@ -29,16 +29,29 @@ const blockReplyOptions = [
const quickAckModeOptions = [
{ value: "inherit", label: "Inherit from gateway" },
{ value: "llm_generated", label: "LLM-generated with fallback" },
{ value: "sidecar_generated", label: "Sidecar-generated with fallback" },
{ value: "llm_generated", label: "Legacy generated with fallback" },
{ value: "fixed_template", label: "Fixed template" },
{ value: "off", label: "Off" },
];
const chatBehaviorOverrideFields: FieldDef[] = [
{ key: "chat_behavior.enabled", label: "Human-like Delivery", type: "select", options: blockReplyOptions, defaultValue: "inherit", help: "Override gateway quick acknowledgement and final reply splitting." },
{ key: "chat_behavior.quick_ack.enabled", label: "Quick Acknowledgement", type: "select", options: blockReplyOptions, defaultValue: "inherit", help: "Override quick acknowledgement for this channel." },
{ key: "chat_behavior.quick_ack.mode", label: "Quick Ack Mode", type: "select", options: quickAckModeOptions, defaultValue: "inherit", help: "Generated uses the main LLM block reply; templates are fallback only." },
{ key: "chat_behavior.final_split.enabled", label: "Final Reply Splitting", type: "select", options: blockReplyOptions, defaultValue: "inherit", help: "Override final multi-message splitting for this channel." },
{ key: "chat_behavior.enabled", label: "Human-like Delivery", type: "select", options: inheritToggleOptions, defaultValue: "inherit", help: "Override gateway acknowledgement, intermediate progress, and final reply splitting." },
{ key: "chat_behavior.intermediate_replies.enabled", label: "Intermediate Replies", type: "select", options: inheritToggleOptions, defaultValue: "inherit", help: "Send natural sidecar progress updates during tool phases without adding them to session history." },
{ key: "chat_behavior.intermediate_replies.mode", label: "Intermediate Reply Mode", type: "select", options: [{ value: "inherit", label: "Inherit from gateway" }, { value: "sidecar_generated", label: "Sidecar-generated" }, { value: "off", label: "Off" }], defaultValue: "inherit" },
{ key: "chat_behavior.intermediate_replies.provider", label: "Intermediate Provider", type: "text", placeholder: "Use agent provider", help: "Optional cheap provider for sidecar progress updates." },
{ key: "chat_behavior.intermediate_replies.model", label: "Intermediate Model", type: "text", placeholder: "Use agent model" },
{ key: "chat_behavior.intermediate_replies.timeout_ms", label: "Intermediate Timeout (ms)", type: "number", defaultValue: 2500 },
{ key: "chat_behavior.intermediate_replies.max_tokens", label: "Intermediate Max Tokens", type: "number", defaultValue: 60 },
{ key: "chat_behavior.intermediate_replies.max_chars", label: "Intermediate Max Chars", type: "number", defaultValue: 180 },
{ key: "chat_behavior.quick_ack.enabled", label: "Quick Acknowledgement", type: "select", options: inheritToggleOptions, defaultValue: "inherit", help: "Override quick acknowledgement for this channel." },
{ key: "chat_behavior.quick_ack.mode", label: "Quick Ack Mode", type: "select", options: quickAckModeOptions, defaultValue: "inherit", help: "Generated acknowledgement uses a sidecar provider/model; templates are fallback only." },
{ key: "chat_behavior.quick_ack.provider", label: "Quick Ack Provider", type: "text", placeholder: "Use agent provider", help: "Optional cheap provider for quick acknowledgements." },
{ key: "chat_behavior.quick_ack.model", label: "Quick Ack Model", type: "text", placeholder: "Use agent model" },
{ key: "chat_behavior.quick_ack.timeout_ms", label: "Quick Ack Timeout (ms)", type: "number", defaultValue: 2500 },
{ key: "chat_behavior.quick_ack.max_tokens", label: "Quick Ack Max Tokens", type: "number", defaultValue: 40 },
{ key: "chat_behavior.quick_ack.max_chars", label: "Quick Ack Max Chars", type: "number", defaultValue: 120 },
{ key: "chat_behavior.final_split.enabled", label: "Final Reply Splitting", type: "select", options: inheritToggleOptions, defaultValue: "inherit", help: "Override final multi-message splitting for this channel." },
];
const dmPolicyOptions = [
@@ -143,7 +156,6 @@ export const configSchema: Record<string, FieldDef[]> = {
{ key: "media_max_mb", label: "Max Media Size (MB)", type: "number", defaultValue: 20, help: "Default: 20 MB (cloud API). Increase when using local Bot API server." },
{ key: "link_preview", label: "Link Preview", type: "boolean", defaultValue: true },
{ key: "allow_from", label: "Allowed Users", type: "tags", help: "User IDs or @usernames, one per line or comma-separated" },
{ key: "block_reply", label: "Block Reply", type: "select", options: blockReplyOptions, defaultValue: "inherit", help: "Deliver intermediate text during tool iterations" },
...chatBehaviorOverrideFields,
],
discord: [
@@ -152,7 +164,6 @@ export const configSchema: Record<string, FieldDef[]> = {
{ key: "require_mention", label: "Require @mention in groups", type: "boolean", defaultValue: true },
{ key: "history_limit", label: "Group History Limit", type: "number", defaultValue: 50, help: "Max pending group messages for context (0 = disabled)" },
{ key: "allow_from", label: "Allowed Users", type: "tags", help: "Discord user IDs" },
{ key: "block_reply", label: "Block Reply", type: "select", options: blockReplyOptions, defaultValue: "inherit", help: "Deliver intermediate text during tool iterations" },
...chatBehaviorOverrideFields,
],
slack: [
@@ -167,7 +178,6 @@ export const configSchema: Record<string, FieldDef[]> = {
{ key: "thread_ttl", label: "Thread Participation TTL (hours)", type: "number", defaultValue: 24, help: "Hours before bot stops auto-replying in threads it participated in. 0 = always require @mention." },
{ key: "reaction_level", label: "Reaction Level", type: "select", options: [{ value: "off", label: "Off" }, { value: "minimal", label: "Minimal (thinking + done)" }, { value: "full", label: "Full (all status emoji)" }], defaultValue: "off", help: "Show emoji reactions on user messages during agent processing" },
{ key: "allow_from", label: "Allowed Users", type: "tags", help: "Slack user IDs (U...) allowed to interact; empty = no allowlist filter" },
{ key: "block_reply", label: "Block Reply", type: "select", options: blockReplyOptions, defaultValue: "inherit", help: "Deliver intermediate text during tool iterations" },
...chatBehaviorOverrideFields,
],
feishu: [
@@ -186,7 +196,6 @@ export const configSchema: Record<string, FieldDef[]> = {
{ key: "reaction_level", label: "Reaction Level", type: "select", options: [{ value: "off", label: "Off" }, { value: "minimal", label: "Minimal" }, { value: "full", label: "Full" }], defaultValue: "off", help: "Typing emoji reaction on user messages while bot is processing" },
{ key: "allow_from", label: "Allowed Users", type: "tags", help: "Lark open_ids (ou_...)" },
{ key: "group_allow_from", label: "Group Allowed Users", type: "tags", help: "Separate allowlist for group senders" },
{ key: "block_reply", label: "Block Reply", type: "select", options: blockReplyOptions, defaultValue: "inherit", help: "Deliver intermediate text during tool iterations" },
...chatBehaviorOverrideFields,
],
zalo_oa: [
@@ -194,7 +203,6 @@ export const configSchema: Record<string, FieldDef[]> = {
{ key: "webhook_url", label: "Webhook URL", type: "text", placeholder: "https://..." },
{ key: "media_max_mb", label: "Max Media Size (MB)", type: "number", defaultValue: 5 },
{ key: "allow_from", label: "Allowed Users", type: "tags", help: "Zalo user IDs" },
{ key: "block_reply", label: "Block Reply", type: "select", options: blockReplyOptions, defaultValue: "inherit", help: "Deliver intermediate text during tool iterations" },
...chatBehaviorOverrideFields,
],
zalo_personal: [
@@ -202,7 +210,6 @@ export const configSchema: Record<string, FieldDef[]> = {
{ key: "group_policy", label: "Group Policy", type: "select", options: groupPolicyOptions, defaultValue: "allowlist" },
{ key: "require_mention", label: "Require @mention in groups", type: "boolean", defaultValue: true },
{ key: "allow_from", label: "Allowed Users", type: "tags", help: "Zalo user IDs or group IDs" },
{ key: "block_reply", label: "Block Reply", type: "select", options: blockReplyOptions, defaultValue: "inherit", help: "Deliver intermediate text during tool iterations" },
...chatBehaviorOverrideFields,
],
whatsapp: [
@@ -210,7 +217,6 @@ export const configSchema: Record<string, FieldDef[]> = {
{ key: "group_policy", label: "Group Policy", type: "select", options: groupPolicyOptions, defaultValue: "pairing" },
{ key: "require_mention", label: "Require @Mention in Groups", type: "boolean", help: "Only respond in group chats when the bot is explicitly @mentioned" },
{ key: "allow_from", label: "Allowed Users", type: "tags", help: "WhatsApp user IDs" },
{ key: "block_reply", label: "Block Reply", type: "select", options: blockReplyOptions, defaultValue: "inherit", help: "Deliver intermediate text during tool iterations" },
...chatBehaviorOverrideFields,
],
facebook: [
@@ -224,7 +230,6 @@ export const configSchema: Record<string, FieldDef[]> = {
{ key: "post_context_cache_ttl", label: "Post Cache TTL", type: "text", placeholder: "e.g. 15m" },
{ key: "first_inbox_message", label: "First Inbox DM Text", type: "textarea", help: "Custom DM sent to first-time commenters. Defaults to Vietnamese if empty." },
{ key: "allow_from", label: "Allowed Users", type: "tags", help: "Facebook user IDs" },
{ key: "block_reply", label: "Block Reply", type: "select", options: blockReplyOptions, defaultValue: "inherit" },
],
pancake: [
{ key: "page_id", label: "Page ID", type: "text", required: true, help: "Pancake internal page ID (numeric, from Pancake dashboard)" },
@@ -258,7 +263,6 @@ export const configSchema: Record<string, FieldDef[]> = {
showWhen: { key: "features.auto_react", value: "true" },
help: "Never react to comments from these user IDs." },
{ key: "allow_from", label: "Allowed Users", type: "tags", help: "Sender IDs to whitelist. Empty = accept all." },
{ key: "block_reply", label: "Block Reply", type: "select", options: blockReplyOptions, defaultValue: "inherit" },
...chatBehaviorOverrideFields,
],
bitrix24: [
@@ -282,7 +286,6 @@ export const configSchema: Record<string, FieldDef[]> = {
{ key: "media_max_mb", label: "Max Media Size (MB)", type: "number", defaultValue: 20, help: "Max inbound media download size." },
{ key: "allow_from", label: "Allowed Users (DM)", type: "tags", help: "Bitrix24 user IDs allowed to DM the bot. Empty = no allowlist filter." },
{ key: "group_allow_from", label: "Allowed Users (Group)", type: "tags", help: "Separate allowlist for group senders." },
{ key: "block_reply", label: "Block Reply", type: "select", options: blockReplyOptions, defaultValue: "inherit", help: "Deliver intermediate text during tool iterations." },
...chatBehaviorOverrideFields,
{ key: "mcp_server_name", label: "MCP Server Name", type: "text", advanced: true, placeholder: "bitrix24-prod", help: "Optional — name from mcp_servers table. Must be set together with MCP Base URL to enable per-user MCP credential auto-onboard. Leave both empty to disable." },
{ key: "mcp_base_url", label: "MCP Base URL", type: "text", advanced: true, placeholder: "https://mcp.example.com", help: "Optional — HTTPS root of the partner MCP server. Channel POSTs {mcp_base_url}/api/auto-onboard to mint per-user credentials on first-sight. The MCP server authenticates each call via the caller's Bitrix access_token, so no admin secret is required." },
@@ -11,8 +11,10 @@ import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { InfoLabel } from "@/components/shared/info-label";
const quickAckModeValues = ["llm_generated", "fixed_template", "off"] as const;
const quickAckModeValues = ["sidecar_generated", "llm_generated", "fixed_template", "off"] as const;
type QuickAckMode = (typeof quickAckModeValues)[number];
const intermediateModeValues = ["sidecar_generated", "off"] as const;
type IntermediateMode = (typeof intermediateModeValues)[number];
export interface ChatBehaviorValues {
enabled?: boolean;
@@ -20,8 +22,22 @@ export interface ChatBehaviorValues {
enabled?: boolean;
mode?: QuickAckMode;
min_delay_ms?: number;
provider?: string;
model?: string;
timeout_ms?: number;
max_tokens?: number;
max_chars?: number;
templates?: string[];
};
intermediate_replies?: {
enabled?: boolean;
mode?: IntermediateMode;
provider?: string;
model?: string;
timeout_ms?: number;
max_tokens?: number;
max_chars?: number;
};
final_split?: {
enabled?: boolean;
min_chars?: number;
@@ -73,6 +89,8 @@ export function BehaviorChatCard({ value, onChange }: Props) {
const patch = (updates: ChatBehaviorValues) => onChange({ ...value, ...updates });
const patchAck = (updates: NonNullable<ChatBehaviorValues["quick_ack"]>) =>
patch({ quick_ack: { ...(value.quick_ack ?? {}), ...updates } });
const patchIntermediate = (updates: NonNullable<ChatBehaviorValues["intermediate_replies"]>) =>
patch({ intermediate_replies: { ...(value.intermediate_replies ?? {}), ...updates } });
const patchSplit = (updates: NonNullable<ChatBehaviorValues["final_split"]>) =>
patch({ final_split: { ...(value.final_split ?? {}), ...updates } });
@@ -94,7 +112,7 @@ export function BehaviorChatCard({ value, onChange }: Props) {
<Switch checked={value.enabled ?? false} onCheckedChange={(enabled) => patch({ enabled })} />
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<div className="space-y-3 rounded-md border p-3">
<div className="flex items-start justify-between gap-4">
<div>
@@ -128,6 +146,7 @@ export function BehaviorChatCard({ value, onChange }: Props) {
<Label htmlFor="chat-behavior-ack-delay">{t("behavior.quickAckDelay")}</Label>
<Input
id="chat-behavior-ack-delay"
className="text-base md:text-sm"
type="number"
min={0}
value={value.quick_ack?.min_delay_ms ?? 1000}
@@ -135,10 +154,20 @@ export function BehaviorChatCard({ value, onChange }: Props) {
disabled={!value.enabled}
/>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<TextField label={t("behavior.provider")} value={value.quick_ack?.provider ?? ""} disabled={!value.enabled} placeholder={t("behavior.providerPlaceholder")} onChange={(provider) => patchAck({ provider })} />
<TextField label={t("behavior.model")} value={value.quick_ack?.model ?? ""} disabled={!value.enabled} placeholder={t("behavior.modelPlaceholder")} onChange={(model) => patchAck({ model })} />
</div>
<div className="grid grid-cols-3 gap-3">
<NumberField label={t("behavior.timeoutMs")} value={value.quick_ack?.timeout_ms ?? 2500} disabled={!value.enabled} onChange={(timeout_ms) => patchAck({ timeout_ms })} />
<NumberField label={t("behavior.maxTokens")} value={value.quick_ack?.max_tokens ?? 40} disabled={!value.enabled} onChange={(max_tokens) => patchAck({ max_tokens })} />
<NumberField label={t("behavior.maxChars")} value={value.quick_ack?.max_chars ?? 120} disabled={!value.enabled} onChange={(max_chars) => patchAck({ max_chars })} />
</div>
<div className="grid gap-1.5">
<Label htmlFor="chat-behavior-ack-templates">{t("behavior.quickAckTemplates")}</Label>
<Textarea
id="chat-behavior-ack-templates"
className="text-base md:text-sm"
rows={3}
value={templatesText}
onChange={(e) => patchAck({ templates: e.target.value.split("\n").map((v) => v.trim()).filter(Boolean) })}
@@ -147,6 +176,46 @@ export function BehaviorChatCard({ value, onChange }: Props) {
</div>
</div>
<div className="space-y-3 rounded-md border p-3">
<div className="flex items-start justify-between gap-4">
<div>
<InfoLabel tip={t("behavior.intermediatePurpose")}>{t("behavior.intermediate")}</InfoLabel>
<p className="text-xs text-muted-foreground">{t("behavior.intermediateHint")}</p>
</div>
<Switch
checked={value.intermediate_replies?.enabled ?? false}
onCheckedChange={(enabled) => patchIntermediate({ enabled })}
disabled={!value.enabled}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="chat-behavior-intermediate-mode">{t("behavior.intermediateMode")}</Label>
<Select
value={value.intermediate_replies?.mode ?? "sidecar_generated"}
onValueChange={(mode) => patchIntermediate({ mode: mode as IntermediateMode })}
disabled={!value.enabled}
>
<SelectTrigger id="chat-behavior-intermediate-mode" className="text-base md:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{intermediateModeValues.map((mode) => (
<SelectItem key={mode} value={mode}>{t(`behavior.intermediateMode.${mode}`)}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<TextField label={t("behavior.provider")} value={value.intermediate_replies?.provider ?? ""} disabled={!value.enabled} placeholder={t("behavior.providerPlaceholder")} onChange={(provider) => patchIntermediate({ provider })} />
<TextField label={t("behavior.model")} value={value.intermediate_replies?.model ?? ""} disabled={!value.enabled} placeholder={t("behavior.modelPlaceholder")} onChange={(model) => patchIntermediate({ model })} />
</div>
<div className="grid grid-cols-3 gap-3">
<NumberField label={t("behavior.timeoutMs")} value={value.intermediate_replies?.timeout_ms ?? 2500} disabled={!value.enabled} onChange={(timeout_ms) => patchIntermediate({ timeout_ms })} />
<NumberField label={t("behavior.maxTokens")} value={value.intermediate_replies?.max_tokens ?? 60} disabled={!value.enabled} onChange={(max_tokens) => patchIntermediate({ max_tokens })} />
<NumberField label={t("behavior.maxChars")} value={value.intermediate_replies?.max_chars ?? 180} disabled={!value.enabled} onChange={(max_chars) => patchIntermediate({ max_chars })} />
</div>
</div>
<div className="space-y-3 rounded-md border p-3">
<div className="flex items-start justify-between gap-4">
<div>
@@ -191,11 +260,20 @@ function formatAckPreview(preview: PreviewResponse | null, t: (key: string, opti
return `${t("behavior.previewAck")}: ${ack.content ?? ""}`;
}
function TextField({ label, value, disabled, placeholder, onChange }: { label: string; value: string; disabled: boolean; placeholder?: string; onChange: (v: string) => void }) {
return (
<div className="grid gap-1.5">
<Label>{label}</Label>
<Input className="text-base md:text-sm" value={value} disabled={disabled} placeholder={placeholder} onChange={(e) => onChange(e.target.value)} />
</div>
);
}
function NumberField({ label, value, disabled, onChange }: { label: string; value: number; disabled: boolean; onChange: (v: number) => void }) {
return (
<div className="grid gap-1.5">
<Label>{label}</Label>
<Input type="number" min={0} value={value} disabled={disabled} onChange={(e) => onChange(Number(e.target.value))} />
<Input className="text-base md:text-sm" type="number" min={0} value={value} disabled={disabled} onChange={(e) => onChange(Number(e.target.value))} />
</div>
);
}
@@ -28,8 +28,6 @@ export function BehaviorSection({ config, onPatch, saving }: Props) {
// UX toggles (from gateway + agents.defaults)
const [ux, setUx] = useState({
tool_status: gw.tool_status !== false,
block_reply: gw.block_reply ?? false,
intent_classify: ag.intent_classify !== false,
});
@@ -63,8 +61,6 @@ export function BehaviorSection({ config, onPatch, saving }: Props) {
// Reset drafts when external config changes
useEffect(() => {
setUx({
tool_status: gw.tool_status !== false,
block_reply: gw.block_reply ?? false,
intent_classify: ag.intent_classify !== false,
});
setRate({
@@ -88,8 +84,6 @@ export function BehaviorSection({ config, onPatch, saving }: Props) {
const handleSave = () => {
onPatch({
gateway: {
tool_status: ux.tool_status,
block_reply: ux.block_reply,
max_message_chars: rate.max_message_chars,
rate_limit_rpm: rate.rate_limit_rpm,
inbound_debounce_ms: rate.inbound_debounce_ms,
@@ -130,10 +124,24 @@ function normalizeChatBehavior(value: any): ChatBehaviorValues {
enabled: value?.enabled ?? false,
quick_ack: {
enabled: value?.quick_ack?.enabled ?? true,
mode: value?.quick_ack?.mode ?? "llm_generated",
mode: value?.quick_ack?.mode ?? "sidecar_generated",
min_delay_ms: value?.quick_ack?.min_delay_ms ?? 1000,
provider: value?.quick_ack?.provider ?? "",
model: value?.quick_ack?.model ?? "",
timeout_ms: value?.quick_ack?.timeout_ms ?? 2500,
max_tokens: value?.quick_ack?.max_tokens ?? 40,
max_chars: value?.quick_ack?.max_chars ?? 120,
templates: value?.quick_ack?.templates ?? ["Got it. Working on it..."],
},
intermediate_replies: {
enabled: value?.intermediate_replies?.enabled ?? false,
mode: value?.intermediate_replies?.mode ?? "sidecar_generated",
provider: value?.intermediate_replies?.provider ?? "",
model: value?.intermediate_replies?.model ?? "",
timeout_ms: value?.intermediate_replies?.timeout_ms ?? 2500,
max_tokens: value?.intermediate_replies?.max_tokens ?? 60,
max_chars: value?.intermediate_replies?.max_chars ?? 180,
},
final_split: {
enabled: value?.final_split?.enabled ?? true,
min_chars: value?.final_split?.min_chars ?? 1200,
@@ -1,11 +1,9 @@
import { Eye, MessageSquareText, Brain } from "lucide-react";
import { Brain } from "lucide-react";
import { useTranslation } from "react-i18next";
import { FeatureSwitchGroup } from "@/components/shared/feature-switch-group";
import type { FeatureSwitchItem } from "@/components/shared/feature-switch-group";
interface UxValues {
tool_status: boolean;
block_reply: boolean;
intent_classify: boolean;
}
@@ -19,28 +17,6 @@ export function BehaviorUxCard({ value, onChange }: Props) {
const { t } = useTranslation("config");
const items: FeatureSwitchItem[] = [
{
icon: Eye,
iconClass: "text-blue-500",
label: t("gateway.toolStatus"),
hint: t("behavior.toolStatusHint"),
tooltip: t("behavior.toolStatusPurpose"),
checked: value.tool_status !== false,
onCheckedChange: (v) => onChange({ ...value, tool_status: v }),
infoWhenOn: t("behavior.toolStatusInfo"),
infoClass: "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-800 dark:bg-blue-950/30 dark:text-blue-300",
},
{
icon: MessageSquareText,
iconClass: "text-emerald-500",
label: t("gateway.blockReply"),
hint: t("behavior.blockReplyHint"),
tooltip: t("behavior.blockReplyPurpose"),
checked: value.block_reply ?? false,
onCheckedChange: (v) => onChange({ ...value, block_reply: v }),
infoWhenOn: t("behavior.blockReplyInfo"),
infoClass: "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-800 dark:bg-emerald-950/30 dark:text-emerald-300",
},
{
icon: Brain,
iconClass: "text-orange-500",
+18
View File
@@ -52,6 +52,24 @@ export interface ContextPruningConfig {
};
}
export interface DeliveryGeneratorConfig {
enabled?: boolean;
mode?: "sidecar_generated" | "llm_generated" | "fixed_template" | "off";
provider?: string;
model?: string;
timeout_ms?: number;
max_tokens?: number;
max_chars?: number;
min_delay_ms?: number;
templates?: string[];
}
export interface DeliveryBehaviorConfig {
enabled?: boolean;
intermediate_replies?: DeliveryGeneratorConfig;
quick_ack?: DeliveryGeneratorConfig;
}
export interface SandboxConfig {
mode?: "off" | "non-main" | "all";
image?: string;