Merge pull request #2672 from lc6464/fix-tool-calls-thought-ui
feat(pico): add structured tool call support to web chat
This commit is contained in:
commit
39dec35408
27 changed files with 1210 additions and 439 deletions
|
|
@ -111,8 +111,10 @@ const (
|
||||||
sessionKeyAgentPrefix = "agent:"
|
sessionKeyAgentPrefix = "agent:"
|
||||||
pendingTurnPrefix = "pending-"
|
pendingTurnPrefix = "pending-"
|
||||||
metadataKeyMessageKind = "message_kind"
|
metadataKeyMessageKind = "message_kind"
|
||||||
|
metadataKeyToolCalls = "tool_calls"
|
||||||
messageKindThought = "thought"
|
messageKindThought = "thought"
|
||||||
messageKindToolFeedback = "tool_feedback"
|
messageKindToolFeedback = "tool_feedback"
|
||||||
|
messageKindToolCalls = "tool_calls"
|
||||||
metadataKeyAccountID = "account_id"
|
metadataKeyAccountID = "account_id"
|
||||||
metadataKeyGuildID = "guild_id"
|
metadataKeyGuildID = "guild_id"
|
||||||
metadataKeyTeamID = "team_id"
|
metadataKeyTeamID = "team_id"
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,17 @@ package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
"github.com/sipeed/picoclaw/pkg/tools"
|
"github.com/sipeed/picoclaw/pkg/tools"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (al *AgentLoop) maybePublishError(ctx context.Context, channel, chatID, sessionKey string, err error) bool {
|
func (al *AgentLoop) maybePublishError(ctx context.Context, channel, chatID, sessionKey string, err error) bool {
|
||||||
|
|
@ -123,6 +127,95 @@ func (al *AgentLoop) publishPicoReasoning(ctx context.Context, reasoningContent,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) publishPicoToolCallInterim(
|
||||||
|
ctx context.Context,
|
||||||
|
ts *turnState,
|
||||||
|
reasoningContent string,
|
||||||
|
content string,
|
||||||
|
toolCalls []providers.ToolCall,
|
||||||
|
) {
|
||||||
|
if ts == nil || ts.chatID == "" || al == nil || al.bus == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(reasoningContent) != "" {
|
||||||
|
pubCtx, pubCancel := context.WithTimeout(ctx, 3*time.Second)
|
||||||
|
err := al.bus.PublishOutbound(
|
||||||
|
pubCtx,
|
||||||
|
outboundMessageForTurnWithKind(ts, reasoningContent, messageKindThought),
|
||||||
|
)
|
||||||
|
pubCancel()
|
||||||
|
if err != nil && !errors.Is(err, context.DeadlineExceeded) &&
|
||||||
|
!errors.Is(err, context.Canceled) &&
|
||||||
|
!errors.Is(err, bus.ErrBusClosed) {
|
||||||
|
logger.WarnCF("agent", "Failed to publish pico reasoning", map[string]any{
|
||||||
|
"channel": ts.channel,
|
||||||
|
"chat_id": ts.chatID,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !ts.opts.AllowInterimPicoPublish {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
visibleToolCalls := utils.BuildVisibleToolCalls(
|
||||||
|
toolCalls,
|
||||||
|
al.cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength(),
|
||||||
|
)
|
||||||
|
duplicateToolCallContent := len(visibleToolCalls) > 0 &&
|
||||||
|
utils.ToolCallExplanationDuplicatesContent(content, toolCalls)
|
||||||
|
|
||||||
|
if strings.TrimSpace(content) != "" && !duplicateToolCallContent {
|
||||||
|
pubCtx, pubCancel := context.WithTimeout(ctx, 3*time.Second)
|
||||||
|
err := al.bus.PublishOutbound(pubCtx, outboundMessageForTurn(ts, content))
|
||||||
|
pubCancel()
|
||||||
|
if err != nil && !errors.Is(err, context.DeadlineExceeded) &&
|
||||||
|
!errors.Is(err, context.Canceled) &&
|
||||||
|
!errors.Is(err, bus.ErrBusClosed) {
|
||||||
|
logger.WarnCF("agent", "Failed to publish pico interim assistant content", map[string]any{
|
||||||
|
"channel": ts.channel,
|
||||||
|
"chat_id": ts.chatID,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(visibleToolCalls) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rawToolCalls, err := json.Marshal(visibleToolCalls)
|
||||||
|
if err != nil {
|
||||||
|
logger.WarnCF("agent", "Failed to serialize pico tool calls", map[string]any{
|
||||||
|
"channel": ts.channel,
|
||||||
|
"chat_id": ts.chatID,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := outboundMessageForTurnWithKind(ts, "", messageKindToolCalls)
|
||||||
|
if msg.Context.Raw == nil {
|
||||||
|
msg.Context.Raw = map[string]string{}
|
||||||
|
}
|
||||||
|
msg.Context.Raw[metadataKeyToolCalls] = string(rawToolCalls)
|
||||||
|
|
||||||
|
pubCtx, pubCancel := context.WithTimeout(ctx, 3*time.Second)
|
||||||
|
err = al.bus.PublishOutbound(pubCtx, msg)
|
||||||
|
pubCancel()
|
||||||
|
if err != nil && !errors.Is(err, context.DeadlineExceeded) &&
|
||||||
|
!errors.Is(err, context.Canceled) &&
|
||||||
|
!errors.Is(err, bus.ErrBusClosed) {
|
||||||
|
logger.WarnCF("agent", "Failed to publish pico tool calls", map[string]any{
|
||||||
|
"channel": ts.channel,
|
||||||
|
"chat_id": ts.chatID,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (al *AgentLoop) handleReasoning(
|
func (al *AgentLoop) handleReasoning(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
reasoningContent, channelName, channelID string,
|
reasoningContent, channelName, channelID string,
|
||||||
|
|
|
||||||
|
|
@ -1892,7 +1892,7 @@ func TestToolFeedbackExplanationFromResponse_UsesCurrentContentFirst(t *testing.
|
||||||
{Role: "tool", Content: "tool output", ToolCallID: "call_1"},
|
{Role: "tool", Content: "tool output", ToolCallID: "call_1"},
|
||||||
}
|
}
|
||||||
|
|
||||||
got := toolFeedbackExplanationFromResponse(response, messages, 300)
|
got := toolFeedbackExplanationFromResponse(response, messages)
|
||||||
if got != "Read README.md first" {
|
if got != "Read README.md first" {
|
||||||
t.Fatalf("toolFeedbackExplanationFromResponse() = %q, want current content", got)
|
t.Fatalf("toolFeedbackExplanationFromResponse() = %q, want current content", got)
|
||||||
}
|
}
|
||||||
|
|
@ -1936,7 +1936,7 @@ func TestToolFeedbackExplanationFromResponse_UsesExplicitToolCallExtraContent(t
|
||||||
{Role: "tool", Content: "tool output", ToolCallID: "call_1"},
|
{Role: "tool", Content: "tool output", ToolCallID: "call_1"},
|
||||||
}
|
}
|
||||||
|
|
||||||
got := toolFeedbackExplanationFromResponse(response, messages, 300)
|
got := toolFeedbackExplanationFromResponse(response, messages)
|
||||||
if got != "Read README.md first to confirm the current project structure." {
|
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)
|
||||||
}
|
}
|
||||||
|
|
@ -1963,8 +1963,8 @@ func TestToolFeedbackExplanationForToolCall_PrefersToolSpecificExtraContent(t *t
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
got1 := toolFeedbackExplanationForToolCall(response, response.ToolCalls[0], nil, 300)
|
got1 := toolFeedbackExplanationForToolCall(response, response.ToolCalls[0], nil)
|
||||||
got2 := toolFeedbackExplanationForToolCall(response, response.ToolCalls[1], nil, 300)
|
got2 := toolFeedbackExplanationForToolCall(response, response.ToolCalls[1], nil)
|
||||||
if got1 != "Read README.md first." {
|
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)
|
||||||
}
|
}
|
||||||
|
|
@ -1993,7 +1993,7 @@ func TestToolFeedbackExplanationForToolCall_DoesNotReuseAnotherToolCallExplanati
|
||||||
{Role: "user", Content: "inspect the config and update the example"},
|
{Role: "user", Content: "inspect the config and update the example"},
|
||||||
}
|
}
|
||||||
|
|
||||||
got := toolFeedbackExplanationForToolCall(response, response.ToolCalls[0], messages, 300)
|
got := toolFeedbackExplanationForToolCall(response, response.ToolCalls[0], messages)
|
||||||
want := utils.ToolFeedbackContinuationHint + ": inspect the config and update the example"
|
want := utils.ToolFeedbackContinuationHint + ": inspect the config and update the example"
|
||||||
if got != want {
|
if got != want {
|
||||||
t.Fatalf("toolFeedbackExplanationForToolCall() = %q, want %q", got, want)
|
t.Fatalf("toolFeedbackExplanationForToolCall() = %q, want %q", got, want)
|
||||||
|
|
@ -2012,13 +2012,31 @@ func TestToolFeedbackExplanationFromResponse_DoesNotUseReasoningContent(t *testi
|
||||||
{Role: "tool", Content: "tool output", ToolCallID: "call_1"},
|
{Role: "tool", Content: "tool output", ToolCallID: "call_1"},
|
||||||
}
|
}
|
||||||
|
|
||||||
got := toolFeedbackExplanationFromResponse(response, messages, 300)
|
got := toolFeedbackExplanationFromResponse(response, messages)
|
||||||
want := utils.ToolFeedbackContinuationHint + ": Inspect README.md and update the config example."
|
want := utils.ToolFeedbackContinuationHint + ": Inspect README.md and update the config example."
|
||||||
if got != want {
|
if got != want {
|
||||||
t.Fatalf("toolFeedbackExplanationFromResponse() = %q, want latest user content fallback", got)
|
t.Fatalf("toolFeedbackExplanationFromResponse() = %q, want latest user content fallback", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestToolFeedbackExplanationForToolCall_DoesNotTruncateLongExplanation(t *testing.T) {
|
||||||
|
explanation := "Read README.md first to confirm the current project structure before editing the config example."
|
||||||
|
response := &providers.LLMResponse{
|
||||||
|
ToolCalls: []providers.ToolCall{{
|
||||||
|
ID: "call_1",
|
||||||
|
Name: "read_file",
|
||||||
|
ExtraContent: &providers.ExtraContent{
|
||||||
|
ToolFeedbackExplanation: explanation,
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
got := toolFeedbackExplanationForToolCall(response, response.ToolCalls[0], nil)
|
||||||
|
if got != explanation {
|
||||||
|
t.Fatalf("toolFeedbackExplanationForToolCall() = %q, want full explanation", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestToolFeedbackArgsPreview_UsesJSONAndTruncates(t *testing.T) {
|
func TestToolFeedbackArgsPreview_UsesJSONAndTruncates(t *testing.T) {
|
||||||
got := toolFeedbackArgsPreview(map[string]any{
|
got := toolFeedbackArgsPreview(map[string]any{
|
||||||
"path": "README.md",
|
"path": "README.md",
|
||||||
|
|
@ -2064,6 +2082,43 @@ func (m *picoInterleavedContentProvider) GetDefaultModel() string {
|
||||||
return "pico-interleaved-content-model"
|
return "pico-interleaved-content-model"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type picoDistinctToolCallContentProvider struct {
|
||||||
|
calls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *picoDistinctToolCallContentProvider) Chat(
|
||||||
|
ctx context.Context,
|
||||||
|
messages []providers.Message,
|
||||||
|
tools []providers.ToolDefinition,
|
||||||
|
model string,
|
||||||
|
opts map[string]any,
|
||||||
|
) (*providers.LLMResponse, error) {
|
||||||
|
m.calls++
|
||||||
|
if m.calls == 1 {
|
||||||
|
return &providers.LLMResponse{
|
||||||
|
Content: "intermediate model text",
|
||||||
|
ToolCalls: []providers.ToolCall{{
|
||||||
|
ID: "call_tool_limit_test",
|
||||||
|
Type: "function",
|
||||||
|
Name: "tool_limit_test_tool",
|
||||||
|
Arguments: map[string]any{"value": "x"},
|
||||||
|
ExtraContent: &providers.ExtraContent{
|
||||||
|
ToolFeedbackExplanation: "Read the file before replying.",
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &providers.LLMResponse{
|
||||||
|
Content: "final model text",
|
||||||
|
ToolCalls: []providers.ToolCall{},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *picoDistinctToolCallContentProvider) GetDefaultModel() string {
|
||||||
|
return "pico-distinct-tool-call-content-model"
|
||||||
|
}
|
||||||
|
|
||||||
type toolLimitOnlyProvider struct{}
|
type toolLimitOnlyProvider struct{}
|
||||||
|
|
||||||
func (m *toolLimitOnlyProvider) Chat(
|
func (m *toolLimitOnlyProvider) Chat(
|
||||||
|
|
@ -3987,6 +4042,7 @@ func TestProcessMessage_PublishesToolFeedbackWhenEnabled(t *testing.T) {
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case outbound := <-msgBus.OutboundChan():
|
case outbound := <-msgBus.OutboundChan():
|
||||||
|
escapedHeartbeatFile := strings.ReplaceAll(heartbeatFile, `\`, `\\`)
|
||||||
if outbound.Channel != "telegram" {
|
if outbound.Channel != "telegram" {
|
||||||
t.Fatalf("tool feedback channel = %q, want %q", outbound.Channel, "telegram")
|
t.Fatalf("tool feedback channel = %q, want %q", outbound.Channel, "telegram")
|
||||||
}
|
}
|
||||||
|
|
@ -4008,7 +4064,7 @@ func TestProcessMessage_PublishesToolFeedbackWhenEnabled(t *testing.T) {
|
||||||
if !strings.Contains(outbound.Content, "\"path\":") {
|
if !strings.Contains(outbound.Content, "\"path\":") {
|
||||||
t.Fatalf("tool feedback content = %q, want serialized tool arguments", outbound.Content)
|
t.Fatalf("tool feedback content = %q, want serialized tool arguments", outbound.Content)
|
||||||
}
|
}
|
||||||
if !strings.Contains(outbound.Content, heartbeatFile) {
|
if !strings.Contains(outbound.Content, escapedHeartbeatFile) {
|
||||||
t.Fatalf("tool feedback content = %q, want tool argument value", outbound.Content)
|
t.Fatalf("tool feedback content = %q, want tool argument value", outbound.Content)
|
||||||
}
|
}
|
||||||
if strings.Contains(outbound.Content, "Previous turn explanation") {
|
if strings.Contains(outbound.Content, "Previous turn explanation") {
|
||||||
|
|
@ -4250,6 +4306,7 @@ func TestProcessMessage_DoesNotLeakReasoningContentInToolFeedback(t *testing.T)
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case outbound := <-msgBus.OutboundChan():
|
case outbound := <-msgBus.OutboundChan():
|
||||||
|
escapedHeartbeatFile := strings.ReplaceAll(heartbeatFile, `\`, `\\`)
|
||||||
if !strings.Contains(outbound.Content, "`read_file`") {
|
if !strings.Contains(outbound.Content, "`read_file`") {
|
||||||
t.Fatalf("tool feedback content = %q, want read_file summary", outbound.Content)
|
t.Fatalf("tool feedback content = %q, want read_file summary", outbound.Content)
|
||||||
}
|
}
|
||||||
|
|
@ -4262,7 +4319,7 @@ func TestProcessMessage_DoesNotLeakReasoningContentInToolFeedback(t *testing.T)
|
||||||
if !strings.Contains(outbound.Content, "\"path\":") {
|
if !strings.Contains(outbound.Content, "\"path\":") {
|
||||||
t.Fatalf("tool feedback content = %q, want serialized tool arguments", outbound.Content)
|
t.Fatalf("tool feedback content = %q, want serialized tool arguments", outbound.Content)
|
||||||
}
|
}
|
||||||
if !strings.Contains(outbound.Content, heartbeatFile) {
|
if !strings.Contains(outbound.Content, escapedHeartbeatFile) {
|
||||||
t.Fatalf("tool feedback content = %q, want tool argument value", outbound.Content)
|
t.Fatalf("tool feedback content = %q, want tool argument value", outbound.Content)
|
||||||
}
|
}
|
||||||
if strings.Contains(outbound.Content, "Read README.md first") {
|
if strings.Contains(outbound.Content, "Read README.md first") {
|
||||||
|
|
@ -4396,7 +4453,7 @@ func TestRun_PicoPublishesAssistantContentDuringToolCallsWithoutFinalDuplicate(t
|
||||||
}
|
}
|
||||||
|
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
provider := &picoInterleavedContentProvider{}
|
provider := &picoDistinctToolCallContentProvider{}
|
||||||
al := NewAgentLoop(cfg, msgBus, provider)
|
al := NewAgentLoop(cfg, msgBus, provider)
|
||||||
|
|
||||||
agent := al.GetRegistry().GetDefaultAgent()
|
agent := al.GetRegistry().GetDefaultAgent()
|
||||||
|
|
@ -4422,22 +4479,28 @@ func TestRun_PicoPublishesAssistantContentDuringToolCallsWithoutFinalDuplicate(t
|
||||||
t.Fatalf("PublishInbound() error = %v", err)
|
t.Fatalf("PublishInbound() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
outputs := make([]string, 0, 2)
|
outputs := make([]bus.OutboundMessage, 0, 3)
|
||||||
deadline := time.After(2 * time.Second)
|
deadline := time.After(2 * time.Second)
|
||||||
for len(outputs) < 2 {
|
for len(outputs) < 3 {
|
||||||
select {
|
select {
|
||||||
case outbound := <-msgBus.OutboundChan():
|
case outbound := <-msgBus.OutboundChan():
|
||||||
outputs = append(outputs, outbound.Content)
|
outputs = append(outputs, outbound)
|
||||||
case <-deadline:
|
case <-deadline:
|
||||||
t.Fatalf("timed out waiting for pico outputs, got %v", outputs)
|
t.Fatalf("timed out waiting for pico outputs, got %v", outputs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if outputs[0] != "intermediate model text" {
|
if outputs[0].Content != "intermediate model text" {
|
||||||
t.Fatalf("first outbound content = %q, want %q", outputs[0], "intermediate model text")
|
t.Fatalf("first outbound content = %q, want %q", outputs[0].Content, "intermediate model text")
|
||||||
}
|
}
|
||||||
if outputs[1] != "final model text" {
|
if outputs[1].Context.Raw[metadataKeyMessageKind] != messageKindToolCalls {
|
||||||
t.Fatalf("second outbound content = %q, want %q", outputs[1], "final model text")
|
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])
|
||||||
|
}
|
||||||
|
if outputs[2].Content != "final model text" {
|
||||||
|
t.Fatalf("third outbound content = %q, want %q", outputs[2].Content, "final model text")
|
||||||
}
|
}
|
||||||
|
|
||||||
runCancel()
|
runCancel()
|
||||||
|
|
@ -4552,22 +4615,28 @@ func TestRun_PicoToolFeedbackSuppressesDuplicateInterimAssistantContent(t *testi
|
||||||
t.Fatalf("PublishInbound() error = %v", err)
|
t.Fatalf("PublishInbound() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
outputs := make([]string, 0, 2)
|
outputs := make([]bus.OutboundMessage, 0, 3)
|
||||||
deadline := time.After(2 * time.Second)
|
deadline := time.After(2 * time.Second)
|
||||||
for len(outputs) < 2 {
|
for len(outputs) < 2 {
|
||||||
select {
|
select {
|
||||||
case outbound := <-msgBus.OutboundChan():
|
case outbound := <-msgBus.OutboundChan():
|
||||||
outputs = append(outputs, outbound.Content)
|
outputs = append(outputs, outbound)
|
||||||
case <-deadline:
|
case <-deadline:
|
||||||
t.Fatalf("timed out waiting for pico outputs, got %v", outputs)
|
t.Fatalf("timed out waiting for pico outputs, got %v", outputs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if outputs[0] != "🔧 `tool_limit_test_tool`\nintermediate model text\n```json\n{\n \"value\": \"x\"\n}\n```" {
|
if outputs[0].Context.Raw[metadataKeyMessageKind] != messageKindToolCalls {
|
||||||
t.Fatalf("first outbound content = %q, want tool feedback summary", outputs[0])
|
t.Fatalf("first outbound = %+v, want tool_calls message", outputs[0])
|
||||||
}
|
}
|
||||||
if outputs[1] != "final model text" {
|
if outputs[0].Content != "" {
|
||||||
t.Fatalf("second outbound content = %q, want %q", outputs[1], "final model text")
|
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])
|
||||||
|
}
|
||||||
|
if outputs[1].Content != "final model text" {
|
||||||
|
t.Fatalf("second outbound content = %q, want %q", outputs[1].Content, "final model text")
|
||||||
}
|
}
|
||||||
|
|
||||||
runCancel()
|
runCancel()
|
||||||
|
|
|
||||||
|
|
@ -115,7 +115,6 @@ func latestUserContent(messages []providers.Message) string {
|
||||||
func toolFeedbackExplanationFromResponse(
|
func toolFeedbackExplanationFromResponse(
|
||||||
response *providers.LLMResponse,
|
response *providers.LLMResponse,
|
||||||
messages []providers.Message,
|
messages []providers.Message,
|
||||||
maxLen int,
|
|
||||||
) string {
|
) string {
|
||||||
if response == nil {
|
if response == nil {
|
||||||
return ""
|
return ""
|
||||||
|
|
@ -127,7 +126,7 @@ func toolFeedbackExplanationFromResponse(
|
||||||
if explanation == "" {
|
if explanation == "" {
|
||||||
explanation = toolFeedbackExplanationFromMessages(messages)
|
explanation = toolFeedbackExplanationFromMessages(messages)
|
||||||
}
|
}
|
||||||
return utils.Truncate(explanation, maxLen)
|
return explanation
|
||||||
}
|
}
|
||||||
|
|
||||||
func toolFeedbackExplanationFromToolCalls(toolCalls []providers.ToolCall) string {
|
func toolFeedbackExplanationFromToolCalls(toolCalls []providers.ToolCall) string {
|
||||||
|
|
@ -146,22 +145,21 @@ func toolFeedbackExplanationForToolCall(
|
||||||
response *providers.LLMResponse,
|
response *providers.LLMResponse,
|
||||||
toolCall providers.ToolCall,
|
toolCall providers.ToolCall,
|
||||||
messages []providers.Message,
|
messages []providers.Message,
|
||||||
maxLen int,
|
|
||||||
) string {
|
) string {
|
||||||
if toolCall.ExtraContent != nil {
|
if toolCall.ExtraContent != nil {
|
||||||
if explanation := strings.TrimSpace(toolCall.ExtraContent.ToolFeedbackExplanation); explanation != "" {
|
if explanation := strings.TrimSpace(toolCall.ExtraContent.ToolFeedbackExplanation); explanation != "" {
|
||||||
return utils.Truncate(explanation, maxLen)
|
return explanation
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if response == nil {
|
if response == nil {
|
||||||
return utils.Truncate(toolFeedbackExplanationFromMessages(messages), maxLen)
|
return toolFeedbackExplanationFromMessages(messages)
|
||||||
}
|
}
|
||||||
|
|
||||||
explanation := strings.TrimSpace(response.Content)
|
explanation := strings.TrimSpace(response.Content)
|
||||||
if explanation == "" {
|
if explanation == "" {
|
||||||
explanation = toolFeedbackExplanationFromMessages(messages)
|
explanation = toolFeedbackExplanationFromMessages(messages)
|
||||||
}
|
}
|
||||||
return utils.Truncate(explanation, maxLen)
|
return explanation
|
||||||
}
|
}
|
||||||
|
|
||||||
func toolFeedbackExplanationFromMessages(messages []providers.Message) string {
|
func toolFeedbackExplanationFromMessages(messages []providers.Message) string {
|
||||||
|
|
|
||||||
|
|
@ -80,13 +80,12 @@ toolLoop:
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
if shouldPublishToolFeedback(al.cfg, ts) {
|
if shouldPublishToolFeedback(al.cfg, ts) && ts.channel != "pico" {
|
||||||
toolFeedbackMaxLen := al.cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength()
|
toolFeedbackMaxLen := al.cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength()
|
||||||
toolFeedbackExplanation := toolFeedbackExplanationForToolCall(
|
toolFeedbackExplanation := toolFeedbackExplanationForToolCall(
|
||||||
exec.response,
|
exec.response,
|
||||||
tc,
|
tc,
|
||||||
messages,
|
messages,
|
||||||
toolFeedbackMaxLen,
|
|
||||||
)
|
)
|
||||||
feedbackMsg := utils.FormatToolFeedbackMessage(
|
feedbackMsg := utils.FormatToolFeedbackMessage(
|
||||||
toolName,
|
toolName,
|
||||||
|
|
@ -362,13 +361,12 @@ toolLoop:
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
if shouldPublishToolFeedback(al.cfg, ts) {
|
if shouldPublishToolFeedback(al.cfg, ts) && ts.channel != "pico" {
|
||||||
toolFeedbackMaxLen := al.cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength()
|
toolFeedbackMaxLen := al.cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength()
|
||||||
toolFeedbackExplanation := toolFeedbackExplanationForToolCall(
|
toolFeedbackExplanation := toolFeedbackExplanationForToolCall(
|
||||||
exec.response,
|
exec.response,
|
||||||
tc,
|
tc,
|
||||||
messages,
|
messages,
|
||||||
toolFeedbackMaxLen,
|
|
||||||
)
|
)
|
||||||
feedbackMsg := utils.FormatToolFeedbackMessage(
|
feedbackMsg := utils.FormatToolFeedbackMessage(
|
||||||
toolName,
|
toolName,
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
|
||||||
"github.com/sipeed/picoclaw/pkg/constants"
|
"github.com/sipeed/picoclaw/pkg/constants"
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
"github.com/sipeed/picoclaw/pkg/providers"
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
|
|
@ -383,7 +382,11 @@ func (p *Pipeline) CallLLM(
|
||||||
}
|
}
|
||||||
|
|
||||||
reasoningContent := responseReasoningContent(exec.response)
|
reasoningContent := responseReasoningContent(exec.response)
|
||||||
if ts.channel == "pico" {
|
shouldPublishPicoToolCallInterim := ts.channel == "pico" && len(exec.response.ToolCalls) > 0
|
||||||
|
if shouldPublishPicoToolCallInterim {
|
||||||
|
// Pico tool-call turns publish their reasoning/content/tool summary as a
|
||||||
|
// structured sequence after the tool-call payload is normalized below.
|
||||||
|
} else if ts.channel == "pico" {
|
||||||
go al.publishPicoReasoning(turnCtx, reasoningContent, ts.chatID)
|
go al.publishPicoReasoning(turnCtx, reasoningContent, ts.chatID)
|
||||||
} else {
|
} else {
|
||||||
go al.handleReasoning(
|
go al.handleReasoning(
|
||||||
|
|
@ -419,30 +422,6 @@ func (p *Pipeline) CallLLM(
|
||||||
}
|
}
|
||||||
logger.DebugCF("agent", "LLM response", llmResponseFields)
|
logger.DebugCF("agent", "LLM response", llmResponseFields)
|
||||||
|
|
||||||
if al.bus != nil &&
|
|
||||||
ts.channel == "pico" &&
|
|
||||||
len(exec.response.ToolCalls) > 0 &&
|
|
||||||
ts.opts.AllowInterimPicoPublish &&
|
|
||||||
!shouldPublishToolFeedback(al.cfg, ts) {
|
|
||||||
if strings.TrimSpace(exec.response.Content) != "" {
|
|
||||||
outCtx, outCancel := context.WithTimeout(turnCtx, 3*time.Second)
|
|
||||||
publishErr := al.bus.PublishOutbound(outCtx, bus.OutboundMessage{
|
|
||||||
Channel: ts.channel,
|
|
||||||
ChatID: ts.chatID,
|
|
||||||
Content: exec.response.Content,
|
|
||||||
})
|
|
||||||
outCancel()
|
|
||||||
if publishErr != nil {
|
|
||||||
logger.WarnCF("agent", "Failed to publish pico interim tool-call content", map[string]any{
|
|
||||||
"error": publishErr.Error(),
|
|
||||||
"channel": ts.channel,
|
|
||||||
"chat_id": ts.chatID,
|
|
||||||
"iteration": iteration,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// No-tool-call path: steering check and direct response
|
// No-tool-call path: steering check and direct response
|
||||||
if len(exec.response.ToolCalls) == 0 || exec.gracefulTerminal {
|
if len(exec.response.ToolCalls) == 0 || exec.gracefulTerminal {
|
||||||
responseContent := exec.response.Content
|
responseContent := exec.response.Content
|
||||||
|
|
@ -499,7 +478,6 @@ func (p *Pipeline) CallLLM(
|
||||||
exec.response,
|
exec.response,
|
||||||
tc,
|
tc,
|
||||||
exec.messages,
|
exec.messages,
|
||||||
al.cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength(),
|
|
||||||
)
|
)
|
||||||
extraContent := tc.ExtraContent
|
extraContent := tc.ExtraContent
|
||||||
if strings.TrimSpace(toolFeedbackExplanation) != "" {
|
if strings.TrimSpace(toolFeedbackExplanation) != "" {
|
||||||
|
|
@ -531,6 +509,15 @@ func (p *Pipeline) CallLLM(
|
||||||
ts.recordPersistedMessage(assistantMsg)
|
ts.recordPersistedMessage(assistantMsg)
|
||||||
ts.ingestMessage(turnCtx, al, assistantMsg)
|
ts.ingestMessage(turnCtx, al, assistantMsg)
|
||||||
}
|
}
|
||||||
|
if shouldPublishPicoToolCallInterim {
|
||||||
|
al.publishPicoToolCallInterim(
|
||||||
|
turnCtx,
|
||||||
|
ts,
|
||||||
|
reasoningContent,
|
||||||
|
exec.response.Content,
|
||||||
|
assistantMsg.ToolCalls,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return ControlToolLoop, nil
|
return ControlToolLoop, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -134,6 +134,14 @@ func outboundMessageIsToolFeedback(msg bus.OutboundMessage) bool {
|
||||||
return strings.EqualFold(strings.TrimSpace(msg.Context.Raw["message_kind"]), "tool_feedback")
|
return strings.EqualFold(strings.TrimSpace(msg.Context.Raw["message_kind"]), "tool_feedback")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func outboundMessageBypassesPlaceholderEdit(msg bus.OutboundMessage) bool {
|
||||||
|
if len(msg.Context.Raw) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
kind := strings.TrimSpace(msg.Context.Raw["message_kind"])
|
||||||
|
return strings.EqualFold(kind, "thought") || strings.EqualFold(kind, "tool_calls")
|
||||||
|
}
|
||||||
|
|
||||||
func outboundMediaChannel(msg bus.OutboundMediaMessage) string {
|
func outboundMediaChannel(msg bus.OutboundMediaMessage) string {
|
||||||
return msg.Context.Channel
|
return msg.Context.Channel
|
||||||
}
|
}
|
||||||
|
|
@ -332,6 +340,12 @@ func (m *Manager) preSend(ctx context.Context, name string, msg bus.OutboundMess
|
||||||
}
|
}
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
|
if outboundMessageBypassesPlaceholderEdit(msg) {
|
||||||
|
if deleter, ok := ch.(MessageDeleter); ok {
|
||||||
|
deleter.DeleteMessage(ctx, chatID, entry.id) // best effort
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
if editor, ok := ch.(MessageEditor); ok {
|
if editor, ok := ch.(MessageEditor); ok {
|
||||||
content := msg.Content
|
content := msg.Content
|
||||||
trackedContent := msg.Content
|
trackedContent := msg.Content
|
||||||
|
|
|
||||||
|
|
@ -1131,6 +1131,107 @@ func TestPreSend_ToolFeedbackSeparateMessagesDeletesPlaceholderAndSkipsEdit(t *t
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPreSend_ThoughtPlaceholderDeleteAndSkipsEdit(t *testing.T) {
|
||||||
|
m := newTestManager()
|
||||||
|
|
||||||
|
ch := &mockDeletingMessageEditor{
|
||||||
|
mockMessageEditor: mockMessageEditor{
|
||||||
|
editFn: func(_ context.Context, _, _, _ string) error {
|
||||||
|
t.Fatal("expected thought message to bypass placeholder edit")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
m.RecordPlaceholder("test", "123", "456")
|
||||||
|
|
||||||
|
msg := testOutboundMessage(bus.OutboundMessage{
|
||||||
|
Channel: "test",
|
||||||
|
ChatID: "123",
|
||||||
|
Content: "thinking trace",
|
||||||
|
Context: bus.InboundContext{
|
||||||
|
Channel: "test",
|
||||||
|
ChatID: "123",
|
||||||
|
Raw: map[string]string{
|
||||||
|
"message_kind": "thought",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
msgIDs, handled := m.preSend(context.Background(), "test", msg, ch)
|
||||||
|
if handled {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected thought message to fall through so the channel can send a structured message, got %v",
|
||||||
|
msgIDs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if ch.deleteCalls != 1 {
|
||||||
|
t.Fatalf("expected placeholder deletion, got %d delete calls", ch.deleteCalls)
|
||||||
|
}
|
||||||
|
if ch.deletedChatID != "123" || ch.deletedMessageID != "456" {
|
||||||
|
t.Fatalf("unexpected placeholder deletion target: %s/%s", ch.deletedChatID, ch.deletedMessageID)
|
||||||
|
}
|
||||||
|
if _, ok := m.placeholders.Load("test:123"); ok {
|
||||||
|
t.Fatal("expected placeholder to be consumed before structured thought send")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendWithRetry_ToolCallsPlaceholderDeleteAndFallsThroughToSend(t *testing.T) {
|
||||||
|
m := newTestManager()
|
||||||
|
|
||||||
|
ch := &mockDeletingMessageEditor{
|
||||||
|
mockMessageEditor: mockMessageEditor{
|
||||||
|
mockChannel: mockChannel{
|
||||||
|
sendFn: func(_ context.Context, msg bus.OutboundMessage) error {
|
||||||
|
if got := msg.Context.Raw["message_kind"]; got != "tool_calls" {
|
||||||
|
t.Fatalf("expected tool_calls message kind, got %q", got)
|
||||||
|
}
|
||||||
|
if msg.Content != "" {
|
||||||
|
t.Fatalf("expected empty tool_calls content, got %q", msg.Content)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
editFn: func(_ context.Context, _, _, _ string) error {
|
||||||
|
t.Fatal("expected tool_calls message to bypass placeholder edit")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
m.RecordPlaceholder("test", "123", "456")
|
||||||
|
|
||||||
|
w := &channelWorker{
|
||||||
|
ch: ch,
|
||||||
|
limiter: rate.NewLimiter(rate.Inf, 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := testOutboundMessage(bus.OutboundMessage{
|
||||||
|
Channel: "test",
|
||||||
|
ChatID: "123",
|
||||||
|
Context: bus.InboundContext{
|
||||||
|
Channel: "test",
|
||||||
|
ChatID: "123",
|
||||||
|
Raw: map[string]string{
|
||||||
|
"message_kind": "tool_calls",
|
||||||
|
"tool_calls": `[{"id":"call_1","type":"function","function":{"name":"read_file","arguments":"{}"},"extra_content":{"tool_feedback_explanation":"Looking up config"}}]`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
m.sendWithRetry(context.Background(), "test", w, msg)
|
||||||
|
|
||||||
|
if ch.deleteCalls != 1 {
|
||||||
|
t.Fatalf("expected placeholder deletion, got %d delete calls", ch.deleteCalls)
|
||||||
|
}
|
||||||
|
if ch.deletedChatID != "123" || ch.deletedMessageID != "456" {
|
||||||
|
t.Fatalf("unexpected placeholder deletion target: %s/%s", ch.deletedChatID, ch.deletedMessageID)
|
||||||
|
}
|
||||||
|
if len(ch.sentMessages) != 1 {
|
||||||
|
t.Fatalf("expected structured tool_calls message to be sent once, got %d", len(ch.sentMessages))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPreSend_NonToolFeedbackSeparateMessagesClearsTrackedMessageWithoutDismiss(t *testing.T) {
|
func TestPreSend_NonToolFeedbackSeparateMessagesClearsTrackedMessageWithoutDismiss(t *testing.T) {
|
||||||
m := newTestManager()
|
m := newTestManager()
|
||||||
m.config = &config.Config{
|
m.config = &config.Config{
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/identity"
|
"github.com/sipeed/picoclaw/pkg/identity"
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
// picoConn represents a single WebSocket connection.
|
// picoConn represents a single WebSocket connection.
|
||||||
|
|
@ -57,8 +58,17 @@ func outboundMessageIsToolFeedback(msg bus.OutboundMessage) bool {
|
||||||
return strings.EqualFold(strings.TrimSpace(msg.Context.Raw["message_kind"]), "tool_feedback")
|
return strings.EqualFold(strings.TrimSpace(msg.Context.Raw["message_kind"]), "tool_feedback")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func outboundMessageIsToolCalls(msg bus.OutboundMessage) bool {
|
||||||
|
if len(msg.Context.Raw) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.EqualFold(strings.TrimSpace(msg.Context.Raw["message_kind"]), MessageKindToolCalls)
|
||||||
|
}
|
||||||
|
|
||||||
func outboundMessageFinalizesTrackedToolFeedback(msg bus.OutboundMessage) bool {
|
func outboundMessageFinalizesTrackedToolFeedback(msg bus.OutboundMessage) bool {
|
||||||
return !outboundMessageIsToolFeedback(msg) && !outboundMessageIsThought(msg)
|
return !outboundMessageIsToolFeedback(msg) &&
|
||||||
|
!outboundMessageIsThought(msg) &&
|
||||||
|
!outboundMessageIsToolCalls(msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeJSON sends a JSON message to the connection with write locking.
|
// writeJSON sends a JSON message to the connection with write locking.
|
||||||
|
|
@ -289,6 +299,7 @@ func (c *PicoChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]stri
|
||||||
}
|
}
|
||||||
isThought := outboundMessageIsThought(msg)
|
isThought := outboundMessageIsThought(msg)
|
||||||
isToolFeedback := outboundMessageIsToolFeedback(msg)
|
isToolFeedback := outboundMessageIsToolFeedback(msg)
|
||||||
|
isToolCalls := outboundMessageIsToolCalls(msg)
|
||||||
if isToolFeedback {
|
if isToolFeedback {
|
||||||
if msgID, handled, err := c.progress.Update(ctx, msg.ChatID, msg.Content); handled {
|
if msgID, handled, err := c.progress.Update(ctx, msg.ChatID, msg.Content); handled {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -315,6 +326,12 @@ func (c *PicoChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]stri
|
||||||
PayloadKeyThought: isThought,
|
PayloadKeyThought: isThought,
|
||||||
"message_id": msgID,
|
"message_id": msgID,
|
||||||
}
|
}
|
||||||
|
if isToolCalls {
|
||||||
|
payload[PayloadKeyKind] = MessageKindToolCalls
|
||||||
|
if toolCalls, ok := picoToolCallsPayload(msg); ok {
|
||||||
|
payload[PayloadKeyToolCalls] = toolCalls
|
||||||
|
}
|
||||||
|
}
|
||||||
setContextUsagePayload(payload, msg.ContextUsage)
|
setContextUsagePayload(payload, msg.ContextUsage)
|
||||||
outMsg := newMessage(TypeMessageCreate, payload)
|
outMsg := newMessage(TypeMessageCreate, payload)
|
||||||
|
|
||||||
|
|
@ -1070,6 +1087,19 @@ func setContextUsagePayload(payload map[string]any, u *bus.ContextUsage) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func picoToolCallsPayload(msg bus.OutboundMessage) ([]utils.VisibleToolCall, bool) {
|
||||||
|
raw := strings.TrimSpace(msg.Context.Raw[PayloadKeyToolCalls])
|
||||||
|
if raw == "" {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
var toolCalls []utils.VisibleToolCall
|
||||||
|
if err := json.Unmarshal([]byte(raw), &toolCalls); err != nil || len(toolCalls) == 0 {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return toolCalls, true
|
||||||
|
}
|
||||||
|
|
||||||
func (c *PicoChannel) editMessage(
|
func (c *PicoChannel) editMessage(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
chatID string,
|
chatID string,
|
||||||
|
|
|
||||||
|
|
@ -19,10 +19,13 @@ const (
|
||||||
TypeError = "error"
|
TypeError = "error"
|
||||||
TypePong = "pong"
|
TypePong = "pong"
|
||||||
|
|
||||||
PayloadKeyContent = "content"
|
PayloadKeyContent = "content"
|
||||||
PayloadKeyThought = "thought"
|
PayloadKeyThought = "thought"
|
||||||
|
PayloadKeyKind = "kind"
|
||||||
|
PayloadKeyToolCalls = "tool_calls"
|
||||||
|
|
||||||
MessageKindThought = "thought"
|
MessageKindThought = "thought"
|
||||||
|
MessageKindToolCalls = "tool_calls"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PicoMessage is the wire format for all Pico Protocol messages.
|
// PicoMessage is the wire format for all Pico Protocol messages.
|
||||||
|
|
|
||||||
|
|
@ -287,7 +287,7 @@ func (d *AgentDefaults) GetMaxMediaSize() int {
|
||||||
return DefaultMaxMediaSize
|
return DefaultMaxMediaSize
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetToolFeedbackMaxArgsLength returns the max visible text length for tool feedback messages.
|
// GetToolFeedbackMaxArgsLength returns the max visible text length for tool argument previews.
|
||||||
func (d *AgentDefaults) GetToolFeedbackMaxArgsLength() int {
|
func (d *AgentDefaults) GetToolFeedbackMaxArgsLength() int {
|
||||||
if d.ToolFeedback.MaxArgsLength > 0 {
|
if d.ToolFeedback.MaxArgsLength > 0 {
|
||||||
return d.ToolFeedback.MaxArgsLength
|
return d.ToolFeedback.MaxArgsLength
|
||||||
|
|
|
||||||
39
pkg/utils/tool_feedback_dedupe.go
Normal file
39
pkg/utils/tool_feedback_dedupe.go
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
|
)
|
||||||
|
|
||||||
|
func normalizeToolFeedbackComparisonText(text string) string {
|
||||||
|
text = strings.ReplaceAll(text, "\r\n", "\n")
|
||||||
|
text = strings.ReplaceAll(text, "\r", "\n")
|
||||||
|
text = strings.TrimSpace(text)
|
||||||
|
if text == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.Join(strings.Fields(text), " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func ToolCallExplanationDuplicatesContent(content string, toolCalls []providers.ToolCall) bool {
|
||||||
|
normalizedContent := normalizeToolFeedbackComparisonText(content)
|
||||||
|
if normalizedContent == "" || len(toolCalls) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range toolCalls {
|
||||||
|
if tc.ExtraContent == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
explanation := normalizeToolFeedbackComparisonText(tc.ExtraContent.ToolFeedbackExplanation)
|
||||||
|
if explanation == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if explanation == normalizedContent {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
55
pkg/utils/tool_feedback_dedupe_test.go
Normal file
55
pkg/utils/tool_feedback_dedupe_test.go
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestToolCallExplanationDuplicatesContent(t *testing.T) {
|
||||||
|
t.Run("exact duplicate", func(t *testing.T) {
|
||||||
|
toolCalls := []providers.ToolCall{{
|
||||||
|
ExtraContent: &providers.ExtraContent{
|
||||||
|
ToolFeedbackExplanation: "Read the file before replying.",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
|
||||||
|
if !ToolCallExplanationDuplicatesContent("Read the file before replying.", toolCalls) {
|
||||||
|
t.Fatal("expected duplicated content to be detected")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("whitespace normalized duplicate", func(t *testing.T) {
|
||||||
|
toolCalls := []providers.ToolCall{{
|
||||||
|
ExtraContent: &providers.ExtraContent{
|
||||||
|
ToolFeedbackExplanation: "Read the file\nbefore replying.",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
|
||||||
|
if !ToolCallExplanationDuplicatesContent(" Read the file before replying. ", toolCalls) {
|
||||||
|
t.Fatal("expected whitespace-only differences to be ignored")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("distinct content", func(t *testing.T) {
|
||||||
|
toolCalls := []providers.ToolCall{{
|
||||||
|
ExtraContent: &providers.ExtraContent{
|
||||||
|
ToolFeedbackExplanation: "Read the file before replying.",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
|
||||||
|
if ToolCallExplanationDuplicatesContent(
|
||||||
|
"I will summarize the findings after reading the file.",
|
||||||
|
toolCalls,
|
||||||
|
) {
|
||||||
|
t.Fatal("expected distinct content to remain visible")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("missing explanation", func(t *testing.T) {
|
||||||
|
toolCalls := []providers.ToolCall{{}}
|
||||||
|
if ToolCallExplanationDuplicatesContent("Read the file before replying.", toolCalls) {
|
||||||
|
t.Fatal("expected empty tool explanations to skip dedupe")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
106
pkg/utils/visible_tool_calls.go
Normal file
106
pkg/utils/visible_tool_calls.go
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
|
)
|
||||||
|
|
||||||
|
type VisibleToolCall struct {
|
||||||
|
ID string `json:"id,omitempty"`
|
||||||
|
Type string `json:"type,omitempty"`
|
||||||
|
Function *VisibleToolCallFunction `json:"function,omitempty"`
|
||||||
|
ExtraContent *VisibleToolCallExtraContent `json:"extra_content,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type VisibleToolCallFunction struct {
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Arguments string `json:"arguments,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type VisibleToolCallExtraContent struct {
|
||||||
|
ToolFeedbackExplanation string `json:"tool_feedback_explanation,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildVisibleToolCalls(
|
||||||
|
toolCalls []providers.ToolCall,
|
||||||
|
maxArgsLen int,
|
||||||
|
) []VisibleToolCall {
|
||||||
|
if len(toolCalls) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
visible := make([]VisibleToolCall, 0, len(toolCalls))
|
||||||
|
for _, tc := range toolCalls {
|
||||||
|
name, _ := VisibleToolCallNameAndArguments(tc)
|
||||||
|
argsPreview := VisibleToolCallArgumentsPreview(tc, maxArgsLen)
|
||||||
|
explanation := ""
|
||||||
|
if tc.ExtraContent != nil {
|
||||||
|
explanation = strings.TrimSpace(tc.ExtraContent.ToolFeedbackExplanation)
|
||||||
|
}
|
||||||
|
if name == "" && explanation == "" && argsPreview == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
visibleCall := VisibleToolCall{
|
||||||
|
ID: strings.TrimSpace(tc.ID),
|
||||||
|
Type: strings.TrimSpace(tc.Type),
|
||||||
|
}
|
||||||
|
if visibleCall.Type == "" {
|
||||||
|
visibleCall.Type = "function"
|
||||||
|
}
|
||||||
|
if name != "" || argsPreview != "" {
|
||||||
|
visibleCall.Function = &VisibleToolCallFunction{
|
||||||
|
Name: name,
|
||||||
|
Arguments: argsPreview,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if explanation != "" {
|
||||||
|
visibleCall.ExtraContent = &VisibleToolCallExtraContent{
|
||||||
|
ToolFeedbackExplanation: explanation,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
visible = append(visible, visibleCall)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(visible) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return visible
|
||||||
|
}
|
||||||
|
|
||||||
|
func VisibleToolCallNameAndArguments(tc providers.ToolCall) (string, string) {
|
||||||
|
name := strings.TrimSpace(tc.Name)
|
||||||
|
argsJSON := ""
|
||||||
|
if tc.Function != nil {
|
||||||
|
if name == "" {
|
||||||
|
name = strings.TrimSpace(tc.Function.Name)
|
||||||
|
}
|
||||||
|
argsJSON = strings.TrimSpace(tc.Function.Arguments)
|
||||||
|
}
|
||||||
|
if argsJSON == "" && len(tc.Arguments) > 0 {
|
||||||
|
if encodedArgs, err := json.Marshal(tc.Arguments); err == nil {
|
||||||
|
argsJSON = string(encodedArgs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return name, strings.TrimSpace(argsJSON)
|
||||||
|
}
|
||||||
|
|
||||||
|
func VisibleToolCallArgumentsPreview(tc providers.ToolCall, maxLen int) string {
|
||||||
|
_, argsJSON := VisibleToolCallNameAndArguments(tc)
|
||||||
|
if argsJSON == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var pretty bytes.Buffer
|
||||||
|
if err := json.Indent(&pretty, []byte(argsJSON), "", " "); err == nil {
|
||||||
|
argsJSON = pretty.String()
|
||||||
|
}
|
||||||
|
if maxLen > 0 {
|
||||||
|
return Truncate(argsJSON, maxLen)
|
||||||
|
}
|
||||||
|
return argsJSON
|
||||||
|
}
|
||||||
33
pkg/utils/visible_tool_calls_test.go
Normal file
33
pkg/utils/visible_tool_calls_test.go
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuildVisibleToolCalls_DoesNotTruncateExplanation(t *testing.T) {
|
||||||
|
explanation := "Read README.md first to confirm the current project structure before editing the config example."
|
||||||
|
toolCalls := []providers.ToolCall{{
|
||||||
|
ID: "call_1",
|
||||||
|
Type: "function",
|
||||||
|
Function: &providers.FunctionCall{
|
||||||
|
Name: "read_file",
|
||||||
|
Arguments: `{"path":"README.md","start_line":1,"end_line":10,"extra":"abcdefghijklmnopqrstuvwxyz"}`,
|
||||||
|
},
|
||||||
|
ExtraContent: &providers.ExtraContent{
|
||||||
|
ToolFeedbackExplanation: explanation,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
|
||||||
|
visible := BuildVisibleToolCalls(toolCalls, 20)
|
||||||
|
if len(visible) != 1 {
|
||||||
|
t.Fatalf("len(visible) = %d, want 1", len(visible))
|
||||||
|
}
|
||||||
|
if visible[0].ExtraContent == nil || visible[0].ExtraContent.ToolFeedbackExplanation != explanation {
|
||||||
|
t.Fatalf("visible explanation = %#v, want %q", visible[0].ExtraContent, explanation)
|
||||||
|
}
|
||||||
|
if visible[0].Function == nil || visible[0].Function.Arguments == "" {
|
||||||
|
t.Fatalf("visible function = %#v, want truncated args preview", visible[0].Function)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,6 @@ package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
@ -53,6 +52,7 @@ type sessionChatMessage struct {
|
||||||
Kind string `json:"kind,omitempty"`
|
Kind string `json:"kind,omitempty"`
|
||||||
Media []string `json:"media,omitempty"`
|
Media []string `json:"media,omitempty"`
|
||||||
Attachments []sessionChatAttachment `json:"attachments,omitempty"`
|
Attachments []sessionChatAttachment `json:"attachments,omitempty"`
|
||||||
|
ToolCalls []utils.VisibleToolCall `json:"tool_calls,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type sessionChatAttachment struct {
|
type sessionChatAttachment struct {
|
||||||
|
|
@ -456,7 +456,10 @@ func truncateRunes(s string, maxLen int) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
func sessionChatMessageVisible(msg sessionChatMessage) bool {
|
func sessionChatMessageVisible(msg sessionChatMessage) bool {
|
||||||
return strings.TrimSpace(msg.Content) != "" || len(msg.Media) > 0 || len(msg.Attachments) > 0
|
return strings.TrimSpace(msg.Content) != "" ||
|
||||||
|
len(msg.Media) > 0 ||
|
||||||
|
len(msg.Attachments) > 0 ||
|
||||||
|
len(msg.ToolCalls) > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func sessionChatMessagePreview(msg sessionChatMessage) string {
|
func sessionChatMessagePreview(msg sessionChatMessage) string {
|
||||||
|
|
@ -475,6 +478,9 @@ func sessionChatMessagePreview(msg sessionChatMessage) string {
|
||||||
}
|
}
|
||||||
return "[attachment]"
|
return "[attachment]"
|
||||||
}
|
}
|
||||||
|
if len(msg.ToolCalls) > 0 {
|
||||||
|
return "[tool call]"
|
||||||
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -521,25 +527,11 @@ func sessionTranscriptMessages(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
toolSummaryMessages := visibleAssistantToolSummaryMessages(msg.ToolCalls, toolFeedbackMaxArgsLength)
|
toolCallsMsg, hasToolCallsMsg := assistantToolCallsMessage(
|
||||||
if len(toolSummaryMessages) > 0 {
|
msg.ToolCalls,
|
||||||
transcript = append(transcript, toolSummaryMessages...)
|
toolFeedbackMaxArgsLength,
|
||||||
}
|
)
|
||||||
|
|
||||||
visibleToolMessages := visibleAssistantToolMessages(msg.ToolCalls)
|
visibleToolMessages := visibleAssistantToolMessages(msg.ToolCalls)
|
||||||
if len(visibleToolMessages) > 0 {
|
|
||||||
transcript = append(transcript, visibleToolMessages...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// When assistant content exactly matches the rendered tool summary or
|
|
||||||
// tool-delivered message, skip it to avoid duplicates. Distinct content
|
|
||||||
// must remain visible in restored session history.
|
|
||||||
if len(msg.ToolCalls) > 0 &&
|
|
||||||
len(msg.Media) == 0 &&
|
|
||||||
len(attachments) == 0 &&
|
|
||||||
assistantToolCallContentDuplicated(msg.Content, toolSummaryMessages, visibleToolMessages) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pico web chat can persist both visible `message` tool output and a
|
// Pico web chat can persist both visible `message` tool output and a
|
||||||
// later plain assistant reply in the same turn. Hide only the fixed
|
// later plain assistant reply in the same turn. Hide only the fixed
|
||||||
|
|
@ -547,10 +539,19 @@ func sessionTranscriptMessages(
|
||||||
content := msg.Content
|
content := msg.Content
|
||||||
if assistantMessageInternalOnly(msg) {
|
if assistantMessageInternalOnly(msg) {
|
||||||
if len(attachments) == 0 {
|
if len(attachments) == 0 {
|
||||||
|
if hasToolCallsMsg {
|
||||||
|
transcript = append(transcript, toolCallsMsg)
|
||||||
|
}
|
||||||
|
if len(visibleToolMessages) > 0 {
|
||||||
|
transcript = append(transcript, visibleToolMessages...)
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
content = ""
|
content = ""
|
||||||
}
|
}
|
||||||
|
if hasToolCallsMsg && utils.ToolCallExplanationDuplicatesContent(content, msg.ToolCalls) {
|
||||||
|
content = ""
|
||||||
|
}
|
||||||
|
|
||||||
chatMsg := sessionChatMessage{
|
chatMsg := sessionChatMessage{
|
||||||
Role: "assistant",
|
Role: "assistant",
|
||||||
|
|
@ -559,10 +560,22 @@ func sessionTranscriptMessages(
|
||||||
Attachments: attachments,
|
Attachments: attachments,
|
||||||
}
|
}
|
||||||
if !sessionChatMessageVisible(chatMsg) {
|
if !sessionChatMessageVisible(chatMsg) {
|
||||||
|
if hasToolCallsMsg {
|
||||||
|
transcript = append(transcript, toolCallsMsg)
|
||||||
|
}
|
||||||
|
if len(visibleToolMessages) > 0 {
|
||||||
|
transcript = append(transcript, visibleToolMessages...)
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
transcript = append(transcript, chatMsg)
|
transcript = append(transcript, chatMsg)
|
||||||
|
if hasToolCallsMsg {
|
||||||
|
transcript = append(transcript, toolCallsMsg)
|
||||||
|
}
|
||||||
|
if len(visibleToolMessages) > 0 {
|
||||||
|
transcript = append(transcript, visibleToolMessages...)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -580,51 +593,6 @@ func filterSessionChatMessages(messages []sessionChatMessage) []sessionChatMessa
|
||||||
return filtered
|
return filtered
|
||||||
}
|
}
|
||||||
|
|
||||||
func assistantToolCallContentDuplicated(
|
|
||||||
content string,
|
|
||||||
toolSummaryMessages []sessionChatMessage,
|
|
||||||
visibleToolMessages []sessionChatMessage,
|
|
||||||
) bool {
|
|
||||||
content = strings.TrimSpace(content)
|
|
||||||
if content == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, msg := range toolSummaryMessages {
|
|
||||||
if toolSummaryContainsContent(msg.Content, content) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, msg := range visibleToolMessages {
|
|
||||||
if strings.TrimSpace(msg.Content) == content {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func toolSummaryContainsContent(summary, content string) bool {
|
|
||||||
summary = strings.TrimSpace(summary)
|
|
||||||
content = strings.TrimSpace(content)
|
|
||||||
if summary == "" || content == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if summary == content {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
_, body, hasBody := strings.Cut(summary, "\n")
|
|
||||||
if !hasBody {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
body = strings.TrimSpace(body)
|
|
||||||
if body == content {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
firstSection, _, _ := strings.Cut(body, "\n```")
|
|
||||||
return strings.TrimSpace(firstSection) == content
|
|
||||||
}
|
|
||||||
|
|
||||||
func sessionAttachments(msg providers.Message) []sessionChatAttachment {
|
func sessionAttachments(msg providers.Message) []sessionChatAttachment {
|
||||||
if len(msg.Attachments) == 0 {
|
if len(msg.Attachments) == 0 {
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -720,80 +688,34 @@ func assistantThoughtMessage(msg providers.Message) (sessionChatMessage, bool) {
|
||||||
}, true
|
}, true
|
||||||
}
|
}
|
||||||
|
|
||||||
func visibleAssistantToolSummaryMessages(
|
func assistantToolCallsMessage(
|
||||||
toolCalls []providers.ToolCall,
|
toolCalls []providers.ToolCall,
|
||||||
toolFeedbackMaxArgsLength int,
|
toolFeedbackMaxArgsLength int,
|
||||||
) []sessionChatMessage {
|
) (sessionChatMessage, bool) {
|
||||||
if len(toolCalls) == 0 {
|
if len(toolCalls) == 0 {
|
||||||
return nil
|
return sessionChatMessage{}, false
|
||||||
}
|
}
|
||||||
if toolFeedbackMaxArgsLength <= 0 {
|
if toolFeedbackMaxArgsLength <= 0 {
|
||||||
toolFeedbackMaxArgsLength = defaultToolFeedbackMaxArgsLength()
|
toolFeedbackMaxArgsLength = defaultToolFeedbackMaxArgsLength()
|
||||||
}
|
}
|
||||||
|
|
||||||
messages := make([]sessionChatMessage, 0, len(toolCalls))
|
visibleToolCalls := utils.BuildVisibleToolCalls(toolCalls, toolFeedbackMaxArgsLength)
|
||||||
for _, tc := range toolCalls {
|
if len(visibleToolCalls) == 0 {
|
||||||
name, argsJSON := toolCallNameAndArguments(tc)
|
return sessionChatMessage{}, false
|
||||||
if strings.TrimSpace(name) == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if name == "web_search" || name == "web_fetch" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if name == "message" {
|
|
||||||
if _, ok := parseMessageToolContent(argsJSON); ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
messages = append(messages, sessionChatMessage{
|
|
||||||
Role: "assistant",
|
|
||||||
Content: utils.FormatToolFeedbackMessage(
|
|
||||||
name,
|
|
||||||
visibleAssistantToolFeedbackExplanation(tc, toolFeedbackMaxArgsLength),
|
|
||||||
visibleAssistantToolArgsPreview(tc, toolFeedbackMaxArgsLength),
|
|
||||||
),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return messages
|
return sessionChatMessage{
|
||||||
}
|
Role: "assistant",
|
||||||
|
Kind: "tool_calls",
|
||||||
func visibleAssistantToolFeedbackExplanation(
|
ToolCalls: visibleToolCalls,
|
||||||
tc providers.ToolCall,
|
}, true
|
||||||
toolFeedbackMaxArgsLength int,
|
|
||||||
) string {
|
|
||||||
if tc.ExtraContent != nil {
|
|
||||||
if explanation := strings.TrimSpace(tc.ExtraContent.ToolFeedbackExplanation); explanation != "" {
|
|
||||||
return utils.Truncate(explanation, toolFeedbackMaxArgsLength)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func visibleAssistantToolArgsPreview(
|
func visibleAssistantToolArgsPreview(
|
||||||
tc providers.ToolCall,
|
tc providers.ToolCall,
|
||||||
toolFeedbackMaxArgsLength int,
|
toolFeedbackMaxArgsLength int,
|
||||||
) string {
|
) string {
|
||||||
argsJSON := ""
|
return utils.VisibleToolCallArgumentsPreview(tc, toolFeedbackMaxArgsLength)
|
||||||
if tc.Function != nil {
|
|
||||||
argsJSON = tc.Function.Arguments
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(argsJSON) == "" && len(tc.Arguments) > 0 {
|
|
||||||
if encodedArgs, err := json.MarshalIndent(tc.Arguments, "", " "); err == nil {
|
|
||||||
argsJSON = string(encodedArgs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
argsJSON = strings.TrimSpace(argsJSON)
|
|
||||||
if argsJSON == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
var pretty bytes.Buffer
|
|
||||||
if err := json.Indent(&pretty, []byte(argsJSON), "", " "); err == nil {
|
|
||||||
argsJSON = pretty.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
return utils.Truncate(argsJSON, toolFeedbackMaxArgsLength)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func visibleAssistantToolMessages(toolCalls []providers.ToolCall) []sessionChatMessage {
|
func visibleAssistantToolMessages(toolCalls []providers.ToolCall) []sessionChatMessage {
|
||||||
|
|
@ -803,7 +725,7 @@ func visibleAssistantToolMessages(toolCalls []providers.ToolCall) []sessionChatM
|
||||||
|
|
||||||
messages := make([]sessionChatMessage, 0, len(toolCalls))
|
messages := make([]sessionChatMessage, 0, len(toolCalls))
|
||||||
for _, tc := range toolCalls {
|
for _, tc := range toolCalls {
|
||||||
name, argsJSON := toolCallNameAndArguments(tc)
|
name, argsJSON := utils.VisibleToolCallNameAndArguments(tc)
|
||||||
if name != "message" {
|
if name != "message" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -820,23 +742,6 @@ func visibleAssistantToolMessages(toolCalls []providers.ToolCall) []sessionChatM
|
||||||
return messages
|
return messages
|
||||||
}
|
}
|
||||||
|
|
||||||
func toolCallNameAndArguments(tc providers.ToolCall) (string, string) {
|
|
||||||
name := tc.Name
|
|
||||||
argsJSON := ""
|
|
||||||
if tc.Function != nil {
|
|
||||||
if name == "" {
|
|
||||||
name = tc.Function.Name
|
|
||||||
}
|
|
||||||
argsJSON = tc.Function.Arguments
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(argsJSON) == "" && len(tc.Arguments) > 0 {
|
|
||||||
if encodedArgs, err := json.Marshal(tc.Arguments); err == nil {
|
|
||||||
argsJSON = string(encodedArgs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return name, argsJSON
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseMessageToolContent(argsJSON string) (string, bool) {
|
func parseMessageToolContent(argsJSON string) (string, bool) {
|
||||||
var args struct {
|
var args struct {
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,25 @@ func sessionsTestDir(t *testing.T, configPath string) string {
|
||||||
return dir
|
return dir
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func assertVisibleToolCallMessage(
|
||||||
|
t *testing.T,
|
||||||
|
msg sessionChatMessage,
|
||||||
|
toolName string,
|
||||||
|
) utils.VisibleToolCall {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
if msg.Role != "assistant" || msg.Kind != "tool_calls" {
|
||||||
|
t.Fatalf("message = %#v, want assistant/tool_calls", msg)
|
||||||
|
}
|
||||||
|
if len(msg.ToolCalls) != 1 {
|
||||||
|
t.Fatalf("len(message.ToolCalls) = %d, want 1", len(msg.ToolCalls))
|
||||||
|
}
|
||||||
|
if got := msg.ToolCalls[0].Function; got == nil || got.Name != toolName {
|
||||||
|
t.Fatalf("tool call = %#v, want function %q", msg.ToolCalls[0], toolName)
|
||||||
|
}
|
||||||
|
return msg.ToolCalls[0]
|
||||||
|
}
|
||||||
|
|
||||||
func TestHandleListSessions_JSONLStorage(t *testing.T) {
|
func TestHandleListSessions_JSONLStorage(t *testing.T) {
|
||||||
configPath, cleanup := setupOAuthTestEnv(t)
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
@ -516,11 +535,7 @@ func TestHandleGetSession_SkipsTransientThoughtMessages(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
var resp struct {
|
var resp struct {
|
||||||
Messages []struct {
|
Messages []sessionChatMessage `json:"messages"`
|
||||||
Role string `json:"role"`
|
|
||||||
Content string `json:"content"`
|
|
||||||
Kind string `json:"kind"`
|
|
||||||
} `json:"messages"`
|
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
t.Fatalf("Unmarshal() error = %v", err)
|
t.Fatalf("Unmarshal() error = %v", err)
|
||||||
|
|
@ -569,11 +584,7 @@ func TestHandleGetSession_ReconstructsThoughtFromAssistantReasoningContent(t *te
|
||||||
}
|
}
|
||||||
|
|
||||||
var resp struct {
|
var resp struct {
|
||||||
Messages []struct {
|
Messages []sessionChatMessage `json:"messages"`
|
||||||
Role string `json:"role"`
|
|
||||||
Content string `json:"content"`
|
|
||||||
Kind string `json:"kind"`
|
|
||||||
} `json:"messages"`
|
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
t.Fatalf("Unmarshal() error = %v", err)
|
t.Fatalf("Unmarshal() error = %v", err)
|
||||||
|
|
@ -667,11 +678,7 @@ func TestHandleGetSession_ReconstructsRefreshMatrixForThoughtAndToolSummary(t *t
|
||||||
}
|
}
|
||||||
|
|
||||||
var resp struct {
|
var resp struct {
|
||||||
Messages []struct {
|
Messages []sessionChatMessage `json:"messages"`
|
||||||
Role string `json:"role"`
|
|
||||||
Content string `json:"content"`
|
|
||||||
Kind string `json:"kind"`
|
|
||||||
} `json:"messages"`
|
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
t.Fatalf("Unmarshal() error = %v", err)
|
t.Fatalf("Unmarshal() error = %v", err)
|
||||||
|
|
@ -694,20 +701,14 @@ func TestHandleGetSession_ReconstructsRefreshMatrixForThoughtAndToolSummary(t *t
|
||||||
assertMessage(2, "assistant", "", "plain visible")
|
assertMessage(2, "assistant", "", "plain visible")
|
||||||
assertMessage(3, "user", "", "turn2")
|
assertMessage(3, "user", "", "turn2")
|
||||||
assertMessage(4, "assistant", "thought", "tool thought")
|
assertMessage(4, "assistant", "thought", "tool thought")
|
||||||
if !strings.Contains(resp.Messages[5].Content, "`read_file`") {
|
assertVisibleToolCallMessage(t, resp.Messages[5], "read_file")
|
||||||
t.Fatalf("messages[5] = %#v, want read_file tool summary", resp.Messages[5])
|
|
||||||
}
|
|
||||||
assertMessage(6, "user", "", "turn3")
|
assertMessage(6, "user", "", "turn3")
|
||||||
if !strings.Contains(resp.Messages[7].Content, "`list_dir`") {
|
assertMessage(7, "assistant", "", "tool visible only")
|
||||||
t.Fatalf("messages[7] = %#v, want list_dir tool summary", resp.Messages[7])
|
assertVisibleToolCallMessage(t, resp.Messages[8], "list_dir")
|
||||||
}
|
|
||||||
assertMessage(8, "assistant", "", "tool visible only")
|
|
||||||
assertMessage(9, "user", "", "turn4")
|
assertMessage(9, "user", "", "turn4")
|
||||||
assertMessage(10, "assistant", "thought", "tool mixed thought")
|
assertMessage(10, "assistant", "thought", "tool mixed thought")
|
||||||
if !strings.Contains(resp.Messages[11].Content, "`exec`") {
|
assertMessage(11, "assistant", "", "tool visible and thought")
|
||||||
t.Fatalf("messages[11] = %#v, want exec tool summary", resp.Messages[11])
|
assertVisibleToolCallMessage(t, resp.Messages[12], "exec")
|
||||||
}
|
|
||||||
assertMessage(12, "assistant", "", "tool visible and thought")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHandleGetSession_ReconstructsVisibleMessageToolOutputWithoutDuplicateSummary(t *testing.T) {
|
func TestHandleGetSession_ReconstructsVisibleMessageToolOutputWithoutDuplicateSummary(t *testing.T) {
|
||||||
|
|
@ -758,27 +759,20 @@ func TestHandleGetSession_ReconstructsVisibleMessageToolOutputWithoutDuplicateSu
|
||||||
}
|
}
|
||||||
|
|
||||||
var resp struct {
|
var resp struct {
|
||||||
Messages []struct {
|
Messages []sessionChatMessage `json:"messages"`
|
||||||
Role string `json:"role"`
|
|
||||||
Content string `json:"content"`
|
|
||||||
} `json:"messages"`
|
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
t.Fatalf("Unmarshal() error = %v", err)
|
t.Fatalf("Unmarshal() error = %v", err)
|
||||||
}
|
}
|
||||||
if len(resp.Messages) != 2 {
|
if len(resp.Messages) != 3 {
|
||||||
t.Fatalf("len(resp.Messages) = %d, want 2", len(resp.Messages))
|
t.Fatalf("len(resp.Messages) = %d, want 3", len(resp.Messages))
|
||||||
}
|
}
|
||||||
if resp.Messages[0].Role != "user" || resp.Messages[0].Content != "test" {
|
if resp.Messages[0].Role != "user" || resp.Messages[0].Content != "test" {
|
||||||
t.Fatalf("first message = %#v, want user/test", resp.Messages[0])
|
t.Fatalf("first message = %#v, want user/test", resp.Messages[0])
|
||||||
}
|
}
|
||||||
if resp.Messages[1].Role != "assistant" || resp.Messages[1].Content != "visible tool output" {
|
assertVisibleToolCallMessage(t, resp.Messages[1], "message")
|
||||||
t.Fatalf("assistant message = %#v, want visible tool output", resp.Messages[1])
|
if resp.Messages[2].Role != "assistant" || resp.Messages[2].Content != "visible tool output" {
|
||||||
}
|
t.Fatalf("assistant message = %#v, want visible tool output", resp.Messages[2])
|
||||||
for _, msg := range resp.Messages {
|
|
||||||
if msg.Role == "tool" || strings.Contains(msg.Content, "`message`") {
|
|
||||||
t.Fatalf("unexpected raw tool or duplicate message-tool summary: %#v", msg)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -829,25 +823,23 @@ func TestHandleGetSession_PreservesFinalAssistantReplyAfterMessageToolOutput(t *
|
||||||
}
|
}
|
||||||
|
|
||||||
var resp struct {
|
var resp struct {
|
||||||
Messages []struct {
|
Messages []sessionChatMessage `json:"messages"`
|
||||||
Role string `json:"role"`
|
|
||||||
Content string `json:"content"`
|
|
||||||
} `json:"messages"`
|
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
t.Fatalf("Unmarshal() error = %v", err)
|
t.Fatalf("Unmarshal() error = %v", err)
|
||||||
}
|
}
|
||||||
if len(resp.Messages) != 3 {
|
if len(resp.Messages) != 4 {
|
||||||
t.Fatalf("len(resp.Messages) = %d, want 3", len(resp.Messages))
|
t.Fatalf("len(resp.Messages) = %d, want 4", len(resp.Messages))
|
||||||
}
|
}
|
||||||
if resp.Messages[0].Role != "user" || resp.Messages[0].Content != "test" {
|
if resp.Messages[0].Role != "user" || resp.Messages[0].Content != "test" {
|
||||||
t.Fatalf("first message = %#v, want user/test", resp.Messages[0])
|
t.Fatalf("first message = %#v, want user/test", resp.Messages[0])
|
||||||
}
|
}
|
||||||
if resp.Messages[1].Role != "assistant" || resp.Messages[1].Content != "visible tool output" {
|
assertVisibleToolCallMessage(t, resp.Messages[1], "message")
|
||||||
t.Fatalf("interim assistant message = %#v, want visible tool output", resp.Messages[1])
|
if resp.Messages[2].Role != "assistant" || resp.Messages[2].Content != "visible tool output" {
|
||||||
|
t.Fatalf("interim assistant message = %#v, want visible tool output", resp.Messages[2])
|
||||||
}
|
}
|
||||||
if resp.Messages[2].Role != "assistant" || resp.Messages[2].Content != "final assistant reply" {
|
if resp.Messages[3].Role != "assistant" || resp.Messages[3].Content != "final assistant reply" {
|
||||||
t.Fatalf("final assistant message = %#v, want final assistant reply", resp.Messages[2])
|
t.Fatalf("final assistant message = %#v, want final assistant reply", resp.Messages[3])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -897,6 +889,67 @@ func TestHandleListSessions_MessageCountUsesVisibleTranscript(t *testing.T) {
|
||||||
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var items []sessionListItem
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil {
|
||||||
|
t.Fatalf("Unmarshal() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(items) != 1 {
|
||||||
|
t.Fatalf("len(items) = %d, want 1", len(items))
|
||||||
|
}
|
||||||
|
if items[0].MessageCount != 3 {
|
||||||
|
t.Fatalf("items[0].MessageCount = %d, want 3", items[0].MessageCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleListSessions_DeduplicatesAssistantToolCallContentFromVisibleTranscript(t *testing.T) {
|
||||||
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
dir := sessionsTestDir(t, configPath)
|
||||||
|
store, err := memory.NewJSONLStore(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewJSONLStore() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionKey := picoSessionPrefix + "list-deduped-tool-content"
|
||||||
|
for _, msg := range []providers.Message{
|
||||||
|
{Role: "user", Content: "check file"},
|
||||||
|
{
|
||||||
|
Role: "assistant",
|
||||||
|
Content: "Read the file before replying.",
|
||||||
|
ToolCalls: []providers.ToolCall{
|
||||||
|
{
|
||||||
|
ID: "call_1",
|
||||||
|
Type: "function",
|
||||||
|
Function: &providers.FunctionCall{
|
||||||
|
Name: "read_file",
|
||||||
|
Arguments: `{"path":"README.md"}`,
|
||||||
|
},
|
||||||
|
ExtraContent: &providers.ExtraContent{
|
||||||
|
ToolFeedbackExplanation: "Read the file before replying.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{Role: "tool", Content: "raw read_file result", ToolCallID: "call_1"},
|
||||||
|
} {
|
||||||
|
if err := store.AddFullMessage(nil, sessionKey, msg); err != nil {
|
||||||
|
t.Fatalf("AddFullMessage() error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/sessions", nil)
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
var items []sessionListItem
|
var items []sessionListItem
|
||||||
if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil {
|
if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil {
|
||||||
t.Fatalf("Unmarshal() error = %v", err)
|
t.Fatalf("Unmarshal() error = %v", err)
|
||||||
|
|
@ -959,10 +1012,7 @@ func TestHandleGetSession_DoesNotDuplicateAssistantToolCallContent(t *testing.T)
|
||||||
}
|
}
|
||||||
|
|
||||||
var resp struct {
|
var resp struct {
|
||||||
Messages []struct {
|
Messages []sessionChatMessage `json:"messages"`
|
||||||
Role string `json:"role"`
|
|
||||||
Content string `json:"content"`
|
|
||||||
} `json:"messages"`
|
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
t.Fatalf("Unmarshal() error = %v", err)
|
t.Fatalf("Unmarshal() error = %v", err)
|
||||||
|
|
@ -973,11 +1023,10 @@ func TestHandleGetSession_DoesNotDuplicateAssistantToolCallContent(t *testing.T)
|
||||||
if resp.Messages[0].Role != "user" || resp.Messages[0].Content != "check file" {
|
if resp.Messages[0].Role != "user" || resp.Messages[0].Content != "check file" {
|
||||||
t.Fatalf("first message = %#v, want user/check file", resp.Messages[0])
|
t.Fatalf("first message = %#v, want user/check file", resp.Messages[0])
|
||||||
}
|
}
|
||||||
if !strings.Contains(resp.Messages[1].Content, "`read_file`") {
|
toolCall := assertVisibleToolCallMessage(t, resp.Messages[1], "read_file")
|
||||||
t.Fatalf("tool summary message = %#v, want read_file summary", resp.Messages[1])
|
if toolCall.ExtraContent == nil ||
|
||||||
}
|
toolCall.ExtraContent.ToolFeedbackExplanation != "Read the file before replying." {
|
||||||
if !strings.Contains(resp.Messages[1].Content, "Read the file before replying.") {
|
t.Fatalf("tool call = %#v, want explanation", toolCall)
|
||||||
t.Fatalf("tool summary message = %#v, want tool explanation", resp.Messages[1])
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1030,10 +1079,7 @@ func TestHandleGetSession_PreservesDistinctAssistantToolCallContent(t *testing.T
|
||||||
}
|
}
|
||||||
|
|
||||||
var resp struct {
|
var resp struct {
|
||||||
Messages []struct {
|
Messages []sessionChatMessage `json:"messages"`
|
||||||
Role string `json:"role"`
|
|
||||||
Content string `json:"content"`
|
|
||||||
} `json:"messages"`
|
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
t.Fatalf("Unmarshal() error = %v", err)
|
t.Fatalf("Unmarshal() error = %v", err)
|
||||||
|
|
@ -1041,13 +1087,11 @@ func TestHandleGetSession_PreservesDistinctAssistantToolCallContent(t *testing.T
|
||||||
if len(resp.Messages) != 3 {
|
if len(resp.Messages) != 3 {
|
||||||
t.Fatalf("len(resp.Messages) = %d, want 3", len(resp.Messages))
|
t.Fatalf("len(resp.Messages) = %d, want 3", len(resp.Messages))
|
||||||
}
|
}
|
||||||
if !strings.Contains(resp.Messages[1].Content, "`read_file`") {
|
if resp.Messages[1].Role != "assistant" ||
|
||||||
t.Fatalf("tool summary message = %#v, want read_file summary", resp.Messages[1])
|
resp.Messages[1].Content != "I will summarize the findings after reading the file." {
|
||||||
}
|
t.Fatalf("assistant content = %#v, want preserved distinct content", resp.Messages[1])
|
||||||
if resp.Messages[2].Role != "assistant" ||
|
|
||||||
resp.Messages[2].Content != "I will summarize the findings after reading the file." {
|
|
||||||
t.Fatalf("assistant content = %#v, want preserved distinct content", resp.Messages[2])
|
|
||||||
}
|
}
|
||||||
|
assertVisibleToolCallMessage(t, resp.Messages[2], "read_file")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHandleGetSession_PreservesMediaWhenAssistantToolCallContentDuplicatesSummary(t *testing.T) {
|
func TestHandleGetSession_PreservesMediaWhenAssistantToolCallContentDuplicatesSummary(t *testing.T) {
|
||||||
|
|
@ -1100,11 +1144,7 @@ func TestHandleGetSession_PreservesMediaWhenAssistantToolCallContentDuplicatesSu
|
||||||
}
|
}
|
||||||
|
|
||||||
var resp struct {
|
var resp struct {
|
||||||
Messages []struct {
|
Messages []sessionChatMessage `json:"messages"`
|
||||||
Role string `json:"role"`
|
|
||||||
Content string `json:"content"`
|
|
||||||
Media []string `json:"media"`
|
|
||||||
} `json:"messages"`
|
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
t.Fatalf("Unmarshal() error = %v", err)
|
t.Fatalf("Unmarshal() error = %v", err)
|
||||||
|
|
@ -1112,23 +1152,16 @@ func TestHandleGetSession_PreservesMediaWhenAssistantToolCallContentDuplicatesSu
|
||||||
if len(resp.Messages) != 3 {
|
if len(resp.Messages) != 3 {
|
||||||
t.Fatalf("len(resp.Messages) = %d, want 3", len(resp.Messages))
|
t.Fatalf("len(resp.Messages) = %d, want 3", len(resp.Messages))
|
||||||
}
|
}
|
||||||
if !strings.Contains(resp.Messages[1].Content, "`view_image`") {
|
if resp.Messages[1].Role != "assistant" {
|
||||||
t.Fatalf("tool summary message = %#v, want view_image summary", resp.Messages[1])
|
t.Fatalf("assistant message role = %q, want assistant", resp.Messages[1].Role)
|
||||||
}
|
}
|
||||||
if resp.Messages[2].Role != "assistant" {
|
if resp.Messages[1].Content != "" {
|
||||||
t.Fatalf("assistant message role = %q, want assistant", resp.Messages[2].Role)
|
t.Fatalf("assistant content = %q, want duplicate content suppressed", resp.Messages[1].Content)
|
||||||
}
|
}
|
||||||
if resp.Messages[2].Content != "Reviewing the generated screenshot." {
|
if len(resp.Messages[1].Media) != 1 || resp.Messages[1].Media[0] != "data:image/png;base64,abc123" {
|
||||||
t.Fatalf("assistant content = %q, want preserved duplicated content with media", resp.Messages[2].Content)
|
t.Fatalf("assistant media = %#v, want preserved media", resp.Messages[1].Media)
|
||||||
}
|
|
||||||
if len(resp.Messages[2].Media) != 1 || resp.Messages[2].Media[0] != "data:image/png;base64,abc123" {
|
|
||||||
t.Fatalf("assistant media = %#v, want preserved media", resp.Messages[2].Media)
|
|
||||||
}
|
|
||||||
for _, msg := range resp.Messages {
|
|
||||||
if msg.Role == "tool" || strings.Contains(msg.Content, "raw read_file result") {
|
|
||||||
t.Fatalf("unexpected raw tool result in history: %#v", msg)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
assertVisibleToolCallMessage(t, resp.Messages[2], "view_image")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHandleGetSession_PreservesAttachmentsWhenAssistantToolCallContentDuplicatesSummary(t *testing.T) {
|
func TestHandleGetSession_PreservesAttachmentsWhenAssistantToolCallContentDuplicatesSummary(t *testing.T) {
|
||||||
|
|
@ -1198,21 +1231,19 @@ func TestHandleGetSession_PreservesAttachmentsWhenAssistantToolCallContentDuplic
|
||||||
if len(resp.Messages) != 3 {
|
if len(resp.Messages) != 3 {
|
||||||
t.Fatalf("len(resp.Messages) = %d, want 3", len(resp.Messages))
|
t.Fatalf("len(resp.Messages) = %d, want 3", len(resp.Messages))
|
||||||
}
|
}
|
||||||
if !strings.Contains(resp.Messages[1].Content, "`read_file`") {
|
if resp.Messages[1].Role != "assistant" {
|
||||||
t.Fatalf("tool summary message = %#v, want read_file summary", resp.Messages[1])
|
t.Fatalf("assistant message role = %q, want assistant", resp.Messages[1].Role)
|
||||||
}
|
}
|
||||||
if resp.Messages[2].Role != "assistant" {
|
if resp.Messages[1].Content != "" {
|
||||||
t.Fatalf("assistant message role = %q, want assistant", resp.Messages[2].Role)
|
t.Fatalf("assistant content = %q, want duplicate content suppressed", resp.Messages[1].Content)
|
||||||
}
|
}
|
||||||
if resp.Messages[2].Content != "Reviewing the generated report." {
|
if len(resp.Messages[1].Attachments) != 1 {
|
||||||
t.Fatalf("assistant content = %q, want preserved duplicated content", resp.Messages[2].Content)
|
t.Fatalf("len(assistant.Attachments) = %d, want 1", len(resp.Messages[1].Attachments))
|
||||||
}
|
}
|
||||||
if len(resp.Messages[2].Attachments) != 1 {
|
if resp.Messages[1].Attachments[0].URL != "https://example.com/report.txt" {
|
||||||
t.Fatalf("len(assistant.Attachments) = %d, want 1", len(resp.Messages[2].Attachments))
|
t.Fatalf("attachment url = %q, want report URL", resp.Messages[1].Attachments[0].URL)
|
||||||
}
|
|
||||||
if resp.Messages[2].Attachments[0].URL != "https://example.com/report.txt" {
|
|
||||||
t.Fatalf("attachment url = %q, want report URL", resp.Messages[2].Attachments[0].URL)
|
|
||||||
}
|
}
|
||||||
|
assertVisibleToolCallMessage(t, resp.Messages[2], "read_file")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHandleGetSession_UsesConfiguredToolFeedbackMaxArgsLength(t *testing.T) {
|
func TestHandleGetSession_UsesConfiguredToolFeedbackMaxArgsLength(t *testing.T) {
|
||||||
|
|
@ -1273,10 +1304,7 @@ func TestHandleGetSession_UsesConfiguredToolFeedbackMaxArgsLength(t *testing.T)
|
||||||
}
|
}
|
||||||
|
|
||||||
var resp struct {
|
var resp struct {
|
||||||
Messages []struct {
|
Messages []sessionChatMessage `json:"messages"`
|
||||||
Role string `json:"role"`
|
|
||||||
Content string `json:"content"`
|
|
||||||
} `json:"messages"`
|
|
||||||
}
|
}
|
||||||
err = json.Unmarshal(rec.Body.Bytes(), &resp)
|
err = json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -1286,18 +1314,15 @@ func TestHandleGetSession_UsesConfiguredToolFeedbackMaxArgsLength(t *testing.T)
|
||||||
t.Fatalf("len(resp.Messages) = %d, want at least 2", len(resp.Messages))
|
t.Fatalf("len(resp.Messages) = %d, want at least 2", len(resp.Messages))
|
||||||
}
|
}
|
||||||
|
|
||||||
wantPreview := utils.Truncate(explanation, 20)
|
|
||||||
if !strings.Contains(resp.Messages[1].Content, wantPreview) {
|
|
||||||
t.Fatalf("tool summary = %q, want preview %q", resp.Messages[1].Content, wantPreview)
|
|
||||||
}
|
|
||||||
wantArgsPreview := visibleAssistantToolArgsPreview(providers.ToolCall{
|
wantArgsPreview := visibleAssistantToolArgsPreview(providers.ToolCall{
|
||||||
Function: &providers.FunctionCall{Arguments: argsJSON},
|
Function: &providers.FunctionCall{Arguments: argsJSON},
|
||||||
}, 20)
|
}, 20)
|
||||||
if !strings.Contains(resp.Messages[1].Content, wantArgsPreview) {
|
toolCall := assertVisibleToolCallMessage(t, resp.Messages[1], "read_file")
|
||||||
t.Fatalf("tool summary = %q, want args preview %q", resp.Messages[1].Content, wantArgsPreview)
|
if toolCall.ExtraContent == nil || toolCall.ExtraContent.ToolFeedbackExplanation != explanation {
|
||||||
|
t.Fatalf("tool call = %#v, want full explanation %q", toolCall, explanation)
|
||||||
}
|
}
|
||||||
if !strings.Contains(resp.Messages[1].Content, "`read_file`") {
|
if toolCall.Function == nil || toolCall.Function.Arguments != wantArgsPreview {
|
||||||
t.Fatalf("tool summary = %q, want read_file summary", resp.Messages[1].Content)
|
t.Fatalf("tool call = %#v, want args preview %q", toolCall, wantArgsPreview)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1357,10 +1382,7 @@ func TestHandleGetSession_FallsBackToLegacyToolArgumentsWhenExplanationMissing(t
|
||||||
}
|
}
|
||||||
|
|
||||||
var resp struct {
|
var resp struct {
|
||||||
Messages []struct {
|
Messages []sessionChatMessage `json:"messages"`
|
||||||
Role string `json:"role"`
|
|
||||||
Content string `json:"content"`
|
|
||||||
} `json:"messages"`
|
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
t.Fatalf("Unmarshal() error = %v", err)
|
t.Fatalf("Unmarshal() error = %v", err)
|
||||||
|
|
@ -1372,11 +1394,9 @@ func TestHandleGetSession_FallsBackToLegacyToolArgumentsWhenExplanationMissing(t
|
||||||
wantPreview := visibleAssistantToolArgsPreview(providers.ToolCall{
|
wantPreview := visibleAssistantToolArgsPreview(providers.ToolCall{
|
||||||
Function: &providers.FunctionCall{Arguments: argsJSON},
|
Function: &providers.FunctionCall{Arguments: argsJSON},
|
||||||
}, 20)
|
}, 20)
|
||||||
if !strings.Contains(resp.Messages[1].Content, "`read_file`") {
|
toolCall := assertVisibleToolCallMessage(t, resp.Messages[1], "read_file")
|
||||||
t.Fatalf("tool summary = %q, want read_file summary", resp.Messages[1].Content)
|
if toolCall.Function == nil || toolCall.Function.Arguments != wantPreview {
|
||||||
}
|
t.Fatalf("tool call = %#v, want legacy args preview %q", toolCall, wantPreview)
|
||||||
if !strings.Contains(resp.Messages[1].Content, wantPreview) {
|
|
||||||
t.Fatalf("tool summary = %q, want legacy args preview %q", resp.Messages[1].Content, wantPreview)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ export interface SessionDetail {
|
||||||
messages: {
|
messages: {
|
||||||
role: "user" | "assistant"
|
role: "user" | "assistant"
|
||||||
content: string
|
content: string
|
||||||
kind?: "normal" | "thought"
|
kind?: "normal" | "thought" | "tool_calls"
|
||||||
media?: string[]
|
media?: string[]
|
||||||
attachments?: {
|
attachments?: {
|
||||||
type?: "image" | "audio" | "video" | "file"
|
type?: "image" | "audio" | "video" | "file"
|
||||||
|
|
@ -22,6 +22,17 @@ export interface SessionDetail {
|
||||||
filename?: string
|
filename?: string
|
||||||
content_type?: string
|
content_type?: string
|
||||||
}[]
|
}[]
|
||||||
|
tool_calls?: {
|
||||||
|
id?: string
|
||||||
|
type?: string
|
||||||
|
function?: {
|
||||||
|
name?: string
|
||||||
|
arguments?: string
|
||||||
|
}
|
||||||
|
extra_content?: {
|
||||||
|
tool_feedback_explanation?: string
|
||||||
|
}
|
||||||
|
}[]
|
||||||
}[]
|
}[]
|
||||||
summary: string
|
summary: string
|
||||||
created: string
|
created: string
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import {
|
||||||
IconCopy,
|
IconCopy,
|
||||||
IconDownload,
|
IconDownload,
|
||||||
IconFileText,
|
IconFileText,
|
||||||
|
IconTool,
|
||||||
} from "@tabler/icons-react"
|
} from "@tabler/icons-react"
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
@ -17,24 +18,34 @@ import remarkGfm from "remark-gfm"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { formatMessageTime } from "@/hooks/use-pico-chat"
|
import { formatMessageTime } from "@/hooks/use-pico-chat"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { type ChatAttachment } from "@/store/chat"
|
import {
|
||||||
|
type AssistantMessageKind,
|
||||||
|
type ChatAttachment,
|
||||||
|
type ChatToolCall,
|
||||||
|
} from "@/store/chat"
|
||||||
|
|
||||||
interface AssistantMessageProps {
|
interface AssistantMessageProps {
|
||||||
content: string
|
content: string
|
||||||
attachments?: ChatAttachment[]
|
attachments?: ChatAttachment[]
|
||||||
isThought?: boolean
|
kind?: AssistantMessageKind
|
||||||
|
toolCalls?: ChatToolCall[]
|
||||||
timestamp?: string | number
|
timestamp?: string | number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AssistantMessage({
|
export function AssistantMessage({
|
||||||
content,
|
content,
|
||||||
attachments = [],
|
attachments = [],
|
||||||
isThought = false,
|
kind = "normal",
|
||||||
|
toolCalls = [],
|
||||||
timestamp = "",
|
timestamp = "",
|
||||||
}: AssistantMessageProps) {
|
}: AssistantMessageProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [isCopied, setIsCopied] = useState(false)
|
const [isCopied, setIsCopied] = useState(false)
|
||||||
|
const isThought = kind === "thought"
|
||||||
|
const isToolCalls = kind === "tool_calls"
|
||||||
|
const isCollapsedBlock = isThought || isToolCalls
|
||||||
const hasText = content.trim().length > 0
|
const hasText = content.trim().length > 0
|
||||||
|
const hasToolCalls = toolCalls.length > 0
|
||||||
const imageAttachments = attachments.filter(
|
const imageAttachments = attachments.filter(
|
||||||
(attachment) => attachment.type === "image",
|
(attachment) => attachment.type === "image",
|
||||||
)
|
)
|
||||||
|
|
@ -52,9 +63,13 @@ export function AssistantMessage({
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const collapsedLabel = isThought
|
||||||
|
? t("chat.reasoningLabel")
|
||||||
|
: t("chat.toolCallsLabel")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="group flex w-full flex-col gap-1.5">
|
<div className="group flex w-full flex-col gap-1.5">
|
||||||
{!isThought && (
|
{!isCollapsedBlock && (
|
||||||
<div className="text-muted-foreground/60 flex items-center justify-between gap-2 px-1 text-xs opacity-70">
|
<div className="text-muted-foreground/60 flex items-center justify-between gap-2 px-1 text-xs opacity-70">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>PicoClaw</span>
|
<span>PicoClaw</span>
|
||||||
|
|
@ -68,23 +83,27 @@ export function AssistantMessage({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(hasText || isThought) && (
|
{(hasText || isCollapsedBlock || hasToolCalls) && (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative overflow-hidden rounded-xl border",
|
"relative overflow-hidden rounded-xl border",
|
||||||
isThought
|
isCollapsedBlock
|
||||||
? "border-border/30 bg-muted/20 text-muted-foreground dark:border-border/20 dark:bg-muted/10"
|
? "border-border/30 bg-muted/20 text-muted-foreground dark:border-border/20 dark:bg-muted/10"
|
||||||
: "bg-card text-card-foreground border-border/60",
|
: "bg-card text-card-foreground border-border/60",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isThought && (
|
{isCollapsedBlock && (
|
||||||
<div
|
<div
|
||||||
className="text-muted-foreground/60 hover:text-muted-foreground/80 flex cursor-pointer items-center justify-between px-3 py-2 text-[12px] font-medium transition-colors select-none"
|
className="text-muted-foreground/60 hover:text-muted-foreground/80 flex cursor-pointer items-center justify-between px-3 py-2 text-[12px] font-medium transition-colors select-none"
|
||||||
onClick={() => setIsExpanded(!isExpanded)}
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<IconBrain className="size-3.5" />
|
{isThought ? (
|
||||||
<span>{t("chat.reasoningLabel")}</span>
|
<IconBrain className="size-3.5" />
|
||||||
|
) : (
|
||||||
|
<IconTool className="size-3.5" />
|
||||||
|
)}
|
||||||
|
<span>{collapsedLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
<IconChevronDown
|
<IconChevronDown
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
@ -94,7 +113,77 @@ export function AssistantMessage({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{(!isThought || isExpanded) && hasText && (
|
{(!isCollapsedBlock || isExpanded) && isToolCalls && hasToolCalls && (
|
||||||
|
<div className="space-y-3 px-3 pt-0 pb-3">
|
||||||
|
{toolCalls.map((toolCall, index) => {
|
||||||
|
const explanation =
|
||||||
|
toolCall.extraContent?.toolFeedbackExplanation?.trim() ?? ""
|
||||||
|
const toolName = toolCall.function?.name?.trim() ?? ""
|
||||||
|
const toolArguments = toolCall.function?.arguments?.trim() ?? ""
|
||||||
|
const hasFunctionSummary = toolName || toolArguments
|
||||||
|
|
||||||
|
if (!explanation && !hasFunctionSummary) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={toolCall.id ?? `${toolName}-${index}`}
|
||||||
|
className={cn(
|
||||||
|
"space-y-3",
|
||||||
|
index > 0 && "border-border/20 border-t pt-3",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{explanation && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="text-muted-foreground/55 text-[11px] font-medium tracking-wide uppercase">
|
||||||
|
{t("chat.toolCallExplanationLabel")}
|
||||||
|
</div>
|
||||||
|
<div className="prose dark:prose-invert prose-p:my-1.5 prose-p:whitespace-pre-wrap max-w-none text-[13px] leading-relaxed [overflow-wrap:anywhere] break-words opacity-75">
|
||||||
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
rehypePlugins={[
|
||||||
|
rehypeRaw,
|
||||||
|
rehypeSanitize,
|
||||||
|
rehypeHighlight,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{explanation}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasFunctionSummary && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"space-y-1.5",
|
||||||
|
explanation && "border-border/20 border-t pt-3",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="text-muted-foreground/55 text-[11px] font-medium tracking-wide uppercase">
|
||||||
|
{t("chat.toolCallFunctionLabel")}
|
||||||
|
</div>
|
||||||
|
<div className="bg-background/55 border-border/25 space-y-2 rounded-lg border px-3 py-2.5">
|
||||||
|
{toolName && (
|
||||||
|
<div className="text-foreground/75 font-mono text-[12px] font-semibold">
|
||||||
|
{toolName}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{toolArguments && (
|
||||||
|
<pre className="text-muted-foreground/75 overflow-x-auto font-mono text-[12px] leading-relaxed break-words whitespace-pre-wrap">
|
||||||
|
{toolArguments}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(!isCollapsedBlock || isExpanded) && !isToolCalls && hasText && (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"prose dark:prose-invert prose-pre:my-2 prose-pre:overflow-x-auto prose-pre:rounded-lg prose-pre:border prose-pre:bg-zinc-100 prose-pre:p-0 prose-pre:text-zinc-900 dark:prose-pre:bg-zinc-950 dark:prose-pre:text-zinc-100 max-w-none [overflow-wrap:anywhere] break-words",
|
"prose dark:prose-invert prose-pre:my-2 prose-pre:overflow-x-auto prose-pre:rounded-lg prose-pre:border prose-pre:bg-zinc-100 prose-pre:p-0 prose-pre:text-zinc-900 dark:prose-pre:bg-zinc-950 dark:prose-pre:text-zinc-100 max-w-none [overflow-wrap:anywhere] break-words",
|
||||||
|
|
@ -112,7 +201,7 @@ export function AssistantMessage({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isThought && hasText && (
|
{!isCollapsedBlock && hasText && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
|
|
@ -139,7 +228,7 @@ export function AssistantMessage({
|
||||||
href={attachment.url}
|
href={attachment.url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className="group/img relative overflow-hidden rounded-xl border border-border/50 bg-muted/30 shadow-sm transition-colors hover:border-border/80"
|
className="group/img border-border/50 bg-muted/30 hover:border-border/80 relative overflow-hidden rounded-xl border shadow-sm transition-colors"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={attachment.url}
|
src={attachment.url}
|
||||||
|
|
@ -159,20 +248,21 @@ export function AssistantMessage({
|
||||||
key={`${attachment.url}-${index}`}
|
key={`${attachment.url}-${index}`}
|
||||||
href={attachment.url}
|
href={attachment.url}
|
||||||
download={attachment.filename}
|
download={attachment.filename}
|
||||||
className="group/file flex w-fit min-w-[220px] max-w-sm items-center gap-3.5 rounded-xl border border-border/60 bg-card px-4 py-3 transition-all duration-300 hover:-translate-y-0.5 hover:border-violet-500/30 hover:shadow-sm dark:hover:border-violet-500/40"
|
className="group/file border-border/60 bg-card flex w-fit max-w-sm min-w-[220px] items-center gap-3.5 rounded-xl border px-4 py-3 transition-all duration-300 hover:-translate-y-0.5 hover:border-violet-500/30 hover:shadow-sm dark:hover:border-violet-500/40"
|
||||||
>
|
>
|
||||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg text-violet-400 ring-1 ring-violet-500/10 dark:bg-violet-500/10 dark:text-violet-400 dark:ring-violet-500/30">
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg text-violet-400 ring-1 ring-violet-500/10 dark:bg-violet-500/10 dark:text-violet-400 dark:ring-violet-500/30">
|
||||||
<IconFileText className="h-5 w-5" />
|
<IconFileText className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex min-w-0 flex-1 flex-col pr-1">
|
<div className="flex min-w-0 flex-1 flex-col pr-1">
|
||||||
<span className="truncate text-[14px] font-medium leading-tight text-foreground/90 transition-colors group-hover/file:text-violet-600 dark:group-hover/file:text-violet-400">
|
<span className="text-foreground/90 truncate text-[14px] leading-tight font-medium transition-colors group-hover/file:text-violet-600 dark:group-hover/file:text-violet-400">
|
||||||
{attachment.filename || "Download file"}
|
{attachment.filename || "Download file"}
|
||||||
</span>
|
</span>
|
||||||
<span className="mt-1 text-[12px] font-medium text-muted-foreground/70">
|
<span className="text-muted-foreground/70 mt-1 text-[12px] font-medium">
|
||||||
{attachment.filename?.split(".").pop()?.toUpperCase() || "FILE"}
|
{attachment.filename?.split(".").pop()?.toUpperCase() ||
|
||||||
|
"FILE"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted/60 text-muted-foreground/50 transition-all duration-300 group-hover/file:bg-violet-400 group-hover/file:text-white group-hover/file:shadow-sm dark:bg-muted/20 dark:group-hover/file:bg-violet-400">
|
<div className="bg-muted/60 text-muted-foreground/50 dark:bg-muted/20 flex h-8 w-8 shrink-0 items-center justify-center rounded-full transition-all duration-300 group-hover/file:bg-violet-400 group-hover/file:text-white group-hover/file:shadow-sm dark:group-hover/file:bg-violet-400">
|
||||||
<IconDownload className="h-4 w-4 transition-transform duration-300 group-hover/file:-translate-y-[1px]" />
|
<IconDownload className="h-4 w-4 transition-transform duration-300 group-hover/file:-translate-y-[1px]" />
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ import { usePicoChat } from "@/hooks/use-pico-chat"
|
||||||
import { useSessionHistory } from "@/hooks/use-session-history"
|
import { useSessionHistory } from "@/hooks/use-session-history"
|
||||||
import type { ConnectionState } from "@/store/chat"
|
import type { ConnectionState } from "@/store/chat"
|
||||||
import type { ChatAttachment } from "@/store/chat"
|
import type { ChatAttachment } from "@/store/chat"
|
||||||
import { showThoughtsAtom } from "@/store/chat"
|
import { showAssistantDetailsAtom } from "@/store/chat"
|
||||||
import type { GatewayState } from "@/store/gateway"
|
import type { GatewayState } from "@/store/gateway"
|
||||||
|
|
||||||
const MAX_IMAGE_SIZE_BYTES = 7 * 1024 * 1024
|
const MAX_IMAGE_SIZE_BYTES = 7 * 1024 * 1024
|
||||||
|
|
@ -112,7 +112,9 @@ export function ChatPage() {
|
||||||
const [hasScrolled, setHasScrolled] = useState(false)
|
const [hasScrolled, setHasScrolled] = useState(false)
|
||||||
const [input, setInput] = useState("")
|
const [input, setInput] = useState("")
|
||||||
const [attachments, setAttachments] = useState<ChatAttachment[]>([])
|
const [attachments, setAttachments] = useState<ChatAttachment[]>([])
|
||||||
const [showThoughts, setShowThoughts] = useAtom(showThoughtsAtom)
|
const [showAssistantDetails, setShowAssistantDetails] = useAtom(
|
||||||
|
showAssistantDetailsAtom,
|
||||||
|
)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
messages,
|
messages,
|
||||||
|
|
@ -269,14 +271,14 @@ export function ChatPage() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="hidden items-center gap-2 rounded-lg border border-border/60 px-3 py-1.5 sm:flex">
|
<div className="border-border/60 hidden items-center gap-2 rounded-lg border px-3 py-1.5 sm:flex">
|
||||||
<span className="text-muted-foreground text-sm">
|
<span className="text-muted-foreground text-sm">
|
||||||
{t("chat.showThoughts")}
|
{t("chat.showAssistantDetails")}
|
||||||
</span>
|
</span>
|
||||||
<Switch
|
<Switch
|
||||||
checked={showThoughts}
|
checked={showAssistantDetails}
|
||||||
onCheckedChange={setShowThoughts}
|
onCheckedChange={setShowAssistantDetails}
|
||||||
aria-label={t("chat.showThoughts")}
|
aria-label={t("chat.showAssistantDetails")}
|
||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -323,7 +325,10 @@ export function ChatPage() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{messages.map((msg) => {
|
{messages.map((msg) => {
|
||||||
if (msg.kind === "thought" && !showThoughts) {
|
if (
|
||||||
|
!showAssistantDetails &&
|
||||||
|
(msg.kind === "thought" || msg.kind === "tool_calls")
|
||||||
|
) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -333,7 +338,8 @@ export function ChatPage() {
|
||||||
<AssistantMessage
|
<AssistantMessage
|
||||||
content={msg.content}
|
content={msg.content}
|
||||||
attachments={msg.attachments}
|
attachments={msg.attachments}
|
||||||
isThought={msg.kind === "thought"}
|
kind={msg.kind}
|
||||||
|
toolCalls={msg.toolCalls}
|
||||||
timestamp={msg.timestamp}
|
timestamp={msg.timestamp}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
112
web/frontend/src/features/chat/assistant-message-state.ts
Normal file
112
web/frontend/src/features/chat/assistant-message-state.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
import {
|
||||||
|
parseToolCallsFromContent,
|
||||||
|
parseToolCallsValue,
|
||||||
|
} from "@/features/chat/tool-calls"
|
||||||
|
import type { AssistantMessageKind, ChatMessage } from "@/store/chat"
|
||||||
|
|
||||||
|
type AssistantToolCalls = ChatMessage["toolCalls"]
|
||||||
|
type ExistingAssistantMessageState = Pick<ChatMessage, "kind" | "toolCalls">
|
||||||
|
|
||||||
|
export interface AssistantMessageCreateState {
|
||||||
|
content: string
|
||||||
|
kind: AssistantMessageKind
|
||||||
|
toolCalls?: AssistantToolCalls
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssistantMessageUpdateState {
|
||||||
|
content: string
|
||||||
|
kind: AssistantMessageKind
|
||||||
|
toolCalls?: AssistantToolCalls
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAssistantMessageKind(
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
toolCalls?: AssistantToolCalls,
|
||||||
|
): AssistantMessageKind {
|
||||||
|
if (payload.thought === true) {
|
||||||
|
return "thought"
|
||||||
|
}
|
||||||
|
if (payload.kind === "tool_calls" || toolCalls) {
|
||||||
|
return "tool_calls"
|
||||||
|
}
|
||||||
|
return "normal"
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasExplicitAssistantKindPayload(
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
typeof payload.thought === "boolean" ||
|
||||||
|
payload.kind === "tool_calls" ||
|
||||||
|
payload.tool_calls !== undefined
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAssistantToolCalls(
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
content: string,
|
||||||
|
): AssistantToolCalls {
|
||||||
|
return (
|
||||||
|
parseToolCallsValue(payload.tool_calls) ??
|
||||||
|
parseToolCallsFromContent(content)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseAssistantMessageCreateState(
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
): AssistantMessageCreateState {
|
||||||
|
const content = typeof payload.content === "string" ? payload.content : ""
|
||||||
|
const toolCalls = parseAssistantToolCalls(payload, content)
|
||||||
|
|
||||||
|
return {
|
||||||
|
content,
|
||||||
|
kind: parseAssistantMessageKind(payload, toolCalls),
|
||||||
|
toolCalls,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseAssistantMessageUpdateState(
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
existing?: ExistingAssistantMessageState,
|
||||||
|
): AssistantMessageUpdateState {
|
||||||
|
const content = typeof payload.content === "string" ? payload.content : ""
|
||||||
|
const toolCalls = parseAssistantToolCalls(payload, content)
|
||||||
|
|
||||||
|
if (hasExplicitAssistantKindPayload(payload)) {
|
||||||
|
const kind = parseAssistantMessageKind(payload, toolCalls)
|
||||||
|
return {
|
||||||
|
content,
|
||||||
|
kind,
|
||||||
|
toolCalls: kind === "tool_calls" ? toolCalls : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toolCalls) {
|
||||||
|
return {
|
||||||
|
content,
|
||||||
|
kind: "tool_calls",
|
||||||
|
toolCalls,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing?.kind === "thought" || existing?.kind === "tool_calls") {
|
||||||
|
return {
|
||||||
|
content,
|
||||||
|
kind: "normal",
|
||||||
|
toolCalls: undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing?.toolCalls) {
|
||||||
|
return {
|
||||||
|
content,
|
||||||
|
kind: existing.kind ?? "normal",
|
||||||
|
toolCalls: undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content,
|
||||||
|
kind: existing?.kind ?? "normal",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
import { getSessionHistory } from "@/api/sessions"
|
import { getSessionHistory } from "@/api/sessions"
|
||||||
import { normalizeUnixTimestamp } from "@/features/chat/state"
|
import { normalizeUnixTimestamp } from "@/features/chat/state"
|
||||||
|
import {
|
||||||
|
parseToolCallsValue,
|
||||||
|
toolCallsSignature,
|
||||||
|
} from "@/features/chat/tool-calls"
|
||||||
import type { ChatAttachment, ChatMessage } from "@/store/chat"
|
import type { ChatAttachment, ChatMessage } from "@/store/chat"
|
||||||
|
|
||||||
function toChatAttachments({
|
function toChatAttachments({
|
||||||
|
|
@ -45,8 +49,11 @@ export async function loadSessionMessages(
|
||||||
id: `hist-${index}-${Date.now()}`,
|
id: `hist-${index}-${Date.now()}`,
|
||||||
role: message.role,
|
role: message.role,
|
||||||
content: message.content,
|
content: message.content,
|
||||||
kind:
|
kind: message.role === "assistant" ? (message.kind ?? "normal") : undefined,
|
||||||
message.role === "assistant" ? (message.kind ?? "normal") : undefined,
|
toolCalls:
|
||||||
|
message.role === "assistant"
|
||||||
|
? parseToolCallsValue(message.tool_calls)
|
||||||
|
: undefined,
|
||||||
attachments: toChatAttachments({
|
attachments: toChatAttachments({
|
||||||
media: message.media,
|
media: message.media,
|
||||||
attachments: message.attachments,
|
attachments: message.attachments,
|
||||||
|
|
@ -79,7 +86,9 @@ function messageSignature(message: ChatMessage): string {
|
||||||
|
|
||||||
return `${message.role}\u0000${message.content}\u0000${normalizeMessageTimestamp(
|
return `${message.role}\u0000${message.content}\u0000${normalizeMessageTimestamp(
|
||||||
message.timestamp,
|
message.timestamp,
|
||||||
)}\u0000${message.kind ?? ""}\u0000${attachmentSignature}`
|
)}\u0000${message.kind ?? ""}\u0000${attachmentSignature}\u0000${toolCallsSignature(
|
||||||
|
message.toolCalls,
|
||||||
|
)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function comparableTimestamp(timestamp: number | string): number {
|
function comparableTimestamp(timestamp: number | string): number {
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import {
|
||||||
|
parseAssistantMessageCreateState,
|
||||||
|
parseAssistantMessageUpdateState,
|
||||||
|
} from "@/features/chat/assistant-message-state"
|
||||||
import { normalizeUnixTimestamp } from "@/features/chat/state"
|
import { normalizeUnixTimestamp } from "@/features/chat/state"
|
||||||
import {
|
import {
|
||||||
type AssistantMessageKind,
|
|
||||||
type ChatAttachment,
|
type ChatAttachment,
|
||||||
type ChatMessage,
|
|
||||||
type ContextUsage,
|
type ContextUsage,
|
||||||
updateChatStore,
|
updateChatStore,
|
||||||
} from "@/store/chat"
|
} from "@/store/chat"
|
||||||
|
|
@ -17,16 +19,6 @@ export interface PicoMessage {
|
||||||
payload?: Record<string, unknown>
|
payload?: Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseAssistantMessageKind(
|
|
||||||
payload: Record<string, unknown>,
|
|
||||||
): AssistantMessageKind {
|
|
||||||
return payload.thought === true ? "thought" : "normal"
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasAssistantKindPayload(payload: Record<string, unknown>): boolean {
|
|
||||||
return typeof payload.thought === "boolean"
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseAttachments(
|
function parseAttachments(
|
||||||
payload: Record<string, unknown>,
|
payload: Record<string, unknown>,
|
||||||
): ChatAttachment[] | undefined {
|
): ChatAttachment[] | undefined {
|
||||||
|
|
@ -91,35 +83,6 @@ function parseContextUsage(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isToolFeedbackMessage(message: ChatMessage): boolean {
|
|
||||||
if (message.role !== "assistant") {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const firstLine = message.content.split("\n", 1)[0]?.trim() ?? ""
|
|
||||||
return /^🔧\s+`[^`]+`/.test(firstLine)
|
|
||||||
}
|
|
||||||
|
|
||||||
function findToolFeedbackMessageIndex(messages: ChatMessage[]): number {
|
|
||||||
let lastUserIndex = -1
|
|
||||||
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
||||||
if (messages[i].role === "user") {
|
|
||||||
lastUserIndex = i
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
||||||
if (i <= lastUserIndex) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if (isToolFeedbackMessage(messages[i])) {
|
|
||||||
return i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
|
|
||||||
export function handlePicoMessage(
|
export function handlePicoMessage(
|
||||||
message: PicoMessage,
|
message: PicoMessage,
|
||||||
expectedSessionId: string,
|
expectedSessionId: string,
|
||||||
|
|
@ -133,9 +96,9 @@ export function handlePicoMessage(
|
||||||
switch (message.type) {
|
switch (message.type) {
|
||||||
case "message.create":
|
case "message.create":
|
||||||
case "media.create": {
|
case "media.create": {
|
||||||
const content = (payload.content as string) || ""
|
|
||||||
const messageId = (payload.message_id as string) || `pico-${Date.now()}`
|
const messageId = (payload.message_id as string) || `pico-${Date.now()}`
|
||||||
const kind = parseAssistantMessageKind(payload)
|
const { content, kind, toolCalls } =
|
||||||
|
parseAssistantMessageCreateState(payload)
|
||||||
const attachments = parseAttachments(payload)
|
const attachments = parseAttachments(payload)
|
||||||
const contextUsage = parseContextUsage(payload)
|
const contextUsage = parseContextUsage(payload)
|
||||||
const timestamp =
|
const timestamp =
|
||||||
|
|
@ -152,6 +115,7 @@ export function handlePicoMessage(
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
content,
|
content,
|
||||||
kind,
|
kind,
|
||||||
|
...(toolCalls ? { toolCalls } : {}),
|
||||||
attachments,
|
attachments,
|
||||||
timestamp,
|
timestamp,
|
||||||
},
|
},
|
||||||
|
|
@ -163,10 +127,7 @@ export function handlePicoMessage(
|
||||||
}
|
}
|
||||||
|
|
||||||
case "message.update": {
|
case "message.update": {
|
||||||
const content = (payload.content as string) || ""
|
|
||||||
const messageId = payload.message_id as string
|
const messageId = payload.message_id as string
|
||||||
const hasKind = hasAssistantKindPayload(payload)
|
|
||||||
const kind = parseAssistantMessageKind(payload)
|
|
||||||
const attachments = parseAttachments(payload)
|
const attachments = parseAttachments(payload)
|
||||||
const contextUsage = parseContextUsage(payload)
|
const contextUsage = parseContextUsage(payload)
|
||||||
const timestamp =
|
const timestamp =
|
||||||
|
|
@ -186,11 +147,14 @@ export function handlePicoMessage(
|
||||||
return msg
|
return msg
|
||||||
}
|
}
|
||||||
found = true
|
found = true
|
||||||
|
const { content, kind, toolCalls } =
|
||||||
|
parseAssistantMessageUpdateState(payload, msg)
|
||||||
return {
|
return {
|
||||||
...msg,
|
...msg,
|
||||||
id: messageId,
|
id: messageId,
|
||||||
content,
|
content,
|
||||||
...(hasKind ? { kind } : {}),
|
kind,
|
||||||
|
toolCalls,
|
||||||
...(attachments ? { attachments } : {}),
|
...(attachments ? { attachments } : {}),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -198,20 +162,8 @@ export function handlePicoMessage(
|
||||||
return messages
|
return messages
|
||||||
}
|
}
|
||||||
|
|
||||||
const fallbackIndex = findToolFeedbackMessageIndex(messages)
|
const { content, kind, toolCalls } =
|
||||||
if (fallbackIndex >= 0) {
|
parseAssistantMessageUpdateState(payload)
|
||||||
return messages.map((msg, index) =>
|
|
||||||
index === fallbackIndex
|
|
||||||
? {
|
|
||||||
...msg,
|
|
||||||
id: messageId,
|
|
||||||
content,
|
|
||||||
...(hasKind ? { kind } : {}),
|
|
||||||
...(attachments ? { attachments } : {}),
|
|
||||||
}
|
|
||||||
: msg,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
...messages,
|
...messages,
|
||||||
|
|
@ -219,7 +171,8 @@ export function handlePicoMessage(
|
||||||
id: messageId,
|
id: messageId,
|
||||||
role: "assistant" as const,
|
role: "assistant" as const,
|
||||||
content,
|
content,
|
||||||
...(hasKind ? { kind } : {}),
|
kind,
|
||||||
|
toolCalls,
|
||||||
...(attachments ? { attachments } : {}),
|
...(attachments ? { attachments } : {}),
|
||||||
timestamp,
|
timestamp,
|
||||||
},
|
},
|
||||||
|
|
@ -237,19 +190,7 @@ export function handlePicoMessage(
|
||||||
}
|
}
|
||||||
|
|
||||||
updateChatStore((prev) => ({
|
updateChatStore((prev) => ({
|
||||||
messages: (() => {
|
messages: prev.messages.filter((msg) => msg.id !== messageId),
|
||||||
const exactMessages = prev.messages.filter((msg) => msg.id !== messageId)
|
|
||||||
if (exactMessages.length !== prev.messages.length) {
|
|
||||||
return exactMessages
|
|
||||||
}
|
|
||||||
|
|
||||||
const fallbackIndex = findToolFeedbackMessageIndex(prev.messages)
|
|
||||||
if (fallbackIndex < 0) {
|
|
||||||
return prev.messages
|
|
||||||
}
|
|
||||||
|
|
||||||
return prev.messages.filter((_, index) => index !== fallbackIndex)
|
|
||||||
})(),
|
|
||||||
}))
|
}))
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
|
||||||
125
web/frontend/src/features/chat/tool-calls.ts
Normal file
125
web/frontend/src/features/chat/tool-calls.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
import type { ChatToolCall } from "@/store/chat"
|
||||||
|
|
||||||
|
function parseLegacyToolFeedbackContent(
|
||||||
|
content: string,
|
||||||
|
): ChatToolCall[] | undefined {
|
||||||
|
const trimmed = content.trim()
|
||||||
|
const match =
|
||||||
|
/^🔧\s+`([^`\n\r]*?)(?:\.{1,2})?`[^\n\r]*(?:\r?\n([\s\S]*))?$/.exec(trimmed)
|
||||||
|
if (!match) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolName = match[1]?.trim() ?? ""
|
||||||
|
const body = match[2]?.trim() ?? ""
|
||||||
|
const codeFence = /```(?:json)?\r?\n([\s\S]*?)\r?\n```/m.exec(body)
|
||||||
|
const argumentsText = codeFence?.[1]?.trim() ?? ""
|
||||||
|
const explanation = body
|
||||||
|
.replace(/```(?:json)?\r?\n[\s\S]*?\r?\n```/gm, "")
|
||||||
|
.trim()
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: toolName,
|
||||||
|
...(argumentsText ? { arguments: argumentsText } : {}),
|
||||||
|
},
|
||||||
|
...(explanation
|
||||||
|
? {
|
||||||
|
extraContent: {
|
||||||
|
toolFeedbackExplanation: explanation,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseToolCallsValue(raw: unknown): ChatToolCall[] | undefined {
|
||||||
|
if (!Array.isArray(raw)) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolCalls: ChatToolCall[] = []
|
||||||
|
for (const item of raw) {
|
||||||
|
if (!item || typeof item !== "object") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolCall = item as Record<string, unknown>
|
||||||
|
const rawFunction =
|
||||||
|
toolCall.function && typeof toolCall.function === "object"
|
||||||
|
? (toolCall.function as Record<string, unknown>)
|
||||||
|
: null
|
||||||
|
const rawExtraContent =
|
||||||
|
toolCall.extra_content && typeof toolCall.extra_content === "object"
|
||||||
|
? (toolCall.extra_content as Record<string, unknown>)
|
||||||
|
: null
|
||||||
|
|
||||||
|
const nextToolCall: ChatToolCall = {
|
||||||
|
...(typeof toolCall.id === "string" ? { id: toolCall.id } : {}),
|
||||||
|
...(typeof toolCall.type === "string" ? { type: toolCall.type } : {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rawFunction) {
|
||||||
|
const name =
|
||||||
|
typeof rawFunction.name === "string" ? rawFunction.name : undefined
|
||||||
|
const argumentsText =
|
||||||
|
typeof rawFunction.arguments === "string"
|
||||||
|
? rawFunction.arguments
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
if (name || argumentsText) {
|
||||||
|
nextToolCall.function = {
|
||||||
|
...(name ? { name } : {}),
|
||||||
|
...(argumentsText ? { arguments: argumentsText } : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rawExtraContent) {
|
||||||
|
const toolFeedbackExplanation =
|
||||||
|
typeof rawExtraContent.tool_feedback_explanation === "string"
|
||||||
|
? rawExtraContent.tool_feedback_explanation
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
if (toolFeedbackExplanation) {
|
||||||
|
nextToolCall.extraContent = {
|
||||||
|
toolFeedbackExplanation,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
nextToolCall.id ||
|
||||||
|
nextToolCall.type ||
|
||||||
|
nextToolCall.function ||
|
||||||
|
nextToolCall.extraContent
|
||||||
|
) {
|
||||||
|
toolCalls.push(nextToolCall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return toolCalls.length > 0 ? toolCalls : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseToolCallsFromContent(
|
||||||
|
content: string,
|
||||||
|
): ChatToolCall[] | undefined {
|
||||||
|
return parseLegacyToolFeedbackContent(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toolCallsSignature(toolCalls?: ChatToolCall[]): string {
|
||||||
|
return (toolCalls ?? [])
|
||||||
|
.map((toolCall) =>
|
||||||
|
[
|
||||||
|
toolCall.id ?? "",
|
||||||
|
toolCall.type ?? "",
|
||||||
|
toolCall.function?.name ?? "",
|
||||||
|
toolCall.function?.arguments ?? "",
|
||||||
|
toolCall.extraContent?.toolFeedbackExplanation ?? "",
|
||||||
|
].join("\u0001"),
|
||||||
|
)
|
||||||
|
.join("\u0002")
|
||||||
|
}
|
||||||
|
|
@ -60,7 +60,10 @@
|
||||||
"step4": "Almost there..."
|
"step4": "Almost there..."
|
||||||
},
|
},
|
||||||
"reasoningLabel": "Reasoning",
|
"reasoningLabel": "Reasoning",
|
||||||
"showThoughts": "Show reasoning",
|
"toolCallsLabel": "Tool calls",
|
||||||
|
"toolCallExplanationLabel": "Call note",
|
||||||
|
"toolCallFunctionLabel": "Call summary",
|
||||||
|
"showAssistantDetails": "Show reasoning and tool calls",
|
||||||
"toolLabel": "Tool",
|
"toolLabel": "Tool",
|
||||||
"history": "History",
|
"history": "History",
|
||||||
"noHistory": "No chat history yet",
|
"noHistory": "No chat history yet",
|
||||||
|
|
@ -609,8 +612,8 @@
|
||||||
"tool_feedback_enabled_hint": "Send a short execution note into the current chat before each tool runs.",
|
"tool_feedback_enabled_hint": "Send a short execution note into the current chat before each tool runs.",
|
||||||
"tool_feedback_separate_messages": "Separate Feedback Messages",
|
"tool_feedback_separate_messages": "Separate Feedback Messages",
|
||||||
"tool_feedback_separate_messages_hint": "Keep each tool feedback update as its own chat message instead of reusing a single placeholder/progress message.",
|
"tool_feedback_separate_messages_hint": "Keep each tool feedback update as its own chat message instead of reusing a single placeholder/progress message.",
|
||||||
"tool_feedback_max_args_length": "Tool Feedback Length",
|
"tool_feedback_max_args_length": "Tool Args Preview Length",
|
||||||
"tool_feedback_max_args_length_hint": "Maximum number of characters shown in each tool feedback message. Set to 0 to use the default.",
|
"tool_feedback_max_args_length_hint": "Maximum number of characters shown in each tool argument preview. Set to 0 to use the default.",
|
||||||
"exec_enabled": "Allow Commands",
|
"exec_enabled": "Allow Commands",
|
||||||
"exec_enabled_hint": "Enable or disable command execution for the app. When disabled, no command requests will run.",
|
"exec_enabled_hint": "Enable or disable command execution for the app. When disabled, no command requests will run.",
|
||||||
"allow_remote": "Allow Remote Commands",
|
"allow_remote": "Allow Remote Commands",
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,10 @@
|
||||||
"step4": "马上就好..."
|
"step4": "马上就好..."
|
||||||
},
|
},
|
||||||
"reasoningLabel": "思考",
|
"reasoningLabel": "思考",
|
||||||
"showThoughts": "展示思考过程",
|
"toolCallsLabel": "工具调用",
|
||||||
|
"toolCallExplanationLabel": "调用提示",
|
||||||
|
"toolCallFunctionLabel": "调用摘要",
|
||||||
|
"showAssistantDetails": "展示思考过程与工具调用",
|
||||||
"toolLabel": "工具",
|
"toolLabel": "工具",
|
||||||
"history": "历史记录",
|
"history": "历史记录",
|
||||||
"noHistory": "暂无对话历史",
|
"noHistory": "暂无对话历史",
|
||||||
|
|
@ -609,8 +612,8 @@
|
||||||
"tool_feedback_enabled_hint": "在每次执行工具前,先向当前会话发送一条简短的执行说明",
|
"tool_feedback_enabled_hint": "在每次执行工具前,先向当前会话发送一条简短的执行说明",
|
||||||
"tool_feedback_separate_messages": "分开发送反馈消息",
|
"tool_feedback_separate_messages": "分开发送反馈消息",
|
||||||
"tool_feedback_separate_messages_hint": "让每次工具反馈都保留为独立消息,而不是反复复用同一条占位/进度消息",
|
"tool_feedback_separate_messages_hint": "让每次工具反馈都保留为独立消息,而不是反复复用同一条占位/进度消息",
|
||||||
"tool_feedback_max_args_length": "工具反馈长度",
|
"tool_feedback_max_args_length": "工具参数预览长度",
|
||||||
"tool_feedback_max_args_length_hint": "每条工具反馈消息中展示的字符上限。设为 0 时使用默认值",
|
"tool_feedback_max_args_length_hint": "每条工具参数预览中展示的字符上限。设为 0 时使用默认值",
|
||||||
"exec_enabled": "允许命令执行",
|
"exec_enabled": "允许命令执行",
|
||||||
"exec_enabled_hint": "控制应用是否允许执行命令。关闭后,所有命令请求都不会执行",
|
"exec_enabled_hint": "控制应用是否允许执行命令。关闭后,所有命令请求都不会执行",
|
||||||
"allow_remote": "允许远程命令执行",
|
"allow_remote": "允许远程命令执行",
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,23 @@ export interface ChatAttachment {
|
||||||
contentType?: string
|
contentType?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AssistantMessageKind = "normal" | "thought"
|
export interface ChatToolCallFunction {
|
||||||
|
name?: string
|
||||||
|
arguments?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatToolCallExtraContent {
|
||||||
|
toolFeedbackExplanation?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatToolCall {
|
||||||
|
id?: string
|
||||||
|
type?: string
|
||||||
|
function?: ChatToolCallFunction
|
||||||
|
extraContent?: ChatToolCallExtraContent
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AssistantMessageKind = "normal" | "thought" | "tool_calls"
|
||||||
|
|
||||||
export interface ChatMessage {
|
export interface ChatMessage {
|
||||||
id: string
|
id: string
|
||||||
|
|
@ -22,6 +38,7 @@ export interface ChatMessage {
|
||||||
timestamp: number | string
|
timestamp: number | string
|
||||||
kind?: AssistantMessageKind
|
kind?: AssistantMessageKind
|
||||||
attachments?: ChatAttachment[]
|
attachments?: ChatAttachment[]
|
||||||
|
toolCalls?: ChatToolCall[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ContextUsage {
|
export interface ContextUsage {
|
||||||
|
|
@ -48,7 +65,8 @@ export interface ChatStoreState {
|
||||||
|
|
||||||
type ChatStorePatch = Partial<ChatStoreState>
|
type ChatStorePatch = Partial<ChatStoreState>
|
||||||
|
|
||||||
const SHOW_THOUGHTS_STORAGE_KEY = "picoclaw:chat-show-thoughts"
|
// Keep the legacy storage value so existing user preferences survive the rename.
|
||||||
|
const SHOW_ASSISTANT_DETAILS_STORAGE_KEY = "picoclaw:chat-show-thoughts"
|
||||||
|
|
||||||
const DEFAULT_CHAT_STATE: ChatStoreState = {
|
const DEFAULT_CHAT_STATE: ChatStoreState = {
|
||||||
messages: [],
|
messages: [],
|
||||||
|
|
@ -59,8 +77,8 @@ const DEFAULT_CHAT_STATE: ChatStoreState = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const chatAtom = atom<ChatStoreState>(DEFAULT_CHAT_STATE)
|
export const chatAtom = atom<ChatStoreState>(DEFAULT_CHAT_STATE)
|
||||||
export const showThoughtsAtom = atomWithStorage<boolean>(
|
export const showAssistantDetailsAtom = atomWithStorage<boolean>(
|
||||||
SHOW_THOUGHTS_STORAGE_KEY,
|
SHOW_ASSISTANT_DETAILS_STORAGE_KEY,
|
||||||
true,
|
true,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue