Compare commits

...
12 Commits
Author SHA1 Message Date
moloch-- 0acc6911d8 Tweak ai mode init 2026-03-26 16:59:27 -07:00
moloch-- 4ca026906b Merge master 2026-03-26 15:54:00 -07:00
moloch-- 97a249e28e Implement http auth for mcp 2026-03-26 15:32:26 -07:00
moloch-- 9f331687d3 wip on agentic actions 2026-03-24 19:06:47 -07:00
Joe 14d68d6fcd Merge pull request #2225 from BishopFox/ai/tui2
Ai/tui
2026-03-22 08:36:46 -07:00
moloch-- 05c5334fea Switch to openai sdk 2026-03-21 09:23:49 -07:00
moloch-- d277a14c57 Add examples dir 2026-03-21 08:29:27 -07:00
moloch-- a7b5dbe6ee Tweak colors for user blocks 2026-03-20 20:29:28 -07:00
moloch-- c188dc1e69 Make messages copy-able 2026-03-20 20:19:02 -07:00
moloch-- 9fc9d1762e Merge branch 'master' into ai/tui2 2026-03-20 20:00:05 -07:00
Joe 4ef86448d0 Merge pull request #2224 from BishopFox/clean-makefile
Cleanup makefile vars
2026-03-20 08:22:50 -07:00
moloch-- 4cd010afb9 wip on copy/paste 2026-03-19 08:52:46 -07:00
33 changed files with 6117 additions and 1123 deletions

No files matched your search

+2
View File
@@ -1,5 +1,7 @@
/crush
/examples
/.vscode
/.vscode/*
/.devcontainer
+24 -19
View File
@@ -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()),
+53 -23
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+321 -7
View File
@@ -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 {
+1
View File
@@ -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")
+29 -4
View File
@@ -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)
}
+6
View File
@@ -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)
+7
View File
@@ -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)
}
}
+191
View File
@@ -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)
}
+97
View File
@@ -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
View File
@@ -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
+229
View File
@@ -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
+5 -1
View File
@@ -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.
File diff suppressed because it is too large. Load diff
+58
View File
@@ -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
+554
View File
@@ -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)
}
+66
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+38
View File
@@ -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,
)
}
+312
View File
@@ -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
}
+37 -4
View File
@@ -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
+54
View File
@@ -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{
+42 -17
View File
@@ -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) {
+36
View File
@@ -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()
}
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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,
}
}
+302
View File
@@ -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
}
}
+576
View File
@@ -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
View File
@@ -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
View File
@@ -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) {