mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-27 07:16:19 +09:00
feat: add per-route message dispatch modes (inject/parallel/queue)
Introduce three inbound message handling modes for channel adapters: - inject (default, /btw): when a route has an active agent stream, inject the new user message into the running stream via the SDK's PrepareStep hook between tool rounds. The message is interleaved at the correct position in the persisted round. - parallel (/now): start a new agent stream immediately, running concurrently with any existing stream (preserves current behavior). - queue (/next): enqueue the message and process it after the current stream completes. Key components: - RouteDispatcher: per-route state management with inject channel, task queue, and active-stream tracking. - PrepareStep integration: drains inject channel between tool rounds, records insertion position via InjectedRecorder for correct persistence ordering. - interleaveInjectedMessages: inserts injected user messages at their actual injection position within the persisted message round. - Parallel mode isolation: /now streams do not interact with the dispatcher, preventing them from clearing another stream's active state.
This commit is contained in:
@@ -12,6 +12,7 @@ import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
sdk "github.com/memohai/twilight-ai/sdk"
|
||||
@@ -136,10 +137,11 @@ type usageInfo struct {
|
||||
}
|
||||
|
||||
type resolvedContext struct {
|
||||
runConfig agentpkg.RunConfig
|
||||
model models.GetResponse
|
||||
provider sqlc.LlmProvider
|
||||
query string // headerified query
|
||||
runConfig agentpkg.RunConfig
|
||||
model models.GetResponse
|
||||
provider sqlc.LlmProvider
|
||||
query string // headerified query
|
||||
injectedRecords *[]conversation.InjectedMessageRecord
|
||||
}
|
||||
|
||||
func (r *Resolver) resolve(ctx context.Context, req conversation.ChatRequest) (resolvedContext, error) {
|
||||
@@ -292,7 +294,40 @@ func (r *Resolver) resolve(ctx context.Context, req conversation.ChatRequest) (r
|
||||
LoopDetection: agentpkg.LoopDetectionConfig{Enabled: loopDetectionEnabled},
|
||||
}
|
||||
|
||||
return resolvedContext{runConfig: runCfg, model: chatModel, provider: provider, query: headerifiedQuery}, nil
|
||||
var injectedRecords *[]conversation.InjectedMessageRecord
|
||||
if req.InjectCh != nil {
|
||||
agentInjectCh := make(chan agentpkg.InjectMessage, cap(req.InjectCh))
|
||||
go func() {
|
||||
for msg := range req.InjectCh {
|
||||
agentInjectCh <- agentpkg.InjectMessage{
|
||||
Text: msg.Text,
|
||||
HeaderifiedText: msg.HeaderifiedText,
|
||||
}
|
||||
}
|
||||
close(agentInjectCh)
|
||||
}()
|
||||
runCfg.InjectCh = agentInjectCh
|
||||
|
||||
records := make([]conversation.InjectedMessageRecord, 0)
|
||||
injectedRecords = &records
|
||||
var recMu sync.Mutex
|
||||
runCfg.InjectedRecorder = func(headerifiedText string, insertAfter int) {
|
||||
recMu.Lock()
|
||||
*injectedRecords = append(*injectedRecords, conversation.InjectedMessageRecord{
|
||||
HeaderifiedText: headerifiedText,
|
||||
InsertAfter: insertAfter,
|
||||
})
|
||||
recMu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
return resolvedContext{
|
||||
runConfig: runCfg,
|
||||
model: chatModel,
|
||||
provider: provider,
|
||||
query: headerifiedQuery,
|
||||
injectedRecords: injectedRecords,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Chat sends a synchronous chat request and stores the result.
|
||||
|
||||
@@ -162,6 +162,10 @@ func (r *Resolver) tryStoreStream(ctx context.Context, req conversation.ChatRequ
|
||||
outputMessages := sdkMessagesToModelMessages(sdkMsgs)
|
||||
roundMessages := prependUserMessage(req.Query, outputMessages)
|
||||
|
||||
if rc.injectedRecords != nil && len(*rc.injectedRecords) > 0 {
|
||||
roundMessages = interleaveInjectedMessages(roundMessages, *rc.injectedRecords)
|
||||
}
|
||||
|
||||
if err := r.storeRound(ctx, req, roundMessages, modelID); err != nil {
|
||||
return false, err
|
||||
}
|
||||
@@ -173,6 +177,37 @@ func (r *Resolver) tryStoreStream(ctx context.Context, req conversation.ChatRequ
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// interleaveInjectedMessages inserts injected user messages at their correct
|
||||
// positions within the round. Each record's InsertAfter value indicates how
|
||||
// many output messages preceded the injection.
|
||||
//
|
||||
// round layout: [user_A, output_0, output_1, ..., output_N]
|
||||
// InsertAfter=K → insert after round[K] (i.e. after the K-th output message).
|
||||
func interleaveInjectedMessages(round []conversation.ModelMessage, injections []conversation.InjectedMessageRecord) []conversation.ModelMessage {
|
||||
if len(injections) == 0 {
|
||||
return round
|
||||
}
|
||||
result := make([]conversation.ModelMessage, 0, len(round)+len(injections))
|
||||
injIdx := 0
|
||||
for i, msg := range round {
|
||||
result = append(result, msg)
|
||||
for injIdx < len(injections) && injections[injIdx].InsertAfter == i {
|
||||
result = append(result, conversation.ModelMessage{
|
||||
Role: "user",
|
||||
Content: conversation.NewTextContent(injections[injIdx].HeaderifiedText),
|
||||
})
|
||||
injIdx++
|
||||
}
|
||||
}
|
||||
for ; injIdx < len(injections); injIdx++ {
|
||||
result = append(result, conversation.ModelMessage{
|
||||
Role: "user",
|
||||
Content: conversation.NewTextContent(injections[injIdx].HeaderifiedText),
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func extractInputTokensFromUsage(raw json.RawMessage) int {
|
||||
if len(raw) == 0 {
|
||||
return 0
|
||||
|
||||
@@ -241,6 +241,10 @@ type ChatRequest struct {
|
||||
// Set by the inbound channel processor; called by the resolver at persist time.
|
||||
OutboundAssetCollector func() []OutboundAssetRef `json:"-"`
|
||||
|
||||
// InjectCh receives user messages to inject into the active agent stream
|
||||
// between tool rounds via the PrepareStep hook. Nil means no injection.
|
||||
InjectCh <-chan InjectMessage `json:"-"`
|
||||
|
||||
Query string `json:"query"`
|
||||
Model string `json:"model,omitempty"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
@@ -251,6 +255,24 @@ type ChatRequest struct {
|
||||
Attachments []ChatAttachment `json:"attachments,omitempty"`
|
||||
}
|
||||
|
||||
// InjectMessage carries a user message to be injected into a running agent
|
||||
// stream between tool rounds.
|
||||
type InjectMessage struct {
|
||||
Text string
|
||||
Attachments []ChatAttachment
|
||||
HeaderifiedText string
|
||||
}
|
||||
|
||||
// InjectedMessageRecord records a message that was injected via PrepareStep,
|
||||
// together with its position in the output message sequence.
|
||||
type InjectedMessageRecord struct {
|
||||
HeaderifiedText string
|
||||
// InsertAfter is the number of SDK output messages that existed before
|
||||
// this injection. Used to determine the correct insertion position when
|
||||
// interleaving injected messages into the persisted round.
|
||||
InsertAfter int
|
||||
}
|
||||
|
||||
// ChatResponse is the output of a non-streaming chat call.
|
||||
type ChatResponse struct {
|
||||
Messages []ModelMessage `json:"messages"`
|
||||
|
||||
Reference in New Issue
Block a user