fix(agent): resolve lint issues in final turn render
This commit is contained in:
parent
5d929f3a5a
commit
d386df4e2d
4 changed files with 423 additions and 158 deletions
|
|
@ -33,7 +33,8 @@ func appendTurnActionRecord(
|
|||
}
|
||||
if n := len(records); n > 0 {
|
||||
prev := records[n-1]
|
||||
if prev.Source == rec.Source && prev.Tool == rec.Tool && prev.Text == rec.Text && prev.Error == rec.Error {
|
||||
if prev.Source == rec.Source && prev.Tool == rec.Tool && prev.Text == rec.Text &&
|
||||
prev.Error == rec.Error {
|
||||
return records
|
||||
}
|
||||
}
|
||||
|
|
@ -70,10 +71,18 @@ func buildFinalTurnRenderInstruction(exec *turnExecution) string {
|
|||
b.WriteString("Write the final user-facing reply for this already-completed turn.\n")
|
||||
b.WriteString("Use the same language and general style as the conversation.\n")
|
||||
b.WriteString("Do not call tools.\n")
|
||||
b.WriteString("Answer the full accumulated user request across this turn, not only the latest follow-up.\n")
|
||||
b.WriteString("If a later follow-up clearly corrected, narrowed, or replaced an earlier request, follow the latest clarified intent.\n")
|
||||
b.WriteString("If later follow-ups added to earlier requests, include the completed additive results together.\n")
|
||||
b.WriteString("Use only the facts already present in the conversation and tool results. Do not invent missing results.\n")
|
||||
b.WriteString(
|
||||
"Answer the full accumulated user request across this turn, not only the latest follow-up.\n",
|
||||
)
|
||||
b.WriteString(
|
||||
"If a later follow-up clearly corrected, narrowed, or replaced an earlier request, follow the latest clarified intent.\n",
|
||||
)
|
||||
b.WriteString(
|
||||
"If later follow-ups added to earlier requests, include the completed additive results together.\n",
|
||||
)
|
||||
b.WriteString(
|
||||
"Use only the facts already present in the conversation and tool results. Do not invent missing results.\n",
|
||||
)
|
||||
b.WriteString("Keep the reply concise and natural.\n")
|
||||
|
||||
if exec == nil || len(exec.actionLog) == 0 {
|
||||
|
|
@ -96,7 +105,7 @@ func buildFinalTurnRenderInstruction(exec *turnExecution) string {
|
|||
return b.String()
|
||||
}
|
||||
b.WriteString("\nExplicit user-facing outcomes recorded during the turn:\n")
|
||||
b.WriteString(string(raw))
|
||||
_, _ = b.Write(raw)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
|
|
@ -128,7 +137,7 @@ func tryRenderFinalTurnReply(
|
|||
})
|
||||
|
||||
opts := map[string]any{
|
||||
"max_tokens": min(ts.agent.MaxTokens, 800),
|
||||
"max_tokens": minInt(ts.agent.MaxTokens, 800),
|
||||
"temperature": 0.2,
|
||||
"prompt_cache_key": ts.agent.ID,
|
||||
}
|
||||
|
|
@ -186,7 +195,7 @@ func shouldFinalizeAfterToolLoopWithRender(al *AgentLoop, exec *turnExecution) b
|
|||
return !exec.allResponsesHandled
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
func minInt(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,7 +52,10 @@ func (f *fakeMediaChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([
|
|||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeMediaChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) {
|
||||
func (f *fakeMediaChannel) SendMedia(
|
||||
ctx context.Context,
|
||||
msg bus.OutboundMediaMessage,
|
||||
) ([]string, error) {
|
||||
f.sentMedia = append(f.sentMedia, msg)
|
||||
return nil, nil
|
||||
}
|
||||
|
|
@ -75,11 +78,17 @@ func (m *recordingChannelManager) SendMessage(ctx context.Context, msg bus.Outbo
|
|||
return nil
|
||||
}
|
||||
|
||||
func (m *recordingChannelManager) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
|
||||
func (m *recordingChannelManager) SendMedia(
|
||||
ctx context.Context,
|
||||
msg bus.OutboundMediaMessage,
|
||||
) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *recordingChannelManager) SendPlaceholder(ctx context.Context, channel, chatID string) bool {
|
||||
func (m *recordingChannelManager) SendPlaceholder(
|
||||
ctx context.Context,
|
||||
channel, chatID string,
|
||||
) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
|
|
@ -261,9 +270,11 @@ func TestPublishResponseIfNeeded_DismissesToolFeedbackWhenMessageToolAlreadySent
|
|||
t.Fatal("expected default agent")
|
||||
}
|
||||
mt := tools.NewMessageTool()
|
||||
mt.SetSendCallback(func(ctx context.Context, channel, chatID, content, replyToMessageID string) error {
|
||||
return nil
|
||||
})
|
||||
mt.SetSendCallback(
|
||||
func(ctx context.Context, channel, chatID, content, replyToMessageID string) error {
|
||||
return nil
|
||||
},
|
||||
)
|
||||
defaultAgent.Tools.Register(mt)
|
||||
|
||||
result := mt.Execute(
|
||||
|
|
@ -277,7 +288,13 @@ func TestPublishResponseIfNeeded_DismissesToolFeedbackWhenMessageToolAlreadySent
|
|||
if result == nil || result.IsError {
|
||||
t.Fatalf("message tool execute failed: %+v", result)
|
||||
}
|
||||
al.PublishResponseIfNeeded(context.Background(), "telegram", "-100123", "session-1", "final reply")
|
||||
al.PublishResponseIfNeeded(
|
||||
context.Background(),
|
||||
"telegram",
|
||||
"-100123",
|
||||
"session-1",
|
||||
"final reply",
|
||||
)
|
||||
|
||||
if got := cm.dismissed; len(got) != 1 || got[0] != "telegram:-100123" {
|
||||
t.Fatalf("dismissed = %v, want [telegram:-100123]", got)
|
||||
|
|
@ -451,7 +468,10 @@ func TestProcessMessage_BtwCommandRunsWithoutPersistingHistory(t *testing.T) {
|
|||
t.Fatal("provider did not receive any messages")
|
||||
}
|
||||
if len(provider.lastMessages) != 4 {
|
||||
t.Fatalf("provider messages len = %d, want 4 (system + prior history + user)", len(provider.lastMessages))
|
||||
t.Fatalf(
|
||||
"provider messages len = %d, want 4 (system + prior history + user)",
|
||||
len(provider.lastMessages),
|
||||
)
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(provider.lastMessages[1:3], initialHistory) {
|
||||
|
|
@ -511,7 +531,10 @@ func TestProcessMessage_BtwCommandIncludesRequestContextAndMedia(t *testing.T) {
|
|||
if !strings.Contains(systemPrompt, "## Current Session\nChannel: discord\nChat ID: group-1") {
|
||||
t.Fatalf("system prompt missing current session context:\n%s", systemPrompt)
|
||||
}
|
||||
if !strings.Contains(systemPrompt, "## Current Sender\nCurrent sender: Alice (ID: discord:123)") {
|
||||
if !strings.Contains(
|
||||
systemPrompt,
|
||||
"## Current Sender\nCurrent sender: Alice (ID: discord:123)",
|
||||
) {
|
||||
t.Fatalf("system prompt missing current sender context:\n%s", systemPrompt)
|
||||
}
|
||||
|
||||
|
|
@ -587,7 +610,11 @@ func TestProcessMessage_BtwCommandUsesIsolatedProvider(t *testing.T) {
|
|||
// Verify main session history was NOT modified
|
||||
currentHistory := defaultAgent.Sessions.GetHistory(mainSessionKey)
|
||||
if !reflect.DeepEqual(currentHistory, initialHistory) {
|
||||
t.Fatalf("main session history was modified:\ngot %#v\nwant %#v", currentHistory, initialHistory)
|
||||
t.Fatalf(
|
||||
"main session history was modified:\ngot %#v\nwant %#v",
|
||||
currentHistory,
|
||||
initialHistory,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1098,7 +1125,9 @@ func TestProcessMessage_MediaToolHandledSkipsFollowUpLLMAndFinalText(t *testing.
|
|||
store := media.NewFileMediaStore()
|
||||
al.SetMediaStore(store)
|
||||
telegramChannel := &fakeMediaChannel{fakeChannel: fakeChannel{id: "rid-telegram"}}
|
||||
al.SetChannelManager(newStartedTestChannelManager(t, msgBus, store, "telegram", telegramChannel))
|
||||
al.SetChannelManager(
|
||||
newStartedTestChannelManager(t, msgBus, store, "telegram", telegramChannel),
|
||||
)
|
||||
|
||||
imagePath := filepath.Join(tmpDir, "screen.png")
|
||||
if err := os.WriteFile(imagePath, []byte("fake screenshot"), 0o644); err != nil {
|
||||
|
|
@ -1120,7 +1149,10 @@ func TestProcessMessage_MediaToolHandledSkipsFollowUpLLMAndFinalText(t *testing.
|
|||
t.Fatalf("processMessage() error = %v", err)
|
||||
}
|
||||
if response != "" {
|
||||
t.Fatalf("expected no final response when media tool already handled delivery, got %q", response)
|
||||
t.Fatalf(
|
||||
"expected no final response when media tool already handled delivery, got %q",
|
||||
response,
|
||||
)
|
||||
}
|
||||
if provider.calls != 1 {
|
||||
t.Fatalf("expected exactly 1 LLM call, got %d", provider.calls)
|
||||
|
|
@ -1133,13 +1165,20 @@ func TestProcessMessage_MediaToolHandledSkipsFollowUpLLMAndFinalText(t *testing.
|
|||
}
|
||||
|
||||
if len(telegramChannel.sentMedia) != 1 {
|
||||
t.Fatalf("expected exactly 1 synchronously sent media message, got %d", len(telegramChannel.sentMedia))
|
||||
t.Fatalf(
|
||||
"expected exactly 1 synchronously sent media message, got %d",
|
||||
len(telegramChannel.sentMedia),
|
||||
)
|
||||
}
|
||||
if telegramChannel.sentMedia[0].Channel != "telegram" || telegramChannel.sentMedia[0].ChatID != "chat1" {
|
||||
if telegramChannel.sentMedia[0].Channel != "telegram" ||
|
||||
telegramChannel.sentMedia[0].ChatID != "chat1" {
|
||||
t.Fatalf("unexpected sent media target: %+v", telegramChannel.sentMedia[0])
|
||||
}
|
||||
if len(telegramChannel.sentMedia[0].Parts) != 1 {
|
||||
t.Fatalf("expected exactly 1 sent media part, got %d", len(telegramChannel.sentMedia[0].Parts))
|
||||
t.Fatalf(
|
||||
"expected exactly 1 sent media part, got %d",
|
||||
len(telegramChannel.sentMedia[0].Parts),
|
||||
)
|
||||
}
|
||||
|
||||
select {
|
||||
|
|
@ -1161,22 +1200,29 @@ func TestProcessMessage_MediaToolHandledSkipsFollowUpLLMAndFinalText(t *testing.
|
|||
if err != nil {
|
||||
t.Fatalf("resolveMessageRoute() error = %v", err)
|
||||
}
|
||||
sessionKey := resolveScopeKey(al.allocateRouteSession(route, testInboundMessage(bus.InboundMessage{
|
||||
Channel: "telegram",
|
||||
ChatID: "chat1",
|
||||
SenderID: "user1",
|
||||
Content: "take a screenshot of the screen and send it to me",
|
||||
})).SessionKey, "")
|
||||
sessionKey := resolveScopeKey(
|
||||
al.allocateRouteSession(route, testInboundMessage(bus.InboundMessage{
|
||||
Channel: "telegram",
|
||||
ChatID: "chat1",
|
||||
SenderID: "user1",
|
||||
Content: "take a screenshot of the screen and send it to me",
|
||||
})).SessionKey,
|
||||
"",
|
||||
)
|
||||
history := defaultAgent.Sessions.GetHistory(sessionKey)
|
||||
if len(history) == 0 {
|
||||
t.Fatal("expected session history to be saved")
|
||||
}
|
||||
last := history[len(history)-1]
|
||||
if last.Role != "assistant" || last.Content != "Requested output delivered via tool attachment." {
|
||||
if last.Role != "assistant" ||
|
||||
last.Content != "Requested output delivered via tool attachment." {
|
||||
t.Fatalf("expected handled assistant summary in history, got %+v", last)
|
||||
}
|
||||
if len(last.Attachments) != 1 {
|
||||
t.Fatalf("expected handled assistant summary attachments in history, got %+v", last.Attachments)
|
||||
t.Fatalf(
|
||||
"expected handled assistant summary attachments in history, got %+v",
|
||||
last.Attachments,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1200,7 +1246,9 @@ func TestProcessMessage_HandledToolProcessesQueuedSteeringBeforeReturning(t *tes
|
|||
store := media.NewFileMediaStore()
|
||||
al.SetMediaStore(store)
|
||||
telegramChannel := &fakeMediaChannel{fakeChannel: fakeChannel{id: "rid-telegram"}}
|
||||
al.SetChannelManager(newStartedTestChannelManager(t, msgBus, store, "telegram", telegramChannel))
|
||||
al.SetChannelManager(
|
||||
newStartedTestChannelManager(t, msgBus, store, "telegram", telegramChannel),
|
||||
)
|
||||
|
||||
imagePath := filepath.Join(tmpDir, "screen-steering.png")
|
||||
if err := os.WriteFile(imagePath, []byte("fake screenshot"), 0o644); err != nil {
|
||||
|
|
@ -1229,7 +1277,10 @@ func TestProcessMessage_HandledToolProcessesQueuedSteeringBeforeReturning(t *tes
|
|||
t.Fatalf("expected 2 LLM calls after queued steering, got %d", provider.calls)
|
||||
}
|
||||
if len(telegramChannel.sentMedia) != 1 {
|
||||
t.Fatalf("expected exactly 1 synchronously sent media message, got %d", len(telegramChannel.sentMedia))
|
||||
t.Fatalf(
|
||||
"expected exactly 1 synchronously sent media message, got %d",
|
||||
len(telegramChannel.sentMedia),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1248,7 +1299,9 @@ func TestRunAgentLoop_ResponseHandledToolPublishesForUserWhenSendResponseDisable
|
|||
store := media.NewFileMediaStore()
|
||||
al.SetMediaStore(store)
|
||||
telegramChannel := &fakeMediaChannel{fakeChannel: fakeChannel{id: "rid-telegram"}}
|
||||
al.SetChannelManager(newStartedTestChannelManager(t, msgBus, store, "telegram", telegramChannel))
|
||||
al.SetChannelManager(
|
||||
newStartedTestChannelManager(t, msgBus, store, "telegram", telegramChannel),
|
||||
)
|
||||
al.RegisterTool(&handledUserTool{})
|
||||
|
||||
defaultAgent := al.registry.GetDefaultAgent()
|
||||
|
|
@ -1298,10 +1351,17 @@ func TestRunAgentLoop_ResponseHandledToolPublishesForUserWhenSendResponseDisable
|
|||
t.Fatalf("unexpected sent text message: %+v", telegramChannel.sentMessages[0])
|
||||
}
|
||||
if telegramChannel.sentMessages[0].AgentID != defaultAgent.ID {
|
||||
t.Fatalf("sent text agent_id = %q, want %q", telegramChannel.sentMessages[0].AgentID, defaultAgent.ID)
|
||||
t.Fatalf(
|
||||
"sent text agent_id = %q, want %q",
|
||||
telegramChannel.sentMessages[0].AgentID,
|
||||
defaultAgent.ID,
|
||||
)
|
||||
}
|
||||
if telegramChannel.sentMessages[0].SessionKey != "session-1" {
|
||||
t.Fatalf("sent text session_key = %q, want session-1", telegramChannel.sentMessages[0].SessionKey)
|
||||
t.Fatalf(
|
||||
"sent text session_key = %q, want session-1",
|
||||
telegramChannel.sentMessages[0].SessionKey,
|
||||
)
|
||||
}
|
||||
if telegramChannel.sentMessages[0].Scope == nil ||
|
||||
telegramChannel.sentMessages[0].Scope.Values["chat"] != "direct:chat1" {
|
||||
|
|
@ -1505,7 +1565,9 @@ func TestProcessMessage_MediaArtifactCanBeForwardedBySendFile(t *testing.T) {
|
|||
store := media.NewFileMediaStore()
|
||||
al.SetMediaStore(store)
|
||||
telegramChannel := &fakeMediaChannel{fakeChannel: fakeChannel{id: "rid-telegram"}}
|
||||
al.SetChannelManager(newStartedTestChannelManager(t, msgBus, store, "telegram", telegramChannel))
|
||||
al.SetChannelManager(
|
||||
newStartedTestChannelManager(t, msgBus, store, "telegram", telegramChannel),
|
||||
)
|
||||
|
||||
mediaDir := media.TempDir()
|
||||
if err := os.MkdirAll(mediaDir, 0o700); err != nil {
|
||||
|
|
@ -1538,13 +1600,20 @@ func TestProcessMessage_MediaArtifactCanBeForwardedBySendFile(t *testing.T) {
|
|||
}
|
||||
|
||||
if len(telegramChannel.sentMedia) != 1 {
|
||||
t.Fatalf("expected exactly 1 synchronously sent media message, got %d", len(telegramChannel.sentMedia))
|
||||
t.Fatalf(
|
||||
"expected exactly 1 synchronously sent media message, got %d",
|
||||
len(telegramChannel.sentMedia),
|
||||
)
|
||||
}
|
||||
if telegramChannel.sentMedia[0].Channel != "telegram" || telegramChannel.sentMedia[0].ChatID != "chat1" {
|
||||
if telegramChannel.sentMedia[0].Channel != "telegram" ||
|
||||
telegramChannel.sentMedia[0].ChatID != "chat1" {
|
||||
t.Fatalf("unexpected sent media target: %+v", telegramChannel.sentMedia[0])
|
||||
}
|
||||
if len(telegramChannel.sentMedia[0].Parts) != 1 {
|
||||
t.Fatalf("expected exactly 1 sent media part, got %d", len(telegramChannel.sentMedia[0].Parts))
|
||||
t.Fatalf(
|
||||
"expected exactly 1 sent media part, got %d",
|
||||
len(telegramChannel.sentMedia[0].Parts),
|
||||
)
|
||||
}
|
||||
|
||||
select {
|
||||
|
|
@ -2014,7 +2083,10 @@ func TestToolFeedbackExplanationFromResponse_UsesExplicitToolCallExtraContent(t
|
|||
|
||||
got := toolFeedbackExplanationFromResponse(response, messages)
|
||||
if got != "Read README.md first to confirm the current project structure." {
|
||||
t.Fatalf("toolFeedbackExplanationFromResponse() = %q, want explicit tool feedback explanation", got)
|
||||
t.Fatalf(
|
||||
"toolFeedbackExplanationFromResponse() = %q, want explicit tool feedback explanation",
|
||||
got,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2042,10 +2114,16 @@ func TestToolFeedbackExplanationForToolCall_PrefersToolSpecificExtraContent(t *t
|
|||
got1 := toolFeedbackExplanationForToolCall(response, response.ToolCalls[0], nil)
|
||||
got2 := toolFeedbackExplanationForToolCall(response, response.ToolCalls[1], nil)
|
||||
if got1 != "Read README.md first." {
|
||||
t.Fatalf("toolFeedbackExplanationForToolCall() first = %q, want tool-specific explanation", got1)
|
||||
t.Fatalf(
|
||||
"toolFeedbackExplanationForToolCall() first = %q, want tool-specific explanation",
|
||||
got1,
|
||||
)
|
||||
}
|
||||
if got2 != "Update config example after reading it." {
|
||||
t.Fatalf("toolFeedbackExplanationForToolCall() second = %q, want tool-specific explanation", got2)
|
||||
t.Fatalf(
|
||||
"toolFeedbackExplanationForToolCall() second = %q, want tool-specific explanation",
|
||||
got2,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2091,7 +2169,10 @@ func TestToolFeedbackExplanationFromResponse_DoesNotUseReasoningContent(t *testi
|
|||
got := toolFeedbackExplanationFromResponse(response, messages)
|
||||
want := utils.ToolFeedbackContinuationHint + ": Inspect README.md and update the config example."
|
||||
if got != want {
|
||||
t.Fatalf("toolFeedbackExplanationFromResponse() = %q, want latest user content fallback", got)
|
||||
t.Fatalf(
|
||||
"toolFeedbackExplanationFromResponse() = %q, want latest user content fallback",
|
||||
got,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2343,7 +2424,10 @@ func (m *handledMediaWithSteeringTool) Parameters() map[string]any {
|
|||
}
|
||||
}
|
||||
|
||||
func (m *handledMediaWithSteeringTool) Execute(ctx context.Context, args map[string]any) *tools.ToolResult {
|
||||
func (m *handledMediaWithSteeringTool) Execute(
|
||||
ctx context.Context,
|
||||
args map[string]any,
|
||||
) *tools.ToolResult {
|
||||
if err := m.loop.Steer(providers.Message{Role: "user", Content: "what about this instead?"}); err != nil {
|
||||
return tools.ErrorResult(err.Error()).WithError(err)
|
||||
}
|
||||
|
|
@ -2496,7 +2580,11 @@ func newStrictChatCompletionTestServer(
|
|||
}))
|
||||
}
|
||||
|
||||
func (h testHelper) executeAndGetResponse(tb testing.TB, ctx context.Context, msg bus.InboundMessage) string {
|
||||
func (h testHelper) executeAndGetResponse(
|
||||
tb testing.TB,
|
||||
ctx context.Context,
|
||||
msg bus.InboundMessage,
|
||||
) string {
|
||||
// Use a short timeout to avoid hanging
|
||||
timeoutCtx, cancel := context.WithTimeout(ctx, responseTimeout)
|
||||
defer cancel()
|
||||
|
|
@ -2652,7 +2740,10 @@ func TestProcessMessage_CommandOutcomes(t *testing.T) {
|
|||
t.Fatalf("unexpected /foo reply: %q", fooResp)
|
||||
}
|
||||
if provider.calls != 1 {
|
||||
t.Fatalf("LLM should be called exactly once after /foo passthrough, calls=%d", provider.calls)
|
||||
t.Fatalf(
|
||||
"LLM should be called exactly once after /foo passthrough, calls=%d",
|
||||
provider.calls,
|
||||
)
|
||||
}
|
||||
|
||||
newResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{
|
||||
|
|
@ -2857,7 +2948,10 @@ func TestProcessMessage_SwitchModelRejectsUnknownAlias(t *testing.T) {
|
|||
}
|
||||
|
||||
if provider.calls != 0 {
|
||||
t.Fatalf("LLM should not be called for rejected /switch and /show, calls=%d", provider.calls)
|
||||
t.Fatalf(
|
||||
"LLM should not be called for rejected /switch and /show, calls=%d",
|
||||
provider.calls,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2875,7 +2969,13 @@ func TestProcessMessage_SwitchModelRoutesSubsequentRequestsToSelectedProvider(t
|
|||
|
||||
remoteCalls := 0
|
||||
remoteModel := ""
|
||||
remoteServer := newChatCompletionTestServer(t, "remote", "remote reply", &remoteCalls, &remoteModel)
|
||||
remoteServer := newChatCompletionTestServer(
|
||||
t,
|
||||
"remote",
|
||||
"remote reply",
|
||||
&remoteCalls,
|
||||
&remoteModel,
|
||||
)
|
||||
defer remoteServer.Close()
|
||||
|
||||
cfg := &config.Config{
|
||||
|
|
@ -3055,18 +3155,20 @@ func TestProcessMessage_FallbackUsesPerCandidateProvider(t *testing.T) {
|
|||
workspace := t.TempDir()
|
||||
|
||||
primaryCalls := 0
|
||||
primaryServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
primaryCalls++
|
||||
// Return 429 so FallbackChain classifies this as retriable and moves on.
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"error": map[string]any{
|
||||
"message": "rate limit exceeded",
|
||||
"type": "rate_limit_error",
|
||||
},
|
||||
})
|
||||
}))
|
||||
primaryServer := httptest.NewServer(
|
||||
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
primaryCalls++
|
||||
// Return 429 so FallbackChain classifies this as retriable and moves on.
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"error": map[string]any{
|
||||
"message": "rate limit exceeded",
|
||||
"type": "rate_limit_error",
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
defer primaryServer.Close()
|
||||
|
||||
fallbackCalls := 0
|
||||
|
|
@ -3139,23 +3241,28 @@ func TestProcessMessage_FallbackUsesActiveProviderWhenCandidateNotRegistered(t *
|
|||
// Both the primary and the unregistered fallback share this server
|
||||
// (same api_base) so activeProvider routes both calls here.
|
||||
callCount := 0
|
||||
primaryServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
callCount++
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if callCount == 1 {
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
primaryServer := httptest.NewServer(
|
||||
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
callCount++
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if callCount == 1 {
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"error": map[string]any{"message": "rate limit", "type": "rate_limit_error"},
|
||||
})
|
||||
return
|
||||
}
|
||||
// Second call (fallback via activeProvider) succeeds.
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"error": map[string]any{"message": "rate limit", "type": "rate_limit_error"},
|
||||
"choices": []map[string]any{
|
||||
{
|
||||
"message": map[string]any{"content": "active provider reply"},
|
||||
"finish_reason": "stop",
|
||||
},
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
// Second call (fallback via activeProvider) succeeds.
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"choices": []map[string]any{
|
||||
{"message": map[string]any{"content": "active provider reply"}, "finish_reason": "stop"},
|
||||
},
|
||||
})
|
||||
}))
|
||||
}),
|
||||
)
|
||||
defer primaryServer.Close()
|
||||
|
||||
cfg := &config.Config{
|
||||
|
|
@ -3199,7 +3306,10 @@ func TestProcessMessage_FallbackUsesActiveProviderWhenCandidateNotRegistered(t *
|
|||
t.Fatalf("response = %q, want %q", resp, "active provider reply")
|
||||
}
|
||||
if callCount < 2 {
|
||||
t.Fatalf("primary server calls = %d, want >= 2 (one 429 + one success via activeProvider)", callCount)
|
||||
t.Fatalf(
|
||||
"primary server calls = %d, want >= 2 (one 429 + one success via activeProvider)",
|
||||
callCount,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3338,7 +3448,9 @@ func TestAgentLoop_ContextExhaustionRetry(t *testing.T) {
|
|||
msgBus := bus.NewMessageBus()
|
||||
|
||||
// Create a provider that fails once with a context error
|
||||
contextErr := fmt.Errorf("InvalidParameter: Total tokens of image and text exceed max message tokens")
|
||||
contextErr := fmt.Errorf(
|
||||
"InvalidParameter: Total tokens of image and text exceed max message tokens",
|
||||
)
|
||||
provider := &failFirstMockProvider{
|
||||
failures: 1,
|
||||
failError: contextErr,
|
||||
|
|
@ -3482,7 +3594,11 @@ func TestAgentLoop_VisionUnsupportedErrorStripsSessionMedia(t *testing.T) {
|
|||
t.Fatalf("response = %q, want %q", resp, "ok")
|
||||
}
|
||||
if provider.calls != 2 {
|
||||
t.Fatalf("calls = %d, want %d (fail with media, then retry without media)", provider.calls, 2)
|
||||
t.Fatalf(
|
||||
"calls = %d, want %d (fail with media, then retry without media)",
|
||||
provider.calls,
|
||||
2,
|
||||
)
|
||||
}
|
||||
if !slices.Equal(provider.mediaSeen, []bool{true, false}) {
|
||||
t.Fatalf("mediaSeen = %v, want %v", provider.mediaSeen, []bool{true, false})
|
||||
|
|
@ -3549,7 +3665,13 @@ func TestAgentLoop_EmptyModelResponseUsesAccurateFallback(t *testing.T) {
|
|||
provider := &simpleMockProvider{response: ""}
|
||||
al := NewAgentLoop(cfg, msgBus, provider)
|
||||
|
||||
response, err := al.ProcessDirectWithChannel(context.Background(), "hello", "empty-response", "test", "chat1")
|
||||
response, err := al.ProcessDirectWithChannel(
|
||||
context.Background(),
|
||||
"hello",
|
||||
"empty-response",
|
||||
"test",
|
||||
"chat1",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("ProcessDirectWithChannel failed: %v", err)
|
||||
}
|
||||
|
|
@ -3581,7 +3703,13 @@ func TestAgentLoop_ToolLimitUsesDedicatedFallback(t *testing.T) {
|
|||
al := NewAgentLoop(cfg, msgBus, provider)
|
||||
al.RegisterTool(&toolLimitTestTool{})
|
||||
|
||||
response, err := al.ProcessDirectWithChannel(context.Background(), "hello", "tool-limit", "test", "chat1")
|
||||
response, err := al.ProcessDirectWithChannel(
|
||||
context.Background(),
|
||||
"hello",
|
||||
"tool-limit",
|
||||
"test",
|
||||
"chat1",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("ProcessDirectWithChannel failed: %v", err)
|
||||
}
|
||||
|
|
@ -3598,11 +3726,13 @@ func TestAgentLoop_ToolLimitUsesDedicatedFallback(t *testing.T) {
|
|||
ChatType: "direct",
|
||||
SenderID: "cron",
|
||||
})
|
||||
history := defaultAgent.Sessions.GetHistory(al.allocateRouteSession(route, testInboundMessage(bus.InboundMessage{
|
||||
Channel: "test",
|
||||
SenderID: "cron",
|
||||
ChatID: "chat1",
|
||||
})).SessionKey)
|
||||
history := defaultAgent.Sessions.GetHistory(
|
||||
al.allocateRouteSession(route, testInboundMessage(bus.InboundMessage{
|
||||
Channel: "test",
|
||||
SenderID: "cron",
|
||||
ChatID: "chat1",
|
||||
})).SessionKey,
|
||||
)
|
||||
if len(history) != 4 {
|
||||
t.Fatalf("history len = %d, want 4", len(history))
|
||||
}
|
||||
|
|
@ -3900,7 +4030,9 @@ func TestHandleReasoning(t *testing.T) {
|
|||
break
|
||||
}
|
||||
if msg.Content == "should timeout" {
|
||||
t.Fatal("expected reasoning message to be dropped when bus is full, but it was published")
|
||||
t.Fatal(
|
||||
"expected reasoning message to be dropped when bus is full, but it was published",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -4015,7 +4147,11 @@ func TestProcessMessage_PicoPublishesReasoningAsThoughtMessage(t *testing.T) {
|
|||
}
|
||||
|
||||
if thoughtMsg.Channel != "pico" || thoughtMsg.ChatID != "pico:test-session" {
|
||||
t.Fatalf("thought message route = %s/%s, want pico/pico:test-session", thoughtMsg.Channel, thoughtMsg.ChatID)
|
||||
t.Fatalf(
|
||||
"thought message route = %s/%s, want pico/pico:test-session",
|
||||
thoughtMsg.Channel,
|
||||
thoughtMsg.ChatID,
|
||||
)
|
||||
}
|
||||
if thoughtMsg.Context.Raw[metadataKeyMessageKind] != messageKindThought {
|
||||
t.Fatalf(
|
||||
|
|
@ -4057,7 +4193,12 @@ func TestProcessHeartbeat_DoesNotPublishToolFeedback(t *testing.T) {
|
|||
provider := &toolFeedbackProvider{filePath: heartbeatFile}
|
||||
al := NewAgentLoop(cfg, msgBus, provider)
|
||||
|
||||
response, err := al.ProcessHeartbeat(context.Background(), "check heartbeat tasks", "telegram", "chat-1")
|
||||
response, err := al.ProcessHeartbeat(
|
||||
context.Background(),
|
||||
"check heartbeat tasks",
|
||||
"telegram",
|
||||
"chat-1",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("ProcessHeartbeat() error = %v", err)
|
||||
}
|
||||
|
|
@ -4132,10 +4273,16 @@ func TestProcessMessage_PublishesToolFeedbackWhenEnabled(t *testing.T) {
|
|||
t.Fatalf("tool feedback content = %q, want read_file summary", outbound.Content)
|
||||
}
|
||||
if !strings.Contains(outbound.Content, utils.ToolFeedbackContinuationHint) {
|
||||
t.Fatalf("tool feedback content = %q, want continuation hint fallback", outbound.Content)
|
||||
t.Fatalf(
|
||||
"tool feedback content = %q, want continuation hint fallback",
|
||||
outbound.Content,
|
||||
)
|
||||
}
|
||||
if !strings.Contains(outbound.Content, "check tool feedback") {
|
||||
t.Fatalf("tool feedback content = %q, want current user intent fallback", outbound.Content)
|
||||
t.Fatalf(
|
||||
"tool feedback content = %q, want current user intent fallback",
|
||||
outbound.Content,
|
||||
)
|
||||
}
|
||||
if !strings.Contains(outbound.Content, "\"path\":") {
|
||||
t.Fatalf("tool feedback content = %q, want serialized tool arguments", outbound.Content)
|
||||
|
|
@ -4144,7 +4291,10 @@ func TestProcessMessage_PublishesToolFeedbackWhenEnabled(t *testing.T) {
|
|||
t.Fatalf("tool feedback content = %q, want tool argument value", outbound.Content)
|
||||
}
|
||||
if strings.Contains(outbound.Content, "Previous turn explanation") {
|
||||
t.Fatalf("tool feedback content = %q, want no previous assistant fallback", outbound.Content)
|
||||
t.Fatalf(
|
||||
"tool feedback content = %q, want no previous assistant fallback",
|
||||
outbound.Content,
|
||||
)
|
||||
}
|
||||
if outbound.AgentID != "main" {
|
||||
t.Fatalf("tool feedback agent_id = %q, want main", outbound.AgentID)
|
||||
|
|
@ -4152,7 +4302,8 @@ func TestProcessMessage_PublishesToolFeedbackWhenEnabled(t *testing.T) {
|
|||
if outbound.SessionKey == "" {
|
||||
t.Fatal("expected tool feedback to carry session_key")
|
||||
}
|
||||
if outbound.Scope == nil || outbound.Scope.AgentID != "main" || outbound.Scope.Channel != "telegram" {
|
||||
if outbound.Scope == nil || outbound.Scope.AgentID != "main" ||
|
||||
outbound.Scope.Channel != "telegram" {
|
||||
t.Fatalf("expected tool feedback scope, got %+v", outbound.Scope)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
|
|
@ -4211,7 +4362,11 @@ func TestProcessMessage_PersistsReasoningContentInSessionHistory(t *testing.T) {
|
|||
t.Fatalf("last message content = %q, want %q", last.Content, "final answer")
|
||||
}
|
||||
if last.ReasoningContent != "thinking trace" {
|
||||
t.Fatalf("last message reasoning_content = %q, want %q", last.ReasoningContent, "thinking trace")
|
||||
t.Fatalf(
|
||||
"last message reasoning_content = %q, want %q",
|
||||
last.ReasoningContent,
|
||||
"thinking trace",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -4268,16 +4423,29 @@ func TestProcessMessage_PersistsReasoningToolResponseAsSingleAssistantRecord(t *
|
|||
t.Fatal("expected assistant history record with tool_calls")
|
||||
}
|
||||
if assistantWithToolCall.Content != "I'll inspect that file now." {
|
||||
t.Fatalf("assistant content = %q, want %q", assistantWithToolCall.Content, "I'll inspect that file now.")
|
||||
t.Fatalf(
|
||||
"assistant content = %q, want %q",
|
||||
assistantWithToolCall.Content,
|
||||
"I'll inspect that file now.",
|
||||
)
|
||||
}
|
||||
if assistantWithToolCall.ReasoningContent != "Read the file before answering." {
|
||||
t.Fatalf("assistant reasoning_content = %q, want preserved", assistantWithToolCall.ReasoningContent)
|
||||
t.Fatalf(
|
||||
"assistant reasoning_content = %q, want preserved",
|
||||
assistantWithToolCall.ReasoningContent,
|
||||
)
|
||||
}
|
||||
if len(assistantWithToolCall.ToolCalls) != 1 {
|
||||
t.Fatalf("assistant tool calls = %+v, want single read_file tool", assistantWithToolCall.ToolCalls)
|
||||
t.Fatalf(
|
||||
"assistant tool calls = %+v, want single read_file tool",
|
||||
assistantWithToolCall.ToolCalls,
|
||||
)
|
||||
}
|
||||
if got := providers.NormalizeToolCall(assistantWithToolCall.ToolCalls[0]).Name; got != "read_file" {
|
||||
t.Fatalf("assistant tool calls = %+v, want single read_file tool", assistantWithToolCall.ToolCalls)
|
||||
t.Fatalf(
|
||||
"assistant tool calls = %+v, want single read_file tool",
|
||||
assistantWithToolCall.ToolCalls,
|
||||
)
|
||||
}
|
||||
|
||||
sessionDir := filepath.Join(tmpDir, "sessions")
|
||||
|
|
@ -4317,7 +4485,8 @@ func TestProcessMessage_PersistsReasoningToolResponseAsSingleAssistantRecord(t *
|
|||
if msg.Role != "assistant" {
|
||||
continue
|
||||
}
|
||||
if msg.Content == "I'll inspect that file now." || msg.ReasoningContent == "Read the file before answering." {
|
||||
if msg.Content == "I'll inspect that file now." ||
|
||||
msg.ReasoningContent == "Read the file before answering." {
|
||||
matchingRecords++
|
||||
toolName := ""
|
||||
if len(msg.ToolCalls) == 1 {
|
||||
|
|
@ -4327,12 +4496,18 @@ func TestProcessMessage_PersistsReasoningToolResponseAsSingleAssistantRecord(t *
|
|||
msg.ReasoningContent != "Read the file before answering." ||
|
||||
len(msg.ToolCalls) != 1 ||
|
||||
toolName != "read_file" {
|
||||
t.Fatalf("assistant jsonl record = %+v, want content+reasoning+tool_calls in one line", msg)
|
||||
t.Fatalf(
|
||||
"assistant jsonl record = %+v, want content+reasoning+tool_calls in one line",
|
||||
msg,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if matchingRecords != 1 {
|
||||
t.Fatalf("matching assistant jsonl records = %d, want exactly 1 canonical assistant record", matchingRecords)
|
||||
t.Fatalf(
|
||||
"matching assistant jsonl records = %d, want exactly 1 canonical assistant record",
|
||||
matchingRecords,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -4387,10 +4562,16 @@ func TestProcessMessage_DoesNotLeakReasoningContentInToolFeedback(t *testing.T)
|
|||
t.Fatalf("tool feedback content = %q, want read_file summary", outbound.Content)
|
||||
}
|
||||
if !strings.Contains(outbound.Content, utils.ToolFeedbackContinuationHint) {
|
||||
t.Fatalf("tool feedback content = %q, want continuation hint fallback", outbound.Content)
|
||||
t.Fatalf(
|
||||
"tool feedback content = %q, want continuation hint fallback",
|
||||
outbound.Content,
|
||||
)
|
||||
}
|
||||
if !strings.Contains(outbound.Content, "check reasoning fallback") {
|
||||
t.Fatalf("tool feedback content = %q, want current user intent fallback", outbound.Content)
|
||||
t.Fatalf(
|
||||
"tool feedback content = %q, want current user intent fallback",
|
||||
outbound.Content,
|
||||
)
|
||||
}
|
||||
if !strings.Contains(outbound.Content, "\"path\":") {
|
||||
t.Fatalf("tool feedback content = %q, want serialized tool arguments", outbound.Content)
|
||||
|
|
@ -4399,7 +4580,10 @@ func TestProcessMessage_DoesNotLeakReasoningContentInToolFeedback(t *testing.T)
|
|||
t.Fatalf("tool feedback content = %q, want tool argument value", outbound.Content)
|
||||
}
|
||||
if strings.Contains(outbound.Content, "Read README.md first") {
|
||||
t.Fatalf("tool feedback content = %q, should not leak hidden reasoning", outbound.Content)
|
||||
t.Fatalf(
|
||||
"tool feedback content = %q, should not leak hidden reasoning",
|
||||
outbound.Content,
|
||||
)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("expected outbound tool feedback without leaking reasoning")
|
||||
|
|
@ -4454,7 +4638,11 @@ func assertToolFeedbackNotPublishedWhenDisabled(t *testing.T, channel string) {
|
|||
|
||||
select {
|
||||
case outbound := <-msgBus.OutboundChan():
|
||||
t.Fatalf("expected no outbound tool feedback for %s when disabled, got %+v", channel, outbound)
|
||||
t.Fatalf(
|
||||
"expected no outbound tool feedback for %s when disabled, got %+v",
|
||||
channel,
|
||||
outbound,
|
||||
)
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
|
@ -4567,13 +4755,20 @@ func TestRun_PicoPublishesAssistantContentDuringToolCallsWithoutFinalDuplicate(t
|
|||
}
|
||||
|
||||
if outputs[0].Content != "intermediate model text" {
|
||||
t.Fatalf("first outbound content = %q, want %q", outputs[0].Content, "intermediate model text")
|
||||
t.Fatalf(
|
||||
"first outbound content = %q, want %q",
|
||||
outputs[0].Content,
|
||||
"intermediate model text",
|
||||
)
|
||||
}
|
||||
if outputs[1].Context.Raw[metadataKeyMessageKind] != messageKindToolCalls {
|
||||
t.Fatalf("second outbound = %+v, want tool_calls message", outputs[1])
|
||||
}
|
||||
if !strings.Contains(outputs[1].Context.Raw[metadataKeyToolCalls], "tool_limit_test_tool") {
|
||||
t.Fatalf("second outbound tool_calls = %q, want tool name", outputs[1].Context.Raw[metadataKeyToolCalls])
|
||||
t.Fatalf(
|
||||
"second outbound tool_calls = %q, want tool name",
|
||||
outputs[1].Context.Raw[metadataKeyToolCalls],
|
||||
)
|
||||
}
|
||||
if outputs[2].Content != "final model text" {
|
||||
t.Fatalf("third outbound content = %q, want %q", outputs[2].Content, "final model text")
|
||||
|
|
@ -4709,7 +4904,10 @@ func TestRun_PicoToolFeedbackSuppressesDuplicateInterimAssistantContent(t *testi
|
|||
t.Fatalf("first outbound content = %q, want empty tool_calls content", outputs[0].Content)
|
||||
}
|
||||
if !strings.Contains(outputs[0].Context.Raw[metadataKeyToolCalls], "tool_limit_test_tool") {
|
||||
t.Fatalf("first outbound tool_calls = %q, want tool name", outputs[0].Context.Raw[metadataKeyToolCalls])
|
||||
t.Fatalf(
|
||||
"first outbound tool_calls = %q, want tool name",
|
||||
outputs[0].Context.Raw[metadataKeyToolCalls],
|
||||
)
|
||||
}
|
||||
if outputs[1].Content != "final model text" {
|
||||
t.Fatalf("second outbound content = %q, want %q", outputs[1].Content, "final model text")
|
||||
|
|
@ -4858,7 +5056,8 @@ func TestResolveMediaRefs_MultiToolCallPreservesOrdering(t *testing.T) {
|
|||
if result[3].Role != "user" {
|
||||
t.Fatalf("result[3] expected user, got %q", result[3].Role)
|
||||
}
|
||||
if len(result[3].Media) != 1 || !strings.HasPrefix(result[3].Media[0], "data:image/png;base64,") {
|
||||
if len(result[3].Media) != 1 ||
|
||||
!strings.HasPrefix(result[3].Media[0], "data:image/png;base64,") {
|
||||
t.Fatal("expected synthetic user message to contain base64 image")
|
||||
}
|
||||
}
|
||||
|
|
@ -5335,8 +5534,14 @@ func TestProcessMessage_ContextOverflowRecovery(t *testing.T) {
|
|||
agent := al.GetRegistry().GetDefaultAgent()
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
agent.Sessions.AddFullMessage(sessionKey, providers.Message{Role: "user", Content: "heavy message"})
|
||||
agent.Sessions.AddFullMessage(sessionKey, providers.Message{Role: "assistant", Content: "response"})
|
||||
agent.Sessions.AddFullMessage(
|
||||
sessionKey,
|
||||
providers.Message{Role: "user", Content: "heavy message"},
|
||||
)
|
||||
agent.Sessions.AddFullMessage(
|
||||
sessionKey,
|
||||
providers.Message{Role: "assistant", Content: "response"},
|
||||
)
|
||||
}
|
||||
|
||||
response, err := al.processMessage(context.Background(), testInboundMessage(bus.InboundMessage{
|
||||
|
|
@ -5723,7 +5928,10 @@ func (m *activityWithSteeringTool) Parameters() map[string]any {
|
|||
}
|
||||
}
|
||||
|
||||
func (m *activityWithSteeringTool) Execute(ctx context.Context, args map[string]any) *tools.ToolResult {
|
||||
func (m *activityWithSteeringTool) Execute(
|
||||
ctx context.Context,
|
||||
args map[string]any,
|
||||
) *tools.ToolResult {
|
||||
if err := m.loop.Steer(providers.Message{Role: "user", Content: "и еще 20 приседаний"}); err != nil {
|
||||
return tools.ErrorResult(err.Error()).WithError(err)
|
||||
}
|
||||
|
|
@ -5733,7 +5941,13 @@ func (m *activityWithSteeringTool) Execute(ctx context.Context, args map[string]
|
|||
}
|
||||
}
|
||||
|
||||
func TestProcessMessage_FinalActionSummarySynthesizesAcrossSteering(t *testing.T) {
|
||||
func newFinalTurnRenderTestLoop(
|
||||
t *testing.T,
|
||||
provider providers.LLMProvider,
|
||||
toolFactory func(*AgentLoop) tools.Tool,
|
||||
) *AgentLoop {
|
||||
t.Helper()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
cfg := &config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
|
|
@ -5748,9 +5962,16 @@ func TestProcessMessage_FinalActionSummarySynthesizesAcrossSteering(t *testing.T
|
|||
}
|
||||
|
||||
msgBus := bus.NewMessageBus()
|
||||
provider := &activitySummaryWithSteeringProvider{}
|
||||
al := NewAgentLoop(cfg, msgBus, provider)
|
||||
al.RegisterTool(&activityWithSteeringTool{loop: al})
|
||||
al.RegisterTool(toolFactory(al))
|
||||
return al
|
||||
}
|
||||
|
||||
func TestProcessMessage_FinalActionSummarySynthesizesAcrossSteering(t *testing.T) {
|
||||
provider := &activitySummaryWithSteeringProvider{}
|
||||
al := newFinalTurnRenderTestLoop(t, provider, func(al *AgentLoop) tools.Tool {
|
||||
return &activityWithSteeringTool{loop: al}
|
||||
})
|
||||
|
||||
response, err := al.processMessage(context.Background(), testInboundMessage(bus.InboundMessage{
|
||||
Channel: "telegram",
|
||||
|
|
@ -5869,7 +6090,10 @@ func (t *daySummaryWithSteeringTool) Parameters() map[string]any {
|
|||
}
|
||||
}
|
||||
|
||||
func (t *daySummaryWithSteeringTool) Execute(ctx context.Context, args map[string]any) *tools.ToolResult {
|
||||
func (t *daySummaryWithSteeringTool) Execute(
|
||||
ctx context.Context,
|
||||
args map[string]any,
|
||||
) *tools.ToolResult {
|
||||
day, _ := args["day"].(string)
|
||||
switch day {
|
||||
case "today":
|
||||
|
|
@ -5890,23 +6114,10 @@ func (t *daySummaryWithSteeringTool) Execute(ctx context.Context, args map[strin
|
|||
}
|
||||
|
||||
func TestProcessMessage_FinalActionSummaryRendersAcrossInformationalSteering(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
cfg := &config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
ModelName: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
FinalTurnRenderMode: "llm",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
msgBus := bus.NewMessageBus()
|
||||
provider := &daySummaryAcrossSteeringProvider{}
|
||||
al := NewAgentLoop(cfg, msgBus, provider)
|
||||
al.RegisterTool(&daySummaryWithSteeringTool{loop: al})
|
||||
al := newFinalTurnRenderTestLoop(t, provider, func(al *AgentLoop) tools.Tool {
|
||||
return &daySummaryWithSteeringTool{loop: al}
|
||||
})
|
||||
|
||||
response, err := al.processMessage(context.Background(), testInboundMessage(bus.InboundMessage{
|
||||
Channel: "telegram",
|
||||
|
|
|
|||
|
|
@ -14,7 +14,11 @@ import (
|
|||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
)
|
||||
|
||||
func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState, pipeline *Pipeline) (turnResult, error) {
|
||||
func (al *AgentLoop) runTurn(
|
||||
ctx context.Context,
|
||||
ts *turnState,
|
||||
pipeline *Pipeline,
|
||||
) (turnResult, error) {
|
||||
turnCtx, turnCancel := context.WithCancel(ctx)
|
||||
defer turnCancel()
|
||||
ts.setTurnCancel(turnCancel)
|
||||
|
|
@ -103,18 +107,26 @@ func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState, pipeline *Pipel
|
|||
// Check if parent turn has ended (SubTurn support from HEAD)
|
||||
if ts.parentTurnState != nil && ts.IsParentEnded() {
|
||||
if !ts.critical {
|
||||
logger.InfoCF("agent", "Parent turn ended, non-critical SubTurn exiting gracefully", map[string]any{
|
||||
logger.InfoCF(
|
||||
"agent",
|
||||
"Parent turn ended, non-critical SubTurn exiting gracefully",
|
||||
map[string]any{
|
||||
"agent_id": ts.agentID,
|
||||
"iteration": iteration,
|
||||
"turn_id": ts.turnID,
|
||||
},
|
||||
)
|
||||
break
|
||||
}
|
||||
logger.InfoCF(
|
||||
"agent",
|
||||
"Parent turn ended, critical SubTurn continues running",
|
||||
map[string]any{
|
||||
"agent_id": ts.agentID,
|
||||
"iteration": iteration,
|
||||
"turn_id": ts.turnID,
|
||||
})
|
||||
break
|
||||
}
|
||||
logger.InfoCF("agent", "Parent turn ended, critical SubTurn continues running", map[string]any{
|
||||
"agent_id": ts.agentID,
|
||||
"iteration": iteration,
|
||||
"turn_id": ts.turnID,
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Poll for pending SubTurn results (from HEAD)
|
||||
|
|
@ -214,24 +226,35 @@ func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState, pipeline *Pipel
|
|||
messages = exec.messages
|
||||
continue
|
||||
case ToolControlFinalize:
|
||||
finalContent, rendered := tryRenderFinalTurnReply(turnCtx, al, ts, exec, finalContent)
|
||||
renderedContent, rendered := tryRenderFinalTurnReply(
|
||||
turnCtx,
|
||||
al,
|
||||
ts,
|
||||
exec,
|
||||
finalContent,
|
||||
)
|
||||
if !rendered {
|
||||
messages = exec.messages
|
||||
continue
|
||||
}
|
||||
if steerMsgs := al.dequeueSteeringMessagesForScope(ts.sessionKey); len(steerMsgs) > 0 {
|
||||
if steerMsgs := al.dequeueSteeringMessagesForScope(ts.sessionKey); len(
|
||||
steerMsgs,
|
||||
) > 0 {
|
||||
exec.markSteeringObserved()
|
||||
logger.InfoCF("agent", "Steering arrived during terminal render; continuing turn",
|
||||
logger.InfoCF(
|
||||
"agent",
|
||||
"Steering arrived during terminal render; continuing turn",
|
||||
map[string]any{
|
||||
"agent_id": ts.agent.ID,
|
||||
"iteration": iteration,
|
||||
"steering_count": len(steerMsgs),
|
||||
})
|
||||
},
|
||||
)
|
||||
exec.pendingMessages = append(exec.pendingMessages, steerMsgs...)
|
||||
messages = exec.messages
|
||||
continue
|
||||
}
|
||||
return pipeline.Finalize(ctx, turnCtx, ts, exec, turnStatus, finalContent)
|
||||
return pipeline.Finalize(ctx, turnCtx, ts, exec, turnStatus, renderedContent)
|
||||
case ToolControlBreak:
|
||||
// Hard abort: delegate to abortTurn (sets TurnEndStatusAborted)
|
||||
if exec.abortedByHardAbort {
|
||||
|
|
@ -323,7 +346,10 @@ func (al *AgentLoop) selectCandidates(
|
|||
"score": score,
|
||||
"threshold": agent.Router.Threshold(),
|
||||
})
|
||||
return agent.LightCandidates, resolvedCandidateModel(agent.LightCandidates, agent.Router.LightModel()), true
|
||||
return agent.LightCandidates, resolvedCandidateModel(
|
||||
agent.LightCandidates,
|
||||
agent.Router.LightModel(),
|
||||
), true
|
||||
}
|
||||
|
||||
func (al *AgentLoop) resolveContextManager() ContextManager {
|
||||
|
|
@ -340,10 +366,14 @@ func (al *AgentLoop) resolveContextManager() ContextManager {
|
|||
}
|
||||
cm, err := factory(al.cfg.Agents.Defaults.ContextManagerConfig, al)
|
||||
if err != nil {
|
||||
logger.WarnCF("agent", "Failed to create context manager, falling back to legacy", map[string]any{
|
||||
"name": name,
|
||||
"error": err.Error(),
|
||||
})
|
||||
logger.WarnCF(
|
||||
"agent",
|
||||
"Failed to create context manager, falling back to legacy",
|
||||
map[string]any{
|
||||
"name": name,
|
||||
"error": err.Error(),
|
||||
},
|
||||
)
|
||||
return &legacyContextManager{al: al}
|
||||
}
|
||||
return cm
|
||||
|
|
@ -423,7 +453,11 @@ func (al *AgentLoop) askSideQuestion(
|
|||
forceModel bool,
|
||||
callMessages []providers.Message,
|
||||
) (*providers.LLMResponse, error) {
|
||||
provider, providerModel, cleanup, err := al.isolatedSideQuestionProvider(agent, selectedModelName, candidate)
|
||||
provider, providerModel, cleanup, err := al.isolatedSideQuestionProvider(
|
||||
agent,
|
||||
selectedModelName,
|
||||
candidate,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -443,7 +477,11 @@ func (al *AgentLoop) askSideQuestion(
|
|||
|
||||
turnCtx := newTurnContext(nil, nil, nil)
|
||||
if opts != nil {
|
||||
turnCtx = newTurnContext(opts.Dispatch.InboundContext, opts.Dispatch.RouteResult, opts.Dispatch.SessionScope)
|
||||
turnCtx = newTurnContext(
|
||||
opts.Dispatch.InboundContext,
|
||||
opts.Dispatch.RouteResult,
|
||||
opts.Dispatch.SessionScope,
|
||||
)
|
||||
}
|
||||
llmModel := activeModel
|
||||
if al.hooks != nil {
|
||||
|
|
@ -499,7 +537,8 @@ func (al *AgentLoop) askSideQuestion(
|
|||
func(ctx context.Context, providerName, model string) (*providers.LLMResponse, error) {
|
||||
candidate := providers.FallbackCandidate{Provider: providerName, Model: model}
|
||||
for _, activeCandidate := range activeCandidates {
|
||||
if activeCandidate.Provider == providerName && activeCandidate.Model == model {
|
||||
if activeCandidate.Provider == providerName &&
|
||||
activeCandidate.Model == model {
|
||||
candidate = activeCandidate
|
||||
break
|
||||
}
|
||||
|
|
@ -587,7 +626,9 @@ func (al *AgentLoop) isolatedSideQuestionProvider(
|
|||
candidate providers.FallbackCandidate,
|
||||
) (providers.LLMProvider, string, func(), error) {
|
||||
if agent == nil {
|
||||
return nil, "", func() {}, fmt.Errorf("isolatedSideQuestionProvider: no agent available for /btw")
|
||||
return nil, "", func() {}, fmt.Errorf(
|
||||
"isolatedSideQuestionProvider: no agent available for /btw",
|
||||
)
|
||||
}
|
||||
|
||||
modelCfg, err := al.sideQuestionModelConfig(agent, baseModelName, candidate)
|
||||
|
|
|
|||
|
|
@ -275,7 +275,7 @@ type AgentDefaults struct {
|
|||
MaxParallelTurns int `json:"max_parallel_turns,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_PARALLEL_TURNS"` // Max concurrent turns (0 or 1 = sequential)
|
||||
SubTurn SubTurnConfig `json:"subturn" envPrefix:"PICOCLAW_AGENTS_DEFAULTS_SUBTURN_"`
|
||||
ToolFeedback ToolFeedbackConfig `json:"tool_feedback,omitempty"`
|
||||
FinalTurnRenderMode string `json:"final_turn_render_mode,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_FINAL_TURN_RENDER_MODE"`
|
||||
FinalTurnRenderMode string `json:"final_turn_render_mode,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_FINAL_TURN_RENDER_MODE"`
|
||||
SplitOnMarker bool `json:"split_on_marker" env:"PICOCLAW_AGENTS_DEFAULTS_SPLIT_ON_MARKER"` // split messages on <|[SPLIT]|> marker
|
||||
ContextManager string `json:"context_manager,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_CONTEXT_MANAGER"`
|
||||
ContextManagerConfig json.RawMessage `json:"context_manager_config,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_CONTEXT_MANAGER_CONFIG"`
|
||||
|
|
@ -1022,7 +1022,11 @@ func LoadConfig(path string) (*Config, error) {
|
|||
}
|
||||
if e := json.Unmarshal(data, &versionInfo); e != nil {
|
||||
e = wrapJSONError(data, e, "config.json")
|
||||
logger.ErrorCF("config", formatDiagnosticLogMessage("Malformed config file", e), map[string]any{"path": path})
|
||||
logger.ErrorCF(
|
||||
"config",
|
||||
formatDiagnosticLogMessage("Malformed config file", e),
|
||||
map[string]any{"path": path},
|
||||
)
|
||||
return nil, e
|
||||
}
|
||||
if len(data) <= 10 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue