Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
502bedc
Refactor prompts: local types and builder
batuhan Mar 29, 2026
ca6763c
Refactor agent defaults and prompt builders
batuhan Mar 29, 2026
bd9bb1e
Introduce PromptContext abstraction and refactor usage
batuhan Mar 29, 2026
ca8d793
Migrate prompts to PromptMessage API
batuhan Mar 29, 2026
ac2d869
Remove unused OpenAI imports; replace slices import
batuhan Mar 29, 2026
ba9fe0e
Delete prompt-flow.html
batuhan Mar 29, 2026
63fb13a
Resolve default command prefixes and add tests
batuhan Mar 29, 2026
66eeb41
Add LoginCredentials; rename prompt helpers
batuhan Mar 29, 2026
cb875a7
Refactor AI bridge helpers and media calls
batuhan Mar 29, 2026
0ee13bc
Update prompt_context_ops.go
batuhan Mar 29, 2026
fc457fe
Remove OpenAI chat provider and update AI helpers
batuhan Mar 29, 2026
61a72f2
Refactor integration types and introduce generics
batuhan Mar 29, 2026
da8935a
Refactor integration types and introduce generics
batuhan Mar 29, 2026
c97b2fd
Use slices.Clone and simplify SystemPrompt trim
batuhan Mar 29, 2026
b722e26
Snapshot pending events and refine tool selection
batuhan Mar 29, 2026
572fd21
Drop thinking blocks and stop follow-up continuations
batuhan Mar 29, 2026
07c8555
Honor pending queue; disable MagicProxy image API
batuhan Mar 30, 2026
7966e20
Regenerate AI model manifest and allowlist
batuhan Mar 30, 2026
dc94f1b
Add Gemma 2 27B model (google/gemma-2-27b-it)
batuhan Mar 30, 2026
712e442
Update beeper_models_manifest_test.go
batuhan Mar 30, 2026
e60b341
Extract follow-up test helper; remove unused wrappers
batuhan Mar 30, 2026
9d20093
Improve robustness and add tests across AI bridges
batuhan Mar 30, 2026
93ca4f3
Make pending queue mutex-safe; normalize models
batuhan Mar 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions bridges/ai/agent_loop_request_builders.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package ai

import (
"context"
"strings"

"github.com/openai/openai-go/v3"
"github.com/openai/openai-go/v3/packages/param"
Expand All @@ -16,7 +17,6 @@ type agentLoopRequestSettings struct {
model string
maxTokens int
temperature *float64
systemPrompt string
reasoningEffort string
}

Expand All @@ -25,7 +25,6 @@ func (oc *AIClient) buildAgentLoopRequestSettings(meta *PortalMetadata) agentLoo
model: oc.effectiveModelForAPI(meta),
maxTokens: oc.effectiveMaxTokens(meta),
temperature: oc.effectiveTemperature(meta),
systemPrompt: oc.effectivePrompt(meta),
reasoningEffort: oc.effectiveReasoningEffort(meta),
}
}
Expand Down Expand Up @@ -101,6 +100,7 @@ func (oc *AIClient) buildChatCompletionsAgentLoopParams(
func (oc *AIClient) buildResponsesAgentLoopParams(
ctx context.Context,
meta *PortalMetadata,
systemPrompt string,
input responses.ResponseInputParam,
allowResolvedBossAgent bool,
) responses.ResponseNewParams {
Expand All @@ -119,8 +119,8 @@ func (oc *AIClient) buildResponsesAgentLoopParams(
if settings.temperature != nil {
params.Temperature = openai.Float(*settings.temperature)
}
if settings.systemPrompt != "" {
params.Instructions = openai.String(settings.systemPrompt)
if trimmed := strings.TrimSpace(systemPrompt); trimmed != "" {
params.Instructions = openai.String(trimmed)
}
if effort, ok := reasoningEffortMap[settings.reasoningEffort]; ok {
params.Reasoning = shared.ReasoningParam{
Expand Down
4 changes: 2 additions & 2 deletions bridges/ai/agent_loop_request_builders_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ func TestAgentLoopRequestBuildersShareModelAndTokenSettings(t *testing.T) {
chatParams := oc.buildChatCompletionsAgentLoopParams(context.Background(), meta, []openai.ChatCompletionMessageParamUnion{
openai.UserMessage("hello"),
})
responsesParams := oc.buildResponsesAgentLoopParams(context.Background(), meta, nil, false)
responsesParams := oc.buildResponsesAgentLoopParams(context.Background(), meta, "system prompt", nil, false)

if chatParams.Model != "openai/gpt-5.2" {
t.Fatalf("expected chat model openai/gpt-5.2, got %q", chatParams.Model)
Expand Down Expand Up @@ -96,7 +96,7 @@ func TestAgentLoopRequestBuildersPreserveExplicitZeroTemperature(t *testing.T) {
chatParams := oc.buildChatCompletionsAgentLoopParams(context.Background(), meta, []openai.ChatCompletionMessageParamUnion{
openai.UserMessage("hello"),
})
responsesParams := oc.buildResponsesAgentLoopParams(context.Background(), meta, nil, false)
responsesParams := oc.buildResponsesAgentLoopParams(context.Background(), meta, "system prompt", nil, false)

if !chatParams.Temperature.Valid() || chatParams.Temperature.Value != 0 {
t.Fatalf("expected explicit zero chat temperature, got %#v", chatParams.Temperature)
Expand Down
10 changes: 1 addition & 9 deletions bridges/ai/agent_loop_routing_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@ import (
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/database"
"maunium.net/go/mautrix/bridgev2/networkid"

bridgesdk "github.com/beeper/agentremote/sdk"
)

func newAgentLoopRoutingTestClient(models ...ModelInfo) *AIClient {
Expand Down Expand Up @@ -45,13 +43,7 @@ func TestSelectAgentLoopRunFunc_UsesChatCompletionsForUnsupportedResponsesPrompt
API: string(ModelAPIResponses),
})

promptContext := PromptContext{
PromptContext: bridgesdk.UserPromptContext(bridgesdk.PromptBlock{
Type: bridgesdk.PromptBlockAudio,
AudioB64: "YXVkaW8=",
AudioFormat: "mp3",
}),
}
promptContext := UserPromptContext(PromptBlock{Type: PromptBlockType("unknown")})

responseFn, logLabel := oc.selectAgentLoopRunFunc(resolvedModelMeta("openai/gpt-4.1"), promptContext)
if responseFn == nil {
Expand Down
155 changes: 101 additions & 54 deletions bridges/ai/agent_loop_steering_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,29 @@ import (
airuntime "github.com/beeper/agentremote/pkg/runtime"
)

func getFollowUpMessagesForTest(oc *AIClient, roomID id.RoomID) []PromptMessage {
if oc == nil || roomID == "" {
return nil
}
snapshot := oc.getQueueSnapshot(roomID)
if snapshot == nil {
return nil
}
behavior := airuntime.ResolveQueueBehavior(snapshot.mode)
if !behavior.Followup {
return nil
}
candidate, _ := oc.takePendingQueueDispatchCandidate(roomID, true)
if candidate == nil || len(candidate.items) == 0 {
return nil
}
_, prompt, ok := preparePendingQueueDispatchCandidate(candidate)
if !ok {
return nil
}
return buildSteeringPromptMessages([]string{prompt})
}

func TestGetSteeringMessages_FiltersAndDrainsQueue(t *testing.T) {
roomID := id.RoomID("!room:example.com")
oc := &AIClient{
Expand Down Expand Up @@ -53,15 +76,15 @@ func TestGetSteeringMessages_FiltersAndDrainsQueue(t *testing.T) {
}

func TestBuildSteeringUserMessages(t *testing.T) {
got := buildSteeringUserMessages([]string{"first", " ", "second"})
got := buildSteeringPromptMessages([]string{"first", " ", "second"})
if len(got) != 2 {
t.Fatalf("expected 2 steering user messages, got %d", len(got))
t.Fatalf("expected 2 steering prompt messages, got %d", len(got))
}
if got[0].OfUser == nil || got[0].OfUser.Content.OfString.Value != "first" {
t.Fatalf("unexpected first steering user message: %#v", got[0])
if got[0].Role != PromptRoleUser || got[0].Text() != "first" {
t.Fatalf("unexpected first steering prompt message: %#v", got[0])
}
if got[1].OfUser == nil || got[1].OfUser.Content.OfString.Value != "second" {
t.Fatalf("unexpected second steering user message: %#v", got[1])
if got[1].Role != PromptRoleUser || got[1].Text() != "second" {
t.Fatalf("unexpected second steering prompt message: %#v", got[1])
}
}

Expand All @@ -78,8 +101,8 @@ func TestGetFollowUpMessages_ConsumesSingleQueuedTextMessage(t *testing.T) {
},
}

messages := oc.getFollowUpMessages(roomID)
if len(messages) != 1 || messages[0].OfUser == nil || messages[0].OfUser.Content.OfString.Value != "follow up" {
messages := getFollowUpMessagesForTest(oc, roomID)
if len(messages) != 1 || messages[0].Role != PromptRoleUser || messages[0].Text() != "follow up" {
t.Fatalf("unexpected follow-up messages: %#v", messages)
}
if snapshot := oc.getQueueSnapshot(roomID); snapshot != nil {
Expand All @@ -101,12 +124,12 @@ func TestGetFollowUpMessages_CollectsQueuedTextMessages(t *testing.T) {
},
}

messages := oc.getFollowUpMessages(roomID)
if len(messages) != 1 || messages[0].OfUser == nil {
messages := getFollowUpMessagesForTest(oc, roomID)
if len(messages) != 1 || messages[0].Role != PromptRoleUser {
t.Fatalf("expected one combined follow-up message, got %#v", messages)
}
if messages[0].OfUser.Content.OfString.Value != "[Queued messages while agent was busy]\n\n---\nQueued #1\nfirst\n\n---\nQueued #2\nsecond" {
t.Fatalf("unexpected combined follow-up prompt: %q", messages[0].OfUser.Content.OfString.Value)
if messages[0].Text() != "[Queued messages while agent was busy]\n\n---\nQueued #1\nfirst\n\n---\nQueued #2\nsecond" {
t.Fatalf("unexpected combined follow-up prompt: %q", messages[0].Text())
}
}

Expand All @@ -127,15 +150,15 @@ func TestGetFollowUpMessages_CollectSummaryIsConsumed(t *testing.T) {
},
}

messages := oc.getFollowUpMessages(roomID)
if len(messages) != 1 || messages[0].OfUser == nil {
messages := getFollowUpMessagesForTest(oc, roomID)
if len(messages) != 1 || messages[0].Role != PromptRoleUser {
t.Fatalf("expected one combined follow-up message, got %#v", messages)
}
if messages[0].OfUser.Content.OfString.Value != "[Queued messages while agent was busy]\n\n[Queue overflow] Dropped 2 messages due to cap.\nSummary:\n- older one\n- older two\n\n---\nQueued #1\nfirst\n\n---\nQueued #2\nsecond" {
t.Fatalf("unexpected combined follow-up prompt with summary: %q", messages[0].OfUser.Content.OfString.Value)
if messages[0].Text() != "[Queued messages while agent was busy]\n\n[Queue overflow] Dropped 2 messages due to cap.\nSummary:\n- older one\n- older two\n\n---\nQueued #1\nfirst\n\n---\nQueued #2\nsecond" {
t.Fatalf("unexpected combined follow-up prompt with summary: %q", messages[0].Text())
}

if again := oc.getFollowUpMessages(roomID); len(again) != 0 {
if again := getFollowUpMessagesForTest(oc, roomID); len(again) != 0 {
t.Fatalf("expected collect summary to be consumed, got %#v", again)
}
if snapshot := oc.getQueueSnapshot(roomID); snapshot != nil {
Expand All @@ -159,12 +182,12 @@ func TestGetFollowUpMessages_UsesSyntheticSummaryPrompt(t *testing.T) {
},
}

messages := oc.getFollowUpMessages(roomID)
if len(messages) != 1 || messages[0].OfUser == nil {
messages := getFollowUpMessagesForTest(oc, roomID)
if len(messages) != 1 || messages[0].Role != PromptRoleUser {
t.Fatalf("expected one synthetic follow-up message, got %#v", messages)
}
if messages[0].OfUser.Content.OfString.Value != "[Queue overflow] Dropped 2 messages due to cap.\nSummary:\n- older one\n- older two" {
t.Fatalf("unexpected synthetic follow-up prompt: %q", messages[0].OfUser.Content.OfString.Value)
if messages[0].Text() != "[Queue overflow] Dropped 2 messages due to cap.\nSummary:\n- older one\n- older two" {
t.Fatalf("unexpected synthetic follow-up prompt: %q", messages[0].Text())
}
}

Expand All @@ -184,23 +207,23 @@ func TestGetFollowUpMessages_SyntheticSummaryIsConsumedBeforeLatestMessage(t *te
},
}

first := oc.getFollowUpMessages(roomID)
if len(first) != 1 || first[0].OfUser == nil {
first := getFollowUpMessagesForTest(oc, roomID)
if len(first) != 1 || first[0].Role != PromptRoleUser {
t.Fatalf("expected one synthetic follow-up message, got %#v", first)
}
if first[0].OfUser.Content.OfString.Value != "[Queue overflow] Dropped 2 messages due to cap.\nSummary:\n- older one\n- older two" {
t.Fatalf("unexpected first synthetic follow-up prompt: %q", first[0].OfUser.Content.OfString.Value)
if first[0].Text() != "[Queue overflow] Dropped 2 messages due to cap.\nSummary:\n- older one\n- older two" {
t.Fatalf("unexpected first synthetic follow-up prompt: %q", first[0].Text())
}

second := oc.getFollowUpMessages(roomID)
if len(second) != 1 || second[0].OfUser == nil {
second := getFollowUpMessagesForTest(oc, roomID)
if len(second) != 1 || second[0].Role != PromptRoleUser {
t.Fatalf("expected queued latest message after summary, got %#v", second)
}
if second[0].OfUser.Content.OfString.Value != "latest" {
t.Fatalf("expected latest queued message after consuming summary, got %q", second[0].OfUser.Content.OfString.Value)
if second[0].Text() != "latest" {
t.Fatalf("expected latest queued message after consuming summary, got %q", second[0].Text())
}

if third := oc.getFollowUpMessages(roomID); len(third) != 0 {
if third := getFollowUpMessagesForTest(oc, roomID); len(third) != 0 {
t.Fatalf("expected queue to be drained after latest message, got %#v", third)
}
}
Expand All @@ -218,7 +241,7 @@ func TestGetFollowUpMessages_LeavesNonTextQueueItemsForBacklogProcessing(t *test
},
}

messages := oc.getFollowUpMessages(roomID)
messages := getFollowUpMessagesForTest(oc, roomID)
if len(messages) != 0 {
t.Fatalf("expected non-text follow-up to stay queued, got %#v", messages)
}
Expand All @@ -240,7 +263,7 @@ func TestGetFollowUpMessages_LeavesNonFollowupQueueUntouched(t *testing.T) {
},
}

messages := oc.getFollowUpMessages(roomID)
messages := getFollowUpMessagesForTest(oc, roomID)
if len(messages) != 0 {
t.Fatalf("expected no follow-up messages for non-followup mode, got %#v", messages)
}
Expand All @@ -251,30 +274,54 @@ func TestGetFollowUpMessages_LeavesNonFollowupQueueUntouched(t *testing.T) {

func TestBuildContinuationParams_UsesPendingSteeringPromptsBeforeDrainingQueue(t *testing.T) {
roomID := id.RoomID("!room:example.com")
oc := &AIClient{
connector: &OpenAIConnector{},
activeRoomRuns: map[id.RoomID]*roomRunState{
roomID: {
steerQueue: []pendingQueueItem{
{pending: pendingMessage{Type: pendingTypeText, MessageBody: "queue steer"}},
newClient := func() *AIClient {
return &AIClient{
connector: &OpenAIConnector{},
activeRoomRuns: map[id.RoomID]*roomRunState{
roomID: {
steerQueue: []pendingQueueItem{
{pending: pendingMessage{Type: pendingTypeText, MessageBody: "queue steer"}},
},
},
},
},
}
}
state := &streamingState{roomID: roomID}
state.addPendingSteeringPrompts([]string{"pending steer"})

params := oc.buildContinuationParams(context.Background(), state, nil, nil, nil)
if len(params.Input.OfInputItemList) == 0 {
t.Fatal("expected continuation input to include stored steering prompt")
}
if pending := state.consumePendingSteeringPrompts(); len(pending) != 0 {
t.Fatalf("expected pending steering prompts to be consumed, got %#v", pending)
}
if len(state.baseInput) == 0 {
t.Fatal("expected steering input to persist in base input even when history starts empty")
}
if snapshot := oc.getRoomRun(roomID); snapshot == nil || len(snapshot.steerQueue) != 1 {
t.Fatalf("expected queued steering item to remain available, got %#v", snapshot)
}
t.Run("non-nil prompt", func(t *testing.T) {
oc := newClient()
state := &streamingState{roomID: roomID}
state.addPendingSteeringPrompts([]string{"pending steer"})
prompt := PromptContext{}

params := oc.buildContinuationParams(context.Background(), &prompt, state, nil, nil, nil)
if len(params.Input.OfInputItemList) == 0 {
t.Fatal("expected continuation input to include stored steering prompt")
}
if pending := state.consumePendingSteeringPrompts(); len(pending) != 0 {
t.Fatalf("expected pending steering prompts to be consumed, got %#v", pending)
}
if len(prompt.Messages) == 0 {
t.Fatal("expected steering input to persist in canonical prompt even when history starts empty")
}
if snapshot := oc.getRoomRun(roomID); snapshot == nil || len(snapshot.steerQueue) != 1 {
t.Fatalf("expected queued steering item to remain available, got %#v", snapshot)
}
})

t.Run("nil prompt", func(t *testing.T) {
oc := newClient()
state := &streamingState{roomID: roomID}
state.addPendingSteeringPrompts([]string{"pending steer"})

params := oc.buildContinuationParams(context.Background(), nil, state, nil, nil, nil)
if len(params.Input.OfInputItemList) == 0 {
t.Fatal("expected continuation input to include stored steering prompt")
}
if pending := state.consumePendingSteeringPrompts(); len(pending) != 0 {
t.Fatalf("expected pending steering prompts to be consumed, got %#v", pending)
}
if snapshot := oc.getRoomRun(roomID); snapshot == nil || len(snapshot.steerQueue) != 1 {
t.Fatalf("expected queued steering item to remain available, got %#v", snapshot)
}
})
}
27 changes: 2 additions & 25 deletions bridges/ai/agent_loop_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,13 @@ import (
"errors"
"testing"

"github.com/openai/openai-go/v3"
"maunium.net/go/mautrix/event"
)

type fakeAgentLoopProvider struct {
track bool
results []fakeAgentLoopResult
followUps map[int][]openai.ChatCompletionMessageParamUnion
finalizeCalls int
continueCalls int
roundsObserved []int
}

Expand All @@ -41,19 +38,6 @@ func (f *fakeAgentLoopProvider) FinalizeAgentLoop(context.Context) {
f.finalizeCalls++
}

func (f *fakeAgentLoopProvider) GetFollowUpMessages(_ context.Context) []openai.ChatCompletionMessageParamUnion {
if len(f.roundsObserved) == 0 {
return nil
}
return f.followUps[f.roundsObserved[len(f.roundsObserved)-1]]
}

func (f *fakeAgentLoopProvider) ContinueAgentLoop(messages []openai.ChatCompletionMessageParamUnion) {
if len(messages) > 0 {
f.continueCalls++
}
}

func TestExecuteAgentLoopRoundsFinalizesOnTerminalTurn(t *testing.T) {
provider := &fakeAgentLoopProvider{
results: []fakeAgentLoopResult{
Expand Down Expand Up @@ -126,14 +110,10 @@ func TestExecuteAgentLoopRoundsStopsOnContextLengthWithFinalize(t *testing.T) {
}
}

func TestExecuteAgentLoopRoundsContinuesForFollowUpMessages(t *testing.T) {
func TestExecuteAgentLoopRoundsDoesNotInlineFollowUpMessages(t *testing.T) {
provider := &fakeAgentLoopProvider{
results: []fakeAgentLoopResult{
{continueLoop: false},
{continueLoop: false},
},
followUps: map[int][]openai.ChatCompletionMessageParamUnion{
0: {openai.UserMessage("follow up")},
},
}

Expand All @@ -147,13 +127,10 @@ func TestExecuteAgentLoopRoundsContinuesForFollowUpMessages(t *testing.T) {
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if provider.continueCalls != 1 {
t.Fatalf("expected one follow-up continuation, got %d", provider.continueCalls)
}
if provider.finalizeCalls != 1 {
t.Fatalf("expected finalize once, got %d", provider.finalizeCalls)
}
if len(provider.roundsObserved) != 2 || provider.roundsObserved[0] != 0 || provider.roundsObserved[1] != 1 {
if len(provider.roundsObserved) != 1 || provider.roundsObserved[0] != 0 {
t.Fatalf("unexpected rounds observed: %#v", provider.roundsObserved)
}
}
Loading
Loading