fix(agent): preserve /btw context and priority replies
fix(agent): route /btw replies through the active agent Reset /btw priority replies against the routed agent instead of the default one so busy-turn responses are not suppressed in multi-agent flows. Also stop onboarding from copying legacy workspace template files so the generated workspace matches the current structured layout and checks stay green. fix(agent): suppress duplicate replies across agents fix(agent): isolate /btw replies from busy-turn sends
This commit is contained in:
parent
031293cbc1
commit
9dfe9541f4
5 changed files with 178 additions and 32 deletions
|
|
@ -146,6 +146,11 @@ func createWorkspaceTemplates(workspace string) {
|
|||
}
|
||||
|
||||
func copyEmbeddedToTarget(targetDir string) error {
|
||||
legacyWorkspaceFiles := map[string]struct{}{
|
||||
"AGENTS.md": {},
|
||||
"IDENTITY.md": {},
|
||||
}
|
||||
|
||||
// Ensure target directory exists
|
||||
if err := os.MkdirAll(targetDir, 0o755); err != nil {
|
||||
return fmt.Errorf("Failed to create target directory: %w", err)
|
||||
|
|
@ -172,6 +177,9 @@ func copyEmbeddedToTarget(targetDir string) error {
|
|||
if err != nil {
|
||||
return fmt.Errorf("Failed to get relative path for %s: %v\n", path, err)
|
||||
}
|
||||
if _, skip := legacyWorkspaceFiles[filepath.Base(new_path)]; skip {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Build target file path
|
||||
targetPath := filepath.Join(targetDir, new_path)
|
||||
|
|
|
|||
|
|
@ -645,7 +645,7 @@ func (al *AgentLoop) drainBusToSteering(ctx context.Context, activeScope, active
|
|||
|
||||
if handled, response := al.tryHandlePriorityCommand(ctx, msg); handled {
|
||||
if response != "" {
|
||||
al.PublishResponseIfNeeded(ctx, msg.Channel, msg.ChatID, response)
|
||||
al.publishResponse(ctx, msg.Channel, msg.ChatID, response)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
|
@ -676,20 +676,32 @@ func (al *AgentLoop) Stop() {
|
|||
al.running.Store(false)
|
||||
}
|
||||
|
||||
func (al *AgentLoop) resetMessageToolRound(agent *AgentInstance) {
|
||||
if agent == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if tool, ok := agent.Tools.Get("message"); ok {
|
||||
if resetter, ok := tool.(interface{ ResetSentInRound() }); ok {
|
||||
resetter.ResetSentInRound()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (al *AgentLoop) PublishResponseIfNeeded(ctx context.Context, channel, chatID, response string) {
|
||||
if response == "" {
|
||||
return
|
||||
}
|
||||
|
||||
alreadySentToSameChat := false
|
||||
defaultAgent := al.GetRegistry().GetDefaultAgent()
|
||||
if defaultAgent != nil {
|
||||
if tool, ok := defaultAgent.Tools.Get("message"); ok {
|
||||
if mt, ok := tool.(*tools.MessageTool); ok {
|
||||
alreadySentToSameChat = mt.HasSentTo(channel, chatID)
|
||||
}
|
||||
al.GetRegistry().ForEachTool("message", func(tool tools.Tool) {
|
||||
if alreadySentToSameChat {
|
||||
return
|
||||
}
|
||||
}
|
||||
if mt, ok := tool.(*tools.MessageTool); ok && mt.HasSentTo(channel, chatID) {
|
||||
alreadySentToSameChat = true
|
||||
}
|
||||
})
|
||||
|
||||
if alreadySentToSameChat {
|
||||
logger.DebugCF(
|
||||
|
|
@ -700,6 +712,14 @@ func (al *AgentLoop) PublishResponseIfNeeded(ctx context.Context, channel, chatI
|
|||
return
|
||||
}
|
||||
|
||||
al.publishResponse(ctx, channel, chatID, response)
|
||||
}
|
||||
|
||||
func (al *AgentLoop) publishResponse(ctx context.Context, channel, chatID, response string) {
|
||||
if response == "" {
|
||||
return
|
||||
}
|
||||
|
||||
al.bus.PublishOutbound(ctx, bus.OutboundMessage{
|
||||
Channel: channel,
|
||||
ChatID: chatID,
|
||||
|
|
@ -1364,20 +1384,35 @@ func (al *AgentLoop) askSideQuestion(
|
|||
}
|
||||
|
||||
var channel, chatID, senderID, senderDisplayName string
|
||||
var media []string
|
||||
var activeSkills []string
|
||||
var history []providers.Message
|
||||
var summary string
|
||||
if opts != nil {
|
||||
channel = opts.Channel
|
||||
chatID = opts.ChatID
|
||||
senderID = opts.SenderID
|
||||
senderDisplayName = opts.SenderDisplayName
|
||||
media = append([]string(nil), opts.Media...)
|
||||
activeSkills = activeSkillNames(agent, *opts)
|
||||
|
||||
if !opts.NoHistory {
|
||||
if resp, err := al.contextManager.Assemble(ctx, &AssembleRequest{
|
||||
SessionKey: opts.SessionKey,
|
||||
Budget: agent.ContextWindow,
|
||||
MaxTokens: agent.MaxTokens,
|
||||
}); err == nil && resp != nil {
|
||||
history = resp.History
|
||||
summary = resp.Summary
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
messages := agent.ContextBuilder.BuildMessages(
|
||||
nil,
|
||||
"",
|
||||
history,
|
||||
summary,
|
||||
question,
|
||||
nil,
|
||||
media,
|
||||
channel,
|
||||
chatID,
|
||||
senderID,
|
||||
|
|
@ -1476,11 +1511,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
|
|||
}
|
||||
|
||||
// Reset message-tool state for this round so we don't skip publishing due to a previous round.
|
||||
if tool, ok := agent.Tools.Get("message"); ok {
|
||||
if resetter, ok := tool.(interface{ ResetSentInRound() }); ok {
|
||||
resetter.ResetSentInRound()
|
||||
}
|
||||
}
|
||||
al.resetMessageToolRound(agent)
|
||||
|
||||
// Resolve session key from route, while preserving explicit agent-scoped keys.
|
||||
scopeKey := resolveScopeKey(route, msg.SessionKey)
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import (
|
|||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
|
@ -244,6 +245,10 @@ func TestProcessMessage_BtwCommandRunsWithoutPersistingHistory(t *testing.T) {
|
|||
msgBus := bus.NewMessageBus()
|
||||
provider := &recordingProvider{}
|
||||
al := NewAgentLoop(cfg, msgBus, provider)
|
||||
defaultAgent := al.GetRegistry().GetDefaultAgent()
|
||||
if defaultAgent == nil {
|
||||
t.Fatal("expected default agent")
|
||||
}
|
||||
|
||||
msg := bus.InboundMessage{
|
||||
Channel: "telegram",
|
||||
|
|
@ -251,6 +256,17 @@ func TestProcessMessage_BtwCommandRunsWithoutPersistingHistory(t *testing.T) {
|
|||
ChatID: "chat-1",
|
||||
Content: "/btw explain side effects",
|
||||
}
|
||||
route, _, err := al.resolveMessageRoute(msg)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveMessageRoute() error = %v", err)
|
||||
}
|
||||
sessionKey := resolveScopeKey(route, msg.SessionKey)
|
||||
initialHistory := []providers.Message{
|
||||
{Role: "user", Content: "We decided to avoid global state."},
|
||||
{Role: "assistant", Content: "Right, keep it request-scoped."},
|
||||
}
|
||||
defaultAgent.Sessions.SetHistory(sessionKey, initialHistory)
|
||||
defaultAgent.Sessions.SetSummary(sessionKey, "The team decided to keep state request-scoped.")
|
||||
|
||||
response, err := al.processMessage(context.Background(), msg)
|
||||
if err != nil {
|
||||
|
|
@ -262,20 +278,88 @@ func TestProcessMessage_BtwCommandRunsWithoutPersistingHistory(t *testing.T) {
|
|||
if len(provider.lastMessages) == 0 {
|
||||
t.Fatal("provider did not receive any messages")
|
||||
}
|
||||
if len(provider.lastMessages) != 4 {
|
||||
t.Fatalf("provider messages len = %d, want 4 (system + history + user)", len(provider.lastMessages))
|
||||
}
|
||||
if !strings.Contains(provider.lastMessages[0].Content, "The team decided to keep state request-scoped.") {
|
||||
t.Fatalf("system prompt missing session summary: %q", provider.lastMessages[0].Content)
|
||||
}
|
||||
if provider.lastMessages[1].Content != initialHistory[0].Content ||
|
||||
provider.lastMessages[2].Content != initialHistory[1].Content {
|
||||
t.Fatalf("provider history = %+v, want seeded session history", provider.lastMessages[1:3])
|
||||
}
|
||||
|
||||
lastMessage := provider.lastMessages[len(provider.lastMessages)-1]
|
||||
if lastMessage.Role != "user" || lastMessage.Content != "explain side effects" {
|
||||
t.Fatalf("last provider message = %+v, want stripped /btw question", lastMessage)
|
||||
}
|
||||
|
||||
route, _, err := al.resolveMessageRoute(msg)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveMessageRoute() error = %v", err)
|
||||
}
|
||||
sessionKey := resolveScopeKey(route, msg.SessionKey)
|
||||
history := al.GetRegistry().GetDefaultAgent().Sessions.GetHistory(sessionKey)
|
||||
if len(history) != 0 {
|
||||
t.Fatalf("session history len = %d, want 0 for /btw", len(history))
|
||||
if !reflect.DeepEqual(history, initialHistory) {
|
||||
t.Fatalf("session history = %#v, want %#v", history, initialHistory)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishResponseIfNeeded_SkipsWhenNonDefaultAgentAlreadySentToChat(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
cfg := &config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
ModelName: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
},
|
||||
List: []config.AgentConfig{
|
||||
{ID: "main", Default: true},
|
||||
{ID: "support"},
|
||||
},
|
||||
},
|
||||
Tools: config.ToolsConfig{
|
||||
Message: config.ToolConfig{Enabled: true},
|
||||
},
|
||||
}
|
||||
|
||||
msgBus := bus.NewMessageBus()
|
||||
al := NewAgentLoop(cfg, msgBus, &recordingProvider{})
|
||||
|
||||
supportAgent, ok := al.GetRegistry().GetAgent("support")
|
||||
if !ok || supportAgent == nil {
|
||||
t.Fatal("expected support agent")
|
||||
}
|
||||
|
||||
tool, ok := supportAgent.Tools.Get("message")
|
||||
if !ok {
|
||||
t.Fatal("expected message tool on support agent")
|
||||
}
|
||||
mt, ok := tool.(*tools.MessageTool)
|
||||
if !ok {
|
||||
t.Fatal("expected message tool type")
|
||||
}
|
||||
|
||||
if result := mt.Execute(context.Background(), map[string]any{
|
||||
"channel": "telegram",
|
||||
"chat_id": "chat-1",
|
||||
"content": "interim reply",
|
||||
}); result == nil || result.IsError {
|
||||
t.Fatalf("message tool setup result = %+v, want successful send", result)
|
||||
}
|
||||
|
||||
select {
|
||||
case outbound := <-msgBus.OutboundChan():
|
||||
if outbound.Content != "interim reply" {
|
||||
t.Fatalf("expected setup outbound %q, got %#v", "interim reply", outbound)
|
||||
}
|
||||
default:
|
||||
t.Fatal("expected interim outbound from message tool setup")
|
||||
}
|
||||
|
||||
al.PublishResponseIfNeeded(context.Background(), "telegram", "chat-1", "final reply")
|
||||
|
||||
select {
|
||||
case outbound := <-msgBus.OutboundChan():
|
||||
t.Fatalf("expected final reply to be suppressed, got outbound %#v", outbound)
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -346,11 +346,7 @@ func (al *AgentLoop) Continue(ctx context.Context, sessionKey, channel, chatID s
|
|||
return "", fmt.Errorf("no agent available for session %q", sessionKey)
|
||||
}
|
||||
|
||||
if tool, ok := agent.Tools.Get("message"); ok {
|
||||
if resetter, ok := tool.(interface{ ResetSentInRound() }); ok {
|
||||
resetter.ResetSentInRound()
|
||||
}
|
||||
}
|
||||
al.resetMessageToolRound(agent)
|
||||
|
||||
return al.continueWithSteeringMessages(ctx, agent, sessionKey, channel, chatID, steeringMsgs)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1081,6 +1081,29 @@ func TestAgentLoop_Steering_BtwCommandBypassesQueuedTurn(t *testing.T) {
|
|||
t.Fatal("timeout waiting for first LLM call to start")
|
||||
}
|
||||
|
||||
messageTool, ok := al.GetRegistry().GetDefaultAgent().Tools.Get("message")
|
||||
var mt *tools.MessageTool
|
||||
if !ok {
|
||||
mt = tools.NewMessageTool()
|
||||
al.RegisterTool(mt)
|
||||
} else {
|
||||
var typeOK bool
|
||||
mt, typeOK = messageTool.(*tools.MessageTool)
|
||||
if !typeOK {
|
||||
t.Fatal("expected message tool type")
|
||||
}
|
||||
}
|
||||
mt.SetSendCallback(func(channel, chatID, content, replyToMessageID string) error {
|
||||
return nil
|
||||
})
|
||||
if result := mt.Execute(context.Background(), map[string]any{
|
||||
"channel": "test",
|
||||
"chat_id": "chat1",
|
||||
"content": "already sent from busy turn",
|
||||
}); result == nil || result.IsError {
|
||||
t.Fatalf("message tool setup result = %+v, want successful send", result)
|
||||
}
|
||||
|
||||
if err := msgBus.PublishInbound(pubCtx, btw); err != nil {
|
||||
t.Fatalf("publish /btw inbound: %v", err)
|
||||
}
|
||||
|
|
@ -1103,11 +1126,15 @@ func TestAgentLoop_Steering_BtwCommandBypassesQueuedTurn(t *testing.T) {
|
|||
|
||||
select {
|
||||
case outbound := <-msgBus.OutboundChan():
|
||||
if outbound.Content != "long turn finished" {
|
||||
t.Fatalf("expected original turn response after release, got %q", outbound.Content)
|
||||
}
|
||||
t.Fatalf("expected busy turn final response to stay suppressed, got %q", outbound.Content)
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timeout waiting for original turn response")
|
||||
}
|
||||
|
||||
provider.mu.Lock()
|
||||
callCount := provider.calls
|
||||
provider.mu.Unlock()
|
||||
if callCount != 2 {
|
||||
t.Fatalf("provider call count = %d, want 2", callCount)
|
||||
}
|
||||
|
||||
cancelRun()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue