mirror of
https://github.com/BishopFox/sliver.git
synced 2026-11-12 15:53:20 -10:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0acc6911d8 | ||
|
|
4ca026906b | ||
|
|
97a249e28e | ||
|
|
9f331687d3 | ||
|
|
14d68d6fcd | ||
|
|
05c5334fea | ||
|
|
d277a14c57 | ||
|
|
a7b5dbe6ee | ||
|
|
c188dc1e69 | ||
|
|
9fc9d1762e | ||
|
|
4ef86448d0 | ||
|
|
4cd010afb9 |
No files matched your search
@@ -1,5 +1,7 @@
|
||||
/crush
|
||||
|
||||
/examples
|
||||
|
||||
/.vscode
|
||||
/.vscode/*
|
||||
/.devcontainer
|
||||
|
||||
+24
-19
@@ -25,6 +25,7 @@ func AICmd(_ *cobra.Command, con *console.SliverClient, _ []string) {
|
||||
defer con.RemoveEventListener(listenerID)
|
||||
|
||||
model := newAIModel(con, buildAIContext(con), listener)
|
||||
model.showExperimentalWarningModal()
|
||||
|
||||
width, height := 100, 30
|
||||
if w, h, err := term.GetSize(0); err == nil && w > 0 && h > 0 {
|
||||
@@ -48,13 +49,15 @@ type aiContext struct {
|
||||
}
|
||||
|
||||
type aiTargetSummary struct {
|
||||
Label string
|
||||
Host string
|
||||
OS string
|
||||
Arch string
|
||||
C2 string
|
||||
Mode string
|
||||
Details []string
|
||||
SessionID string
|
||||
BeaconID string
|
||||
Label string
|
||||
Host string
|
||||
OS string
|
||||
Arch string
|
||||
C2 string
|
||||
Mode string
|
||||
Details []string
|
||||
}
|
||||
|
||||
type aiConnectionSummary struct {
|
||||
@@ -97,12 +100,13 @@ func buildAIContext(con *console.SliverClient) aiContext {
|
||||
switch {
|
||||
case session != nil:
|
||||
ctx.target = aiTargetSummary{
|
||||
Label: fmt.Sprintf("Session %s", fallback(session.Name, session.ID)),
|
||||
Host: fallback(session.Hostname, "<unknown host>"),
|
||||
OS: fallback(session.OS, "unknown"),
|
||||
Arch: fallback(session.Arch, "unknown"),
|
||||
C2: fallback(session.ActiveC2, "unknown"),
|
||||
Mode: "interactive session",
|
||||
SessionID: session.ID,
|
||||
Label: fmt.Sprintf("Session %s", fallback(session.Name, session.ID)),
|
||||
Host: fallback(session.Hostname, "<unknown host>"),
|
||||
OS: fallback(session.OS, "unknown"),
|
||||
Arch: fallback(session.Arch, "unknown"),
|
||||
C2: fallback(session.ActiveC2, "unknown"),
|
||||
Mode: "interactive session",
|
||||
Details: []string{
|
||||
fmt.Sprintf("User: %s", fallback(session.Username, "<unknown>")),
|
||||
fmt.Sprintf("PID: %d", session.PID),
|
||||
@@ -111,12 +115,13 @@ func buildAIContext(con *console.SliverClient) aiContext {
|
||||
}
|
||||
case beacon != nil:
|
||||
ctx.target = aiTargetSummary{
|
||||
Label: fmt.Sprintf("Beacon %s", fallback(beacon.Name, beacon.ID)),
|
||||
Host: fallback(beacon.Hostname, "<unknown host>"),
|
||||
OS: fallback(beacon.OS, "unknown"),
|
||||
Arch: fallback(beacon.Arch, "unknown"),
|
||||
C2: fallback(beacon.ActiveC2, "unknown"),
|
||||
Mode: "asynchronous beacon",
|
||||
BeaconID: beacon.ID,
|
||||
Label: fmt.Sprintf("Beacon %s", fallback(beacon.Name, beacon.ID)),
|
||||
Host: fallback(beacon.Hostname, "<unknown host>"),
|
||||
OS: fallback(beacon.OS, "unknown"),
|
||||
Arch: fallback(beacon.Arch, "unknown"),
|
||||
C2: fallback(beacon.ActiveC2, "unknown"),
|
||||
Mode: "asynchronous beacon",
|
||||
Details: []string{
|
||||
fmt.Sprintf("User: %s", fallback(beacon.Username, "<unknown>")),
|
||||
fmt.Sprintf("Interval: %s", time.Duration(beacon.Interval).String()),
|
||||
|
||||
@@ -49,7 +49,7 @@ func TestLoadAIStateCmdLoadsConversationOverRPC(t *testing.T) {
|
||||
rpcClient, cleanup := newAITestRPCClient(t, server)
|
||||
defer cleanup()
|
||||
|
||||
cmd := loadAIStateCmd(&console.SliverClient{Rpc: rpcClient}, "")
|
||||
cmd := loadAIStateCmd(&console.SliverClient{Rpc: rpcClient}, aiTargetSummary{}, "")
|
||||
msg := cmd()
|
||||
|
||||
loaded, ok := msg.(aiLoadedMsg)
|
||||
@@ -112,7 +112,7 @@ func TestLoadAIStateCmdCreatesConversationWhenNoneExist(t *testing.T) {
|
||||
rpcClient, cleanup := newAITestRPCClient(t, server)
|
||||
defer cleanup()
|
||||
|
||||
msg := loadAIStateCmd(&console.SliverClient{Rpc: rpcClient}, "")()
|
||||
msg := loadAIStateCmd(&console.SliverClient{Rpc: rpcClient}, aiTargetSummary{}, "")()
|
||||
|
||||
loaded, ok := msg.(aiLoadedMsg)
|
||||
if !ok {
|
||||
@@ -157,6 +157,7 @@ func TestSubmitPromptCmdCreatesConversationAndSavesUserMessage(t *testing.T) {
|
||||
|
||||
msg := submitPromptCmd(
|
||||
&console.SliverClient{Rpc: rpcClient},
|
||||
aiTargetSummary{SessionID: "session-1"},
|
||||
nil,
|
||||
"openai",
|
||||
"gpt-test",
|
||||
@@ -179,6 +180,9 @@ func TestSubmitPromptCmdCreatesConversationAndSavesUserMessage(t *testing.T) {
|
||||
if server.saveConversationReqs[0].GetTitle() != "Explain the workflow." {
|
||||
t.Fatalf("unexpected conversation title: %q", server.saveConversationReqs[0].GetTitle())
|
||||
}
|
||||
if server.saveConversationReqs[0].GetTargetSessionID() != "session-1" {
|
||||
t.Fatalf("expected target session to be forwarded, got %+v", server.saveConversationReqs[0])
|
||||
}
|
||||
if len(server.saveMessageReqs) != 1 {
|
||||
t.Fatalf("expected SaveAIConversationMessage to be called once, got %d", len(server.saveMessageReqs))
|
||||
}
|
||||
@@ -206,6 +210,7 @@ func TestSubmitPromptCmdUsesExistingConversationSettings(t *testing.T) {
|
||||
|
||||
msg := submitPromptCmd(
|
||||
&console.SliverClient{Rpc: rpcClient},
|
||||
aiTargetSummary{},
|
||||
&clientpb.AIConversation{ID: "conv-1", Provider: "anthropic", Model: "claude-test", Title: "Thread"},
|
||||
"openai",
|
||||
"gpt-test",
|
||||
@@ -262,10 +267,16 @@ func TestDeleteConversationCmdSendsDeleteRequest(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestWaitForAIConversationEventCmdFiltersForAIEvents(t *testing.T) {
|
||||
conversation := &clientpb.AIConversation{ID: "conv-1", OperatorName: "alice"}
|
||||
data, err := proto.Marshal(conversation)
|
||||
eventPayload := &clientpb.AIConversationEvent{
|
||||
EventType: clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_CONVERSATION_UPDATED,
|
||||
Conversation: &clientpb.AIConversation{
|
||||
ID: "conv-1",
|
||||
OperatorName: "alice",
|
||||
},
|
||||
}
|
||||
data, err := proto.Marshal(eventPayload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal conversation: %v", err)
|
||||
t.Fatalf("marshal conversation event: %v", err)
|
||||
}
|
||||
|
||||
listener := make(chan *clientpb.Event, 2)
|
||||
@@ -278,8 +289,8 @@ func TestWaitForAIConversationEventCmdFiltersForAIEvents(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatalf("expected aiConversationEventMsg, got %T", msg)
|
||||
}
|
||||
if aiEvent.conversation == nil || aiEvent.conversation.GetID() != "conv-1" {
|
||||
t.Fatalf("unexpected AI conversation event payload: %+v", aiEvent.conversation)
|
||||
if aiEvent.event == nil || aiEvent.event.GetConversation() == nil || aiEvent.event.GetConversation().GetID() != "conv-1" {
|
||||
t.Fatalf("unexpected AI conversation event payload: %+v", aiEvent.event)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,7 +304,7 @@ func TestWaitForAIConversationEventCmdReturnsClosedMessage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIModelReloadsSharedConversationEvents(t *testing.T) {
|
||||
func TestAIModelAppliesSharedConversationEventsWithoutReload(t *testing.T) {
|
||||
listener := make(chan *clientpb.Event)
|
||||
close(listener)
|
||||
|
||||
@@ -301,25 +312,30 @@ func TestAIModelReloadsSharedConversationEvents(t *testing.T) {
|
||||
connection: aiConnectionSummary{Operator: "alice"},
|
||||
}, listener)
|
||||
model.loading = false
|
||||
model.conversations = []*clientpb.AIConversation{{ID: "conv-1", Title: "First"}}
|
||||
|
||||
updated, cmd := model.Update(aiConversationEventMsg{conversation: &clientpb.AIConversation{
|
||||
ID: "conv-2",
|
||||
OperatorName: "bob",
|
||||
updated, cmd := model.Update(aiConversationEventMsg{event: &clientpb.AIConversationEvent{
|
||||
EventType: clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_CONVERSATION_UPDATED,
|
||||
Conversation: &clientpb.AIConversation{
|
||||
ID: "conv-2",
|
||||
OperatorName: "bob",
|
||||
Title: "Shared thread",
|
||||
},
|
||||
}})
|
||||
|
||||
nextModel := updated.(*aiModel)
|
||||
if !nextModel.loading {
|
||||
t.Fatal("expected shared conversation event to trigger a reload")
|
||||
if nextModel.loading {
|
||||
t.Fatal("did not expect shared conversation event to trigger a reload")
|
||||
}
|
||||
if nextModel.status != "Conversation updated on the server. Refreshing..." {
|
||||
t.Fatalf("unexpected reload status: %q", nextModel.status)
|
||||
if conversationIndexByID(nextModel.conversations, "conv-2") < 0 {
|
||||
t.Fatalf("expected shared conversation to be merged locally, got %+v", nextModel.conversations)
|
||||
}
|
||||
if cmd == nil {
|
||||
t.Fatal("expected shared conversation event to schedule a refresh")
|
||||
t.Fatal("expected shared conversation event to continue listening for events")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIModelReloadsDeleteEventsWhileAwaitingResponse(t *testing.T) {
|
||||
func TestAIModelAppliesDeleteEventsWhileAwaitingResponse(t *testing.T) {
|
||||
listener := make(chan *clientpb.Event)
|
||||
close(listener)
|
||||
|
||||
@@ -332,22 +348,36 @@ func TestAIModelReloadsDeleteEventsWhileAwaitingResponse(t *testing.T) {
|
||||
ID: "conv-2",
|
||||
OperatorName: "alice",
|
||||
UpdatedAt: 100,
|
||||
TurnState: clientpb.AIConversationTurnState_AI_TURN_STATE_IN_PROGRESS,
|
||||
Messages: []*clientpb.AIConversationMessage{
|
||||
{Role: "user", Content: "still waiting"},
|
||||
},
|
||||
}
|
||||
model.conversations = []*clientpb.AIConversation{
|
||||
{ID: "conv-2", Title: "Current"},
|
||||
{ID: "conv-1", Title: "Fallback"},
|
||||
}
|
||||
|
||||
updated, cmd := model.Update(aiConversationEventMsg{conversation: &clientpb.AIConversation{
|
||||
ID: "conv-2",
|
||||
OperatorName: "bob",
|
||||
updated, cmd := model.Update(aiConversationEventMsg{event: &clientpb.AIConversationEvent{
|
||||
EventType: clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_CONVERSATION_DELETED,
|
||||
ConversationID: "conv-2",
|
||||
Conversation: &clientpb.AIConversation{
|
||||
ID: "conv-2",
|
||||
},
|
||||
}})
|
||||
|
||||
nextModel := updated.(*aiModel)
|
||||
if !nextModel.loading {
|
||||
t.Fatal("expected delete tombstone to reload the conversation")
|
||||
if nextModel.loading {
|
||||
t.Fatal("did not expect delete tombstone to trigger a reload")
|
||||
}
|
||||
if nextModel.currentConversation != nil {
|
||||
t.Fatalf("expected deleted conversation to be cleared, got %+v", nextModel.currentConversation)
|
||||
}
|
||||
if conversationIndexByID(nextModel.conversations, "conv-2") >= 0 {
|
||||
t.Fatalf("expected deleted conversation to be removed, got %+v", nextModel.conversations)
|
||||
}
|
||||
if cmd == nil {
|
||||
t.Fatal("expected delete tombstone to schedule a refresh")
|
||||
t.Fatal("expected delete tombstone to continue listening for events")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1104
-157
File diff suppressed because it is too large.
Load diff
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"image/color"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -13,10 +14,20 @@ import (
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/bishopfox/sliver/client/console"
|
||||
aithinking "github.com/bishopfox/sliver/client/spin/thinking"
|
||||
clienttheme "github.com/bishopfox/sliver/client/theme"
|
||||
"github.com/bishopfox/sliver/protobuf/clientpb"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
)
|
||||
|
||||
func assertColorEqual(t *testing.T, got color.Color, want color.Color) {
|
||||
t.Helper()
|
||||
gotR, gotG, gotB, gotA := got.RGBA()
|
||||
wantR, wantG, wantB, wantA := want.RGBA()
|
||||
if gotR != wantR || gotG != wantG || gotB != wantB || gotA != wantA {
|
||||
t.Fatalf("unexpected color: got rgba(%d,%d,%d,%d), want rgba(%d,%d,%d,%d)", gotR, gotG, gotB, gotA, wantR, wantG, wantB, wantA)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromptConversationTitleUsesFirstNonEmptyLine(t *testing.T) {
|
||||
title := promptConversationTitle("\n\n First line title \nsecond line")
|
||||
if title != "First line title" {
|
||||
@@ -65,6 +76,27 @@ func TestBuildConversationMarkdownFallsBackToUserLabel(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageBlockLabelIgnoresUnknownOperatorPlaceholders(t *testing.T) {
|
||||
conversation := &clientpb.AIConversation{OperatorName: "<unknown>"}
|
||||
message := &clientpb.AIConversationMessage{
|
||||
Role: "user",
|
||||
OperatorName: "<unknown>",
|
||||
Content: "Hello",
|
||||
}
|
||||
|
||||
label := messageBlockLabel(conversation, message)
|
||||
if label != "User" {
|
||||
t.Fatalf("expected placeholder operator names to fall back to User, got %q", label)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranscriptSpeakerStyleUsesPrimaryThemeForUserMessages(t *testing.T) {
|
||||
styles := transcriptSpeakerStyle("alice", "user")
|
||||
|
||||
assertColorEqual(t, styles.border.GetForeground(), clienttheme.Primary())
|
||||
assertColorEqual(t, styles.label.GetBackground(), clienttheme.Primary())
|
||||
}
|
||||
|
||||
func TestBuildConversationMarkdownWithoutConversationAvoidsKeyHints(t *testing.T) {
|
||||
markdown := buildConversationMarkdown(nil)
|
||||
if strings.Contains(markdown, "`n`") {
|
||||
@@ -171,6 +203,37 @@ func TestRenderConversationTranscriptLinesOnlyBoxesMessages(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderConversationTranscriptLinesRendersReasoningAndToolBlocks(t *testing.T) {
|
||||
conversation := &clientpb.AIConversation{
|
||||
OperatorName: "alice",
|
||||
Messages: []*clientpb.AIConversationMessage{
|
||||
{
|
||||
Kind: clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_REASONING,
|
||||
Visibility: clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_UI_ONLY,
|
||||
State: clientpb.AIConversationMessageState_AI_MESSAGE_STATE_COMPLETED,
|
||||
Content: "Summary:\nChecked the active target before choosing a tool.",
|
||||
},
|
||||
{
|
||||
Kind: clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_TOOL_CALL,
|
||||
Visibility: clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_UI_ONLY,
|
||||
State: clientpb.AIConversationMessageState_AI_MESSAGE_STATE_FAILED,
|
||||
ToolName: "fs_ls",
|
||||
ToolArguments: `{"path":"/tmp","session_id":"session-1"}`,
|
||||
ToolResult: `{"path":"/tmp","exists":true}`,
|
||||
ErrorText: "permission denied",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
renderedRaw := strings.Join(renderConversationTranscriptLines(72, conversation), "\n")
|
||||
rendered := ansi.Strip(renderedRaw)
|
||||
for _, fragment := range []string{"Reasoning", "Checked the active target", "Tool: fs_ls", "ARGUMENTS", "\"path\": \"/tmp\"", "ERROR", "permission denied", "RESULT"} {
|
||||
if !strings.Contains(rendered, fragment) {
|
||||
t.Fatalf("expected transcript to contain %q, got %q", fragment, rendered)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderTranscriptHeaderLinesUsesCompactInlineMetadata(t *testing.T) {
|
||||
model := newAIModel(nil, aiContext{}, nil)
|
||||
model.currentConversation = &clientpb.AIConversation{
|
||||
@@ -234,6 +297,35 @@ func TestTranscriptSpeakerPaletteVariesAcrossUsers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranscriptSpeakerStyleUsesPrimaryBorderForUserMessages(t *testing.T) {
|
||||
userStyles := transcriptSpeakerStyle("alice", "user")
|
||||
userBorder := transcriptSpeakerStyle("alice", "user").border.Render("│")
|
||||
wantUserBorder := lipgloss.NewStyle().Foreground(clienttheme.Primary()).Render("│")
|
||||
if userBorder != wantUserBorder {
|
||||
t.Fatalf("expected user message border to use theme primary color")
|
||||
}
|
||||
|
||||
wantUserLabel := lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(clienttheme.DefaultMod(900)).
|
||||
Background(clienttheme.Primary()).
|
||||
Padding(0, 1).
|
||||
Render("alice")
|
||||
if got := userStyles.label.Render("alice"); got != wantUserLabel {
|
||||
t.Fatalf("expected user message label to use theme primary color")
|
||||
}
|
||||
|
||||
wantUserMeta := lipgloss.NewStyle().Foreground(clienttheme.Primary()).Render("openai | gpt-5.4")
|
||||
if got := userStyles.meta.Render("openai | gpt-5.4"); got != wantUserMeta {
|
||||
t.Fatalf("expected user message metadata to use theme primary color")
|
||||
}
|
||||
|
||||
assistantBorder := transcriptSpeakerStyle("AI", "assistant").border.Render("│")
|
||||
if assistantBorder == wantUserBorder {
|
||||
t.Fatalf("expected assistant message border to keep its non-primary speaker color")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowResizeQueuesTranscriptRenderAsync(t *testing.T) {
|
||||
model := newAIModel(nil, aiContext{}, nil)
|
||||
model.width = 100
|
||||
@@ -474,6 +566,29 @@ func TestConversationAwaitingResponseStopsWhenAssistantReplies(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConversationAwaitingResponseUsesTurnState(t *testing.T) {
|
||||
conversation := &clientpb.AIConversation{
|
||||
TurnState: clientpb.AIConversationTurnState_AI_TURN_STATE_IN_PROGRESS,
|
||||
Messages: []*clientpb.AIConversationMessage{
|
||||
{
|
||||
Role: "assistant",
|
||||
Content: "Listing files",
|
||||
Kind: clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_TOOL_CALL,
|
||||
Visibility: clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_UI_ONLY,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if !conversationAwaitingResponse(conversation) {
|
||||
t.Fatal("expected in-progress turn state to keep the conversation pending")
|
||||
}
|
||||
|
||||
conversation.TurnState = clientpb.AIConversationTurnState_AI_TURN_STATE_FAILED
|
||||
if conversationAwaitingResponse(conversation) {
|
||||
t.Fatal("expected failed turn state to clear the pending marker")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPendingLabelUsesThinkingWhenConfigured(t *testing.T) {
|
||||
model := &aiModel{
|
||||
config: &clientpb.AIConfigSummary{ThinkingLevel: "high"},
|
||||
@@ -558,10 +673,10 @@ func TestTabCyclesVisiblePanesOnly(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIViewEnablesMouseCellMotion(t *testing.T) {
|
||||
func TestAIViewLeavesMouseSelectionToTerminal(t *testing.T) {
|
||||
view := aiView("hello")
|
||||
if view.MouseMode != tea.MouseModeCellMotion {
|
||||
t.Fatalf("expected AI view mouse mode %v, got %v", tea.MouseModeCellMotion, view.MouseMode)
|
||||
if view.MouseMode != tea.MouseModeNone {
|
||||
t.Fatalf("expected AI view mouse mode %v, got %v", tea.MouseModeNone, view.MouseMode)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -648,6 +763,57 @@ func TestRenderComposerOmitsInlineControls(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposerPasteMsgInsertsAtCursor(t *testing.T) {
|
||||
model := newAIModel(nil, aiContext{}, nil)
|
||||
model.focus = aiFocusComposer
|
||||
model.input = []rune("hello")
|
||||
model.cursor = len(model.input)
|
||||
|
||||
updated, cmd := model.Update(tea.PasteMsg{Content: " world"})
|
||||
if cmd != nil {
|
||||
t.Fatalf("expected paste to update in place without commands, got %v", cmd)
|
||||
}
|
||||
|
||||
updatedModel := updated.(*aiModel)
|
||||
if got := string(updatedModel.input); got != "hello world" {
|
||||
t.Fatalf("expected pasted composer input, got %q", got)
|
||||
}
|
||||
if updatedModel.cursor != len([]rune("hello world")) {
|
||||
t.Fatalf("expected cursor at end of pasted input, got %d", updatedModel.cursor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranscriptFocusDisablesMouseForNativeSelection(t *testing.T) {
|
||||
model := newAIModel(nil, aiContext{}, nil)
|
||||
model.width = 108
|
||||
model.height = 28
|
||||
model.focus = aiFocusTranscript
|
||||
view := model.View()
|
||||
if view.MouseMode != tea.MouseModeNone {
|
||||
t.Fatalf("expected transcript focus to disable mouse reporting, got %v", view.MouseMode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranscriptClickPromptsNativeSelection(t *testing.T) {
|
||||
model := newAIModel(nil, aiContext{}, nil)
|
||||
model.width = 108
|
||||
model.height = 28
|
||||
model.focus = aiFocusSidebar
|
||||
rect := model.currentPaneRects().transcript
|
||||
updated, _ := model.Update(tea.MouseClickMsg{
|
||||
X: rect.x + rect.width/2,
|
||||
Y: rect.y + rect.height/2,
|
||||
Button: tea.MouseLeft,
|
||||
})
|
||||
model = updated.(*aiModel)
|
||||
if got := model.focus; got != aiFocusTranscript {
|
||||
t.Fatalf("expected transcript click to focus transcript, got %s", got.String())
|
||||
}
|
||||
if !strings.Contains(model.status, "Drag again") {
|
||||
t.Fatalf("expected transcript click to explain native selection flow, got %q", model.status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayoutHeightsKeepWideComposerCompact(t *testing.T) {
|
||||
model := newAIModel(nil, aiContext{}, nil)
|
||||
model.width = 120
|
||||
@@ -1362,6 +1528,69 @@ func TestContextModalCancelsOnEscape(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestShowExperimentalWarningModalDefaultsToCancelFocus(t *testing.T) {
|
||||
model := newAIModel(nil, aiContext{}, nil)
|
||||
|
||||
model.showExperimentalWarningModal()
|
||||
|
||||
if model.modal == nil || model.modal.kind != aiModalKindExperimentalWarning {
|
||||
t.Fatalf("expected experimental warning modal, got %+v", model.modal)
|
||||
}
|
||||
if model.modal.title != aiExperimentalWarningTitle {
|
||||
t.Fatalf("unexpected warning title: %q", model.modal.title)
|
||||
}
|
||||
if model.modal.body != aiExperimentalWarningBody {
|
||||
t.Fatalf("unexpected warning body: %q", model.modal.body)
|
||||
}
|
||||
if model.modal.focus != aiModalFocusCancel {
|
||||
t.Fatalf("expected cancel focus by default, got %v", model.modal.focus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExperimentalWarningModalCancelsOnEnterFromDefaultFocus(t *testing.T) {
|
||||
model := newAIModel(nil, aiContext{}, nil)
|
||||
model.showExperimentalWarningModal()
|
||||
|
||||
updated, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
|
||||
if updated == nil {
|
||||
t.Fatal("expected model to be returned")
|
||||
}
|
||||
if cmd == nil {
|
||||
t.Fatal("expected cancel action to quit")
|
||||
}
|
||||
|
||||
msg := cmd()
|
||||
if _, ok := msg.(tea.QuitMsg); !ok {
|
||||
t.Fatalf("expected tea.QuitMsg, got %#v", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExperimentalWarningModalAcceptsAfterTabFocus(t *testing.T) {
|
||||
model := newAIModel(nil, aiContext{status: "Loading AI conversations from the server..."}, nil)
|
||||
model.showExperimentalWarningModal()
|
||||
|
||||
updated, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyTab})
|
||||
if cmd != nil {
|
||||
t.Fatalf("did not expect focus change to queue work, got %v", cmd)
|
||||
}
|
||||
model = updated.(*aiModel)
|
||||
if model.modal == nil || model.modal.focus != aiModalFocusConfirm {
|
||||
t.Fatalf("expected confirm focus, got %+v", model.modal)
|
||||
}
|
||||
|
||||
updated, cmd = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
|
||||
if cmd == nil {
|
||||
t.Fatal("expected accept to start AI startup")
|
||||
}
|
||||
updatedModel := updated.(*aiModel)
|
||||
if updatedModel.modal != nil {
|
||||
t.Fatalf("expected warning modal to close after accept, got %+v", updatedModel.modal)
|
||||
}
|
||||
if updatedModel.status != "Loading AI conversations from the server..." {
|
||||
t.Fatalf("unexpected status after accept: %q", updatedModel.status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModalViewRetainsBackgroundContent(t *testing.T) {
|
||||
model := newAIModel(nil, aiContext{}, nil)
|
||||
model.width = 120
|
||||
@@ -1453,6 +1682,31 @@ func TestNewConversationModalViewIncludesOverlayContent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExperimentalWarningModalViewIncludesDangerContent(t *testing.T) {
|
||||
model := newAIModel(nil, aiContext{}, nil)
|
||||
model.width = 120
|
||||
model.height = 30
|
||||
model.loading = false
|
||||
model.conversations = []*clientpb.AIConversation{{ID: "conv-1", Title: "Thread"}}
|
||||
model.currentConversation = &clientpb.AIConversation{ID: "conv-1", Title: "Thread"}
|
||||
model.showExperimentalWarningModal()
|
||||
|
||||
view := ansi.Strip(model.View().Content)
|
||||
expected := []string{
|
||||
"Conversations",
|
||||
aiExperimentalWarningTitle,
|
||||
"provided on an EXPERIMENTAL basis",
|
||||
"reliability or data integrity",
|
||||
aiExperimentalWarningCancelLabel,
|
||||
aiExperimentalWarningConfirmLabel,
|
||||
}
|
||||
for _, fragment := range expected {
|
||||
if !strings.Contains(view, fragment) {
|
||||
t.Fatalf("expected warning modal view to contain %q, got %q", fragment, view)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeletedConversationMsgRemovesConversationBeforeReload(t *testing.T) {
|
||||
model := newAIModel(nil, aiContext{}, nil)
|
||||
model.loading = false
|
||||
@@ -1557,6 +1811,62 @@ func TestPromptSubmittedStartsAwaitingResponseImmediately(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromptSubmittedDoesNotDuplicateUserMessageWhenEventArrivesFirst(t *testing.T) {
|
||||
model := newAIModel(nil, aiContext{
|
||||
connection: aiConnectionSummary{Operator: "<unknown>"},
|
||||
}, nil)
|
||||
model.loading = false
|
||||
model.submittingPrompt = true
|
||||
model.pendingPrompt = "What changed?"
|
||||
model.conversations = []*clientpb.AIConversation{
|
||||
{ID: "conv-1", Title: "Thread", Provider: "openai", Model: "gpt-test"},
|
||||
}
|
||||
model.currentConversation = &clientpb.AIConversation{
|
||||
ID: "conv-1",
|
||||
Title: "Thread",
|
||||
Provider: "openai",
|
||||
Model: "gpt-test",
|
||||
Messages: []*clientpb.AIConversationMessage{
|
||||
{ID: "assistant-1", Role: "assistant", Content: "Previous reply"},
|
||||
{
|
||||
ID: "msg-1",
|
||||
ConversationID: "conv-1",
|
||||
Role: "user",
|
||||
Content: "What changed?",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
updated, _ := model.Update(aiPromptSubmittedMsg{
|
||||
conversationID: "conv-1",
|
||||
conversation: &clientpb.AIConversation{
|
||||
ID: "conv-1",
|
||||
Title: "Thread",
|
||||
Provider: "openai",
|
||||
Model: "gpt-test",
|
||||
},
|
||||
message: &clientpb.AIConversationMessage{
|
||||
ID: "msg-1",
|
||||
ConversationID: "conv-1",
|
||||
Role: "user",
|
||||
Content: "What changed?",
|
||||
},
|
||||
status: "Saved prompt to Thread. Waiting for AI response...",
|
||||
})
|
||||
|
||||
updatedModel := updated.(*aiModel)
|
||||
if got := len(updatedModel.currentConversation.GetMessages()); got != 2 {
|
||||
t.Fatalf("expected submit replay to preserve 2 messages without duplication, got %d", got)
|
||||
}
|
||||
last := lastConversationMessage(updatedModel.currentConversation)
|
||||
if last == nil || last.GetID() != "msg-1" {
|
||||
t.Fatalf("expected the existing saved user message to remain the last message, got %#v", last)
|
||||
}
|
||||
if label := messageBlockLabel(updatedModel.currentConversation, last); label != "User" {
|
||||
t.Fatalf("expected unknown operator placeholders to fall back to User, got %q", label)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAsyncErrClearsAwaitingResponseWhenSubmitFails(t *testing.T) {
|
||||
model := newAIModel(nil, aiContext{}, nil)
|
||||
model.awaitingResponse = true
|
||||
@@ -1963,10 +2273,14 @@ func TestAIProgramSkipsRedundantPendingConversationReload(t *testing.T) {
|
||||
t.Fatal("expected animation to start before redundant event processing")
|
||||
}
|
||||
|
||||
program.Send(aiConversationEventMsg{conversation: &clientpb.AIConversation{
|
||||
ID: "conv-1",
|
||||
OperatorName: "alice",
|
||||
UpdatedAt: 100,
|
||||
program.Send(aiConversationEventMsg{event: &clientpb.AIConversationEvent{
|
||||
EventType: clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_TURN_STARTED,
|
||||
Conversation: &clientpb.AIConversation{
|
||||
ID: "conv-1",
|
||||
OperatorName: "alice",
|
||||
UpdatedAt: 100,
|
||||
TurnState: clientpb.AIConversationTurnState_AI_TURN_STATE_IN_PROGRESS,
|
||||
},
|
||||
}})
|
||||
|
||||
select {
|
||||
|
||||
@@ -66,6 +66,7 @@ func Commands(con *console.SliverClient) []*cobra.Command {
|
||||
flags.Bind("", false, consoleCmd, func(f *pflag.FlagSet) {
|
||||
f.String("transport", string(defaultConfig.Transport), "mcp transport (http or sse)")
|
||||
f.String("url", "", "mcp server URL (defaults to local server settings)")
|
||||
f.String("auth-token", "", "MCP auth token (defaults to the local mcp.yaml token)")
|
||||
})
|
||||
flags.BindFlagCompletions(consoleCmd, func(comp *carapace.ActionMap) {
|
||||
(*comp)["transport"] = carapace.ActionValues("http", "sse")
|
||||
|
||||
@@ -6,12 +6,14 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/bishopfox/sliver/client/console"
|
||||
clientmcp "github.com/bishopfox/sliver/client/mcp"
|
||||
"github.com/bishopfox/sliver/client/version"
|
||||
mcpclient "github.com/mark3labs/mcp-go/client"
|
||||
mcptransport "github.com/mark3labs/mcp-go/client/transport"
|
||||
mcpapi "github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -36,7 +38,23 @@ func McpConsoleCmd(cmd *cobra.Command, con *console.SliverClient, args []string)
|
||||
}
|
||||
}
|
||||
|
||||
client, err := newConsoleClient(transport, targetURL)
|
||||
authToken, _ := cmd.Flags().GetString("auth-token")
|
||||
if authToken == "" {
|
||||
status := clientmcp.GetStatus()
|
||||
if status.AuthToken != "" {
|
||||
authToken = status.AuthToken
|
||||
} else {
|
||||
authInfo, loadErr := clientmcp.LoadAuthInfo()
|
||||
if loadErr == nil {
|
||||
authToken = authInfo.Token
|
||||
} else if !errors.Is(loadErr, os.ErrNotExist) {
|
||||
con.PrintErrorf("%s\n", loadErr)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
client, err := newConsoleClient(transport, targetURL, authToken)
|
||||
if err != nil {
|
||||
con.PrintErrorf("%s\n", err)
|
||||
return
|
||||
@@ -68,12 +86,19 @@ func McpConsoleCmd(cmd *cobra.Command, con *console.SliverClient, args []string)
|
||||
runConsoleREPL(ctx, client, cmd.InOrStdin(), cmd.OutOrStdout())
|
||||
}
|
||||
|
||||
func newConsoleClient(transport clientmcp.Transport, targetURL string) (*mcpclient.Client, error) {
|
||||
func newConsoleClient(transport clientmcp.Transport, targetURL string, authToken string) (*mcpclient.Client, error) {
|
||||
if strings.TrimSpace(authToken) == "" {
|
||||
return nil, fmt.Errorf("mcp auth token is required (use --auth-token or start the local MCP server first)")
|
||||
}
|
||||
headers := map[string]string{
|
||||
clientmcp.AuthHeaderName(): authToken,
|
||||
}
|
||||
|
||||
switch transport {
|
||||
case clientmcp.TransportHTTP:
|
||||
return mcpclient.NewStreamableHttpClient(targetURL)
|
||||
return mcpclient.NewStreamableHttpClient(targetURL, mcptransport.WithHTTPHeaders(headers))
|
||||
case clientmcp.TransportSSE:
|
||||
return mcpclient.NewSSEMCPClient(targetURL)
|
||||
return mcpclient.NewSSEMCPClient(targetURL, mcpclient.WithHeaders(headers))
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported transport %q", transport)
|
||||
}
|
||||
|
||||
@@ -26,6 +26,12 @@ func McpCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
|
||||
} else {
|
||||
con.PrintErrorf("Endpoint: %s\n", err)
|
||||
}
|
||||
if status.AuthHeader != "" {
|
||||
con.Printf("Auth Header: %s\n", status.AuthHeader)
|
||||
}
|
||||
if status.AuthToken != "" {
|
||||
con.Printf("Auth Token: %s\n", status.AuthToken)
|
||||
}
|
||||
|
||||
if status.Running && !status.StartedAt.IsZero() {
|
||||
uptime := time.Since(status.StartedAt).Truncate(time.Second)
|
||||
|
||||
@@ -43,4 +43,11 @@ func McpStartCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
|
||||
if err == nil {
|
||||
con.PrintInfof("Endpoint: %s\n", endpoint)
|
||||
}
|
||||
status := slivermcp.GetStatus()
|
||||
if status.AuthHeader != "" {
|
||||
con.PrintInfof("Auth Header: %s\n", status.AuthHeader)
|
||||
}
|
||||
if status.AuthToken != "" {
|
||||
con.PrintInfof("Auth Token: %s\n", status.AuthToken)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/bishopfox/sliver/client/assets"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const (
|
||||
authConfigFileName = "mcp.yaml"
|
||||
authHeaderName = "Authorization"
|
||||
minAuthTokenLength = 8
|
||||
authTokenBytes = 16
|
||||
sseMessagePath = "/message"
|
||||
)
|
||||
|
||||
// AuthInfo describes the persisted MCP HTTP/SSE authentication settings.
|
||||
type AuthInfo struct {
|
||||
Header string
|
||||
Token string
|
||||
ConfigPath string
|
||||
}
|
||||
|
||||
// AuthHeaderName returns the required header name for MCP HTTP/SSE requests.
|
||||
func AuthHeaderName() string {
|
||||
return authHeaderName
|
||||
}
|
||||
|
||||
// AuthConfigPath returns the MCP auth config path in the client app directory.
|
||||
func AuthConfigPath() string {
|
||||
rootDir, _ := filepath.Abs(assets.GetRootAppDir())
|
||||
return filepath.Join(rootDir, authConfigFileName)
|
||||
}
|
||||
|
||||
type authConfig struct {
|
||||
Token string `yaml:"token"`
|
||||
}
|
||||
|
||||
// LoadAuthInfo loads the persisted MCP auth configuration without creating it.
|
||||
func LoadAuthInfo() (AuthInfo, error) {
|
||||
cfg, exists, err := loadAuthConfig()
|
||||
if err != nil {
|
||||
return AuthInfo{}, err
|
||||
}
|
||||
if !exists {
|
||||
return AuthInfo{}, os.ErrNotExist
|
||||
}
|
||||
cfg.Token = strings.TrimSpace(cfg.Token)
|
||||
if err := validateAuthToken(cfg.Token); err != nil {
|
||||
return AuthInfo{}, fmt.Errorf("invalid mcp token in %s: %w", AuthConfigPath(), err)
|
||||
}
|
||||
return AuthInfo{
|
||||
Header: authHeaderName,
|
||||
Token: cfg.Token,
|
||||
ConfigPath: AuthConfigPath(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ResolveAuthInfo loads the persisted auth token or creates and saves one on first use.
|
||||
func ResolveAuthInfo() (AuthInfo, bool, error) {
|
||||
cfg, exists, err := loadAuthConfig()
|
||||
if err != nil {
|
||||
return AuthInfo{}, false, err
|
||||
}
|
||||
if exists {
|
||||
cfg.Token = strings.TrimSpace(cfg.Token)
|
||||
if err := validateAuthToken(cfg.Token); err != nil {
|
||||
return AuthInfo{}, false, fmt.Errorf("invalid mcp token in %s: %w", AuthConfigPath(), err)
|
||||
}
|
||||
return AuthInfo{
|
||||
Header: authHeaderName,
|
||||
Token: cfg.Token,
|
||||
ConfigPath: AuthConfigPath(),
|
||||
}, false, nil
|
||||
}
|
||||
|
||||
token, err := generateAuthToken()
|
||||
if err != nil {
|
||||
return AuthInfo{}, false, err
|
||||
}
|
||||
cfg = &authConfig{Token: token}
|
||||
if err := saveAuthConfig(cfg); err != nil {
|
||||
return AuthInfo{}, false, err
|
||||
}
|
||||
return AuthInfo{
|
||||
Header: authHeaderName,
|
||||
Token: token,
|
||||
ConfigPath: AuthConfigPath(),
|
||||
}, true, nil
|
||||
}
|
||||
|
||||
func loadAuthConfig() (*authConfig, bool, error) {
|
||||
data, err := os.ReadFile(AuthConfigPath())
|
||||
if os.IsNotExist(err) {
|
||||
return nil, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
cfg := &authConfig{}
|
||||
if err := yaml.Unmarshal(data, cfg); err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
return cfg, true, nil
|
||||
}
|
||||
|
||||
func saveAuthConfig(cfg *authConfig) error {
|
||||
data, err := yaml.Marshal(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(AuthConfigPath(), data, 0o600)
|
||||
}
|
||||
|
||||
func generateAuthToken() (string, error) {
|
||||
tokenBytes := make([]byte, authTokenBytes)
|
||||
if _, err := rand.Read(tokenBytes); err != nil {
|
||||
return "", fmt.Errorf("generate mcp auth token: %w", err)
|
||||
}
|
||||
return hex.EncodeToString(tokenBytes), nil
|
||||
}
|
||||
|
||||
func validateAuthToken(token string) error {
|
||||
if len(strings.TrimSpace(token)) < minAuthTokenLength {
|
||||
return fmt.Errorf("token must be at least %d characters", minAuthTokenLength)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func authMiddleware(token string, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !authTokenMatches(r.Header.Get(authHeaderName), token) {
|
||||
http.Error(w, fmt.Sprintf("missing or invalid %s header", authHeaderName), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func authTokenMatches(actual string, expected string) bool {
|
||||
if len(expected) == 0 || len(actual) != len(expected) {
|
||||
return false
|
||||
}
|
||||
return subtle.ConstantTimeCompare([]byte(actual), []byte(expected)) == 1
|
||||
}
|
||||
|
||||
type authenticatedHTTPTransport struct {
|
||||
server *http.Server
|
||||
}
|
||||
|
||||
func newAuthenticatedTransport(cfg Config, handler http.Handler, token string) serverTransport {
|
||||
mux := http.NewServeMux()
|
||||
authenticatedHandler := authMiddleware(token, handler)
|
||||
|
||||
switch cfg.Transport {
|
||||
case TransportHTTP:
|
||||
mux.Handle("/mcp", authenticatedHandler)
|
||||
case TransportSSE:
|
||||
mux.Handle("/sse", authenticatedHandler)
|
||||
mux.Handle(sseMessagePath, authenticatedHandler)
|
||||
}
|
||||
|
||||
return &authenticatedHTTPTransport{
|
||||
server: &http.Server{
|
||||
Handler: mux,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (t *authenticatedHTTPTransport) Start(addr string) error {
|
||||
if t.server.Addr == "" {
|
||||
t.server.Addr = addr
|
||||
} else if t.server.Addr != addr {
|
||||
return fmt.Errorf("conflicting listen address: %q vs %q", t.server.Addr, addr)
|
||||
}
|
||||
return t.server.ListenAndServe()
|
||||
}
|
||||
|
||||
func (t *authenticatedHTTPTransport) Shutdown(ctx context.Context) error {
|
||||
return t.server.Shutdown(ctx)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveAuthInfoCreatesAndPersistsToken(t *testing.T) {
|
||||
t.Setenv("SLIVER_CLIENT_ROOT_DIR", t.TempDir())
|
||||
|
||||
authInfo, created, err := ResolveAuthInfo()
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveAuthInfo returned error: %v", err)
|
||||
}
|
||||
if !created {
|
||||
t.Fatalf("expected first ResolveAuthInfo call to create a token")
|
||||
}
|
||||
if authInfo.Header != AuthHeaderName() {
|
||||
t.Fatalf("expected header %q, got %q", AuthHeaderName(), authInfo.Header)
|
||||
}
|
||||
if len(authInfo.Token) != authTokenBytes*2 {
|
||||
t.Fatalf("expected %d hex characters, got %d", authTokenBytes*2, len(authInfo.Token))
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(AuthConfigPath())
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read persisted auth config: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(data), "token: "+authInfo.Token) {
|
||||
t.Fatalf("persisted config did not contain generated token: %s", string(data))
|
||||
}
|
||||
|
||||
authInfoAgain, createdAgain, err := ResolveAuthInfo()
|
||||
if err != nil {
|
||||
t.Fatalf("second ResolveAuthInfo returned error: %v", err)
|
||||
}
|
||||
if createdAgain {
|
||||
t.Fatalf("expected second ResolveAuthInfo call to reuse the existing token")
|
||||
}
|
||||
if authInfoAgain.Token != authInfo.Token {
|
||||
t.Fatalf("expected persisted token %q, got %q", authInfo.Token, authInfoAgain.Token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAuthInfoRejectsInvalidExistingToken(t *testing.T) {
|
||||
t.Setenv("SLIVER_CLIENT_ROOT_DIR", t.TempDir())
|
||||
|
||||
if err := os.WriteFile(AuthConfigPath(), []byte("token: short\n"), 0o600); err != nil {
|
||||
t.Fatalf("failed to seed auth config: %v", err)
|
||||
}
|
||||
|
||||
_, _, err := ResolveAuthInfo()
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid token error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "at least 8 characters") {
|
||||
t.Fatalf("expected length validation error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthMiddlewareRequiresExactToken(t *testing.T) {
|
||||
const token = "0123456789abcdef"
|
||||
|
||||
handler := authMiddleware(token, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
header string
|
||||
statusCode int
|
||||
}{
|
||||
{name: "missing", statusCode: http.StatusUnauthorized},
|
||||
{name: "wrong", header: "wrong-token", statusCode: http.StatusUnauthorized},
|
||||
{name: "prefixed", header: "Bearer " + token, statusCode: http.StatusUnauthorized},
|
||||
{name: "exact", header: token, statusCode: http.StatusNoContent},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/mcp", nil)
|
||||
if testCase.header != "" {
|
||||
req.Header.Set(AuthHeaderName(), testCase.header)
|
||||
}
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recorder, req)
|
||||
|
||||
if recorder.Code != testCase.statusCode {
|
||||
t.Fatalf("expected status %d, got %d", testCase.statusCode, recorder.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+24
-11
@@ -20,10 +20,13 @@ var (
|
||||
|
||||
// Status describes the running state of the MCP server.
|
||||
type Status struct {
|
||||
Running bool
|
||||
StartedAt time.Time
|
||||
Config Config
|
||||
LastError string
|
||||
Running bool
|
||||
StartedAt time.Time
|
||||
Config Config
|
||||
LastError string
|
||||
AuthHeader string
|
||||
AuthToken string
|
||||
AuthConfigPath string
|
||||
}
|
||||
|
||||
type serverTransport interface {
|
||||
@@ -42,6 +45,7 @@ type mcpManager struct {
|
||||
done chan struct{}
|
||||
logger *log.Logger
|
||||
logFile *os.File
|
||||
authInfo AuthInfo
|
||||
}
|
||||
|
||||
func newManager() *mcpManager {
|
||||
@@ -93,10 +97,13 @@ func (m *mcpManager) status() Status {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return Status{
|
||||
Running: m.running,
|
||||
StartedAt: m.startedAt,
|
||||
Config: m.cfg,
|
||||
LastError: m.lastErr,
|
||||
Running: m.running,
|
||||
StartedAt: m.startedAt,
|
||||
Config: m.cfg,
|
||||
LastError: m.lastErr,
|
||||
AuthHeader: m.authInfo.Header,
|
||||
AuthToken: m.authInfo.Token,
|
||||
AuthConfigPath: m.authInfo.ConfigPath,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,6 +112,10 @@ func (m *mcpManager) start(cfg Config, rpc rpcpb.SliverRPCClient) error {
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
authInfo, _, err := ResolveAuthInfo()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
if m.running {
|
||||
@@ -120,9 +131,9 @@ func (m *mcpManager) start(cfg Config, rpc rpcpb.SliverRPCClient) error {
|
||||
var transport serverTransport
|
||||
switch cfg.Transport {
|
||||
case TransportHTTP:
|
||||
transport = mcpserver.NewStreamableHTTPServer(mcpServer.server)
|
||||
transport = newAuthenticatedTransport(cfg, mcpserver.NewStreamableHTTPServer(mcpServer.server), authInfo.Token)
|
||||
case TransportSSE:
|
||||
transport = mcpserver.NewSSEServer(mcpServer.server)
|
||||
transport = newAuthenticatedTransport(cfg, mcpserver.NewSSEServer(mcpServer.server), authInfo.Token)
|
||||
default:
|
||||
m.mu.Unlock()
|
||||
logFile.Close()
|
||||
@@ -138,11 +149,12 @@ func (m *mcpManager) start(cfg Config, rpc rpcpb.SliverRPCClient) error {
|
||||
m.done = make(chan struct{})
|
||||
m.logger = logger
|
||||
m.logFile = logFile
|
||||
m.authInfo = authInfo
|
||||
addr := cfg.ListenAddress
|
||||
done := m.done
|
||||
m.mu.Unlock()
|
||||
|
||||
logger.Printf("starting mcp server transport=%s listen=%s", cfg.Transport, cfg.ListenAddress)
|
||||
logger.Printf("starting mcp server transport=%s listen=%s auth_header=%s auth_config=%s", cfg.Transport, cfg.ListenAddress, authInfo.Header, authInfo.ConfigPath)
|
||||
go m.run(addr, transport, done)
|
||||
return nil
|
||||
}
|
||||
@@ -161,6 +173,7 @@ func (m *mcpManager) run(addr string, transport serverTransport, done chan struc
|
||||
m.running = false
|
||||
m.transport = nil
|
||||
m.server = nil
|
||||
m.authInfo = AuthInfo{}
|
||||
logFile := m.logFile
|
||||
m.logFile = nil
|
||||
m.logger = nil
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
# Sliver AI Agentic Loop
|
||||
|
||||
## Goal
|
||||
|
||||
Port the example agentic loop from `examples/codex/codex-rs` into Sliver's AI chat so the server can:
|
||||
|
||||
- run a multi-step Responses API turn
|
||||
- emit turn and item lifecycle events similar to Codex
|
||||
- persist reasoning and tool-call items for sync and restoration
|
||||
- keep those non-chat items out of the model context window
|
||||
- render the new items in the client TUI
|
||||
|
||||
## How The Codex Example Loop Works
|
||||
|
||||
The Codex example splits the loop into three layers.
|
||||
|
||||
### 1. Provider/Protocol Layer
|
||||
|
||||
The protocol layer receives raw model events such as:
|
||||
|
||||
- turn started / completed / failed
|
||||
- reasoning deltas and completed reasoning items
|
||||
- command/tool begin and end notifications
|
||||
- assistant messages
|
||||
|
||||
These provider events are fine-grained and often arrive as partial updates.
|
||||
|
||||
### 2. Event Processor Layer
|
||||
|
||||
`event_processor_with_jsonl_output.rs` converts the raw provider stream into a stable thread event model.
|
||||
|
||||
The important behaviors are:
|
||||
|
||||
- a turn gets a lifecycle: `turn.started`, then `turn.completed` or `turn.failed`
|
||||
- each logical item gets a stable id
|
||||
- begin/end pairs are folded into a single item lifecycle
|
||||
- reasoning is emitted as its own item type
|
||||
- tool calls and command executions are emitted as their own item types
|
||||
- active items can be updated in place before completion
|
||||
|
||||
This is the key abstraction that makes the TUI and persisted history simple.
|
||||
|
||||
### 3. TUI Layer
|
||||
|
||||
The Codex TUI consumes those stable events rather than reloading the whole thread.
|
||||
|
||||
That lets it:
|
||||
|
||||
- append new items incrementally
|
||||
- update an existing in-flight item in place
|
||||
- keep the transcript stable while a turn runs
|
||||
- restore the same item types from stored history later
|
||||
|
||||
## Sliver Port
|
||||
|
||||
Sliver ports the same shape, adapted to its existing RPC and database model.
|
||||
|
||||
### Conversation State
|
||||
|
||||
`AIConversation` now stores:
|
||||
|
||||
- `ActiveTurnID`
|
||||
- `TurnState`
|
||||
- `TargetSessionID`
|
||||
- `TargetBeaconID`
|
||||
|
||||
The turn fields let clients restore "waiting", "completed", and "failed" state without inferring it from the last chat message.
|
||||
|
||||
The target fields bind a conversation to the active session or beacon so server-side tools can default to the conversation target.
|
||||
|
||||
### Message State
|
||||
|
||||
`AIConversationMessage` now stores:
|
||||
|
||||
- `Kind`: chat, reasoning, or tool call
|
||||
- `Visibility`: context or UI-only
|
||||
- `State`: in progress, completed, or failed
|
||||
- `TurnID`
|
||||
- `ItemID`
|
||||
- tool call metadata: name, arguments, result, error text, tool call id
|
||||
|
||||
This keeps one ordered transcript table while still distinguishing chat context from UI-only telemetry.
|
||||
|
||||
### UI-Only Persistence
|
||||
|
||||
Reasoning and tool-call items are stored as normal conversation messages, but with `Visibility = UI_ONLY`.
|
||||
|
||||
That gives Sliver:
|
||||
|
||||
- cross-client sync through the existing conversation storage path
|
||||
- transcript restoration after reconnect or TUI restart
|
||||
- ordered replay of an entire turn, including intermediate items
|
||||
|
||||
At the same time, the model context builder filters out any non-context item, so the provider only receives actual user/assistant chat context.
|
||||
|
||||
### Event Model
|
||||
|
||||
Sliver now publishes `AIConversationEvent` payloads instead of raw `AIConversation` snapshots.
|
||||
|
||||
Supported event types:
|
||||
|
||||
- `CONVERSATION_UPDATED`
|
||||
- `CONVERSATION_DELETED`
|
||||
- `TURN_STARTED`
|
||||
- `TURN_COMPLETED`
|
||||
- `TURN_FAILED`
|
||||
- `MESSAGE_STARTED`
|
||||
- `MESSAGE_UPDATED`
|
||||
- `MESSAGE_COMPLETED`
|
||||
|
||||
Each event can carry:
|
||||
|
||||
- a conversation summary
|
||||
- a single message/item
|
||||
- the turn id
|
||||
- optional error text
|
||||
|
||||
This mirrors the Codex pattern of stable turn and item lifecycle events.
|
||||
|
||||
## Server Turn Flow
|
||||
|
||||
For each new user message:
|
||||
|
||||
1. The user chat message is saved as a normal context-visible chat item.
|
||||
2. The server resolves runtime provider/model settings.
|
||||
3. A new turn id is generated and persisted with `TURN_STARTED`.
|
||||
4. If the runtime supports the Responses API loop, Sliver runs the agentic loop.
|
||||
5. Reasoning items are persisted as completed UI-only reasoning blocks.
|
||||
6. Tool calls are persisted as in-progress UI-only tool blocks, executed locally, then updated in place to completed or failed.
|
||||
7. Tool outputs are fed back into the Responses API with `previous_response_id`.
|
||||
8. When the model returns final assistant text, that assistant message is persisted as a normal context-visible chat item.
|
||||
9. The conversation turn state is cleared with `TURN_COMPLETED`.
|
||||
|
||||
If anything fails:
|
||||
|
||||
- a UI-only failed system message is persisted
|
||||
- the conversation is marked `TURN_FAILED`
|
||||
- the failure remains visible in the transcript without polluting future model context
|
||||
|
||||
## Tool Surface
|
||||
|
||||
The first port exposes a safe read-only tool set:
|
||||
|
||||
- `list_sessions_and_beacons`
|
||||
- `fs_ls`
|
||||
- `fs_cat`
|
||||
- `fs_pwd`
|
||||
|
||||
Why read-only first:
|
||||
|
||||
- it proves the loop, storage, eventing, and TUI behavior
|
||||
- it avoids surprising destructive remote actions in the first server-side tool release
|
||||
|
||||
Filesystem tools accept explicit `session_id` or `beacon_id`, but when omitted they fall back to the conversation target.
|
||||
|
||||
Beacon-backed tools wait for task completion on the server before emitting the completed item update.
|
||||
|
||||
## Context Filtering
|
||||
|
||||
Only messages with:
|
||||
|
||||
- `Kind = CHAT`
|
||||
- `Visibility = CONTEXT`
|
||||
|
||||
are included in future provider requests.
|
||||
|
||||
That means these items are excluded from the model context window:
|
||||
|
||||
- reasoning blocks
|
||||
- tool call blocks
|
||||
- failed system status blocks
|
||||
|
||||
This is the main rule that preserves transcript richness without degrading prompt quality.
|
||||
|
||||
## TUI Behavior
|
||||
|
||||
The AI TUI now consumes `AIConversationEvent` incrementally.
|
||||
|
||||
Instead of reloading the whole conversation for every server event, it:
|
||||
|
||||
- merges conversation summaries into the sidebar
|
||||
- upserts messages in place by message id or item id
|
||||
- updates waiting state from `TurnState`
|
||||
- removes deleted conversations immediately
|
||||
|
||||
Reasoning and tool-call blocks render as dedicated dark-grey transcript boxes.
|
||||
|
||||
### Reasoning Blocks
|
||||
|
||||
Reasoning blocks show:
|
||||
|
||||
- the `Reasoning` label
|
||||
- the persisted reasoning text
|
||||
- item state metadata
|
||||
|
||||
### Tool Call Blocks
|
||||
|
||||
Tool blocks show:
|
||||
|
||||
- `Tool: <name>`
|
||||
- arguments
|
||||
- result
|
||||
- error text when present
|
||||
- item state metadata
|
||||
|
||||
These blocks are restored exactly like chat messages because they are stored in the same ordered transcript.
|
||||
|
||||
## Restore And Sync
|
||||
|
||||
Because UI-only items live in the database:
|
||||
|
||||
- another Sliver client can attach to the same conversation and see the full turn history
|
||||
- reconnecting the TUI restores reasoning and tool-call blocks
|
||||
- turn state survives restart, so "in progress" and "failed" status can be reconstructed from persisted conversation metadata
|
||||
|
||||
## Differences From Codex
|
||||
|
||||
This port intentionally differs from the example in a few places.
|
||||
|
||||
- Sliver currently uses non-streaming Responses API requests, so reasoning items arrive as completed items rather than token deltas.
|
||||
- Tool execution is currently a bounded read-only server-side function set, not the full Codex local tool surface.
|
||||
- Sliver persists intermediate items in its conversation DB rather than only in a transient thread runtime.
|
||||
|
||||
The important invariant is still the same:
|
||||
|
||||
- turn lifecycle is explicit
|
||||
- item lifecycle is explicit
|
||||
- UI-only items are persisted and replayable
|
||||
- only context-visible chat reaches the model
|
||||
@@ -47,6 +47,10 @@ sliver > mcp start --transport http
|
||||
|
||||
[*] Starting MCP server (http) on 127.0.0.1:8080
|
||||
[*] Endpoint: http://127.0.0.1:8080/mcp
|
||||
[*] Auth Header: Authorization
|
||||
[*] Auth Token: 6f90c3b3c6058fa59f570e281f3f8d39
|
||||
```
|
||||
|
||||
You can use `mcp stop` to stop the server, see `mcp start --help` for additional options when starting the MCP server. Authentication is not yet supported for MCP over HTTP/SSE.
|
||||
You can use `mcp stop` to stop the server, see `mcp start --help` for additional options when starting the MCP server.
|
||||
|
||||
On first HTTP/SSE use, Sliver generates a random 128-bit token and saves it to `~/.sliver-client/mcp.yaml`. Every MCP HTTP/SSE request must include that token exactly in the `Authorization` header. If `mcp.yaml` already exists, the listener will only start when the token is at least 8 characters long.
|
||||
+866
-345
File diff suppressed because it is too large.
Load diff
@@ -1437,6 +1437,41 @@ message AIConversations {
|
||||
repeated AIConversation Conversations = 1;
|
||||
}
|
||||
|
||||
enum AIConversationTurnState {
|
||||
AI_TURN_STATE_IDLE = 0;
|
||||
AI_TURN_STATE_IN_PROGRESS = 1;
|
||||
AI_TURN_STATE_FAILED = 2;
|
||||
}
|
||||
|
||||
enum AIConversationMessageKind {
|
||||
AI_MESSAGE_KIND_CHAT = 0;
|
||||
AI_MESSAGE_KIND_REASONING = 1;
|
||||
AI_MESSAGE_KIND_TOOL_CALL = 2;
|
||||
}
|
||||
|
||||
enum AIConversationMessageVisibility {
|
||||
AI_MESSAGE_VISIBILITY_CONTEXT = 0;
|
||||
AI_MESSAGE_VISIBILITY_UI_ONLY = 1;
|
||||
}
|
||||
|
||||
enum AIConversationMessageState {
|
||||
AI_MESSAGE_STATE_COMPLETED = 0;
|
||||
AI_MESSAGE_STATE_IN_PROGRESS = 1;
|
||||
AI_MESSAGE_STATE_FAILED = 2;
|
||||
}
|
||||
|
||||
enum AIConversationEventType {
|
||||
AI_CONVERSATION_EVENT_TYPE_UNKNOWN = 0;
|
||||
AI_CONVERSATION_EVENT_TYPE_CONVERSATION_UPDATED = 1;
|
||||
AI_CONVERSATION_EVENT_TYPE_CONVERSATION_DELETED = 2;
|
||||
AI_CONVERSATION_EVENT_TYPE_TURN_STARTED = 3;
|
||||
AI_CONVERSATION_EVENT_TYPE_TURN_COMPLETED = 4;
|
||||
AI_CONVERSATION_EVENT_TYPE_TURN_FAILED = 5;
|
||||
AI_CONVERSATION_EVENT_TYPE_MESSAGE_STARTED = 6;
|
||||
AI_CONVERSATION_EVENT_TYPE_MESSAGE_UPDATED = 7;
|
||||
AI_CONVERSATION_EVENT_TYPE_MESSAGE_COMPLETED = 8;
|
||||
}
|
||||
|
||||
message AIConversation {
|
||||
string ID = 1;
|
||||
int64 CreatedAt = 2;
|
||||
@@ -1448,6 +1483,10 @@ message AIConversation {
|
||||
string Summary = 8;
|
||||
string SystemPrompt = 9;
|
||||
repeated AIConversationMessage Messages = 10;
|
||||
string ActiveTurnID = 11;
|
||||
AIConversationTurnState TurnState = 12;
|
||||
string TargetSessionID = 13;
|
||||
string TargetBeaconID = 14;
|
||||
}
|
||||
|
||||
message AIConversationMessages {
|
||||
@@ -1468,6 +1507,25 @@ message AIConversationMessage {
|
||||
string Content = 10;
|
||||
string ProviderMessageID = 11;
|
||||
string FinishReason = 12;
|
||||
AIConversationMessageKind Kind = 13;
|
||||
AIConversationMessageVisibility Visibility = 14;
|
||||
AIConversationMessageState State = 15;
|
||||
string TurnID = 16;
|
||||
string ItemID = 17;
|
||||
string ToolCallID = 18;
|
||||
string ToolName = 19;
|
||||
string ToolArguments = 20;
|
||||
string ToolResult = 21;
|
||||
string ErrorText = 22;
|
||||
}
|
||||
|
||||
message AIConversationEvent {
|
||||
AIConversationEventType EventType = 1;
|
||||
string ConversationID = 2;
|
||||
string TurnID = 3;
|
||||
string ErrorText = 4;
|
||||
AIConversation Conversation = 5;
|
||||
AIConversationMessage Message = 6;
|
||||
}
|
||||
|
||||
// watchtower
|
||||
|
||||
@@ -0,0 +1,554 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/bishopfox/sliver/protobuf/clientpb"
|
||||
openai "github.com/openai/openai-go/v2"
|
||||
openairesponses "github.com/openai/openai-go/v2/responses"
|
||||
"github.com/openai/openai-go/v2/shared"
|
||||
)
|
||||
|
||||
const maxAgenticLoopIterations = 16
|
||||
|
||||
// AgenticToolDefinition describes a custom function tool exposed to the model.
|
||||
type AgenticToolDefinition struct {
|
||||
Name string
|
||||
Description string
|
||||
Parameters map[string]any
|
||||
}
|
||||
|
||||
// AgenticToolExecutor executes model-requested tools for a conversation turn.
|
||||
type AgenticToolExecutor interface {
|
||||
ToolDefinitions() []AgenticToolDefinition
|
||||
CallTool(context.Context, string, string) (string, error)
|
||||
}
|
||||
|
||||
// AgenticEventSink persists and publishes non-context-window UI items.
|
||||
type AgenticEventSink interface {
|
||||
ReasoningItem(context.Context, AgenticReasoningItem) error
|
||||
ToolCallStarted(context.Context, AgenticToolCall) error
|
||||
ToolCallCompleted(context.Context, AgenticToolCallResult) error
|
||||
}
|
||||
|
||||
// AgenticReasoningItem is a completed reasoning item emitted by the provider.
|
||||
type AgenticReasoningItem struct {
|
||||
ItemID string
|
||||
Summary string
|
||||
Content string
|
||||
EncryptedContent string
|
||||
Status string
|
||||
}
|
||||
|
||||
// AgenticToolCall is a provider-emitted function tool call.
|
||||
type AgenticToolCall struct {
|
||||
ItemID string
|
||||
CallID string
|
||||
Name string
|
||||
Arguments string
|
||||
Status string
|
||||
}
|
||||
|
||||
// AgenticToolCallResult is the resolved tool call state after local execution.
|
||||
type AgenticToolCallResult struct {
|
||||
AgenticToolCall
|
||||
Output string
|
||||
Error error
|
||||
}
|
||||
|
||||
// SupportsAgenticConversation reports whether the configured runtime can run the
|
||||
// Responses API tool loop used by the Sliver AI chat.
|
||||
func SupportsAgenticConversation(runtime *RuntimeConfig) bool {
|
||||
if runtime == nil || !runtime.UseResponsesAPI {
|
||||
return false
|
||||
}
|
||||
|
||||
switch NormalizeProviderName(runtime.Provider) {
|
||||
case ProviderOpenAI, ProviderOpenAICompat, ProviderOpenRouter:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// CompleteConversationAgentic runs a Codex-style Responses API loop that emits
|
||||
// reasoning and tool-call items between the user prompt and the final assistant
|
||||
// reply. The returned completion is the assistant message that should be stored
|
||||
// in the conversation context window.
|
||||
func CompleteConversationAgentic(
|
||||
ctx context.Context,
|
||||
runtime *RuntimeConfig,
|
||||
conversation *clientpb.AIConversation,
|
||||
tools AgenticToolExecutor,
|
||||
sink AgenticEventSink,
|
||||
) (*Completion, error) {
|
||||
if runtime == nil {
|
||||
return nil, fmt.Errorf("AI runtime config is required")
|
||||
}
|
||||
if conversation == nil {
|
||||
return nil, fmt.Errorf("AI conversation is required")
|
||||
}
|
||||
if !SupportsAgenticConversation(runtime) {
|
||||
return nil, fmt.Errorf("AI provider %q does not support the agentic Responses API loop", runtime.Provider)
|
||||
}
|
||||
|
||||
systemPrompt, messages, err := conversationHistory(conversation)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client := newOpenAIClient(openAIRequestOptions(runtime)...)
|
||||
currentInput := buildResponseInput(systemPrompt, messages)
|
||||
toolParams := buildAgenticToolParams(tools)
|
||||
previousResponseID := ""
|
||||
|
||||
for step := 0; step < maxAgenticLoopIterations; step++ {
|
||||
params := openairesponses.ResponseNewParams{
|
||||
Input: openairesponses.ResponseNewParamsInputUnion{
|
||||
OfInputItemList: currentInput,
|
||||
},
|
||||
Model: shared.ResponsesModel(runtime.Model),
|
||||
}
|
||||
if previousResponseID != "" {
|
||||
params.PreviousResponseID = openai.String(previousResponseID)
|
||||
}
|
||||
if len(toolParams) > 0 {
|
||||
params.Tools = toolParams
|
||||
params.ParallelToolCalls = openai.Bool(false)
|
||||
}
|
||||
if runtime.MaxOutputTokens > 0 {
|
||||
params.MaxOutputTokens = openai.Int(runtime.MaxOutputTokens)
|
||||
}
|
||||
if runtime.Temperature != nil {
|
||||
params.Temperature = openai.Float(*runtime.Temperature)
|
||||
}
|
||||
if runtime.TopP != nil {
|
||||
params.TopP = openai.Float(*runtime.TopP)
|
||||
}
|
||||
if effort, ok := reasoningEffortForThinkingLevel(runtime.ThinkingLevel); ok {
|
||||
params.Reasoning = shared.ReasoningParam{Effort: effort}
|
||||
}
|
||||
|
||||
response, err := client.responses.New(ctx, params)
|
||||
if err != nil {
|
||||
return nil, formatOpenAIError(runtime.Provider, err)
|
||||
}
|
||||
|
||||
previousResponseID = strings.TrimSpace(response.ID)
|
||||
nextInput := make(openairesponses.ResponseInputParam, 0)
|
||||
finalTexts := make([]string, 0, len(response.Output))
|
||||
|
||||
for _, item := range response.Output {
|
||||
switch output := item.AsAny().(type) {
|
||||
case openairesponses.ResponseReasoningItem:
|
||||
if sink != nil {
|
||||
if err := sink.ReasoningItem(ctx, AgenticReasoningItem{
|
||||
ItemID: strings.TrimSpace(output.ID),
|
||||
Summary: responseReasoningSummary(output),
|
||||
Content: responseReasoningContent(output),
|
||||
EncryptedContent: strings.TrimSpace(output.EncryptedContent),
|
||||
Status: strings.TrimSpace(string(output.Status)),
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
case openairesponses.ResponseFunctionToolCall:
|
||||
toolCall := AgenticToolCall{
|
||||
ItemID: strings.TrimSpace(output.ID),
|
||||
CallID: strings.TrimSpace(output.CallID),
|
||||
Name: strings.TrimSpace(output.Name),
|
||||
Arguments: strings.TrimSpace(output.Arguments),
|
||||
Status: strings.TrimSpace(string(output.Status)),
|
||||
}
|
||||
if sink != nil {
|
||||
if err := sink.ToolCallStarted(ctx, toolCall); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
toolOutput, toolErr := agenticToolOutput(ctx, tools, toolCall)
|
||||
if sink != nil {
|
||||
if err := sink.ToolCallCompleted(ctx, AgenticToolCallResult{
|
||||
AgenticToolCall: toolCall,
|
||||
Output: toolOutput,
|
||||
Error: toolErr,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
nextInput = append(nextInput, openairesponses.ResponseInputItemParamOfFunctionCallOutput(toolCall.CallID, toolOutput))
|
||||
|
||||
case openairesponses.ResponseOutputMessage:
|
||||
if text := strings.TrimSpace(responseOutputMessageText(output)); text != "" {
|
||||
finalTexts = append(finalTexts, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(nextInput) > 0 {
|
||||
currentInput = nextInput
|
||||
continue
|
||||
}
|
||||
|
||||
finalText := joinTextBlocks(finalTexts)
|
||||
if finalText == "" {
|
||||
finalText = strings.TrimSpace(response.OutputText())
|
||||
}
|
||||
if finalText == "" {
|
||||
return nil, fmt.Errorf("%s response did not include assistant text", runtime.Provider)
|
||||
}
|
||||
|
||||
return &Completion{
|
||||
Provider: runtime.Provider,
|
||||
Model: fallbackString(response.Model, runtime.Model),
|
||||
Content: finalText,
|
||||
ProviderMessageID: strings.TrimSpace(response.ID),
|
||||
FinishReason: responseFinishReason(response),
|
||||
}, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("AI agent loop exceeded %d provider steps", maxAgenticLoopIterations)
|
||||
}
|
||||
|
||||
func buildAgenticToolParams(tools AgenticToolExecutor) []openairesponses.ToolUnionParam {
|
||||
if tools == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
definitions := tools.ToolDefinitions()
|
||||
if len(definitions) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
params := make([]openairesponses.ToolUnionParam, 0, len(definitions))
|
||||
for _, definition := range definitions {
|
||||
name := strings.TrimSpace(definition.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
tool := openairesponses.ToolParamOfFunction(name, normalizeOpenAIStrictSchema(definition.Parameters), true)
|
||||
if description := strings.TrimSpace(definition.Description); description != "" && tool.OfFunction != nil {
|
||||
tool.OfFunction.Description = openai.String(description)
|
||||
}
|
||||
params = append(params, tool)
|
||||
}
|
||||
if len(params) == 0 {
|
||||
return nil
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
func normalizeOpenAIStrictSchema(schema map[string]any) map[string]any {
|
||||
if len(schema) == 0 {
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{},
|
||||
"required": []string{},
|
||||
"additionalProperties": false,
|
||||
}
|
||||
}
|
||||
|
||||
cloned, ok := cloneJSONValue(schema).(map[string]any)
|
||||
if !ok || cloned == nil {
|
||||
return schema
|
||||
}
|
||||
|
||||
normalizeOpenAIStrictSchemaNode(cloned)
|
||||
return cloned
|
||||
}
|
||||
|
||||
func normalizeOpenAIStrictSchemaNode(node map[string]any) {
|
||||
if node == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if properties, ok := schemaProperties(node); ok {
|
||||
required := schemaRequiredSet(node["required"])
|
||||
names := make([]string, 0, len(properties))
|
||||
for name, rawProperty := range properties {
|
||||
names = append(names, name)
|
||||
|
||||
property, ok := rawProperty.(map[string]any)
|
||||
if !ok || property == nil {
|
||||
continue
|
||||
}
|
||||
if !required[name] {
|
||||
makeJSONSchemaNullable(property)
|
||||
}
|
||||
normalizeOpenAIStrictSchemaNode(property)
|
||||
}
|
||||
sort.Strings(names)
|
||||
node["required"] = names
|
||||
if _, exists := node["additionalProperties"]; !exists {
|
||||
node["additionalProperties"] = false
|
||||
}
|
||||
}
|
||||
|
||||
if items, ok := node["items"].(map[string]any); ok {
|
||||
normalizeOpenAIStrictSchemaNode(items)
|
||||
}
|
||||
|
||||
for _, key := range []string{"anyOf", "allOf", "oneOf"} {
|
||||
variants, ok := node[key].([]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, rawVariant := range variants {
|
||||
variant, ok := rawVariant.(map[string]any)
|
||||
if !ok || variant == nil {
|
||||
continue
|
||||
}
|
||||
normalizeOpenAIStrictSchemaNode(variant)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func schemaProperties(node map[string]any) (map[string]any, bool) {
|
||||
if node == nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
rawProperties, ok := node["properties"]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
properties, ok := rawProperties.(map[string]any)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
return properties, true
|
||||
}
|
||||
|
||||
func schemaRequiredSet(raw any) map[string]bool {
|
||||
required := map[string]bool{}
|
||||
switch values := raw.(type) {
|
||||
case []string:
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value != "" {
|
||||
required[value] = true
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
for _, rawValue := range values {
|
||||
value, ok := rawValue.(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
value = strings.TrimSpace(value)
|
||||
if value != "" {
|
||||
required[value] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return required
|
||||
}
|
||||
|
||||
func makeJSONSchemaNullable(node map[string]any) {
|
||||
if node == nil {
|
||||
return
|
||||
}
|
||||
|
||||
switch value := node["type"].(type) {
|
||||
case string:
|
||||
value = strings.TrimSpace(value)
|
||||
if value != "" && value != "null" {
|
||||
node["type"] = []string{value, "null"}
|
||||
}
|
||||
case []string:
|
||||
if !stringSliceContains(value, "null") {
|
||||
node["type"] = append(append([]string(nil), value...), "null")
|
||||
}
|
||||
case []any:
|
||||
if !anyStringSliceContains(value, "null") {
|
||||
node["type"] = append(append([]any(nil), value...), "null")
|
||||
}
|
||||
default:
|
||||
if _, ok := node["anyOf"]; ok {
|
||||
appendSchemaVariant(node, "anyOf", map[string]any{"type": "null"})
|
||||
} else if _, ok := node["properties"]; ok {
|
||||
node["type"] = []string{"object", "null"}
|
||||
} else if _, ok := node["items"]; ok {
|
||||
node["type"] = []string{"array", "null"}
|
||||
}
|
||||
}
|
||||
|
||||
if enum, ok := nullableEnum(node["enum"]); ok {
|
||||
node["enum"] = enum
|
||||
}
|
||||
}
|
||||
|
||||
func appendSchemaVariant(node map[string]any, key string, variant map[string]any) {
|
||||
if node == nil || variant == nil {
|
||||
return
|
||||
}
|
||||
|
||||
rawVariants, ok := node[key].([]any)
|
||||
if !ok {
|
||||
node[key] = []any{variant}
|
||||
return
|
||||
}
|
||||
for _, rawVariant := range rawVariants {
|
||||
existing, ok := rawVariant.(map[string]any)
|
||||
if !ok || existing == nil {
|
||||
continue
|
||||
}
|
||||
if len(existing) == len(variant) {
|
||||
matched := true
|
||||
for variantKey, variantValue := range variant {
|
||||
if existing[variantKey] != variantValue {
|
||||
matched = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if matched {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
node[key] = append(rawVariants, variant)
|
||||
}
|
||||
|
||||
func nullableEnum(raw any) (any, bool) {
|
||||
switch values := raw.(type) {
|
||||
case []any:
|
||||
for _, value := range values {
|
||||
if value == nil {
|
||||
return values, true
|
||||
}
|
||||
}
|
||||
return append(append([]any(nil), values...), nil), true
|
||||
case []string:
|
||||
enum := make([]any, 0, len(values)+1)
|
||||
for _, value := range values {
|
||||
enum = append(enum, value)
|
||||
}
|
||||
enum = append(enum, nil)
|
||||
return enum, true
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
func stringSliceContains(values []string, needle string) bool {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) == needle {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func anyStringSliceContains(values []any, needle string) bool {
|
||||
for _, rawValue := range values {
|
||||
value, ok := rawValue.(string)
|
||||
if ok && strings.TrimSpace(value) == needle {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func cloneJSONValue(value any) any {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
cloned := make(map[string]any, len(typed))
|
||||
for key, nested := range typed {
|
||||
cloned[key] = cloneJSONValue(nested)
|
||||
}
|
||||
return cloned
|
||||
case []any:
|
||||
cloned := make([]any, len(typed))
|
||||
for idx, nested := range typed {
|
||||
cloned[idx] = cloneJSONValue(nested)
|
||||
}
|
||||
return cloned
|
||||
case []string:
|
||||
cloned := make([]string, len(typed))
|
||||
copy(cloned, typed)
|
||||
return cloned
|
||||
default:
|
||||
return typed
|
||||
}
|
||||
}
|
||||
|
||||
func responseOutputMessageText(message openairesponses.ResponseOutputMessage) string {
|
||||
parts := make([]string, 0, len(message.Content))
|
||||
for _, content := range message.Content {
|
||||
switch part := content.AsAny().(type) {
|
||||
case openairesponses.ResponseOutputText:
|
||||
if text := strings.TrimSpace(part.Text); text != "" {
|
||||
parts = append(parts, text)
|
||||
}
|
||||
case openairesponses.ResponseOutputRefusal:
|
||||
if refusal := strings.TrimSpace(part.Refusal); refusal != "" {
|
||||
parts = append(parts, refusal)
|
||||
}
|
||||
}
|
||||
}
|
||||
return joinTextBlocks(parts)
|
||||
}
|
||||
|
||||
func responseReasoningSummary(item openairesponses.ResponseReasoningItem) string {
|
||||
summaries := make([]string, 0, len(item.Summary))
|
||||
for _, summary := range item.Summary {
|
||||
if text := strings.TrimSpace(summary.Text); text != "" {
|
||||
summaries = append(summaries, text)
|
||||
}
|
||||
}
|
||||
return joinTextBlocks(summaries)
|
||||
}
|
||||
|
||||
func responseReasoningContent(item openairesponses.ResponseReasoningItem) string {
|
||||
content := make([]string, 0, len(item.Content))
|
||||
for _, block := range item.Content {
|
||||
if text := strings.TrimSpace(block.Text); text != "" {
|
||||
content = append(content, text)
|
||||
}
|
||||
}
|
||||
return joinTextBlocks(content)
|
||||
}
|
||||
|
||||
func agenticToolOutput(ctx context.Context, tools AgenticToolExecutor, toolCall AgenticToolCall) (string, error) {
|
||||
if tools == nil {
|
||||
err := fmt.Errorf("tool executor is not configured")
|
||||
return agenticToolErrorJSON(err), err
|
||||
}
|
||||
|
||||
output, err := tools.CallTool(ctx, toolCall.Name, toolCall.Arguments)
|
||||
if err != nil {
|
||||
return agenticToolErrorJSON(err), err
|
||||
}
|
||||
|
||||
output = strings.TrimSpace(output)
|
||||
if output == "" {
|
||||
empty, marshalErr := json.Marshal(map[string]any{"ok": true})
|
||||
if marshalErr != nil {
|
||||
return `{"ok":true}`, nil
|
||||
}
|
||||
return string(empty), nil
|
||||
}
|
||||
return output, nil
|
||||
}
|
||||
|
||||
func agenticToolErrorJSON(err error) string {
|
||||
payload := map[string]any{
|
||||
"ok": false,
|
||||
"error": "tool call failed",
|
||||
}
|
||||
if err != nil && strings.TrimSpace(err.Error()) != "" {
|
||||
payload["error"] = truncateForError(err.Error())
|
||||
}
|
||||
data, marshalErr := json.Marshal(payload)
|
||||
if marshalErr != nil {
|
||||
return `{"ok":false,"error":"tool call failed"}`
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type staticAgenticToolExecutor struct {
|
||||
definitions []AgenticToolDefinition
|
||||
}
|
||||
|
||||
func (s staticAgenticToolExecutor) ToolDefinitions() []AgenticToolDefinition {
|
||||
return s.definitions
|
||||
}
|
||||
|
||||
func (staticAgenticToolExecutor) CallTool(_ context.Context, _ string, _ string) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func TestBuildAgenticToolParamsNormalizesStrictOpenAISchemas(t *testing.T) {
|
||||
params := buildAgenticToolParams(staticAgenticToolExecutor{
|
||||
definitions: []AgenticToolDefinition{
|
||||
{
|
||||
Name: "fs_ls",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"session_id": map[string]any{"type": "string"},
|
||||
"beacon_id": map[string]any{"type": "string"},
|
||||
"path": map[string]any{"type": "string"},
|
||||
},
|
||||
"additionalProperties": false,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if len(params) != 1 || params[0].OfFunction == nil {
|
||||
t.Fatalf("expected one function tool param, got %+v", params)
|
||||
}
|
||||
|
||||
parameters := params[0].OfFunction.Parameters
|
||||
required := schemaRequiredSet(parameters["required"])
|
||||
for _, field := range []string{"session_id", "beacon_id", "path"} {
|
||||
if !required[field] {
|
||||
t.Fatalf("expected strict schema to require %q, got %#v", field, parameters["required"])
|
||||
}
|
||||
}
|
||||
|
||||
properties, ok := parameters["properties"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected strict schema properties map, got %#v", parameters["properties"])
|
||||
}
|
||||
for _, field := range []string{"session_id", "beacon_id", "path"} {
|
||||
property, ok := properties[field].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected property %q to be a schema object, got %#v", field, properties[field])
|
||||
}
|
||||
typeValues, ok := property["type"].([]string)
|
||||
if !ok {
|
||||
t.Fatalf("expected property %q type to be []string, got %#v", field, property["type"])
|
||||
}
|
||||
if !stringSliceContains(typeValues, "string") || !stringSliceContains(typeValues, "null") {
|
||||
t.Fatalf("expected property %q to allow string and null, got %#v", field, typeValues)
|
||||
}
|
||||
}
|
||||
}
|
||||
+29
-358
@@ -8,12 +8,6 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"charm.land/fantasy"
|
||||
fantasyanthropic "charm.land/fantasy/providers/anthropic"
|
||||
fantasygoogle "charm.land/fantasy/providers/google"
|
||||
fantasyopenai "charm.land/fantasy/providers/openai"
|
||||
fantasyopenaicompat "charm.land/fantasy/providers/openaicompat"
|
||||
fantasyopenrouter "charm.land/fantasy/providers/openrouter"
|
||||
"github.com/bishopfox/sliver/protobuf/clientpb"
|
||||
"github.com/bishopfox/sliver/server/configs"
|
||||
)
|
||||
@@ -24,8 +18,6 @@ const (
|
||||
defaultGoogleModel = "gemini-2.5-pro"
|
||||
defaultOpenRouterModel = "openai/gpt-5"
|
||||
defaultCompletionTimeout = 2 * time.Minute
|
||||
|
||||
anthropicThinkingBudgetLow = 1024
|
||||
)
|
||||
|
||||
var httpClient = &http.Client{Timeout: defaultCompletionTimeout}
|
||||
@@ -130,6 +122,8 @@ func ResolveRuntimeConfig(cfg *configs.ServerConfig, conversation *clientpb.AICo
|
||||
return runtime, errors.New(missingProviderConfigError(runtime.Provider))
|
||||
case runtime.Model == "":
|
||||
return runtime, fmt.Errorf("server AI provider %q is missing a model; update `ai.model` or choose a provider default", runtime.Provider)
|
||||
case !completionDriverAvailable(runtime):
|
||||
return runtime, missingDriverError(runtime)
|
||||
default:
|
||||
return runtime, nil
|
||||
}
|
||||
@@ -149,229 +143,15 @@ func CompleteConversation(ctx context.Context, runtime *RuntimeConfig, conversat
|
||||
return nil, err
|
||||
}
|
||||
|
||||
provider, err := newFantasyProvider(runtime)
|
||||
driver, err := selectCompletionDriver(runtime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
model, err := provider.LanguageModel(ctx, runtime.Model)
|
||||
if err != nil {
|
||||
return nil, formatFantasyError(runtime.Provider, err)
|
||||
}
|
||||
|
||||
response, err := model.Generate(ctx, buildFantasyCall(runtime, systemPrompt, messages))
|
||||
if err != nil {
|
||||
return nil, formatFantasyError(runtime.Provider, err)
|
||||
}
|
||||
|
||||
content := extractFantasyResponseText(response)
|
||||
if content == "" {
|
||||
return nil, fmt.Errorf("%s response did not include assistant text", runtime.Provider)
|
||||
}
|
||||
|
||||
finishReason := strings.TrimSpace(string(response.FinishReason))
|
||||
if finishReason == "" || finishReason == string(fantasy.FinishReasonUnknown) {
|
||||
finishReason = "completed"
|
||||
}
|
||||
|
||||
return &Completion{
|
||||
Provider: fallbackString(model.Provider(), runtime.Provider),
|
||||
Model: fallbackString(model.Model(), runtime.Model),
|
||||
Content: content,
|
||||
ProviderMessageID: extractProviderMessageID(response),
|
||||
FinishReason: finishReason,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func newFantasyProvider(runtime *RuntimeConfig) (fantasy.Provider, error) {
|
||||
if runtime == nil {
|
||||
return nil, fmt.Errorf("AI runtime config is required")
|
||||
}
|
||||
|
||||
headers := providerHeaders(runtime)
|
||||
|
||||
switch runtime.Provider {
|
||||
case ProviderAnthropic:
|
||||
opts := []fantasyanthropic.Option{
|
||||
fantasyanthropic.WithName(ProviderAnthropic),
|
||||
fantasyanthropic.WithHTTPClient(httpClient),
|
||||
}
|
||||
if runtime.APIKey != "" {
|
||||
opts = append(opts, fantasyanthropic.WithAPIKey(runtime.APIKey))
|
||||
}
|
||||
if runtime.BaseURL != "" {
|
||||
opts = append(opts, fantasyanthropic.WithBaseURL(runtime.BaseURL))
|
||||
}
|
||||
if len(headers) > 0 {
|
||||
opts = append(opts, fantasyanthropic.WithHeaders(headers))
|
||||
}
|
||||
if runtime.Project != "" && runtime.Location != "" {
|
||||
opts = append(opts, fantasyanthropic.WithVertex(runtime.Project, runtime.Location))
|
||||
}
|
||||
if runtime.SkipAuth {
|
||||
opts = append(opts, fantasyanthropic.WithSkipAuth(true))
|
||||
}
|
||||
if runtime.UseBedrock {
|
||||
opts = append(opts, fantasyanthropic.WithBedrock())
|
||||
}
|
||||
return fantasyanthropic.New(opts...)
|
||||
case ProviderGoogle:
|
||||
opts := []fantasygoogle.Option{
|
||||
fantasygoogle.WithName(ProviderGoogle),
|
||||
fantasygoogle.WithHTTPClient(httpClient),
|
||||
}
|
||||
if runtime.BaseURL != "" {
|
||||
opts = append(opts, fantasygoogle.WithBaseURL(runtime.BaseURL))
|
||||
}
|
||||
if len(headers) > 0 {
|
||||
opts = append(opts, fantasygoogle.WithHeaders(headers))
|
||||
}
|
||||
if runtime.APIKey != "" {
|
||||
opts = append(opts, fantasygoogle.WithGeminiAPIKey(runtime.APIKey))
|
||||
} else if runtime.Project != "" && runtime.Location != "" {
|
||||
opts = append(opts, fantasygoogle.WithVertex(runtime.Project, runtime.Location))
|
||||
}
|
||||
if runtime.SkipAuth {
|
||||
opts = append(opts, fantasygoogle.WithSkipAuth(true))
|
||||
}
|
||||
return fantasygoogle.New(opts...)
|
||||
case ProviderOpenAI:
|
||||
opts := []fantasyopenai.Option{
|
||||
fantasyopenai.WithName(ProviderOpenAI),
|
||||
fantasyopenai.WithHTTPClient(httpClient),
|
||||
}
|
||||
if runtime.APIKey != "" {
|
||||
opts = append(opts, fantasyopenai.WithAPIKey(runtime.APIKey))
|
||||
}
|
||||
if runtime.BaseURL != "" {
|
||||
opts = append(opts, fantasyopenai.WithBaseURL(runtime.BaseURL))
|
||||
}
|
||||
if len(headers) > 0 {
|
||||
opts = append(opts, fantasyopenai.WithHeaders(headers))
|
||||
}
|
||||
if runtime.Organization != "" {
|
||||
opts = append(opts, fantasyopenai.WithOrganization(runtime.Organization))
|
||||
}
|
||||
if runtime.Project != "" {
|
||||
opts = append(opts, fantasyopenai.WithProject(runtime.Project))
|
||||
}
|
||||
if runtime.UseResponsesAPI {
|
||||
opts = append(opts, fantasyopenai.WithUseResponsesAPI())
|
||||
}
|
||||
return fantasyopenai.New(opts...)
|
||||
case ProviderOpenAICompat:
|
||||
opts := []fantasyopenaicompat.Option{
|
||||
fantasyopenaicompat.WithName(ProviderOpenAICompat),
|
||||
fantasyopenaicompat.WithHTTPClient(httpClient),
|
||||
}
|
||||
if runtime.APIKey != "" {
|
||||
opts = append(opts, fantasyopenaicompat.WithAPIKey(runtime.APIKey))
|
||||
}
|
||||
if runtime.BaseURL != "" {
|
||||
opts = append(opts, fantasyopenaicompat.WithBaseURL(runtime.BaseURL))
|
||||
}
|
||||
if len(headers) > 0 {
|
||||
opts = append(opts, fantasyopenaicompat.WithHeaders(headers))
|
||||
}
|
||||
if runtime.UseResponsesAPI {
|
||||
opts = append(opts, fantasyopenaicompat.WithUseResponsesAPI())
|
||||
}
|
||||
return fantasyopenaicompat.New(opts...)
|
||||
case ProviderOpenRouter:
|
||||
opts := []fantasyopenrouter.Option{
|
||||
fantasyopenrouter.WithName(ProviderOpenRouter),
|
||||
fantasyopenrouter.WithHTTPClient(httpClient),
|
||||
}
|
||||
if runtime.APIKey != "" {
|
||||
opts = append(opts, fantasyopenrouter.WithAPIKey(runtime.APIKey))
|
||||
}
|
||||
if len(headers) > 0 {
|
||||
opts = append(opts, fantasyopenrouter.WithHeaders(headers))
|
||||
}
|
||||
return fantasyopenrouter.New(opts...)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported AI provider %q", runtime.Provider)
|
||||
}
|
||||
}
|
||||
|
||||
func buildFantasyCall(runtime *RuntimeConfig, systemPrompt string, messages []providerMessage) fantasy.Call {
|
||||
prompt := make(fantasy.Prompt, 0, len(messages)+1)
|
||||
if strings.TrimSpace(systemPrompt) != "" {
|
||||
prompt = append(prompt, fantasy.Message{
|
||||
Role: fantasy.MessageRoleSystem,
|
||||
Content: []fantasy.MessagePart{
|
||||
fantasy.TextPart{Text: strings.TrimSpace(systemPrompt)},
|
||||
},
|
||||
})
|
||||
}
|
||||
for _, message := range messages {
|
||||
prompt = append(prompt, fantasy.Message{
|
||||
Role: toFantasyMessageRole(message.Role),
|
||||
Content: []fantasy.MessagePart{
|
||||
fantasy.TextPart{Text: message.Content},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
call := fantasy.Call{
|
||||
Prompt: prompt,
|
||||
ProviderOptions: fantasyProviderOptions(runtime),
|
||||
MaxOutputTokens: optionalRuntimeInt(runtime.MaxOutputTokens),
|
||||
Temperature: copyOptionalFloat(runtime.Temperature),
|
||||
TopP: copyOptionalFloat(runtime.TopP),
|
||||
TopK: copyOptionalInt(runtime.TopK),
|
||||
PresencePenalty: copyOptionalFloat(runtime.PresencePenalty),
|
||||
FrequencyPenalty: copyOptionalFloat(runtime.FrequencyPenalty),
|
||||
}
|
||||
return call
|
||||
}
|
||||
|
||||
func fantasyProviderOptions(runtime *RuntimeConfig) fantasy.ProviderOptions {
|
||||
if runtime == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch runtime.Provider {
|
||||
case ProviderAnthropic:
|
||||
if budget := anthropicThinkingBudget(runtime.ThinkingLevel); budget > 0 {
|
||||
return fantasyanthropic.NewProviderOptions(&fantasyanthropic.ProviderOptions{
|
||||
Thinking: &fantasyanthropic.ThinkingProviderOption{BudgetTokens: budget},
|
||||
})
|
||||
}
|
||||
case ProviderGoogle:
|
||||
if thinkingConfig := googleThinkingConfig(runtime.ThinkingLevel); thinkingConfig != nil {
|
||||
return fantasy.ProviderOptions{
|
||||
fantasygoogle.Name: &fantasygoogle.ProviderOptions{
|
||||
ThinkingConfig: thinkingConfig,
|
||||
},
|
||||
}
|
||||
}
|
||||
case ProviderOpenAI:
|
||||
if effort := openAIReasoningEffort(runtime.ThinkingLevel); effort != nil {
|
||||
if runtime.UseResponsesAPI && fantasyopenai.IsResponsesModel(runtime.Model) {
|
||||
return fantasyopenai.NewResponsesProviderOptions(&fantasyopenai.ResponsesProviderOptions{
|
||||
ReasoningEffort: effort,
|
||||
})
|
||||
}
|
||||
return fantasyopenai.NewProviderOptions(&fantasyopenai.ProviderOptions{
|
||||
ReasoningEffort: effort,
|
||||
})
|
||||
}
|
||||
case ProviderOpenAICompat:
|
||||
if effort := openAIReasoningEffort(runtime.ThinkingLevel); effort != nil {
|
||||
return fantasyopenaicompat.NewProviderOptions(&fantasyopenaicompat.ProviderOptions{
|
||||
ReasoningEffort: effort,
|
||||
})
|
||||
}
|
||||
case ProviderOpenRouter:
|
||||
if reasoning := openRouterReasoningOptions(runtime.ThinkingLevel); reasoning != nil {
|
||||
return fantasyopenrouter.NewProviderOptions(&fantasyopenrouter.ProviderOptions{
|
||||
Reasoning: reasoning,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
return driver.CompleteConversation(ctx, runtime, &completionRequest{
|
||||
SystemPrompt: systemPrompt,
|
||||
Messages: messages,
|
||||
})
|
||||
}
|
||||
|
||||
func conversationHistory(conversation *clientpb.AIConversation) (string, []providerMessage, error) {
|
||||
@@ -385,6 +165,12 @@ func conversationHistory(conversation *clientpb.AIConversation) (string, []provi
|
||||
if message == nil {
|
||||
continue
|
||||
}
|
||||
if message.GetVisibility() == clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_UI_ONLY {
|
||||
continue
|
||||
}
|
||||
if message.GetKind() != clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_CHAT {
|
||||
continue
|
||||
}
|
||||
|
||||
role := strings.ToLower(strings.TrimSpace(message.GetRole()))
|
||||
content := strings.TrimSpace(message.GetContent())
|
||||
@@ -432,6 +218,22 @@ func defaultModelForProvider(provider string) string {
|
||||
}
|
||||
}
|
||||
|
||||
func completionDriverAvailable(runtime *RuntimeConfig) bool {
|
||||
_, err := selectCompletionDriver(runtime)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func missingDriverError(runtime *RuntimeConfig) error {
|
||||
if runtime == nil {
|
||||
return fmt.Errorf("AI runtime config is required")
|
||||
}
|
||||
_, err := selectCompletionDriver(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeThinkingLevel(cfg *configs.ServerConfig) string {
|
||||
if cfg == nil || cfg.AI == nil {
|
||||
return ""
|
||||
@@ -444,72 +246,6 @@ func normalizeThinkingLevel(cfg *configs.ServerConfig) string {
|
||||
}
|
||||
}
|
||||
|
||||
func openAIReasoningEffort(thinkingLevel string) *fantasyopenai.ReasoningEffort {
|
||||
switch strings.ToLower(strings.TrimSpace(thinkingLevel)) {
|
||||
case "low":
|
||||
return fantasyopenai.ReasoningEffortOption(fantasyopenai.ReasoningEffortLow)
|
||||
case "medium":
|
||||
return fantasyopenai.ReasoningEffortOption(fantasyopenai.ReasoningEffortMedium)
|
||||
case "high":
|
||||
return fantasyopenai.ReasoningEffortOption(fantasyopenai.ReasoningEffortHigh)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func anthropicThinkingBudget(thinkingLevel string) int64 {
|
||||
switch strings.ToLower(strings.TrimSpace(thinkingLevel)) {
|
||||
case "low":
|
||||
return anthropicThinkingBudgetLow
|
||||
case "medium":
|
||||
return anthropicThinkingBudgetLow * 2
|
||||
case "high":
|
||||
return anthropicThinkingBudgetLow * 4
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func googleThinkingConfig(thinkingLevel string) *fantasygoogle.ThinkingConfig {
|
||||
var budget int64
|
||||
switch strings.ToLower(strings.TrimSpace(thinkingLevel)) {
|
||||
case "low":
|
||||
budget = 1024
|
||||
case "medium":
|
||||
budget = 2048
|
||||
case "high":
|
||||
budget = 4096
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
return &fantasygoogle.ThinkingConfig{
|
||||
ThinkingBudget: fantasy.Opt(budget),
|
||||
}
|
||||
}
|
||||
|
||||
func openRouterReasoningOptions(thinkingLevel string) *fantasyopenrouter.ReasoningOptions {
|
||||
switch strings.ToLower(strings.TrimSpace(thinkingLevel)) {
|
||||
case "low":
|
||||
return &fantasyopenrouter.ReasoningOptions{
|
||||
Enabled: fantasy.Opt(true),
|
||||
Effort: fantasyopenrouter.ReasoningEffortOption(fantasyopenrouter.ReasoningEffortLow),
|
||||
}
|
||||
case "medium":
|
||||
return &fantasyopenrouter.ReasoningOptions{
|
||||
Enabled: fantasy.Opt(true),
|
||||
Effort: fantasyopenrouter.ReasoningEffortOption(fantasyopenrouter.ReasoningEffortMedium),
|
||||
}
|
||||
case "high":
|
||||
return &fantasyopenrouter.ReasoningOptions{
|
||||
Enabled: fantasy.Opt(true),
|
||||
Effort: fantasyopenrouter.ReasoningEffortOption(fantasyopenrouter.ReasoningEffortHigh),
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func applyProviderRuntimeConfig(runtime *RuntimeConfig, providerConfig *configs.AIProviderConfig) {
|
||||
if runtime == nil || providerConfig == nil {
|
||||
return
|
||||
@@ -583,71 +319,6 @@ func runtimeProviderConfigured(runtime *RuntimeConfig) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func extractFantasyResponseText(response *fantasy.Response) string {
|
||||
if response == nil {
|
||||
return ""
|
||||
}
|
||||
blocks := make([]string, 0, len(response.Content))
|
||||
for _, content := range response.Content {
|
||||
if content == nil || content.GetType() != fantasy.ContentTypeText {
|
||||
continue
|
||||
}
|
||||
textContent, ok := fantasy.AsContentType[fantasy.TextContent](content)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(textContent.Text) == "" {
|
||||
continue
|
||||
}
|
||||
blocks = append(blocks, textContent.Text)
|
||||
}
|
||||
return joinTextBlocks(blocks)
|
||||
}
|
||||
|
||||
func extractProviderMessageID(_ *fantasy.Response) string {
|
||||
// Fantasy's unified response model does not currently expose a stable top-level
|
||||
// provider message ID for plain text generations.
|
||||
return ""
|
||||
}
|
||||
|
||||
func formatFantasyError(provider string, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var providerErr *fantasy.ProviderError
|
||||
if errors.As(err, &providerErr) {
|
||||
message := strings.TrimSpace(providerErr.Error())
|
||||
if providerErr.StatusCode > 0 {
|
||||
if message == "" {
|
||||
message = http.StatusText(providerErr.StatusCode)
|
||||
}
|
||||
return fmt.Errorf("%s API request failed with HTTP %d: %s", provider, providerErr.StatusCode, truncateForError(message))
|
||||
}
|
||||
if message != "" {
|
||||
return fmt.Errorf("%s API request failed: %s", provider, truncateForError(message))
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func toFantasyMessageRole(role string) fantasy.MessageRole {
|
||||
switch strings.ToLower(strings.TrimSpace(role)) {
|
||||
case "assistant":
|
||||
return fantasy.MessageRoleAssistant
|
||||
default:
|
||||
return fantasy.MessageRoleUser
|
||||
}
|
||||
}
|
||||
|
||||
func optionalRuntimeInt(value int64) *int64 {
|
||||
if value <= 0 {
|
||||
return nil
|
||||
}
|
||||
return fantasy.Opt(value)
|
||||
}
|
||||
|
||||
func copyOptionalFloat(value *float64) *float64 {
|
||||
if value == nil {
|
||||
return nil
|
||||
|
||||
+173
-63
@@ -108,17 +108,16 @@ func TestCompleteConversationOpenAIUsesConfiguredCredentialsAndSettings(t *testi
|
||||
if completion.Content != "OpenAI assistant reply" {
|
||||
t.Fatalf("unexpected completion content: %q", completion.Content)
|
||||
}
|
||||
if completion.ProviderMessageID != "" {
|
||||
t.Fatalf("expected fantasy-backed completion to leave provider message id empty, got %q", completion.ProviderMessageID)
|
||||
if completion.ProviderMessageID != "resp_123" {
|
||||
t.Fatalf("unexpected provider message id: %q", completion.ProviderMessageID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteConversationAnthropicUsesConfiguredCredentialsAndSettings(t *testing.T) {
|
||||
func TestCompleteConversationOpenAIUsesDefaultBaseURLWhenUnset(t *testing.T) {
|
||||
type capturedRequest struct {
|
||||
Path string
|
||||
APIKey string
|
||||
AnthropicVersion string
|
||||
Body string
|
||||
Path string
|
||||
Authorization string
|
||||
Body string
|
||||
}
|
||||
|
||||
requests := make(chan capturedRequest, 1)
|
||||
@@ -132,25 +131,80 @@ func TestCompleteConversationAnthropicUsesConfiguredCredentialsAndSettings(t *te
|
||||
}
|
||||
|
||||
requests <- capturedRequest{
|
||||
Path: r.URL.Path,
|
||||
APIKey: r.Header.Get("x-api-key"),
|
||||
AnthropicVersion: r.Header.Get("anthropic-version"),
|
||||
Body: string(payload),
|
||||
Path: r.URL.Path,
|
||||
Authorization: r.Header.Get("Authorization"),
|
||||
Body: string(payload),
|
||||
}
|
||||
|
||||
return jsonResponse(http.StatusOK, `{
|
||||
"id": "msg_123",
|
||||
"model": "claude-sonnet-4-0",
|
||||
"stop_reason": "end_turn",
|
||||
"content": [
|
||||
{"type": "thinking", "thinking": "internal reasoning"},
|
||||
{"type": "text", "text": "Anthropic assistant reply"}
|
||||
"id": "resp_default_base",
|
||||
"model": "gpt-5.4",
|
||||
"status": "completed",
|
||||
"output": [
|
||||
{
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "output_text", "text": "OpenAI default-base reply"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`), nil
|
||||
}),
|
||||
})
|
||||
defer restoreClient()
|
||||
|
||||
cfg := &configs.ServerConfig{
|
||||
AI: &configs.AIConfig{
|
||||
Provider: ProviderOpenAI,
|
||||
Model: "gpt-5.4",
|
||||
OpenAI: &configs.AIProviderConfig{
|
||||
APIKey: "openai-key",
|
||||
UseResponsesAPI: boolPtr(true),
|
||||
},
|
||||
Anthropic: &configs.AIProviderConfig{},
|
||||
},
|
||||
}
|
||||
conversation := &clientpb.AIConversation{
|
||||
Provider: ProviderOpenAI,
|
||||
Model: "gpt-5.4",
|
||||
Messages: []*clientpb.AIConversationMessage{
|
||||
{Role: "user", Content: "Say hi."},
|
||||
},
|
||||
}
|
||||
|
||||
runtime, err := ResolveRuntimeConfig(cfg, conversation)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve runtime config: %v", err)
|
||||
}
|
||||
if runtime.BaseURL != "" {
|
||||
t.Fatalf("expected openai runtime base url to remain unset, got %q", runtime.BaseURL)
|
||||
}
|
||||
|
||||
completion, err := CompleteConversation(context.Background(), runtime, conversation)
|
||||
if err != nil {
|
||||
t.Fatalf("complete conversation: %v", err)
|
||||
}
|
||||
|
||||
request := <-requests
|
||||
if request.Path != "/v1/responses" {
|
||||
t.Fatalf("unexpected openai default-base request path: got=%q want=%q", request.Path, "/v1/responses")
|
||||
}
|
||||
if request.Authorization != "Bearer openai-key" {
|
||||
t.Fatalf("unexpected authorization header: %q", request.Authorization)
|
||||
}
|
||||
if !strings.Contains(request.Body, `"Say hi."`) {
|
||||
t.Fatalf("expected openai request body to contain the user prompt, got %s", request.Body)
|
||||
}
|
||||
if completion.Content != "OpenAI default-base reply" {
|
||||
t.Fatalf("unexpected completion content: %q", completion.Content)
|
||||
}
|
||||
if completion.ProviderMessageID != "resp_default_base" {
|
||||
t.Fatalf("unexpected provider message id: %q", completion.ProviderMessageID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRuntimeConfigAnthropicRequiresAnInstalledDriver(t *testing.T) {
|
||||
cfg := &configs.ServerConfig{
|
||||
AI: &configs.AIConfig{
|
||||
Provider: ProviderAnthropic,
|
||||
@@ -162,57 +216,16 @@ func TestCompleteConversationAnthropicUsesConfiguredCredentialsAndSettings(t *te
|
||||
OpenAI: &configs.AIProviderConfig{},
|
||||
},
|
||||
}
|
||||
conversation := &clientpb.AIConversation{
|
||||
Provider: ProviderAnthropic,
|
||||
SystemPrompt: "Use short answers.",
|
||||
Messages: []*clientpb.AIConversationMessage{
|
||||
{Role: "user", Content: "Hello"},
|
||||
{Role: "assistant", Content: "Hi there"},
|
||||
{Role: "user", Content: "What changed?"},
|
||||
},
|
||||
}
|
||||
|
||||
runtime, err := ResolveRuntimeConfig(cfg, conversation)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve runtime config: %v", err)
|
||||
}
|
||||
runtime, err := ResolveRuntimeConfig(cfg, &clientpb.AIConversation{Provider: ProviderAnthropic})
|
||||
if runtime.Model != defaultAnthropicModel {
|
||||
t.Fatalf("unexpected default anthropic model: got=%q want=%q", runtime.Model, defaultAnthropicModel)
|
||||
}
|
||||
|
||||
completion, err := CompleteConversation(context.Background(), runtime, conversation)
|
||||
if err != nil {
|
||||
t.Fatalf("complete conversation: %v", err)
|
||||
if err == nil {
|
||||
t.Fatal("expected anthropic runtime resolution to require a dedicated driver")
|
||||
}
|
||||
|
||||
request := <-requests
|
||||
if request.Path != "/edge/v1/messages" {
|
||||
t.Fatalf("unexpected anthropic request path: got=%q want=%q", request.Path, "/edge/v1/messages")
|
||||
}
|
||||
if request.APIKey != "anthropic-key" {
|
||||
t.Fatalf("unexpected anthropic api key header: %q", request.APIKey)
|
||||
}
|
||||
if request.AnthropicVersion == "" {
|
||||
t.Fatal("expected anthropic version header to be set")
|
||||
}
|
||||
for _, fragment := range []string{
|
||||
`"model":"claude-sonnet-4-0"`,
|
||||
`"Use short answers."`,
|
||||
`"budget_tokens":2048`,
|
||||
`"Hello"`,
|
||||
`"Hi there"`,
|
||||
`"What changed?"`,
|
||||
} {
|
||||
if !strings.Contains(request.Body, fragment) {
|
||||
t.Fatalf("expected anthropic request body to contain %q, got %s", fragment, request.Body)
|
||||
}
|
||||
}
|
||||
|
||||
if completion.Content != "Anthropic assistant reply" {
|
||||
t.Fatalf("unexpected completion content: %q", completion.Content)
|
||||
}
|
||||
if completion.FinishReason != "stop" {
|
||||
t.Fatalf("unexpected finish reason: %q", completion.FinishReason)
|
||||
if !strings.Contains(err.Error(), `provider "anthropic"`) || !strings.Contains(err.Error(), "SDK driver") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -330,6 +343,103 @@ func TestCompleteConversationOpenAICompatUsesBaseURLWithoutAuth(t *testing.T) {
|
||||
if completion.Content != "OpenAI-compatible assistant reply" {
|
||||
t.Fatalf("unexpected completion content: %q", completion.Content)
|
||||
}
|
||||
if completion.ProviderMessageID != "chatcmpl_123" {
|
||||
t.Fatalf("unexpected provider message id: %q", completion.ProviderMessageID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteConversationOpenRouterUsesDefaultBaseURL(t *testing.T) {
|
||||
type capturedRequest struct {
|
||||
Path string
|
||||
Authorization string
|
||||
Body string
|
||||
}
|
||||
|
||||
requests := make(chan capturedRequest, 1)
|
||||
restoreClient := SetHTTPClientForTests(&http.Client{
|
||||
Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
defer r.Body.Close()
|
||||
|
||||
payload, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read request body: %v", err)
|
||||
}
|
||||
|
||||
requests <- capturedRequest{
|
||||
Path: r.URL.Path,
|
||||
Authorization: r.Header.Get("Authorization"),
|
||||
Body: string(payload),
|
||||
}
|
||||
|
||||
return jsonResponse(http.StatusOK, `{
|
||||
"id": "chatcmpl_or_123",
|
||||
"object": "chat.completion",
|
||||
"model": "openai/gpt-5",
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"finish_reason": "stop",
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "OpenRouter assistant reply"
|
||||
}
|
||||
}
|
||||
]
|
||||
}`), nil
|
||||
}),
|
||||
})
|
||||
defer restoreClient()
|
||||
|
||||
cfg := &configs.ServerConfig{
|
||||
AI: &configs.AIConfig{
|
||||
Provider: ProviderOpenRouter,
|
||||
ThinkingLevel: "medium",
|
||||
OpenRouter: &configs.AIProviderConfig{
|
||||
APIKey: "openrouter-key",
|
||||
},
|
||||
},
|
||||
}
|
||||
conversation := &clientpb.AIConversation{
|
||||
Provider: ProviderOpenRouter,
|
||||
Messages: []*clientpb.AIConversationMessage{
|
||||
{Role: "user", Content: "Say hi."},
|
||||
},
|
||||
}
|
||||
|
||||
runtime, err := ResolveRuntimeConfig(cfg, conversation)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve runtime config: %v", err)
|
||||
}
|
||||
if runtime.Model != defaultOpenRouterModel {
|
||||
t.Fatalf("unexpected default openrouter model: got=%q want=%q", runtime.Model, defaultOpenRouterModel)
|
||||
}
|
||||
|
||||
completion, err := CompleteConversation(context.Background(), runtime, conversation)
|
||||
if err != nil {
|
||||
t.Fatalf("complete conversation: %v", err)
|
||||
}
|
||||
|
||||
request := <-requests
|
||||
if request.Path != "/api/v1/chat/completions" {
|
||||
t.Fatalf("unexpected openrouter request path: got=%q want=%q", request.Path, "/api/v1/chat/completions")
|
||||
}
|
||||
if request.Authorization != "Bearer openrouter-key" {
|
||||
t.Fatalf("unexpected authorization header: %q", request.Authorization)
|
||||
}
|
||||
for _, fragment := range []string{
|
||||
`"model":"openai/gpt-5"`,
|
||||
`"Say hi."`,
|
||||
} {
|
||||
if !strings.Contains(request.Body, fragment) {
|
||||
t.Fatalf("expected openrouter request body to contain %q, got %s", fragment, request.Body)
|
||||
}
|
||||
}
|
||||
if completion.Content != "OpenRouter assistant reply" {
|
||||
t.Fatalf("unexpected completion content: %q", completion.Content)
|
||||
}
|
||||
if completion.ProviderMessageID != "chatcmpl_or_123" {
|
||||
t.Fatalf("unexpected provider message id: %q", completion.ProviderMessageID)
|
||||
}
|
||||
}
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type completionRequest struct {
|
||||
SystemPrompt string
|
||||
Messages []providerMessage
|
||||
}
|
||||
|
||||
type sdkDriver interface {
|
||||
Name() string
|
||||
Supports(*RuntimeConfig) bool
|
||||
CompleteConversation(context.Context, *RuntimeConfig, *completionRequest) (*Completion, error)
|
||||
}
|
||||
|
||||
var completionDrivers = []sdkDriver{
|
||||
newOpenAIDriver(),
|
||||
}
|
||||
|
||||
func selectCompletionDriver(runtime *RuntimeConfig) (sdkDriver, error) {
|
||||
if runtime == nil {
|
||||
return nil, fmt.Errorf("AI runtime config is required")
|
||||
}
|
||||
|
||||
for _, driver := range completionDrivers {
|
||||
if driver.Supports(runtime) {
|
||||
return driver, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf(
|
||||
"server AI provider %q does not have an available SDK driver; use openai, openai-compat, or openrouter, or add a driver in server/ai",
|
||||
runtime.Provider,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
openai "github.com/openai/openai-go/v2"
|
||||
"github.com/openai/openai-go/v2/option"
|
||||
openairesponses "github.com/openai/openai-go/v2/responses"
|
||||
"github.com/openai/openai-go/v2/shared"
|
||||
)
|
||||
|
||||
const (
|
||||
openAIDriverName = "openai-go"
|
||||
defaultOpenRouterBaseURL = "https://openrouter.ai/api/v1"
|
||||
)
|
||||
|
||||
type openAIDriver struct{}
|
||||
|
||||
type openAIClient struct {
|
||||
chat openai.ChatCompletionService
|
||||
responses openairesponses.ResponseService
|
||||
}
|
||||
|
||||
func newOpenAIDriver() sdkDriver {
|
||||
return &openAIDriver{}
|
||||
}
|
||||
|
||||
func (d *openAIDriver) Name() string {
|
||||
return openAIDriverName
|
||||
}
|
||||
|
||||
func (d *openAIDriver) Supports(runtime *RuntimeConfig) bool {
|
||||
if runtime == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
switch NormalizeProviderName(runtime.Provider) {
|
||||
case ProviderOpenAI, ProviderOpenAICompat, ProviderOpenRouter:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (d *openAIDriver) CompleteConversation(ctx context.Context, runtime *RuntimeConfig, request *completionRequest) (*Completion, error) {
|
||||
if runtime == nil {
|
||||
return nil, fmt.Errorf("AI runtime config is required")
|
||||
}
|
||||
if request == nil {
|
||||
return nil, fmt.Errorf("AI completion request is required")
|
||||
}
|
||||
|
||||
client := newOpenAIClient(openAIRequestOptions(runtime)...)
|
||||
if runtime.UseResponsesAPI {
|
||||
return d.completeResponses(ctx, client, runtime, request)
|
||||
}
|
||||
return d.completeChat(ctx, client, runtime, request)
|
||||
}
|
||||
|
||||
func newOpenAIClient(opts ...option.RequestOption) *openAIClient {
|
||||
return &openAIClient{
|
||||
chat: openai.NewChatCompletionService(opts...),
|
||||
responses: openairesponses.NewResponseService(opts...),
|
||||
}
|
||||
}
|
||||
|
||||
func openAIRequestOptions(runtime *RuntimeConfig) []option.RequestOption {
|
||||
opts := []option.RequestOption{
|
||||
option.WithHTTPClient(httpClient),
|
||||
option.WithRequestTimeout(defaultCompletionTimeout),
|
||||
option.WithMaxRetries(0),
|
||||
}
|
||||
|
||||
if runtime != nil && NormalizeProviderName(runtime.Provider) == ProviderOpenAI {
|
||||
opts = append(opts, option.WithEnvironmentProduction())
|
||||
}
|
||||
if baseURL := openAIBaseURL(runtime); baseURL != "" {
|
||||
opts = append(opts, option.WithBaseURL(baseURL))
|
||||
}
|
||||
if runtime != nil {
|
||||
if runtime.APIKey != "" {
|
||||
opts = append(opts, option.WithAPIKey(runtime.APIKey))
|
||||
}
|
||||
if runtime.Organization != "" {
|
||||
opts = append(opts, option.WithOrganization(runtime.Organization))
|
||||
}
|
||||
if runtime.Project != "" {
|
||||
opts = append(opts, option.WithProject(runtime.Project))
|
||||
}
|
||||
for key, value := range providerHeaders(runtime) {
|
||||
opts = append(opts, option.WithHeader(key, value))
|
||||
}
|
||||
if runtime.TopK != nil && NormalizeProviderName(runtime.Provider) != ProviderOpenAI {
|
||||
opts = append(opts, option.WithJSONSet("top_k", *runtime.TopK))
|
||||
}
|
||||
}
|
||||
|
||||
return opts
|
||||
}
|
||||
|
||||
func openAIBaseURL(runtime *RuntimeConfig) string {
|
||||
if runtime == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if runtime.BaseURL != "" {
|
||||
return runtime.BaseURL
|
||||
}
|
||||
if NormalizeProviderName(runtime.Provider) == ProviderOpenRouter {
|
||||
return defaultOpenRouterBaseURL
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (d *openAIDriver) completeResponses(ctx context.Context, client *openAIClient, runtime *RuntimeConfig, request *completionRequest) (*Completion, error) {
|
||||
params := openairesponses.ResponseNewParams{
|
||||
Input: openairesponses.ResponseNewParamsInputUnion{
|
||||
OfInputItemList: buildResponseInput(request.SystemPrompt, request.Messages),
|
||||
},
|
||||
Model: shared.ResponsesModel(runtime.Model),
|
||||
}
|
||||
if runtime.MaxOutputTokens > 0 {
|
||||
params.MaxOutputTokens = openai.Int(runtime.MaxOutputTokens)
|
||||
}
|
||||
if runtime.Temperature != nil {
|
||||
params.Temperature = openai.Float(*runtime.Temperature)
|
||||
}
|
||||
if runtime.TopP != nil {
|
||||
params.TopP = openai.Float(*runtime.TopP)
|
||||
}
|
||||
if effort, ok := reasoningEffortForThinkingLevel(runtime.ThinkingLevel); ok {
|
||||
params.Reasoning = shared.ReasoningParam{Effort: effort}
|
||||
}
|
||||
|
||||
response, err := client.responses.New(ctx, params)
|
||||
if err != nil {
|
||||
return nil, formatOpenAIError(runtime.Provider, err)
|
||||
}
|
||||
|
||||
content := strings.TrimSpace(response.OutputText())
|
||||
if content == "" {
|
||||
return nil, fmt.Errorf("%s response did not include assistant text", runtime.Provider)
|
||||
}
|
||||
|
||||
return &Completion{
|
||||
Provider: runtime.Provider,
|
||||
Model: fallbackString(response.Model, runtime.Model),
|
||||
Content: content,
|
||||
ProviderMessageID: strings.TrimSpace(response.ID),
|
||||
FinishReason: responseFinishReason(response),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (d *openAIDriver) completeChat(ctx context.Context, client *openAIClient, runtime *RuntimeConfig, request *completionRequest) (*Completion, error) {
|
||||
params := openai.ChatCompletionNewParams{
|
||||
Messages: buildChatMessages(request.SystemPrompt, request.Messages),
|
||||
Model: shared.ChatModel(runtime.Model),
|
||||
}
|
||||
if runtime.MaxOutputTokens > 0 {
|
||||
if NormalizeProviderName(runtime.Provider) == ProviderOpenAI {
|
||||
params.MaxCompletionTokens = openai.Int(runtime.MaxOutputTokens)
|
||||
} else {
|
||||
params.MaxTokens = openai.Int(runtime.MaxOutputTokens)
|
||||
}
|
||||
}
|
||||
if runtime.Temperature != nil {
|
||||
params.Temperature = openai.Float(*runtime.Temperature)
|
||||
}
|
||||
if runtime.TopP != nil {
|
||||
params.TopP = openai.Float(*runtime.TopP)
|
||||
}
|
||||
if runtime.PresencePenalty != nil {
|
||||
params.PresencePenalty = openai.Float(*runtime.PresencePenalty)
|
||||
}
|
||||
if runtime.FrequencyPenalty != nil {
|
||||
params.FrequencyPenalty = openai.Float(*runtime.FrequencyPenalty)
|
||||
}
|
||||
if effort, ok := reasoningEffortForThinkingLevel(runtime.ThinkingLevel); ok {
|
||||
params.ReasoningEffort = effort
|
||||
}
|
||||
|
||||
response, err := client.chat.New(ctx, params)
|
||||
if err != nil {
|
||||
return nil, formatOpenAIError(runtime.Provider, err)
|
||||
}
|
||||
|
||||
content, finishReason := extractChatCompletionResult(response)
|
||||
if content == "" {
|
||||
return nil, fmt.Errorf("%s response did not include assistant text", runtime.Provider)
|
||||
}
|
||||
|
||||
return &Completion{
|
||||
Provider: runtime.Provider,
|
||||
Model: fallbackString(response.Model, runtime.Model),
|
||||
Content: content,
|
||||
ProviderMessageID: strings.TrimSpace(response.ID),
|
||||
FinishReason: finishReason,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func buildResponseInput(systemPrompt string, messages []providerMessage) openairesponses.ResponseInputParam {
|
||||
input := make(openairesponses.ResponseInputParam, 0, len(messages)+1)
|
||||
if strings.TrimSpace(systemPrompt) != "" {
|
||||
input = append(input, openairesponses.ResponseInputItemParamOfMessage(strings.TrimSpace(systemPrompt), openairesponses.EasyInputMessageRoleSystem))
|
||||
}
|
||||
for _, message := range messages {
|
||||
input = append(input, openairesponses.ResponseInputItemParamOfMessage(message.Content, responseMessageRole(message.Role)))
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
func buildChatMessages(systemPrompt string, messages []providerMessage) []openai.ChatCompletionMessageParamUnion {
|
||||
chatMessages := make([]openai.ChatCompletionMessageParamUnion, 0, len(messages)+1)
|
||||
if strings.TrimSpace(systemPrompt) != "" {
|
||||
chatMessages = append(chatMessages, openai.SystemMessage(strings.TrimSpace(systemPrompt)))
|
||||
}
|
||||
for _, message := range messages {
|
||||
switch strings.ToLower(strings.TrimSpace(message.Role)) {
|
||||
case "assistant":
|
||||
chatMessages = append(chatMessages, openai.AssistantMessage(message.Content))
|
||||
default:
|
||||
chatMessages = append(chatMessages, openai.UserMessage(message.Content))
|
||||
}
|
||||
}
|
||||
return chatMessages
|
||||
}
|
||||
|
||||
func responseMessageRole(role string) openairesponses.EasyInputMessageRole {
|
||||
switch strings.ToLower(strings.TrimSpace(role)) {
|
||||
case "assistant":
|
||||
return openairesponses.EasyInputMessageRoleAssistant
|
||||
default:
|
||||
return openairesponses.EasyInputMessageRoleUser
|
||||
}
|
||||
}
|
||||
|
||||
func extractChatCompletionResult(response *openai.ChatCompletion) (string, string) {
|
||||
if response == nil || len(response.Choices) == 0 {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
for _, choice := range response.Choices {
|
||||
content := strings.TrimSpace(choice.Message.Content)
|
||||
if content == "" {
|
||||
continue
|
||||
}
|
||||
finishReason := strings.TrimSpace(choice.FinishReason)
|
||||
if finishReason == "" {
|
||||
finishReason = "completed"
|
||||
}
|
||||
return content, finishReason
|
||||
}
|
||||
|
||||
return "", ""
|
||||
}
|
||||
|
||||
func responseFinishReason(response *openairesponses.Response) string {
|
||||
if response == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if response.Status == openairesponses.ResponseStatusIncomplete && strings.TrimSpace(response.IncompleteDetails.Reason) != "" {
|
||||
return strings.TrimSpace(response.IncompleteDetails.Reason)
|
||||
}
|
||||
|
||||
finishReason := strings.TrimSpace(string(response.Status))
|
||||
if finishReason == "" {
|
||||
return "completed"
|
||||
}
|
||||
return finishReason
|
||||
}
|
||||
|
||||
func reasoningEffortForThinkingLevel(thinkingLevel string) (shared.ReasoningEffort, bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(thinkingLevel)) {
|
||||
case "low":
|
||||
return shared.ReasoningEffortLow, true
|
||||
case "medium":
|
||||
return shared.ReasoningEffortMedium, true
|
||||
case "high":
|
||||
return shared.ReasoningEffortHigh, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func formatOpenAIError(provider string, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var apiErr *openai.Error
|
||||
if errors.As(err, &apiErr) {
|
||||
message := strings.TrimSpace(apiErr.Message)
|
||||
if message == "" {
|
||||
message = strings.TrimSpace(apiErr.RawJSON())
|
||||
}
|
||||
if message == "" {
|
||||
message = strings.TrimSpace(err.Error())
|
||||
}
|
||||
if apiErr.StatusCode > 0 {
|
||||
return fmt.Errorf("%s API request failed with HTTP %d: %s", provider, apiErr.StatusCode, truncateForError(message))
|
||||
}
|
||||
if message != "" {
|
||||
return fmt.Errorf("%s API request failed: %s", provider, truncateForError(message))
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
@@ -42,7 +42,18 @@ func aiConfigCmd(_ *cobra.Command, args []string) {
|
||||
}
|
||||
|
||||
result := currentAIConfigFormResult(serverConfig)
|
||||
if err := forms.AIConfig(result); err != nil {
|
||||
originalProvider := ai.NormalizeProviderName(result.Provider)
|
||||
if err := forms.SelectAIProvider(result); err != nil {
|
||||
if errors.Is(err, forms.ErrUserAborted) {
|
||||
return
|
||||
}
|
||||
fmt.Printf(Warn+"AI configuration form failed: %v\n", err)
|
||||
return
|
||||
}
|
||||
if ai.NormalizeProviderName(result.Provider) != originalProvider {
|
||||
result = currentAIConfigFormResultForProvider(serverConfig, result.Provider)
|
||||
}
|
||||
if err := forms.EditAIConfig(result); err != nil {
|
||||
if errors.Is(err, forms.ErrUserAborted) {
|
||||
return
|
||||
}
|
||||
@@ -68,15 +79,30 @@ func aiConfigCmd(_ *cobra.Command, args []string) {
|
||||
}
|
||||
|
||||
func currentAIConfigFormResult(serverConfig *configs.ServerConfig) *forms.AIConfigFormResult {
|
||||
return currentAIConfigFormResultForProvider(serverConfig, selectedAIProvider(serverConfigAIConfig(serverConfig)))
|
||||
}
|
||||
|
||||
func currentAIConfigFormResultForProvider(serverConfig *configs.ServerConfig, provider string) *forms.AIConfigFormResult {
|
||||
if serverConfig == nil {
|
||||
return &forms.AIConfigFormResult{Provider: ai.ProviderOpenAI, UseResponsesAPI: true}
|
||||
provider = ai.NormalizeProviderName(provider)
|
||||
if !ai.IsSupportedProvider(provider) {
|
||||
provider = ai.ProviderOpenAI
|
||||
}
|
||||
return &forms.AIConfigFormResult{
|
||||
Provider: provider,
|
||||
UseResponsesAPI: provider == ai.ProviderOpenAI,
|
||||
}
|
||||
}
|
||||
|
||||
provider := selectedAIProvider(serverConfig.AI)
|
||||
provider = ai.NormalizeProviderName(provider)
|
||||
if !ai.IsSupportedProvider(provider) {
|
||||
provider = selectedAIProvider(serverConfig.AI)
|
||||
}
|
||||
providerConfig := aiProviderConfig(serverConfig.AI, provider)
|
||||
|
||||
result := &forms.AIConfigFormResult{
|
||||
Provider: provider,
|
||||
Provider: provider,
|
||||
UseResponsesAPI: provider == ai.ProviderOpenAI,
|
||||
}
|
||||
if serverConfig.AI != nil {
|
||||
result.Model = strings.TrimSpace(serverConfig.AI.Model)
|
||||
@@ -99,6 +125,13 @@ func currentAIConfigFormResult(serverConfig *configs.ServerConfig) *forms.AIConf
|
||||
return result
|
||||
}
|
||||
|
||||
func serverConfigAIConfig(serverConfig *configs.ServerConfig) *configs.AIConfig {
|
||||
if serverConfig == nil {
|
||||
return nil
|
||||
}
|
||||
return serverConfig.AI
|
||||
}
|
||||
|
||||
func applyAIConfigFormResult(serverConfig *configs.ServerConfig, result *forms.AIConfigFormResult) {
|
||||
if serverConfig == nil || result == nil {
|
||||
return
|
||||
|
||||
@@ -31,6 +31,60 @@ func TestCurrentAIConfigFormResultUsesConfiguredProviderFallback(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentAIConfigFormResultForProviderLoadsRequestedProviderSettings(t *testing.T) {
|
||||
serverConfig := &configs.ServerConfig{
|
||||
AI: &configs.AIConfig{
|
||||
Provider: ai.ProviderAnthropic,
|
||||
Model: "shared-model",
|
||||
ThinkingLevel: "medium",
|
||||
Anthropic: &configs.AIProviderConfig{APIKey: "anthropic-key"},
|
||||
Google: &configs.AIProviderConfig{},
|
||||
OpenAI: &configs.AIProviderConfig{
|
||||
APIKey: "openai-key",
|
||||
BaseURL: "https://api.openai.test",
|
||||
Organization: "org-test",
|
||||
Project: "proj-test",
|
||||
UseResponsesAPI: boolPtr(true),
|
||||
},
|
||||
OpenAICompat: &configs.AIProviderConfig{},
|
||||
OpenRouter: &configs.AIProviderConfig{},
|
||||
},
|
||||
}
|
||||
|
||||
result := currentAIConfigFormResultForProvider(serverConfig, ai.ProviderOpenAI)
|
||||
if result.Provider != ai.ProviderOpenAI {
|
||||
t.Fatalf("expected %q provider, got %q", ai.ProviderOpenAI, result.Provider)
|
||||
}
|
||||
if result.Model != "shared-model" {
|
||||
t.Fatalf("expected shared model, got %q", result.Model)
|
||||
}
|
||||
if result.APIKey != "openai-key" {
|
||||
t.Fatalf("expected openai API key, got %q", result.APIKey)
|
||||
}
|
||||
if result.BaseURL != "https://api.openai.test" {
|
||||
t.Fatalf("expected openai base url, got %q", result.BaseURL)
|
||||
}
|
||||
if result.Organization != "org-test" {
|
||||
t.Fatalf("expected openai organization, got %q", result.Organization)
|
||||
}
|
||||
if result.Project != "proj-test" {
|
||||
t.Fatalf("expected openai project, got %q", result.Project)
|
||||
}
|
||||
if !result.UseResponsesAPI {
|
||||
t.Fatal("expected openai responses api to remain enabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentAIConfigFormResultForProviderDefaultsResponsesAPIForOpenAI(t *testing.T) {
|
||||
result := currentAIConfigFormResultForProvider(&configs.ServerConfig{}, ai.ProviderOpenAI)
|
||||
if result.Provider != ai.ProviderOpenAI {
|
||||
t.Fatalf("expected %q provider, got %q", ai.ProviderOpenAI, result.Provider)
|
||||
}
|
||||
if !result.UseResponsesAPI {
|
||||
t.Fatal("expected openai to default to responses API")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyAIConfigFormResultUpdatesOnlySelectedProvider(t *testing.T) {
|
||||
serverConfig := &configs.ServerConfig{
|
||||
AI: &configs.AIConfig{
|
||||
|
||||
@@ -31,6 +31,18 @@ func AIConfig(result *AIConfigFormResult) error {
|
||||
return errors.New("AI config result is required")
|
||||
}
|
||||
|
||||
if err := SelectAIProvider(result); err != nil {
|
||||
return err
|
||||
}
|
||||
return EditAIConfig(result)
|
||||
}
|
||||
|
||||
// SelectAIProvider prompts for the AI provider to edit.
|
||||
func SelectAIProvider(result *AIConfigFormResult) error {
|
||||
if result == nil {
|
||||
return errors.New("AI config result is required")
|
||||
}
|
||||
|
||||
normalizeAIConfigResult(result)
|
||||
|
||||
providerOptions := []huh.Option[string]{
|
||||
@@ -40,15 +52,8 @@ func AIConfig(result *AIConfigFormResult) error {
|
||||
huh.NewOption("OpenAI-Compatible", ai.ProviderOpenAICompat),
|
||||
huh.NewOption("OpenRouter", ai.ProviderOpenRouter),
|
||||
}
|
||||
thinkingOptions := []huh.Option[string]{
|
||||
huh.NewOption("Provider default", ""),
|
||||
huh.NewOption("Disabled", "disabled"),
|
||||
huh.NewOption("Low", "low"),
|
||||
huh.NewOption("Medium", "medium"),
|
||||
huh.NewOption("High", "high"),
|
||||
}
|
||||
|
||||
providerForm := huh.NewForm(
|
||||
providerForm := newConsoleForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[string]().
|
||||
Title("AI provider").
|
||||
@@ -64,11 +69,31 @@ func AIConfig(result *AIConfigFormResult) error {
|
||||
}),
|
||||
),
|
||||
)
|
||||
if err := providerForm.Run(); err != nil {
|
||||
if err := runConsoleForm(providerForm); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
detailsForm := huh.NewForm(
|
||||
normalizeAIConfigResult(result)
|
||||
return nil
|
||||
}
|
||||
|
||||
// EditAIConfig prompts for the provider-independent and provider-specific AI settings.
|
||||
func EditAIConfig(result *AIConfigFormResult) error {
|
||||
if result == nil {
|
||||
return errors.New("AI config result is required")
|
||||
}
|
||||
|
||||
normalizeAIConfigResult(result)
|
||||
|
||||
thinkingOptions := []huh.Option[string]{
|
||||
huh.NewOption("Provider default", ""),
|
||||
huh.NewOption("Disabled", "disabled"),
|
||||
huh.NewOption("Low", "low"),
|
||||
huh.NewOption("Medium", "medium"),
|
||||
huh.NewOption("High", "high"),
|
||||
}
|
||||
|
||||
detailsForm := newConsoleForm(
|
||||
huh.NewGroup(
|
||||
huh.NewNote().
|
||||
Title("Server AI configuration").
|
||||
@@ -116,7 +141,7 @@ func AIConfig(result *AIConfigFormResult) error {
|
||||
Value(&result.UserAgent),
|
||||
),
|
||||
)
|
||||
if err := detailsForm.Run(); err != nil {
|
||||
if err := runConsoleForm(detailsForm); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -142,7 +167,7 @@ func providerSpecificAIConfig(result *AIConfigFormResult) error {
|
||||
}
|
||||
|
||||
func openAIAIConfig(result *AIConfigFormResult) error {
|
||||
form := huh.NewForm(
|
||||
form := newConsoleForm(
|
||||
huh.NewGroup(
|
||||
huh.NewInput().
|
||||
Title("Organization").
|
||||
@@ -160,11 +185,11 @@ func openAIAIConfig(result *AIConfigFormResult) error {
|
||||
Value(&result.UseResponsesAPI),
|
||||
),
|
||||
)
|
||||
return form.Run()
|
||||
return runConsoleForm(form)
|
||||
}
|
||||
|
||||
func anthropicAIConfig(result *AIConfigFormResult) error {
|
||||
form := huh.NewForm(
|
||||
form := newConsoleForm(
|
||||
huh.NewGroup(
|
||||
huh.NewInput().
|
||||
Title("Vertex project").
|
||||
@@ -186,11 +211,11 @@ func anthropicAIConfig(result *AIConfigFormResult) error {
|
||||
Value(&result.UseBedrock),
|
||||
),
|
||||
)
|
||||
return form.Run()
|
||||
return runConsoleForm(form)
|
||||
}
|
||||
|
||||
func googleAIConfig(result *AIConfigFormResult) error {
|
||||
form := huh.NewForm(
|
||||
form := newConsoleForm(
|
||||
huh.NewGroup(
|
||||
huh.NewInput().
|
||||
Title("Vertex project").
|
||||
@@ -208,7 +233,7 @@ func googleAIConfig(result *AIConfigFormResult) error {
|
||||
Value(&result.SkipAuth),
|
||||
),
|
||||
)
|
||||
return form.Run()
|
||||
return runConsoleForm(form)
|
||||
}
|
||||
|
||||
func normalizeAIConfigResult(result *AIConfigFormResult) {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package forms
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/huh/v2"
|
||||
)
|
||||
|
||||
func newConsoleForm(groups ...*huh.Group) *huh.Form {
|
||||
return huh.NewForm(groups...)
|
||||
}
|
||||
|
||||
func runConsoleForm(form *huh.Form) error {
|
||||
if form == nil {
|
||||
return errors.New("form is required")
|
||||
}
|
||||
|
||||
ttyIn, ttyOut, err := tea.OpenTTY()
|
||||
if err == nil {
|
||||
if ttyIn != nil {
|
||||
defer ttyIn.Close()
|
||||
}
|
||||
if ttyOut != nil && (ttyIn == nil || ttyOut.Fd() != ttyIn.Fd()) {
|
||||
defer ttyOut.Close()
|
||||
}
|
||||
if ttyIn != nil {
|
||||
form.WithInput(ttyIn)
|
||||
}
|
||||
if ttyOut != nil {
|
||||
form.WithOutput(ttyOut)
|
||||
}
|
||||
}
|
||||
|
||||
return form.Run()
|
||||
}
|
||||
@@ -35,8 +35,8 @@ func SelectOperator(title string, operators []string, value *string) error {
|
||||
return nil
|
||||
})
|
||||
|
||||
form := huh.NewForm(huh.NewGroup(field))
|
||||
return form.Run()
|
||||
form := newConsoleForm(huh.NewGroup(field))
|
||||
return runConsoleForm(form)
|
||||
}
|
||||
|
||||
func ensureSelectedValue(options []string, value *string) {
|
||||
|
||||
+37
-3
@@ -1438,6 +1438,10 @@ func SaveAIConversation(conversation *clientpb.AIConversation, operatorName stri
|
||||
existing.Title = dbConversation.Title
|
||||
existing.Summary = dbConversation.Summary
|
||||
existing.SystemPrompt = dbConversation.SystemPrompt
|
||||
existing.ActiveTurnID = dbConversation.ActiveTurnID
|
||||
existing.TurnState = dbConversation.TurnState
|
||||
existing.TargetSessionID = dbConversation.TargetSessionID
|
||||
existing.TargetBeaconID = dbConversation.TargetBeaconID
|
||||
|
||||
if err := dbSession.Save(existing).Error; err != nil {
|
||||
return nil, err
|
||||
@@ -1509,6 +1513,20 @@ func SaveAIConversationMessage(message *clientpb.AIConversationMessage, operator
|
||||
dbSession := Session()
|
||||
var savedMessage *models.AIConversationMessage
|
||||
err = dbSession.Transaction(func(tx *gorm.DB) error {
|
||||
if dbMessage.ID == uuid.Nil && strings.TrimSpace(dbMessage.ItemID) != "" {
|
||||
existingByItem := &models.AIConversationMessage{}
|
||||
err := tx.Where("conversation_id = ?", dbMessage.ConversationID).
|
||||
Where("item_id = ?", strings.TrimSpace(dbMessage.ItemID)).
|
||||
First(existingByItem).Error
|
||||
if err != nil {
|
||||
if !errors.Is(err, ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
dbMessage.ID = existingByItem.ID
|
||||
}
|
||||
}
|
||||
|
||||
if dbMessage.ID == uuid.Nil {
|
||||
if dbMessage.Sequence == 0 {
|
||||
lastMessage := &models.AIConversationMessage{}
|
||||
@@ -1539,15 +1557,31 @@ func SaveAIConversationMessage(message *clientpb.AIConversationMessage, operator
|
||||
if dbMessage.OperatorName != "" {
|
||||
existing.OperatorName = dbMessage.OperatorName
|
||||
}
|
||||
existing.Provider = dbMessage.Provider
|
||||
existing.Model = dbMessage.Model
|
||||
if dbMessage.Provider != "" {
|
||||
existing.Provider = dbMessage.Provider
|
||||
}
|
||||
if dbMessage.Model != "" {
|
||||
existing.Model = dbMessage.Model
|
||||
}
|
||||
if dbMessage.Sequence != 0 {
|
||||
existing.Sequence = dbMessage.Sequence
|
||||
}
|
||||
existing.Role = dbMessage.Role
|
||||
if dbMessage.Role != "" {
|
||||
existing.Role = dbMessage.Role
|
||||
}
|
||||
existing.Content = dbMessage.Content
|
||||
existing.ProviderMessageID = dbMessage.ProviderMessageID
|
||||
existing.FinishReason = dbMessage.FinishReason
|
||||
existing.Kind = dbMessage.Kind
|
||||
existing.Visibility = dbMessage.Visibility
|
||||
existing.State = dbMessage.State
|
||||
existing.TurnID = dbMessage.TurnID
|
||||
existing.ItemID = dbMessage.ItemID
|
||||
existing.ToolCallID = dbMessage.ToolCallID
|
||||
existing.ToolName = dbMessage.ToolName
|
||||
existing.ToolArguments = dbMessage.ToolArguments
|
||||
existing.ToolResult = dbMessage.ToolResult
|
||||
existing.ErrorText = dbMessage.ErrorText
|
||||
|
||||
if err := tx.Save(existing).Error; err != nil {
|
||||
return err
|
||||
|
||||
+68
-26
@@ -28,15 +28,19 @@ import (
|
||||
|
||||
// AIConversation - A server-side AI conversation thread.
|
||||
type AIConversation struct {
|
||||
ID uuid.UUID `gorm:"primaryKey;->;<-:create;type:uuid;"`
|
||||
CreatedAt time.Time `gorm:"->;<-:create;"`
|
||||
UpdatedAt time.Time
|
||||
OperatorName string `gorm:"index"`
|
||||
Provider string `gorm:"index"`
|
||||
Model string
|
||||
Title string
|
||||
Summary string
|
||||
SystemPrompt string `gorm:"type:text;"`
|
||||
ID uuid.UUID `gorm:"primaryKey;->;<-:create;type:uuid;"`
|
||||
CreatedAt time.Time `gorm:"->;<-:create;"`
|
||||
UpdatedAt time.Time
|
||||
OperatorName string `gorm:"index"`
|
||||
Provider string `gorm:"index"`
|
||||
Model string
|
||||
Title string
|
||||
Summary string
|
||||
SystemPrompt string `gorm:"type:text;"`
|
||||
ActiveTurnID string `gorm:"index"`
|
||||
TurnState int32
|
||||
TargetSessionID string `gorm:"index"`
|
||||
TargetBeaconID string `gorm:"index"`
|
||||
|
||||
Messages []AIConversationMessage `gorm:"foreignKey:ConversationID;constraint:OnDelete:CASCADE;"`
|
||||
}
|
||||
@@ -61,16 +65,20 @@ func (a *AIConversation) ToProtobuf() *clientpb.AIConversation {
|
||||
}
|
||||
|
||||
return &clientpb.AIConversation{
|
||||
ID: a.ID.String(),
|
||||
CreatedAt: a.CreatedAt.Unix(),
|
||||
UpdatedAt: a.UpdatedAt.Unix(),
|
||||
OperatorName: a.OperatorName,
|
||||
Provider: a.Provider,
|
||||
Model: a.Model,
|
||||
Title: a.Title,
|
||||
Summary: a.Summary,
|
||||
SystemPrompt: a.SystemPrompt,
|
||||
Messages: messages,
|
||||
ID: a.ID.String(),
|
||||
CreatedAt: a.CreatedAt.Unix(),
|
||||
UpdatedAt: a.UpdatedAt.Unix(),
|
||||
OperatorName: a.OperatorName,
|
||||
Provider: a.Provider,
|
||||
Model: a.Model,
|
||||
Title: a.Title,
|
||||
Summary: a.Summary,
|
||||
SystemPrompt: a.SystemPrompt,
|
||||
Messages: messages,
|
||||
ActiveTurnID: a.ActiveTurnID,
|
||||
TurnState: clientpb.AIConversationTurnState(a.TurnState),
|
||||
TargetSessionID: a.TargetSessionID,
|
||||
TargetBeaconID: a.TargetBeaconID,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,13 +91,17 @@ func AIConversationFromProtobuf(pbConversation *clientpb.AIConversation) *AIConv
|
||||
id, _ := uuid.FromString(pbConversation.ID)
|
||||
|
||||
return &AIConversation{
|
||||
ID: id,
|
||||
OperatorName: pbConversation.OperatorName,
|
||||
Provider: pbConversation.Provider,
|
||||
Model: pbConversation.Model,
|
||||
Title: pbConversation.Title,
|
||||
Summary: pbConversation.Summary,
|
||||
SystemPrompt: pbConversation.SystemPrompt,
|
||||
ID: id,
|
||||
OperatorName: pbConversation.OperatorName,
|
||||
Provider: pbConversation.Provider,
|
||||
Model: pbConversation.Model,
|
||||
Title: pbConversation.Title,
|
||||
Summary: pbConversation.Summary,
|
||||
SystemPrompt: pbConversation.SystemPrompt,
|
||||
ActiveTurnID: pbConversation.ActiveTurnID,
|
||||
TurnState: int32(pbConversation.TurnState),
|
||||
TargetSessionID: pbConversation.TargetSessionID,
|
||||
TargetBeaconID: pbConversation.TargetBeaconID,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,6 +119,16 @@ type AIConversationMessage struct {
|
||||
Content string `gorm:"type:text;"`
|
||||
ProviderMessageID string
|
||||
FinishReason string
|
||||
Kind int32
|
||||
Visibility int32
|
||||
State int32
|
||||
TurnID string `gorm:"index"`
|
||||
ItemID string `gorm:"index"`
|
||||
ToolCallID string `gorm:"index"`
|
||||
ToolName string
|
||||
ToolArguments string `gorm:"type:text;"`
|
||||
ToolResult string `gorm:"type:text;"`
|
||||
ErrorText string `gorm:"type:text;"`
|
||||
}
|
||||
|
||||
// BeforeCreate - GORM hook.
|
||||
@@ -136,6 +158,16 @@ func (a *AIConversationMessage) ToProtobuf() *clientpb.AIConversationMessage {
|
||||
Content: a.Content,
|
||||
ProviderMessageID: a.ProviderMessageID,
|
||||
FinishReason: a.FinishReason,
|
||||
Kind: clientpb.AIConversationMessageKind(a.Kind),
|
||||
Visibility: clientpb.AIConversationMessageVisibility(a.Visibility),
|
||||
State: clientpb.AIConversationMessageState(a.State),
|
||||
TurnID: a.TurnID,
|
||||
ItemID: a.ItemID,
|
||||
ToolCallID: a.ToolCallID,
|
||||
ToolName: a.ToolName,
|
||||
ToolArguments: a.ToolArguments,
|
||||
ToolResult: a.ToolResult,
|
||||
ErrorText: a.ErrorText,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,5 +191,15 @@ func AIConversationMessageFromProtobuf(pbMessage *clientpb.AIConversationMessage
|
||||
Content: pbMessage.Content,
|
||||
ProviderMessageID: pbMessage.ProviderMessageID,
|
||||
FinishReason: pbMessage.FinishReason,
|
||||
Kind: int32(pbMessage.Kind),
|
||||
Visibility: int32(pbMessage.Visibility),
|
||||
State: int32(pbMessage.State),
|
||||
TurnID: pbMessage.TurnID,
|
||||
ItemID: pbMessage.ItemID,
|
||||
ToolCallID: pbMessage.ToolCallID,
|
||||
ToolName: pbMessage.ToolName,
|
||||
ToolArguments: pbMessage.ToolArguments,
|
||||
ToolResult: pbMessage.ToolResult,
|
||||
ErrorText: pbMessage.ErrorText,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
consts "github.com/bishopfox/sliver/client/constants"
|
||||
"github.com/bishopfox/sliver/protobuf/clientpb"
|
||||
serverai "github.com/bishopfox/sliver/server/ai"
|
||||
"github.com/bishopfox/sliver/server/core"
|
||||
"github.com/bishopfox/sliver/server/db"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
func publishAIConversationEvent(event *clientpb.AIConversationEvent) {
|
||||
if event == nil {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(event.GetConversationID()) == "" {
|
||||
switch {
|
||||
case event.GetConversation() != nil:
|
||||
event.ConversationID = event.GetConversation().GetID()
|
||||
case event.GetMessage() != nil:
|
||||
event.ConversationID = event.GetMessage().GetConversationID()
|
||||
}
|
||||
}
|
||||
if event.GetConversation() != nil && len(event.GetConversation().GetMessages()) > 0 {
|
||||
event.Conversation = aiConversationSummary(event.GetConversation())
|
||||
}
|
||||
|
||||
data, err := proto.Marshal(event)
|
||||
if err != nil {
|
||||
rpcLog.Warnf("Failed to marshal AI conversation event: %s", err)
|
||||
return
|
||||
}
|
||||
|
||||
core.EventBroker.Publish(core.Event{
|
||||
EventType: consts.AIConversationEvent,
|
||||
Data: data,
|
||||
})
|
||||
}
|
||||
|
||||
func aiConversationSummary(conversation *clientpb.AIConversation) *clientpb.AIConversation {
|
||||
if conversation == nil {
|
||||
return nil
|
||||
}
|
||||
summary, ok := proto.Clone(conversation).(*clientpb.AIConversation)
|
||||
if !ok {
|
||||
return conversation
|
||||
}
|
||||
summary.Messages = nil
|
||||
return summary
|
||||
}
|
||||
|
||||
func loadAIConversationSummary(conversationID string) (*clientpb.AIConversation, error) {
|
||||
conversation, err := db.AIConversationByID(conversationID, "", false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return aiConversationSummary(conversation), nil
|
||||
}
|
||||
|
||||
func publishAIConversationSummaryEvent(eventType clientpb.AIConversationEventType, conversation *clientpb.AIConversation, turnID string, errText string) {
|
||||
publishAIConversationEvent(&clientpb.AIConversationEvent{
|
||||
EventType: eventType,
|
||||
ConversationID: selectedAIConversationID(conversation),
|
||||
TurnID: strings.TrimSpace(turnID),
|
||||
ErrorText: strings.TrimSpace(errText),
|
||||
Conversation: aiConversationSummary(conversation),
|
||||
})
|
||||
}
|
||||
|
||||
func publishAIConversationMessageEvent(
|
||||
eventType clientpb.AIConversationEventType,
|
||||
conversation *clientpb.AIConversation,
|
||||
message *clientpb.AIConversationMessage,
|
||||
turnID string,
|
||||
errText string,
|
||||
) {
|
||||
publishAIConversationEvent(&clientpb.AIConversationEvent{
|
||||
EventType: eventType,
|
||||
ConversationID: selectedAIConversationID(conversation),
|
||||
TurnID: strings.TrimSpace(turnID),
|
||||
ErrorText: strings.TrimSpace(errText),
|
||||
Conversation: aiConversationSummary(conversation),
|
||||
Message: message,
|
||||
})
|
||||
}
|
||||
|
||||
func selectedAIConversationID(conversation *clientpb.AIConversation) string {
|
||||
if conversation == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(conversation.GetID())
|
||||
}
|
||||
|
||||
type aiConversationEventSink struct {
|
||||
conversationID string
|
||||
operatorName string
|
||||
runtime *serverai.RuntimeConfig
|
||||
turnID string
|
||||
}
|
||||
|
||||
func newAIConversationEventSink(conversationID string, operatorName string, runtime *serverai.RuntimeConfig, turnID string) *aiConversationEventSink {
|
||||
return &aiConversationEventSink{
|
||||
conversationID: strings.TrimSpace(conversationID),
|
||||
operatorName: strings.TrimSpace(operatorName),
|
||||
runtime: runtime,
|
||||
turnID: strings.TrimSpace(turnID),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *aiConversationEventSink) TurnStarted() error {
|
||||
conversation, err := s.updateConversationTurnState(s.turnID, clientpb.AIConversationTurnState_AI_TURN_STATE_IN_PROGRESS)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
publishAIConversationSummaryEvent(clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_TURN_STARTED, conversation, s.turnID, "")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *aiConversationEventSink) TurnCompleted() error {
|
||||
conversation, err := s.updateConversationTurnState("", clientpb.AIConversationTurnState_AI_TURN_STATE_IDLE)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
publishAIConversationSummaryEvent(clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_TURN_COMPLETED, conversation, s.turnID, "")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *aiConversationEventSink) TurnFailed(failure error) error {
|
||||
errText := ""
|
||||
if failure != nil {
|
||||
errText = strings.TrimSpace(failure.Error())
|
||||
}
|
||||
conversation, err := s.updateConversationTurnState("", clientpb.AIConversationTurnState_AI_TURN_STATE_FAILED)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
publishAIConversationSummaryEvent(clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_TURN_FAILED, conversation, s.turnID, errText)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *aiConversationEventSink) ReasoningItem(ctx context.Context, item serverai.AgenticReasoningItem) error {
|
||||
content := strings.TrimSpace(item.Content)
|
||||
summary := strings.TrimSpace(item.Summary)
|
||||
if content == "" {
|
||||
content = summary
|
||||
} else if summary != "" && summary != content {
|
||||
content = "Summary:\n" + summary + "\n\n" + content
|
||||
}
|
||||
|
||||
message := &clientpb.AIConversationMessage{
|
||||
ConversationID: s.conversationID,
|
||||
OperatorName: s.operatorName,
|
||||
Provider: s.provider(),
|
||||
Model: s.model(),
|
||||
Role: "assistant",
|
||||
Content: content,
|
||||
FinishReason: strings.TrimSpace(item.Status),
|
||||
Kind: clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_REASONING,
|
||||
Visibility: clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_UI_ONLY,
|
||||
State: normalizeAIMessageState(item.Status, clientpb.AIConversationMessageState_AI_MESSAGE_STATE_COMPLETED),
|
||||
TurnID: s.turnID,
|
||||
ItemID: strings.TrimSpace(item.ItemID),
|
||||
}
|
||||
_, err := s.saveMessageAndPublish(message, clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_MESSAGE_COMPLETED, "")
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *aiConversationEventSink) ToolCallStarted(ctx context.Context, item serverai.AgenticToolCall) error {
|
||||
message := &clientpb.AIConversationMessage{
|
||||
ConversationID: s.conversationID,
|
||||
OperatorName: s.operatorName,
|
||||
Provider: s.provider(),
|
||||
Model: s.model(),
|
||||
Role: "assistant",
|
||||
Kind: clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_TOOL_CALL,
|
||||
Visibility: clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_UI_ONLY,
|
||||
State: clientpb.AIConversationMessageState_AI_MESSAGE_STATE_IN_PROGRESS,
|
||||
TurnID: s.turnID,
|
||||
ItemID: strings.TrimSpace(item.ItemID),
|
||||
ToolCallID: strings.TrimSpace(item.CallID),
|
||||
ToolName: strings.TrimSpace(item.Name),
|
||||
ToolArguments: strings.TrimSpace(item.Arguments),
|
||||
FinishReason: strings.TrimSpace(item.Status),
|
||||
}
|
||||
_, err := s.saveMessageAndPublish(message, clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_MESSAGE_STARTED, "")
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *aiConversationEventSink) ToolCallCompleted(ctx context.Context, item serverai.AgenticToolCallResult) error {
|
||||
errText := ""
|
||||
state := clientpb.AIConversationMessageState_AI_MESSAGE_STATE_COMPLETED
|
||||
if item.Error != nil {
|
||||
errText = strings.TrimSpace(item.Error.Error())
|
||||
state = clientpb.AIConversationMessageState_AI_MESSAGE_STATE_FAILED
|
||||
}
|
||||
|
||||
message := &clientpb.AIConversationMessage{
|
||||
ConversationID: s.conversationID,
|
||||
OperatorName: s.operatorName,
|
||||
Provider: s.provider(),
|
||||
Model: s.model(),
|
||||
Role: "assistant",
|
||||
Kind: clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_TOOL_CALL,
|
||||
Visibility: clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_UI_ONLY,
|
||||
State: state,
|
||||
TurnID: s.turnID,
|
||||
ItemID: strings.TrimSpace(item.ItemID),
|
||||
ToolCallID: strings.TrimSpace(item.CallID),
|
||||
ToolName: strings.TrimSpace(item.Name),
|
||||
ToolArguments: strings.TrimSpace(item.Arguments),
|
||||
ToolResult: strings.TrimSpace(item.Output),
|
||||
ErrorText: errText,
|
||||
FinishReason: strings.TrimSpace(item.Status),
|
||||
}
|
||||
_, err := s.saveMessageAndPublish(message, clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_MESSAGE_COMPLETED, errText)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *aiConversationEventSink) SaveFailureMessage(content string, errText string) error {
|
||||
message := &clientpb.AIConversationMessage{
|
||||
ConversationID: s.conversationID,
|
||||
OperatorName: s.operatorName,
|
||||
Provider: s.provider(),
|
||||
Model: s.model(),
|
||||
Role: "system",
|
||||
Content: strings.TrimSpace(content),
|
||||
FinishReason: "error",
|
||||
Kind: clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_CHAT,
|
||||
Visibility: clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_UI_ONLY,
|
||||
State: clientpb.AIConversationMessageState_AI_MESSAGE_STATE_FAILED,
|
||||
TurnID: s.turnID,
|
||||
ErrorText: strings.TrimSpace(errText),
|
||||
}
|
||||
_, err := s.saveMessageAndPublish(message, clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_MESSAGE_COMPLETED, errText)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *aiConversationEventSink) saveMessageAndPublish(
|
||||
message *clientpb.AIConversationMessage,
|
||||
eventType clientpb.AIConversationEventType,
|
||||
errText string,
|
||||
) (*clientpb.AIConversationMessage, error) {
|
||||
savedMessage, err := db.SaveAIConversationMessage(message, s.operatorName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
conversation, err := loadAIConversationSummary(s.conversationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
publishAIConversationMessageEvent(eventType, conversation, savedMessage, s.turnID, errText)
|
||||
return savedMessage, nil
|
||||
}
|
||||
|
||||
func (s *aiConversationEventSink) updateConversationTurnState(activeTurnID string, state clientpb.AIConversationTurnState) (*clientpb.AIConversation, error) {
|
||||
conversation, err := db.AIConversationByID(s.conversationID, "", false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
conversation.ActiveTurnID = strings.TrimSpace(activeTurnID)
|
||||
conversation.TurnState = state
|
||||
if conversation.GetProvider() == "" {
|
||||
conversation.Provider = s.provider()
|
||||
}
|
||||
if conversation.GetModel() == "" {
|
||||
conversation.Model = s.model()
|
||||
}
|
||||
if _, err := db.SaveAIConversation(conversation, s.operatorName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return loadAIConversationSummary(s.conversationID)
|
||||
}
|
||||
|
||||
func (s *aiConversationEventSink) provider() string {
|
||||
if s != nil && s.runtime != nil {
|
||||
return strings.TrimSpace(s.runtime.Provider)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *aiConversationEventSink) model() string {
|
||||
if s != nil && s.runtime != nil {
|
||||
return strings.TrimSpace(s.runtime.Model)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func normalizeAIMessageState(status string, fallback clientpb.AIConversationMessageState) clientpb.AIConversationMessageState {
|
||||
switch strings.ToLower(strings.TrimSpace(status)) {
|
||||
case "in_progress":
|
||||
return clientpb.AIConversationMessageState_AI_MESSAGE_STATE_IN_PROGRESS
|
||||
case "incomplete", "failed", "error":
|
||||
return clientpb.AIConversationMessageState_AI_MESSAGE_STATE_FAILED
|
||||
case "completed":
|
||||
return clientpb.AIConversationMessageState_AI_MESSAGE_STATE_COMPLETED
|
||||
default:
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,576 @@
|
||||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/bishopfox/sliver/protobuf/clientpb"
|
||||
"github.com/bishopfox/sliver/protobuf/commonpb"
|
||||
"github.com/bishopfox/sliver/protobuf/sliverpb"
|
||||
serverai "github.com/bishopfox/sliver/server/ai"
|
||||
"github.com/bishopfox/sliver/server/db"
|
||||
"github.com/bishopfox/sliver/util/encoders"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
const (
|
||||
aiToolDefaultWaitSeconds = int64(60)
|
||||
)
|
||||
|
||||
type aiToolExecutor struct {
|
||||
rpc *Server
|
||||
conversation *clientpb.AIConversation
|
||||
}
|
||||
|
||||
type aiTargetArgs struct {
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
BeaconID string `json:"beacon_id,omitempty"`
|
||||
}
|
||||
|
||||
type aiLSToolArgs struct {
|
||||
aiTargetArgs
|
||||
Path string `json:"path,omitempty"`
|
||||
}
|
||||
|
||||
type aiCatToolArgs struct {
|
||||
aiTargetArgs
|
||||
Path string `json:"path,omitempty"`
|
||||
MaxBytes int64 `json:"max_bytes,omitempty"`
|
||||
MaxLines int64 `json:"max_lines,omitempty"`
|
||||
}
|
||||
|
||||
type aiLSFileEntry struct {
|
||||
Name string `json:"name"`
|
||||
IsDir bool `json:"is_dir"`
|
||||
Size int64 `json:"size"`
|
||||
ModTime int64 `json:"mod_time"`
|
||||
Mode string `json:"mode"`
|
||||
Link string `json:"link,omitempty"`
|
||||
UID string `json:"uid,omitempty"`
|
||||
GID string `json:"gid,omitempty"`
|
||||
}
|
||||
|
||||
type aiLSResult struct {
|
||||
Path string `json:"path"`
|
||||
Exists bool `json:"exists"`
|
||||
Timezone string `json:"timezone"`
|
||||
TimezoneOffset int32 `json:"timezone_offset"`
|
||||
Files []aiLSFileEntry `json:"files"`
|
||||
}
|
||||
|
||||
type aiCatResult struct {
|
||||
Path string `json:"path"`
|
||||
RequestedPath string `json:"requested_path"`
|
||||
Exists bool `json:"exists"`
|
||||
IsDir bool `json:"is_dir"`
|
||||
Encoder string `json:"encoder,omitempty"`
|
||||
Decoded bool `json:"decoded"`
|
||||
ByteLen int `json:"byte_len"`
|
||||
DataBase64 string `json:"data_base64"`
|
||||
Text string `json:"text,omitempty"`
|
||||
}
|
||||
|
||||
type aiPathResult struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
type aiSessionSummary struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Transport string `json:"transport"`
|
||||
RemoteAddress string `json:"remote_address"`
|
||||
Hostname string `json:"hostname"`
|
||||
Username string `json:"username"`
|
||||
PID int32 `json:"pid"`
|
||||
OS string `json:"os"`
|
||||
Arch string `json:"arch"`
|
||||
Locale string `json:"locale"`
|
||||
LastCheckin int64 `json:"last_checkin"`
|
||||
IsDead bool `json:"is_dead"`
|
||||
Integrity string `json:"integrity"`
|
||||
}
|
||||
|
||||
type aiBeaconSummary struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Transport string `json:"transport"`
|
||||
RemoteAddress string `json:"remote_address"`
|
||||
Hostname string `json:"hostname"`
|
||||
Username string `json:"username"`
|
||||
PID int32 `json:"pid"`
|
||||
OS string `json:"os"`
|
||||
Arch string `json:"arch"`
|
||||
Locale string `json:"locale"`
|
||||
LastCheckin int64 `json:"last_checkin"`
|
||||
NextCheckin int64 `json:"next_checkin"`
|
||||
Interval int64 `json:"interval"`
|
||||
Jitter int64 `json:"jitter"`
|
||||
TasksCount int64 `json:"tasks_count"`
|
||||
TasksCountCompleted int64 `json:"tasks_count_completed"`
|
||||
IsDead bool `json:"is_dead"`
|
||||
Integrity string `json:"integrity"`
|
||||
}
|
||||
|
||||
type aiSessionsAndBeaconsResult struct {
|
||||
Sessions []aiSessionSummary `json:"sessions"`
|
||||
SessionsCount int `json:"sessions_count"`
|
||||
Beacons []aiBeaconSummary `json:"beacons"`
|
||||
BeaconsCount int `json:"beacons_count"`
|
||||
}
|
||||
|
||||
func newAIToolExecutor(rpc *Server, conversation *clientpb.AIConversation) *aiToolExecutor {
|
||||
return &aiToolExecutor{
|
||||
rpc: rpc,
|
||||
conversation: conversation,
|
||||
}
|
||||
}
|
||||
|
||||
func (e *aiToolExecutor) ToolDefinitions() []serverai.AgenticToolDefinition {
|
||||
return []serverai.AgenticToolDefinition{
|
||||
{
|
||||
Name: "list_sessions_and_beacons",
|
||||
Description: "List active sessions and beacons so the model can discover valid target IDs before invoking filesystem tools.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{},
|
||||
"additionalProperties": false,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "fs_ls",
|
||||
Description: "List the contents of a remote directory. If session_id and beacon_id are both omitted, the conversation target is used when available.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"session_id": map[string]any{"type": "string", "description": "Interactive session ID to run the command against."},
|
||||
"beacon_id": map[string]any{"type": "string", "description": "Beacon ID to run the command against."},
|
||||
"path": map[string]any{"type": "string", "description": "Directory path to list. Defaults to . when omitted."},
|
||||
},
|
||||
"additionalProperties": false,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "fs_cat",
|
||||
Description: "Download and return the contents of a remote file. If session_id and beacon_id are both omitted, the conversation target is used when available.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"session_id": map[string]any{"type": "string", "description": "Interactive session ID to run the command against."},
|
||||
"beacon_id": map[string]any{"type": "string", "description": "Beacon ID to run the command against."},
|
||||
"path": map[string]any{"type": "string", "description": "Remote file path to download."},
|
||||
"max_bytes": map[string]any{"type": "integer", "description": "Optional byte limit for the download."},
|
||||
"max_lines": map[string]any{"type": "integer", "description": "Optional line limit for the download."},
|
||||
},
|
||||
"required": []string{"path"},
|
||||
"additionalProperties": false,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "fs_pwd",
|
||||
Description: "Return the current working directory of a remote session or beacon. If session_id and beacon_id are both omitted, the conversation target is used when available.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"session_id": map[string]any{"type": "string", "description": "Interactive session ID to run the command against."},
|
||||
"beacon_id": map[string]any{"type": "string", "description": "Beacon ID to run the command against."},
|
||||
},
|
||||
"additionalProperties": false,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (e *aiToolExecutor) CallTool(ctx context.Context, name string, arguments string) (string, error) {
|
||||
switch strings.TrimSpace(name) {
|
||||
case "list_sessions_and_beacons":
|
||||
return e.callListSessionsAndBeacons(ctx)
|
||||
case "fs_ls":
|
||||
var args aiLSToolArgs
|
||||
if err := decodeAIToolArgs(arguments, &args); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return e.callLS(ctx, args)
|
||||
case "fs_cat":
|
||||
var args aiCatToolArgs
|
||||
if err := decodeAIToolArgs(arguments, &args); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return e.callCat(ctx, args)
|
||||
case "fs_pwd":
|
||||
var args aiTargetArgs
|
||||
if err := decodeAIToolArgs(arguments, &args); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return e.callPWD(ctx, args)
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported AI tool %q", name)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *aiToolExecutor) callListSessionsAndBeacons(ctx context.Context) (string, error) {
|
||||
if e == nil || e.rpc == nil {
|
||||
return "", fmt.Errorf("AI tool executor is unavailable")
|
||||
}
|
||||
|
||||
sessionsResp, err := e.rpc.GetSessions(ctx, &commonpb.Empty{})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
beaconsResp, err := e.rpc.GetBeacons(ctx, &commonpb.Empty{})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
result := aiSessionsAndBeaconsResult{
|
||||
Sessions: make([]aiSessionSummary, 0, len(sessionsResp.GetSessions())),
|
||||
Beacons: make([]aiBeaconSummary, 0, len(beaconsResp.GetBeacons())),
|
||||
}
|
||||
|
||||
for _, session := range sessionsResp.GetSessions() {
|
||||
if session == nil {
|
||||
continue
|
||||
}
|
||||
result.Sessions = append(result.Sessions, aiSessionSummary{
|
||||
ID: session.ID,
|
||||
Name: session.Name,
|
||||
Transport: session.Transport,
|
||||
RemoteAddress: session.RemoteAddress,
|
||||
Hostname: session.Hostname,
|
||||
Username: session.Username,
|
||||
PID: session.PID,
|
||||
OS: session.OS,
|
||||
Arch: session.Arch,
|
||||
Locale: session.Locale,
|
||||
LastCheckin: session.LastCheckin,
|
||||
IsDead: session.IsDead,
|
||||
Integrity: session.Integrity,
|
||||
})
|
||||
}
|
||||
|
||||
for _, beacon := range beaconsResp.GetBeacons() {
|
||||
if beacon == nil {
|
||||
continue
|
||||
}
|
||||
result.Beacons = append(result.Beacons, aiBeaconSummary{
|
||||
ID: beacon.ID,
|
||||
Name: beacon.Name,
|
||||
Transport: beacon.Transport,
|
||||
RemoteAddress: beacon.RemoteAddress,
|
||||
Hostname: beacon.Hostname,
|
||||
Username: beacon.Username,
|
||||
PID: beacon.PID,
|
||||
OS: beacon.OS,
|
||||
Arch: beacon.Arch,
|
||||
Locale: beacon.Locale,
|
||||
LastCheckin: beacon.LastCheckin,
|
||||
NextCheckin: beacon.NextCheckin,
|
||||
Interval: beacon.Interval,
|
||||
Jitter: beacon.Jitter,
|
||||
TasksCount: beacon.TasksCount,
|
||||
TasksCountCompleted: beacon.TasksCountCompleted,
|
||||
IsDead: beacon.IsDead,
|
||||
Integrity: beacon.Integrity,
|
||||
})
|
||||
}
|
||||
|
||||
result.SessionsCount = len(result.Sessions)
|
||||
result.BeaconsCount = len(result.Beacons)
|
||||
return marshalAIToolResult(result)
|
||||
}
|
||||
|
||||
func (e *aiToolExecutor) callLS(ctx context.Context, args aiLSToolArgs) (string, error) {
|
||||
target, err := e.resolveTarget(args.SessionID, args.BeaconID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if strings.TrimSpace(args.Path) == "" {
|
||||
args.Path = "."
|
||||
}
|
||||
|
||||
req, isBeacon, callCtx, cancel, err := e.buildRequestContext(ctx, target)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
resp, err := e.rpc.Ls(callCtx, &sliverpb.LsReq{
|
||||
Request: req,
|
||||
Path: args.Path,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := aiGenericResponseError(resp.Response); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if isBeacon && resp.Response != nil && resp.Response.Async {
|
||||
resolved := &sliverpb.Ls{}
|
||||
if err := waitForAIBeaconTaskResponse(callCtx, resp.Response.TaskID, resolved); err != nil {
|
||||
return "", err
|
||||
}
|
||||
resp = resolved
|
||||
if err := aiGenericResponseError(resp.Response); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
result := aiLSResult{
|
||||
Path: resp.Path,
|
||||
Exists: resp.Exists,
|
||||
Timezone: resp.Timezone,
|
||||
TimezoneOffset: resp.TimezoneOffset,
|
||||
Files: make([]aiLSFileEntry, 0, len(resp.Files)),
|
||||
}
|
||||
for _, fileInfo := range resp.Files {
|
||||
if fileInfo == nil {
|
||||
continue
|
||||
}
|
||||
result.Files = append(result.Files, aiLSFileEntry{
|
||||
Name: fileInfo.Name,
|
||||
IsDir: fileInfo.IsDir,
|
||||
Size: fileInfo.Size,
|
||||
ModTime: fileInfo.ModTime,
|
||||
Mode: fileInfo.Mode,
|
||||
Link: fileInfo.Link,
|
||||
UID: fileInfo.Uid,
|
||||
GID: fileInfo.Gid,
|
||||
})
|
||||
}
|
||||
return marshalAIToolResult(result)
|
||||
}
|
||||
|
||||
func (e *aiToolExecutor) callCat(ctx context.Context, args aiCatToolArgs) (string, error) {
|
||||
target, err := e.resolveTarget(args.SessionID, args.BeaconID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if strings.TrimSpace(args.Path) == "" {
|
||||
return "", fmt.Errorf("path is required")
|
||||
}
|
||||
|
||||
req, isBeacon, callCtx, cancel, err := e.buildRequestContext(ctx, target)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
resp, err := e.rpc.Download(callCtx, &sliverpb.DownloadReq{
|
||||
Request: req,
|
||||
RestrictedToFile: true,
|
||||
Path: args.Path,
|
||||
MaxBytes: args.MaxBytes,
|
||||
MaxLines: args.MaxLines,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := aiGenericResponseError(resp.Response); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if isBeacon && resp.Response != nil && resp.Response.Async {
|
||||
resolved := &sliverpb.Download{}
|
||||
if err := waitForAIBeaconTaskResponse(callCtx, resp.Response.TaskID, resolved); err != nil {
|
||||
return "", err
|
||||
}
|
||||
resp = resolved
|
||||
if err := aiGenericResponseError(resp.Response); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
result, err := buildAICatResult(resp, args.Path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return marshalAIToolResult(result)
|
||||
}
|
||||
|
||||
func (e *aiToolExecutor) callPWD(ctx context.Context, args aiTargetArgs) (string, error) {
|
||||
target, err := e.resolveTarget(args.SessionID, args.BeaconID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
req, isBeacon, callCtx, cancel, err := e.buildRequestContext(ctx, target)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
resp, err := e.rpc.Pwd(callCtx, &sliverpb.PwdReq{Request: req})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := aiGenericResponseError(resp.Response); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if isBeacon && resp.Response != nil && resp.Response.Async {
|
||||
resolved := &sliverpb.Pwd{}
|
||||
if err := waitForAIBeaconTaskResponse(callCtx, resp.Response.TaskID, resolved); err != nil {
|
||||
return "", err
|
||||
}
|
||||
resp = resolved
|
||||
if err := aiGenericResponseError(resp.Response); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
return marshalAIToolResult(aiPathResult{Path: resp.Path})
|
||||
}
|
||||
|
||||
type aiToolTarget struct {
|
||||
SessionID string
|
||||
BeaconID string
|
||||
}
|
||||
|
||||
func (e *aiToolExecutor) resolveTarget(sessionID, beaconID string) (aiToolTarget, error) {
|
||||
sessionID = strings.TrimSpace(sessionID)
|
||||
beaconID = strings.TrimSpace(beaconID)
|
||||
if sessionID != "" && beaconID != "" {
|
||||
return aiToolTarget{}, fmt.Errorf("provide only one of session_id or beacon_id")
|
||||
}
|
||||
if sessionID == "" && beaconID == "" && e != nil && e.conversation != nil {
|
||||
sessionID = strings.TrimSpace(e.conversation.GetTargetSessionID())
|
||||
beaconID = strings.TrimSpace(e.conversation.GetTargetBeaconID())
|
||||
}
|
||||
if sessionID == "" && beaconID == "" {
|
||||
return aiToolTarget{}, fmt.Errorf("session_id or beacon_id is required; call list_sessions_and_beacons or start a conversation from an active target")
|
||||
}
|
||||
return aiToolTarget{
|
||||
SessionID: sessionID,
|
||||
BeaconID: beaconID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (e *aiToolExecutor) buildRequestContext(ctx context.Context, target aiToolTarget) (*commonpb.Request, bool, context.Context, context.CancelFunc, error) {
|
||||
if strings.TrimSpace(target.SessionID) != "" && strings.TrimSpace(target.BeaconID) != "" {
|
||||
return nil, false, nil, nil, fmt.Errorf("provide only one of session_id or beacon_id")
|
||||
}
|
||||
|
||||
timeoutSeconds := aiToolDefaultWaitSeconds
|
||||
req := &commonpb.Request{
|
||||
Timeout: (int64(time.Second) * timeoutSeconds) - 1,
|
||||
}
|
||||
isBeacon := false
|
||||
|
||||
if strings.TrimSpace(target.SessionID) != "" {
|
||||
req.SessionID = strings.TrimSpace(target.SessionID)
|
||||
req.Async = false
|
||||
} else {
|
||||
req.BeaconID = strings.TrimSpace(target.BeaconID)
|
||||
req.Async = true
|
||||
isBeacon = true
|
||||
}
|
||||
|
||||
callCtx, cancel := context.WithTimeout(ctx, time.Duration(timeoutSeconds)*time.Second)
|
||||
return req, isBeacon, callCtx, cancel, nil
|
||||
}
|
||||
|
||||
func decodeAIToolArgs(raw string, out any) error {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
raw = "{}"
|
||||
}
|
||||
if err := json.Unmarshal([]byte(raw), out); err != nil {
|
||||
return fmt.Errorf("invalid tool arguments: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func marshalAIToolResult(result any) (string, error) {
|
||||
data, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func aiGenericResponseError(resp *commonpb.Response) error {
|
||||
if resp == nil || strings.TrimSpace(resp.Err) == "" {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%s", strings.TrimSpace(resp.Err))
|
||||
}
|
||||
|
||||
func waitForAIBeaconTaskResponse(ctx context.Context, taskID string, out proto.Message) error {
|
||||
task, err := waitForAIBeaconTask(ctx, taskID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
state := strings.ToLower(strings.TrimSpace(task.GetState()))
|
||||
if state != "completed" {
|
||||
if state == "" {
|
||||
state = "unknown"
|
||||
}
|
||||
return fmt.Errorf("beacon task %s %s", task.GetID(), state)
|
||||
}
|
||||
if len(task.GetResponse()) == 0 {
|
||||
return fmt.Errorf("beacon task %s returned empty response", task.GetID())
|
||||
}
|
||||
return proto.Unmarshal(task.GetResponse(), out)
|
||||
}
|
||||
|
||||
func waitForAIBeaconTask(ctx context.Context, taskID string) (*clientpb.BeaconTask, error) {
|
||||
ticker := time.NewTicker(time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
task, err := db.BeaconTaskByID(taskID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(task.GetState())) {
|
||||
case "completed", "failed", "canceled":
|
||||
return task, nil
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func buildAICatResult(download *sliverpb.Download, requestedPath string) (*aiCatResult, error) {
|
||||
data := download.Data
|
||||
decoded := false
|
||||
if download.Encoder == "gzip" {
|
||||
decoded = true
|
||||
var err error
|
||||
data, err = new(encoders.Gzip).Decode(download.Data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
result := &aiCatResult{
|
||||
Path: download.Path,
|
||||
RequestedPath: requestedPath,
|
||||
Exists: download.Exists,
|
||||
IsDir: download.IsDir,
|
||||
Encoder: download.Encoder,
|
||||
Decoded: decoded,
|
||||
ByteLen: len(data),
|
||||
DataBase64: base64.StdEncoding.EncodeToString(data),
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
if utf8.Valid(data) {
|
||||
result.Text = string(data)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
+99
-69
@@ -5,16 +5,14 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
consts "github.com/bishopfox/sliver/client/constants"
|
||||
"github.com/bishopfox/sliver/protobuf/clientpb"
|
||||
"github.com/bishopfox/sliver/protobuf/commonpb"
|
||||
serverai "github.com/bishopfox/sliver/server/ai"
|
||||
"github.com/bishopfox/sliver/server/configs"
|
||||
"github.com/bishopfox/sliver/server/core"
|
||||
"github.com/bishopfox/sliver/server/db"
|
||||
"github.com/gofrs/uuid"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
func (rpc *Server) aiOperatorName(ctx context.Context, fallback string) string {
|
||||
@@ -35,23 +33,6 @@ func validateAIProvider(provider string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func publishAIConversationEvent(conversation *clientpb.AIConversation) {
|
||||
if conversation == nil {
|
||||
return
|
||||
}
|
||||
|
||||
data, err := proto.Marshal(conversation)
|
||||
if err != nil {
|
||||
rpcLog.Warnf("Failed to marshal AI conversation event: %s", err)
|
||||
return
|
||||
}
|
||||
|
||||
core.EventBroker.Publish(core.Event{
|
||||
EventType: consts.AIConversationEvent,
|
||||
Data: data,
|
||||
})
|
||||
}
|
||||
|
||||
func (rpc *Server) GetAIProviders(ctx context.Context, _ *commonpb.Empty) (*clientpb.AIProviderConfigs, error) {
|
||||
return &clientpb.AIProviderConfigs{
|
||||
Providers: serverai.ConfiguredProviders(),
|
||||
@@ -92,7 +73,7 @@ func (rpc *Server) SaveAIConversation(ctx context.Context, req *clientpb.AIConve
|
||||
if err != nil {
|
||||
return nil, rpcError(err)
|
||||
}
|
||||
publishAIConversationEvent(conversation)
|
||||
publishAIConversationSummaryEvent(clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_CONVERSATION_UPDATED, conversation, "", "")
|
||||
return conversation, nil
|
||||
}
|
||||
|
||||
@@ -106,9 +87,13 @@ func (rpc *Server) DeleteAIConversation(ctx context.Context, req *clientpb.AICon
|
||||
if err != nil {
|
||||
return nil, rpcError(err)
|
||||
}
|
||||
publishAIConversationEvent(&clientpb.AIConversation{
|
||||
ID: req.ID,
|
||||
OperatorName: operatorName,
|
||||
publishAIConversationEvent(&clientpb.AIConversationEvent{
|
||||
EventType: clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_CONVERSATION_DELETED,
|
||||
ConversationID: req.ID,
|
||||
Conversation: &clientpb.AIConversation{
|
||||
ID: req.ID,
|
||||
OperatorName: operatorName,
|
||||
},
|
||||
})
|
||||
return &commonpb.Empty{}, nil
|
||||
}
|
||||
@@ -147,13 +132,19 @@ func (rpc *Server) SaveAIConversationMessage(ctx context.Context, req *clientpb.
|
||||
return nil, rpcError(err)
|
||||
}
|
||||
|
||||
conversation, err := db.AIConversationByID(message.ConversationID, "", false)
|
||||
conversation, err := loadAIConversationSummary(message.GetConversationID())
|
||||
if err != nil {
|
||||
return nil, rpcError(err)
|
||||
}
|
||||
publishAIConversationEvent(conversation)
|
||||
publishAIConversationMessageEvent(
|
||||
clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_MESSAGE_COMPLETED,
|
||||
conversation,
|
||||
message,
|
||||
message.GetTurnID(),
|
||||
message.GetErrorText(),
|
||||
)
|
||||
if shouldTriggerAICompletion(req, message) {
|
||||
go runAIConversationCompletion(message.GetConversationID(), message.GetOperatorName())
|
||||
go rpc.runAIConversationCompletion(message.GetConversationID(), message.GetOperatorName())
|
||||
}
|
||||
|
||||
return message, nil
|
||||
@@ -169,7 +160,7 @@ func shouldTriggerAICompletion(req *clientpb.AIConversationMessage, saved *clien
|
||||
return strings.EqualFold(strings.TrimSpace(saved.GetRole()), "user")
|
||||
}
|
||||
|
||||
func runAIConversationCompletion(conversationID string, operatorName string) {
|
||||
func (rpc *Server) runAIConversationCompletion(conversationID string, operatorName string) {
|
||||
conversation, err := db.AIConversationByID(conversationID, "", true)
|
||||
if err != nil {
|
||||
rpcLog.Warnf("Failed to load AI conversation %q for completion: %s", conversationID, err)
|
||||
@@ -184,18 +175,46 @@ func runAIConversationCompletion(conversationID string, operatorName string) {
|
||||
conversation = updatedConversation
|
||||
}
|
||||
}
|
||||
if resolveErr != nil {
|
||||
persistAIConversationFailure(conversationID, operatorName, runtime, resolveErr)
|
||||
return
|
||||
}
|
||||
|
||||
completion, err := serverai.CompleteConversation(context.Background(), runtime, conversation)
|
||||
turnID, err := newAITurnID()
|
||||
if err != nil {
|
||||
persistAIConversationFailure(conversationID, operatorName, runtime, err)
|
||||
rpcLog.Warnf("Failed to create AI turn id for %q: %s", conversationID, err)
|
||||
return
|
||||
}
|
||||
sink := newAIConversationEventSink(conversationID, operatorName, runtime, turnID)
|
||||
if err := sink.TurnStarted(); err != nil {
|
||||
rpcLog.Warnf("Failed to mark AI turn %q as started for %q: %s", turnID, conversationID, err)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := db.SaveAIConversationMessage(&clientpb.AIConversationMessage{
|
||||
if resolveErr != nil {
|
||||
persistAIConversationFailure(sink, resolveErr)
|
||||
if err := sink.TurnFailed(resolveErr); err != nil {
|
||||
rpcLog.Warnf("Failed to mark AI turn %q as failed for %q: %s", turnID, conversationID, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
var completion *serverai.Completion
|
||||
if serverai.SupportsAgenticConversation(runtime) {
|
||||
completion, err = serverai.CompleteConversationAgentic(
|
||||
context.Background(),
|
||||
runtime,
|
||||
conversation,
|
||||
newAIToolExecutor(rpc, conversation),
|
||||
sink,
|
||||
)
|
||||
} else {
|
||||
completion, err = serverai.CompleteConversation(context.Background(), runtime, conversation)
|
||||
}
|
||||
if err != nil {
|
||||
persistAIConversationFailure(sink, err)
|
||||
if turnErr := sink.TurnFailed(err); turnErr != nil {
|
||||
rpcLog.Warnf("Failed to mark AI turn %q as failed for %q: %s", turnID, conversationID, turnErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
message, err := db.SaveAIConversationMessage(&clientpb.AIConversationMessage{
|
||||
ConversationID: conversationID,
|
||||
OperatorName: operatorName,
|
||||
Provider: completion.Provider,
|
||||
@@ -204,12 +223,31 @@ func runAIConversationCompletion(conversationID string, operatorName string) {
|
||||
Content: completion.Content,
|
||||
ProviderMessageID: completion.ProviderMessageID,
|
||||
FinishReason: completion.FinishReason,
|
||||
}, operatorName); err != nil {
|
||||
Kind: clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_CHAT,
|
||||
Visibility: clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_CONTEXT,
|
||||
State: clientpb.AIConversationMessageState_AI_MESSAGE_STATE_COMPLETED,
|
||||
TurnID: turnID,
|
||||
}, operatorName)
|
||||
if err != nil {
|
||||
rpcLog.Warnf("Failed to save AI assistant reply for %q: %s", conversationID, err)
|
||||
return
|
||||
}
|
||||
|
||||
publishAIConversationByID(conversationID)
|
||||
summary, err := loadAIConversationSummary(conversationID)
|
||||
if err != nil {
|
||||
rpcLog.Warnf("Failed to load AI conversation summary for %q: %s", conversationID, err)
|
||||
} else {
|
||||
publishAIConversationMessageEvent(
|
||||
clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_MESSAGE_COMPLETED,
|
||||
summary,
|
||||
message,
|
||||
turnID,
|
||||
"",
|
||||
)
|
||||
}
|
||||
if err := sink.TurnCompleted(); err != nil {
|
||||
rpcLog.Warnf("Failed to mark AI turn %q as completed for %q: %s", turnID, conversationID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func persistAIConversationRuntime(conversation *clientpb.AIConversation, operatorName string, runtime *serverai.RuntimeConfig) (*clientpb.AIConversation, error) {
|
||||
@@ -227,49 +265,41 @@ func persistAIConversationRuntime(conversation *clientpb.AIConversation, operato
|
||||
}
|
||||
|
||||
return db.SaveAIConversation(&clientpb.AIConversation{
|
||||
ID: conversation.GetID(),
|
||||
OperatorName: conversation.GetOperatorName(),
|
||||
Provider: provider,
|
||||
Model: model,
|
||||
Title: conversation.GetTitle(),
|
||||
Summary: conversation.GetSummary(),
|
||||
SystemPrompt: conversation.GetSystemPrompt(),
|
||||
ID: conversation.GetID(),
|
||||
OperatorName: conversation.GetOperatorName(),
|
||||
Provider: provider,
|
||||
Model: model,
|
||||
Title: conversation.GetTitle(),
|
||||
Summary: conversation.GetSummary(),
|
||||
SystemPrompt: conversation.GetSystemPrompt(),
|
||||
ActiveTurnID: conversation.GetActiveTurnID(),
|
||||
TurnState: conversation.GetTurnState(),
|
||||
TargetSessionID: conversation.GetTargetSessionID(),
|
||||
TargetBeaconID: conversation.GetTargetBeaconID(),
|
||||
}, operatorName)
|
||||
}
|
||||
|
||||
func persistAIConversationFailure(conversationID string, operatorName string, runtime *serverai.RuntimeConfig, failure error) {
|
||||
func persistAIConversationFailure(sink *aiConversationEventSink, failure error) {
|
||||
content := "AI request failed."
|
||||
errText := ""
|
||||
if failure != nil && strings.TrimSpace(failure.Error()) != "" {
|
||||
content = "AI request failed: " + strings.TrimSpace(failure.Error())
|
||||
errText = strings.TrimSpace(failure.Error())
|
||||
content = "AI request failed: " + errText
|
||||
}
|
||||
|
||||
message := &clientpb.AIConversationMessage{
|
||||
ConversationID: conversationID,
|
||||
OperatorName: operatorName,
|
||||
Role: "system",
|
||||
Content: content,
|
||||
FinishReason: "error",
|
||||
}
|
||||
if runtime != nil {
|
||||
message.Provider = runtime.Provider
|
||||
message.Model = runtime.Model
|
||||
}
|
||||
|
||||
if _, err := db.SaveAIConversationMessage(message, operatorName); err != nil {
|
||||
rpcLog.Warnf("Failed to save AI failure message for %q: %s", conversationID, err)
|
||||
if sink == nil {
|
||||
return
|
||||
}
|
||||
|
||||
publishAIConversationByID(conversationID)
|
||||
if err := sink.SaveFailureMessage(content, errText); err != nil {
|
||||
rpcLog.Warnf("Failed to save AI failure message for %q: %s", sink.conversationID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func publishAIConversationByID(conversationID string) {
|
||||
conversation, err := db.AIConversationByID(conversationID, "", false)
|
||||
func newAITurnID() (string, error) {
|
||||
id, err := uuid.NewV4()
|
||||
if err != nil {
|
||||
rpcLog.Warnf("Failed to publish AI conversation update for %q: %s", conversationID, err)
|
||||
return
|
||||
return "", err
|
||||
}
|
||||
publishAIConversationEvent(conversation)
|
||||
return id.String(), nil
|
||||
}
|
||||
|
||||
func fallbackAIString(value string, fallback string) string {
|
||||
|
||||
+675
-14
@@ -2,6 +2,7 @@ package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
@@ -78,22 +79,59 @@ func TestSaveAIConversationMessageCompletesConversationAndPublishesEvents(t *tes
|
||||
|
||||
waitForAIConversationEvent(t, eventStream, conversation.GetID())
|
||||
|
||||
if _, err := client.SaveAIConversationMessage(context.Background(), &clientpb.AIConversationMessage{
|
||||
savedUserMessage, err := client.SaveAIConversationMessage(context.Background(), &clientpb.AIConversationMessage{
|
||||
ConversationID: conversation.GetID(),
|
||||
OperatorName: "alice",
|
||||
Role: "user",
|
||||
Content: "Walk me through the gRPC flow.",
|
||||
}); err != nil {
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("save conversation message: %v", err)
|
||||
}
|
||||
|
||||
current := waitForAIConversationRole(t, client, eventStream, conversation.GetID(), "assistant", 2)
|
||||
events := waitForAIConversationFlow(t, eventStream, conversation.GetID(), clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_TURN_COMPLETED)
|
||||
assertAIConversationEventSequence(t, events, []aiConversationEventExpectation{
|
||||
{
|
||||
eventType: clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_MESSAGE_COMPLETED,
|
||||
role: "user",
|
||||
kind: clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_CHAT,
|
||||
visibility: clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_CONTEXT,
|
||||
state: clientpb.AIConversationMessageState_AI_MESSAGE_STATE_COMPLETED,
|
||||
},
|
||||
{
|
||||
eventType: clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_TURN_STARTED,
|
||||
},
|
||||
{
|
||||
eventType: clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_MESSAGE_COMPLETED,
|
||||
role: "assistant",
|
||||
kind: clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_CHAT,
|
||||
visibility: clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_CONTEXT,
|
||||
state: clientpb.AIConversationMessageState_AI_MESSAGE_STATE_COMPLETED,
|
||||
},
|
||||
{
|
||||
eventType: clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_TURN_COMPLETED,
|
||||
},
|
||||
})
|
||||
if got := events[0].GetMessage().GetID(); got != savedUserMessage.GetID() {
|
||||
t.Fatalf("expected first flow event to reference saved user message %q, got %q", savedUserMessage.GetID(), got)
|
||||
}
|
||||
|
||||
current, err := client.GetAIConversation(context.Background(), &clientpb.AIConversationReq{
|
||||
ID: conversation.GetID(),
|
||||
IncludeMessages: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("refresh ai conversation: %v", err)
|
||||
}
|
||||
if len(current.GetMessages()) != 2 {
|
||||
t.Fatalf("unexpected message count: got=%d want=%d", len(current.GetMessages()), 2)
|
||||
}
|
||||
if current.GetMessages()[1].GetContent() != "Assistant reply from the provider" {
|
||||
t.Fatalf("unexpected assistant reply: %q", current.GetMessages()[1].GetContent())
|
||||
}
|
||||
if current.GetMessages()[1].GetProviderMessageID() != "resp_success" {
|
||||
t.Fatalf("unexpected provider message id: %q", current.GetMessages()[1].GetProviderMessageID())
|
||||
}
|
||||
if current.GetModel() != "gpt-5.2" {
|
||||
t.Fatalf("unexpected conversation model: %q", current.GetModel())
|
||||
}
|
||||
@@ -105,6 +143,111 @@ func TestSaveAIConversationMessageCompletesConversationAndPublishesEvents(t *tes
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveAIConversationMessageCompletesOpenAIWithoutExplicitBaseURL(t *testing.T) {
|
||||
setupAIRPCTestEnv(t)
|
||||
|
||||
requests := make(chan string, 1)
|
||||
restoreClient := serverai.SetHTTPClientForTests(&http.Client{
|
||||
Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
requests <- r.URL.Path
|
||||
return jsonResponse(http.StatusOK, `{
|
||||
"id": "resp_default_base",
|
||||
"model": "gpt-5.4",
|
||||
"status": "completed",
|
||||
"output": [
|
||||
{
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "output_text", "text": "Assistant reply from default OpenAI base URL"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`), nil
|
||||
}),
|
||||
})
|
||||
defer restoreClient()
|
||||
|
||||
saveOpenAICompletionConfig(t, "gpt-5.4", "high", "openai-key", "")
|
||||
|
||||
client, cleanup := newBufnetRPCClient(t)
|
||||
defer cleanup()
|
||||
|
||||
streamCtx, cancelStream := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancelStream()
|
||||
eventStream, err := client.Events(streamCtx, &commonpb.Empty{})
|
||||
if err != nil {
|
||||
t.Fatalf("start events stream: %v", err)
|
||||
}
|
||||
|
||||
conversation, err := client.SaveAIConversation(context.Background(), &clientpb.AIConversation{
|
||||
OperatorName: "alice",
|
||||
Provider: serverai.ProviderOpenAI,
|
||||
Title: "OpenAI default base URL",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("save conversation: %v", err)
|
||||
}
|
||||
|
||||
waitForAIConversationEvent(t, eventStream, conversation.GetID())
|
||||
|
||||
if _, err := client.SaveAIConversationMessage(context.Background(), &clientpb.AIConversationMessage{
|
||||
ConversationID: conversation.GetID(),
|
||||
OperatorName: "alice",
|
||||
Role: "user",
|
||||
Content: "Confirm the default OpenAI base URL path works.",
|
||||
}); err != nil {
|
||||
t.Fatalf("save conversation message: %v", err)
|
||||
}
|
||||
|
||||
events := waitForAIConversationFlow(t, eventStream, conversation.GetID(), clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_TURN_COMPLETED)
|
||||
assertAIConversationEventSequence(t, events, []aiConversationEventExpectation{
|
||||
{
|
||||
eventType: clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_MESSAGE_COMPLETED,
|
||||
role: "user",
|
||||
kind: clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_CHAT,
|
||||
visibility: clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_CONTEXT,
|
||||
state: clientpb.AIConversationMessageState_AI_MESSAGE_STATE_COMPLETED,
|
||||
},
|
||||
{
|
||||
eventType: clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_TURN_STARTED,
|
||||
},
|
||||
{
|
||||
eventType: clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_MESSAGE_COMPLETED,
|
||||
role: "assistant",
|
||||
kind: clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_CHAT,
|
||||
visibility: clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_CONTEXT,
|
||||
state: clientpb.AIConversationMessageState_AI_MESSAGE_STATE_COMPLETED,
|
||||
},
|
||||
{
|
||||
eventType: clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_TURN_COMPLETED,
|
||||
},
|
||||
})
|
||||
|
||||
current, err := client.GetAIConversation(context.Background(), &clientpb.AIConversationReq{
|
||||
ID: conversation.GetID(),
|
||||
IncludeMessages: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("refresh ai conversation: %v", err)
|
||||
}
|
||||
if len(current.GetMessages()) != 2 {
|
||||
t.Fatalf("unexpected message count: got=%d want=%d", len(current.GetMessages()), 2)
|
||||
}
|
||||
if current.GetMessages()[1].GetContent() != "Assistant reply from default OpenAI base URL" {
|
||||
t.Fatalf("unexpected assistant reply: %q", current.GetMessages()[1].GetContent())
|
||||
}
|
||||
|
||||
select {
|
||||
case gotPath := <-requests:
|
||||
if gotPath != "/v1/responses" {
|
||||
t.Fatalf("unexpected default-base provider path: got=%q want=%q", gotPath, "/v1/responses")
|
||||
}
|
||||
default:
|
||||
t.Fatal("expected the server to call the provider API with the default OpenAI base URL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveAIConversationMessagePublishesFailureMessageWhenProviderErrors(t *testing.T) {
|
||||
setupAIRPCTestEnv(t)
|
||||
|
||||
@@ -138,16 +281,50 @@ func TestSaveAIConversationMessagePublishesFailureMessageWhenProviderErrors(t *t
|
||||
|
||||
waitForAIConversationEvent(t, eventStream, conversation.GetID())
|
||||
|
||||
if _, err := client.SaveAIConversationMessage(context.Background(), &clientpb.AIConversationMessage{
|
||||
savedUserMessage, err := client.SaveAIConversationMessage(context.Background(), &clientpb.AIConversationMessage{
|
||||
ConversationID: conversation.GetID(),
|
||||
OperatorName: "alice",
|
||||
Role: "user",
|
||||
Content: "Trigger a provider error.",
|
||||
}); err != nil {
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("save conversation message: %v", err)
|
||||
}
|
||||
|
||||
current := waitForAIConversationRole(t, client, eventStream, conversation.GetID(), "system", 2)
|
||||
events := waitForAIConversationFlow(t, eventStream, conversation.GetID(), clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_TURN_FAILED)
|
||||
assertAIConversationEventSequence(t, events, []aiConversationEventExpectation{
|
||||
{
|
||||
eventType: clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_MESSAGE_COMPLETED,
|
||||
role: "user",
|
||||
kind: clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_CHAT,
|
||||
visibility: clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_CONTEXT,
|
||||
state: clientpb.AIConversationMessageState_AI_MESSAGE_STATE_COMPLETED,
|
||||
},
|
||||
{
|
||||
eventType: clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_TURN_STARTED,
|
||||
},
|
||||
{
|
||||
eventType: clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_MESSAGE_COMPLETED,
|
||||
role: "system",
|
||||
kind: clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_CHAT,
|
||||
visibility: clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_UI_ONLY,
|
||||
state: clientpb.AIConversationMessageState_AI_MESSAGE_STATE_FAILED,
|
||||
},
|
||||
{
|
||||
eventType: clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_TURN_FAILED,
|
||||
},
|
||||
})
|
||||
if got := events[0].GetMessage().GetID(); got != savedUserMessage.GetID() {
|
||||
t.Fatalf("expected first flow event to reference saved user message %q, got %q", savedUserMessage.GetID(), got)
|
||||
}
|
||||
|
||||
current, err := client.GetAIConversation(context.Background(), &clientpb.AIConversationReq{
|
||||
ID: conversation.GetID(),
|
||||
IncludeMessages: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("refresh ai conversation: %v", err)
|
||||
}
|
||||
if len(current.GetMessages()) != 2 {
|
||||
t.Fatalf("unexpected message count: got=%d want=%d", len(current.GetMessages()), 2)
|
||||
}
|
||||
@@ -158,6 +335,233 @@ func TestSaveAIConversationMessagePublishesFailureMessageWhenProviderErrors(t *t
|
||||
if lastMessage.GetFinishReason() != "error" {
|
||||
t.Fatalf("unexpected finish reason: %q", lastMessage.GetFinishReason())
|
||||
}
|
||||
if lastMessage.GetVisibility() != clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_UI_ONLY {
|
||||
t.Fatalf("expected failure message to stay out of the context window, got %+v", lastMessage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveAIConversationMessagePersistsReasoningAndToolBlocks(t *testing.T) {
|
||||
setupAIRPCTestEnv(t)
|
||||
|
||||
requestCount := 0
|
||||
restoreClient := serverai.SetHTTPClientForTests(&http.Client{
|
||||
Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Path != "/v1/responses" {
|
||||
t.Fatalf("unexpected provider request path: %q", r.URL.Path)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("decode provider request: %v", err)
|
||||
}
|
||||
|
||||
requestCount++
|
||||
switch requestCount {
|
||||
case 1:
|
||||
tools, ok := payload["tools"].([]any)
|
||||
if !ok || len(tools) == 0 {
|
||||
t.Fatalf("expected first request to advertise tools, got %#v", payload["tools"])
|
||||
}
|
||||
assertStrictResponseToolSchema(t, tools, "list_sessions_and_beacons", []string{}, map[string][]string{})
|
||||
assertStrictResponseToolSchema(t, tools, "fs_ls", []string{"beacon_id", "path", "session_id"}, map[string][]string{
|
||||
"beacon_id": []string{"string", "null"},
|
||||
"path": []string{"string", "null"},
|
||||
"session_id": []string{"string", "null"},
|
||||
})
|
||||
assertStrictResponseToolSchema(t, tools, "fs_cat", []string{"beacon_id", "max_bytes", "max_lines", "path", "session_id"}, map[string][]string{
|
||||
"beacon_id": []string{"string", "null"},
|
||||
"max_bytes": []string{"integer", "null"},
|
||||
"max_lines": []string{"integer", "null"},
|
||||
"path": []string{"string"},
|
||||
"session_id": []string{"string", "null"},
|
||||
})
|
||||
assertStrictResponseToolSchema(t, tools, "fs_pwd", []string{"beacon_id", "session_id"}, map[string][]string{
|
||||
"beacon_id": []string{"string", "null"},
|
||||
"session_id": []string{"string", "null"},
|
||||
})
|
||||
return jsonResponse(http.StatusOK, `{
|
||||
"id": "resp_step1",
|
||||
"model": "gpt-5.2",
|
||||
"status": "completed",
|
||||
"output": [
|
||||
{
|
||||
"id": "reasoning_1",
|
||||
"type": "reasoning",
|
||||
"summary": [
|
||||
{"type": "summary_text", "text": "Need to inspect the available targets first."}
|
||||
],
|
||||
"content": [
|
||||
{"type": "reasoning_text", "text": "Need to inspect the available targets first."}
|
||||
],
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"id": "call_item_1",
|
||||
"type": "function_call",
|
||||
"call_id": "call_1",
|
||||
"name": "list_sessions_and_beacons",
|
||||
"arguments": "{}",
|
||||
"status": "completed"
|
||||
}
|
||||
]
|
||||
}`), nil
|
||||
case 2:
|
||||
if payload["previous_response_id"] != "resp_step1" {
|
||||
t.Fatalf("expected previous_response_id to be chained, got %#v", payload["previous_response_id"])
|
||||
}
|
||||
input, ok := payload["input"].([]any)
|
||||
if !ok || len(input) != 1 {
|
||||
t.Fatalf("expected tool output input item, got %#v", payload["input"])
|
||||
}
|
||||
return jsonResponse(http.StatusOK, `{
|
||||
"id": "resp_step2",
|
||||
"model": "gpt-5.2",
|
||||
"status": "completed",
|
||||
"output": [
|
||||
{
|
||||
"id": "msg_1",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"status": "completed",
|
||||
"content": [
|
||||
{"type": "output_text", "text": "Assistant reply after a tool call"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`), nil
|
||||
default:
|
||||
t.Fatalf("unexpected provider request count %d", requestCount)
|
||||
return nil, nil
|
||||
}
|
||||
}),
|
||||
})
|
||||
defer restoreClient()
|
||||
|
||||
saveOpenAICompletionConfig(t, "gpt-5.2", "high", "openai-key", "https://openai.example/v1")
|
||||
|
||||
client, cleanup := newBufnetRPCClient(t)
|
||||
defer cleanup()
|
||||
|
||||
streamCtx, cancelStream := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancelStream()
|
||||
eventStream, err := client.Events(streamCtx, &commonpb.Empty{})
|
||||
if err != nil {
|
||||
t.Fatalf("start events stream: %v", err)
|
||||
}
|
||||
|
||||
conversation, err := client.SaveAIConversation(context.Background(), &clientpb.AIConversation{
|
||||
OperatorName: "alice",
|
||||
Provider: serverai.ProviderOpenAI,
|
||||
Title: "Agentic workflow",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("save conversation: %v", err)
|
||||
}
|
||||
waitForAIConversationEvent(t, eventStream, conversation.GetID())
|
||||
|
||||
savedUserMessage, err := client.SaveAIConversationMessage(context.Background(), &clientpb.AIConversationMessage{
|
||||
ConversationID: conversation.GetID(),
|
||||
OperatorName: "alice",
|
||||
Role: "user",
|
||||
Content: "Figure out what targets are available and summarize them.",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("save conversation message: %v", err)
|
||||
}
|
||||
|
||||
events := waitForAIConversationFlow(t, eventStream, conversation.GetID(), clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_TURN_COMPLETED)
|
||||
assertAIConversationEventSequence(t, events, []aiConversationEventExpectation{
|
||||
{
|
||||
eventType: clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_MESSAGE_COMPLETED,
|
||||
role: "user",
|
||||
kind: clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_CHAT,
|
||||
visibility: clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_CONTEXT,
|
||||
state: clientpb.AIConversationMessageState_AI_MESSAGE_STATE_COMPLETED,
|
||||
},
|
||||
{
|
||||
eventType: clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_TURN_STARTED,
|
||||
},
|
||||
{
|
||||
eventType: clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_MESSAGE_COMPLETED,
|
||||
role: "assistant",
|
||||
kind: clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_REASONING,
|
||||
visibility: clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_UI_ONLY,
|
||||
state: clientpb.AIConversationMessageState_AI_MESSAGE_STATE_COMPLETED,
|
||||
itemID: "reasoning_1",
|
||||
},
|
||||
{
|
||||
eventType: clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_MESSAGE_STARTED,
|
||||
role: "assistant",
|
||||
kind: clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_TOOL_CALL,
|
||||
visibility: clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_UI_ONLY,
|
||||
state: clientpb.AIConversationMessageState_AI_MESSAGE_STATE_IN_PROGRESS,
|
||||
itemID: "call_item_1",
|
||||
toolName: "list_sessions_and_beacons",
|
||||
},
|
||||
{
|
||||
eventType: clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_MESSAGE_COMPLETED,
|
||||
role: "assistant",
|
||||
kind: clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_TOOL_CALL,
|
||||
visibility: clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_UI_ONLY,
|
||||
state: clientpb.AIConversationMessageState_AI_MESSAGE_STATE_COMPLETED,
|
||||
itemID: "call_item_1",
|
||||
toolName: "list_sessions_and_beacons",
|
||||
},
|
||||
{
|
||||
eventType: clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_MESSAGE_COMPLETED,
|
||||
role: "assistant",
|
||||
kind: clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_CHAT,
|
||||
visibility: clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_CONTEXT,
|
||||
state: clientpb.AIConversationMessageState_AI_MESSAGE_STATE_COMPLETED,
|
||||
},
|
||||
{
|
||||
eventType: clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_TURN_COMPLETED,
|
||||
},
|
||||
})
|
||||
if got := events[0].GetMessage().GetID(); got != savedUserMessage.GetID() {
|
||||
t.Fatalf("expected first flow event to reference saved user message %q, got %q", savedUserMessage.GetID(), got)
|
||||
}
|
||||
|
||||
current, err := client.GetAIConversation(context.Background(), &clientpb.AIConversationReq{
|
||||
ID: conversation.GetID(),
|
||||
IncludeMessages: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("refresh ai conversation: %v", err)
|
||||
}
|
||||
if len(current.GetMessages()) != 4 {
|
||||
t.Fatalf("unexpected message count: got=%d want=%d", len(current.GetMessages()), 4)
|
||||
}
|
||||
|
||||
reasoning := current.GetMessages()[1]
|
||||
if reasoning.GetKind() != clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_REASONING {
|
||||
t.Fatalf("expected reasoning block, got %+v", reasoning)
|
||||
}
|
||||
if reasoning.GetVisibility() != clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_UI_ONLY {
|
||||
t.Fatalf("expected reasoning block to be UI-only, got %+v", reasoning)
|
||||
}
|
||||
|
||||
toolCall := current.GetMessages()[2]
|
||||
if toolCall.GetKind() != clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_TOOL_CALL {
|
||||
t.Fatalf("expected tool call block, got %+v", toolCall)
|
||||
}
|
||||
if toolCall.GetToolName() != "list_sessions_and_beacons" {
|
||||
t.Fatalf("unexpected tool name: %+v", toolCall)
|
||||
}
|
||||
if strings.TrimSpace(toolCall.GetToolResult()) == "" {
|
||||
t.Fatalf("expected tool result to be stored, got %+v", toolCall)
|
||||
}
|
||||
if toolCall.GetVisibility() != clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_UI_ONLY {
|
||||
t.Fatalf("expected tool call to be UI-only, got %+v", toolCall)
|
||||
}
|
||||
|
||||
reply := current.GetMessages()[3]
|
||||
if reply.GetContent() != "Assistant reply after a tool call" {
|
||||
t.Fatalf("unexpected assistant reply: %q", reply.GetContent())
|
||||
}
|
||||
if reply.GetVisibility() != clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_CONTEXT {
|
||||
t.Fatalf("expected assistant reply to remain in the context window, got %+v", reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIConversationsAreSharedAcrossOperators(t *testing.T) {
|
||||
@@ -253,7 +657,12 @@ func setupAIRPCTestEnv(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("open test db: %v", err)
|
||||
}
|
||||
if err := testDB.AutoMigrate(&models.AIConversation{}, &models.AIConversationMessage{}); err != nil {
|
||||
if err := testDB.AutoMigrate(
|
||||
&models.AIConversation{},
|
||||
&models.AIConversationMessage{},
|
||||
&models.Beacon{},
|
||||
&models.BeaconTask{},
|
||||
); err != nil {
|
||||
t.Fatalf("migrate test db: %v", err)
|
||||
}
|
||||
db.Client = testDB
|
||||
@@ -299,13 +708,13 @@ func waitForAIConversationEvent(t *testing.T, eventStream rpcpb.SliverRPC_Events
|
||||
continue
|
||||
}
|
||||
|
||||
conversation := &clientpb.AIConversation{}
|
||||
conversationEvent := &clientpb.AIConversationEvent{}
|
||||
if len(event.GetData()) > 0 {
|
||||
if err := proto.Unmarshal(event.GetData(), conversation); err != nil {
|
||||
if err := proto.Unmarshal(event.GetData(), conversationEvent); err != nil {
|
||||
t.Fatalf("unmarshal ai conversation event: %v", err)
|
||||
}
|
||||
}
|
||||
if conversation.GetID() == conversationID {
|
||||
if conversationEvent.GetConversationID() == conversationID {
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -324,13 +733,13 @@ func waitForAIConversationRole(t *testing.T, client rpcpb.SliverRPCClient, event
|
||||
continue
|
||||
}
|
||||
|
||||
conversation := &clientpb.AIConversation{}
|
||||
conversationEvent := &clientpb.AIConversationEvent{}
|
||||
if len(event.GetData()) > 0 {
|
||||
if err := proto.Unmarshal(event.GetData(), conversation); err != nil {
|
||||
if err := proto.Unmarshal(event.GetData(), conversationEvent); err != nil {
|
||||
t.Fatalf("unmarshal ai conversation event: %v", err)
|
||||
}
|
||||
}
|
||||
if conversation.GetID() != conversationID {
|
||||
if conversationEvent.GetConversationID() != conversationID {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -346,12 +755,264 @@ func waitForAIConversationRole(t *testing.T, client rpcpb.SliverRPCClient, event
|
||||
if len(messages) == 0 {
|
||||
continue
|
||||
}
|
||||
if messages[len(messages)-1].GetRole() == role && eventCount >= minEvents {
|
||||
if aiConversationHasRole(current, role) && eventCount >= minEvents {
|
||||
return current
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type aiConversationEventExpectation struct {
|
||||
eventType clientpb.AIConversationEventType
|
||||
role string
|
||||
kind clientpb.AIConversationMessageKind
|
||||
visibility clientpb.AIConversationMessageVisibility
|
||||
state clientpb.AIConversationMessageState
|
||||
itemID string
|
||||
toolName string
|
||||
}
|
||||
|
||||
func waitForAIConversationFlow(t *testing.T, eventStream rpcpb.SliverRPC_EventsClient, conversationID string, terminalType clientpb.AIConversationEventType) []*clientpb.AIConversationEvent {
|
||||
t.Helper()
|
||||
|
||||
events := []*clientpb.AIConversationEvent{}
|
||||
for {
|
||||
event, err := eventStream.Recv()
|
||||
if err != nil {
|
||||
t.Fatalf("receive ai conversation event: %v", err)
|
||||
}
|
||||
if event.GetEventType() != consts.AIConversationEvent {
|
||||
continue
|
||||
}
|
||||
|
||||
conversationEvent := &clientpb.AIConversationEvent{}
|
||||
if len(event.GetData()) > 0 {
|
||||
if err := proto.Unmarshal(event.GetData(), conversationEvent); err != nil {
|
||||
t.Fatalf("unmarshal ai conversation event: %v", err)
|
||||
}
|
||||
}
|
||||
if conversationEvent.GetConversationID() != conversationID {
|
||||
continue
|
||||
}
|
||||
|
||||
events = append(events, conversationEvent)
|
||||
if conversationEvent.GetEventType() == terminalType {
|
||||
return events
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertAIConversationEventSequence(t *testing.T, events []*clientpb.AIConversationEvent, expectations []aiConversationEventExpectation) {
|
||||
t.Helper()
|
||||
|
||||
if len(events) != len(expectations) {
|
||||
t.Fatalf("unexpected ai event count: got=%d want=%d\nflow:\n%s", len(events), len(expectations), formatAIConversationEventFlow(events))
|
||||
}
|
||||
|
||||
for idx, expectation := range expectations {
|
||||
event := events[idx]
|
||||
if event.GetEventType() != expectation.eventType {
|
||||
t.Fatalf("unexpected ai event[%d] type: got=%s want=%s\nflow:\n%s", idx, event.GetEventType(), expectation.eventType, formatAIConversationEventFlow(events))
|
||||
}
|
||||
if expectation.role == "" {
|
||||
if event.GetMessage() != nil {
|
||||
t.Fatalf("expected ai event[%d] to omit a message payload, got %+v", idx, event.GetMessage())
|
||||
}
|
||||
continue
|
||||
}
|
||||
message := event.GetMessage()
|
||||
if message == nil {
|
||||
t.Fatalf("expected ai event[%d] to include a message payload\nflow:\n%s", idx, formatAIConversationEventFlow(events))
|
||||
}
|
||||
if got := strings.ToLower(strings.TrimSpace(message.GetRole())); got != expectation.role {
|
||||
t.Fatalf("unexpected ai event[%d] role: got=%q want=%q\nflow:\n%s", idx, got, expectation.role, formatAIConversationEventFlow(events))
|
||||
}
|
||||
if message.GetKind() != expectation.kind {
|
||||
t.Fatalf("unexpected ai event[%d] kind: got=%s want=%s\nflow:\n%s", idx, message.GetKind(), expectation.kind, formatAIConversationEventFlow(events))
|
||||
}
|
||||
if message.GetVisibility() != expectation.visibility {
|
||||
t.Fatalf("unexpected ai event[%d] visibility: got=%s want=%s\nflow:\n%s", idx, message.GetVisibility(), expectation.visibility, formatAIConversationEventFlow(events))
|
||||
}
|
||||
if message.GetState() != expectation.state {
|
||||
t.Fatalf("unexpected ai event[%d] state: got=%s want=%s\nflow:\n%s", idx, message.GetState(), expectation.state, formatAIConversationEventFlow(events))
|
||||
}
|
||||
if expectation.itemID != "" && strings.TrimSpace(message.GetItemID()) != expectation.itemID {
|
||||
t.Fatalf("unexpected ai event[%d] item id: got=%q want=%q\nflow:\n%s", idx, message.GetItemID(), expectation.itemID, formatAIConversationEventFlow(events))
|
||||
}
|
||||
if expectation.toolName != "" && strings.TrimSpace(message.GetToolName()) != expectation.toolName {
|
||||
t.Fatalf("unexpected ai event[%d] tool name: got=%q want=%q\nflow:\n%s", idx, message.GetToolName(), expectation.toolName, formatAIConversationEventFlow(events))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func formatAIConversationEventFlow(events []*clientpb.AIConversationEvent) string {
|
||||
parts := make([]string, 0, len(events))
|
||||
for _, event := range events {
|
||||
if event == nil {
|
||||
parts = append(parts, "<nil>")
|
||||
continue
|
||||
}
|
||||
part := event.GetEventType().String()
|
||||
if message := event.GetMessage(); message != nil {
|
||||
part += " role=" + strings.TrimSpace(message.GetRole())
|
||||
part += " kind=" + message.GetKind().String()
|
||||
part += " visibility=" + message.GetVisibility().String()
|
||||
part += " state=" + message.GetState().String()
|
||||
if itemID := strings.TrimSpace(message.GetItemID()); itemID != "" {
|
||||
part += " item=" + itemID
|
||||
}
|
||||
if toolName := strings.TrimSpace(message.GetToolName()); toolName != "" {
|
||||
part += " tool=" + toolName
|
||||
}
|
||||
}
|
||||
parts = append(parts, part)
|
||||
}
|
||||
return strings.Join(parts, "\n")
|
||||
}
|
||||
|
||||
func assertStrictResponseToolSchema(t *testing.T, tools []any, name string, required []string, propertyTypes map[string][]string) {
|
||||
t.Helper()
|
||||
|
||||
schema := responseToolSchemaByName(t, tools, name)
|
||||
if strict, ok := schema["strict"].(bool); !ok || !strict {
|
||||
t.Fatalf("expected tool %q to enable strict mode, got %#v", name, schema["strict"])
|
||||
}
|
||||
|
||||
parameters, ok := schema["parameters"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected tool %q parameters map, got %#v", name, schema["parameters"])
|
||||
}
|
||||
if additionalProperties, ok := parameters["additionalProperties"].(bool); !ok || additionalProperties {
|
||||
t.Fatalf("expected tool %q to disable additionalProperties, got %#v", name, parameters["additionalProperties"])
|
||||
}
|
||||
assertJSONSchemaStringSet(t, name+" required", parameters["required"], required)
|
||||
|
||||
properties, ok := parameters["properties"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected tool %q properties map, got %#v", name, parameters["properties"])
|
||||
}
|
||||
if len(properties) != len(propertyTypes) {
|
||||
t.Fatalf("unexpected tool %q property count: got=%d want=%d", name, len(properties), len(propertyTypes))
|
||||
}
|
||||
for propertyName, expectedTypes := range propertyTypes {
|
||||
property, ok := properties[propertyName].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected tool %q property %q to be a schema object, got %#v", name, propertyName, properties[propertyName])
|
||||
}
|
||||
assertJSONSchemaStringSet(t, name+"."+propertyName+" type", property["type"], expectedTypes)
|
||||
}
|
||||
}
|
||||
|
||||
func responseToolSchemaByName(t *testing.T, tools []any, name string) map[string]any {
|
||||
t.Helper()
|
||||
|
||||
for _, rawTool := range tools {
|
||||
tool, ok := rawTool.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(anyStringValue(tool["name"])) == name {
|
||||
return tool
|
||||
}
|
||||
}
|
||||
t.Fatalf("expected tools payload to include %q, got %#v", name, tools)
|
||||
return nil
|
||||
}
|
||||
|
||||
func assertJSONSchemaStringSet(t *testing.T, label string, raw any, want []string) {
|
||||
t.Helper()
|
||||
|
||||
got := jsonStringSet(raw)
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("unexpected %s count: got=%d want=%d raw=%#v", label, len(got), len(want), raw)
|
||||
}
|
||||
for _, expected := range want {
|
||||
if !got[expected] {
|
||||
t.Fatalf("expected %s to contain %q, got %#v", label, expected, raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func jsonStringSet(raw any) map[string]bool {
|
||||
values := map[string]bool{}
|
||||
switch typed := raw.(type) {
|
||||
case []any:
|
||||
for _, rawValue := range typed {
|
||||
if value, ok := rawValue.(string); ok {
|
||||
value = strings.TrimSpace(value)
|
||||
if value != "" {
|
||||
values[value] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
case []string:
|
||||
for _, value := range typed {
|
||||
value = strings.TrimSpace(value)
|
||||
if value != "" {
|
||||
values[value] = true
|
||||
}
|
||||
}
|
||||
case string:
|
||||
value := strings.TrimSpace(typed)
|
||||
if value != "" {
|
||||
values[value] = true
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func anyStringValue(raw any) string {
|
||||
value, _ := raw.(string)
|
||||
return value
|
||||
}
|
||||
|
||||
func aiConversationHasRole(conversation *clientpb.AIConversation, role string) bool {
|
||||
if conversation == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
role = strings.TrimSpace(strings.ToLower(role))
|
||||
messages := conversation.GetMessages()
|
||||
for i := len(messages) - 1; i >= 0; i-- {
|
||||
message := messages[i]
|
||||
if message == nil || strings.TrimSpace(strings.ToLower(message.GetRole())) != role {
|
||||
continue
|
||||
}
|
||||
if role == "assistant" {
|
||||
return message.GetKind() == clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_CHAT &&
|
||||
message.GetVisibility() == clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_CONTEXT
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func waitForAIConversationTurnEvent(t *testing.T, eventStream rpcpb.SliverRPC_EventsClient, conversationID string, wantType clientpb.AIConversationEventType) {
|
||||
t.Helper()
|
||||
|
||||
for {
|
||||
event, err := eventStream.Recv()
|
||||
if err != nil {
|
||||
t.Fatalf("receive ai conversation event: %v", err)
|
||||
}
|
||||
if event.GetEventType() != consts.AIConversationEvent {
|
||||
continue
|
||||
}
|
||||
|
||||
conversationEvent := &clientpb.AIConversationEvent{}
|
||||
if len(event.GetData()) > 0 {
|
||||
if err := proto.Unmarshal(event.GetData(), conversationEvent); err != nil {
|
||||
t.Fatalf("unmarshal ai conversation event: %v", err)
|
||||
}
|
||||
}
|
||||
if conversationEvent.GetConversationID() != conversationID {
|
||||
continue
|
||||
}
|
||||
if conversationEvent.GetEventType() == wantType {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
|
||||
Reference in new issue
Block a user