mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 18:15:34 +00:00
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:
1 parent
23b18fa6cd
commit
fdba1c2138
46 files changed
+1960
-201
No files matched your search
+19
-9
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
+87
@@ -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.
|
||||
+119
@@ -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.
|
||||
+111
@@ -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.
|
||||
+94
@@ -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.
|
||||
+98
@@ -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.
|
||||
+78
@@ -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" },
|
||||
];
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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ả)",
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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": "创建代理失败",
|
||||
|
||||
@@ -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": "开放(接受所有)",
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user