Files
enkly-ai/services/agent/internal/interfaces/grpc/server.go
enkly 79ce8dc4c0
Some checks failed
Go CI Pipeline / build_and_test (push) Failing after 22m0s
Init Project
2026-10-30 18:06:55 +01:00

180 lines
6.6 KiB
Go

// Package grpc adapts application.Service onto the generated
// agentv1.AgentServiceServer interface. StreamTurn's polling/diffing/
// coalescing loop lives here rather than in the application layer
// because it's intrinsically about the generated grpc.ServerStream
// type — see its doc comment for how it maps to the monolith's
// Handlers.StreamChat.
package grpc
import (
"context"
"time"
"google.golang.org/grpc/metadata"
agentv1 "enkly/gen/agent/v1"
"enkly/pkg/grpcerr"
"enkly/pkg/i18n"
"enkly/services/agent/internal/application"
"enkly/services/agent/internal/domain"
)
const (
sseKeepAliveInterval = 15 * time.Second
sseCoalesceDelay = 30 * time.Millisecond
// langMetadataKey mirrors the gateway's handlers.langMetadataKey —
// the gRPC metadata key it sets on StartTurn to tell this service
// which UI locale the visitor has chosen. Kept as a plain string
// constant rather than a shared pkg symbol because it's the one
// and only coupling point between the two services' transport
// layers, and a shared constant for a single string isn't worth a
// new shared pkg import on either side.
langMetadataKey = "x-enkly-lang"
)
// langFromIncomingContext reads the visitor's chosen locale off ctx's
// incoming gRPC metadata (set by the gateway — see langMetadataKey's
// doc comment), defaulting to i18n.Default when the call carries none
// (any caller other than the gateway, or an older gateway build).
func langFromIncomingContext(ctx context.Context) i18n.Locale {
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return i18n.Default
}
values := md.Get(langMetadataKey)
if len(values) == 0 {
return i18n.Default
}
return i18n.ParseLocale(values[0])
}
type Server struct {
agentv1.UnimplementedAgentServiceServer
Svc *application.Service
}
func New(svc *application.Service) *Server { return &Server{Svc: svc} }
func toProtoOutcome(o application.Outcome) agentv1.StartTurnOutcome {
switch o {
case application.OutcomeOK:
return agentv1.StartTurnOutcome_START_TURN_OUTCOME_OK
case application.OutcomeConversationNotFound:
return agentv1.StartTurnOutcome_START_TURN_OUTCOME_CONVERSATION_NOT_FOUND
case application.OutcomeConversationBusy:
return agentv1.StartTurnOutcome_START_TURN_OUTCOME_CONVERSATION_BUSY
case application.OutcomeTooManyStreams:
return agentv1.StartTurnOutcome_START_TURN_OUTCOME_TOO_MANY_STREAMS
case application.OutcomeRateLimited:
return agentv1.StartTurnOutcome_START_TURN_OUTCOME_RATE_LIMITED
case application.OutcomeQuotaExceeded:
return agentv1.StartTurnOutcome_START_TURN_OUTCOME_QUOTA_EXCEEDED
default:
return agentv1.StartTurnOutcome_START_TURN_OUTCOME_INTERNAL_ERROR
}
}
func (s *Server) StartTurn(ctx context.Context, req *agentv1.StartTurnRequest) (*agentv1.StartTurnResponse, error) {
lang := langFromIncomingContext(ctx)
res := s.Svc.StartTurn(ctx, req.GetUserId(), req.GetConversationId(), req.GetPrompt(), lang)
return &agentv1.StartTurnResponse{Outcome: toProtoOutcome(res.Outcome), Message: res.Message, StreamId: res.StreamID}, nil
}
func toProtoToolCall(tc domain.ToolCallResult) *agentv1.ToolCall {
return &agentv1.ToolCall{Name: tc.Name, ArgumentsJson: tc.ArgumentsJSON, Result: tc.Result, IsError: tc.IsError}
}
func toProtoThinkingRound(tr domain.ThinkingRound) *agentv1.ThinkingRound {
return &agentv1.ThinkingRound{Round: int32(tr.Round), Thinking: tr.Thinking}
}
// StreamTurn replaces Handlers.StreamChat, translated from SSE to
// server-streaming gRPC: same snapshot/diff/coalesce loop, same
// Last-Event-ID-style resume (resume_from_tool_index), same keepalive
// interval — just agentv1.TurnEvent messages instead of SSE frames. The
// gateway's own SSE handler reads this stream and does the reverse
// translation back to SSE for the browser, plus all HTML rendering
// (this service deals in plain text only).
func (s *Server) StreamTurn(req *agentv1.StreamTurnRequest, stream agentv1.AgentService_StreamTurnServer) error {
st := s.Svc.Streams.Get(req.GetStreamId())
if st == nil || st.UserID != req.GetUserId() {
return grpcerr.NotFound("stream")
}
sentTools := int(req.GetResumeFromToolIndex())
// sentThinkRounds has no resume-from-request counterpart the way
// sentTools does (StreamTurnRequest carries no thinking-round
// cursor) — a reconnect just resends every round from the start,
// same as lastThinking resetting to "" below and re-sending the
// full cumulative trace. Cheap and harmless: a run has at most
// MaxRounds (12) of these, each a short text, never the
// unboundedly-large payloads tool results can be.
sentThinkRounds := 0
var lastToken, lastThinking string
ctx := stream.Context()
for {
snap, changed := st.Snapshot()
for i := sentTools; i < len(snap.Tools); i++ {
if err := stream.Send(&agentv1.TurnEvent{Event: &agentv1.TurnEvent_ToolCall{ToolCall: toProtoToolCall(snap.Tools[i])}}); err != nil {
return err
}
}
if len(snap.Tools) > sentTools {
sentTools = len(snap.Tools)
}
for i := sentThinkRounds; i < len(snap.ThinkingRounds); i++ {
if err := stream.Send(&agentv1.TurnEvent{Event: &agentv1.TurnEvent_ThinkingRound{ThinkingRound: toProtoThinkingRound(snap.ThinkingRounds[i])}}); err != nil {
return err
}
}
if len(snap.ThinkingRounds) > sentThinkRounds {
sentThinkRounds = len(snap.ThinkingRounds)
}
if snap.ThinkText != lastThinking {
lastThinking = snap.ThinkText
if err := stream.Send(&agentv1.TurnEvent{Event: &agentv1.TurnEvent_ThinkingDelta{ThinkingDelta: lastThinking}}); err != nil {
return err
}
}
if snap.Done {
done := &agentv1.TurnDone{FinalText: snap.FinalText}
for _, tc := range snap.FinalToolCalls {
done.ToolCalls = append(done.ToolCalls, toProtoToolCall(tc))
}
return stream.Send(&agentv1.TurnEvent{Event: &agentv1.TurnEvent_Done{Done: done}})
}
if snap.TokenText != lastToken {
lastToken = snap.TokenText
if err := stream.Send(&agentv1.TurnEvent{Event: &agentv1.TurnEvent_TokenDelta{TokenDelta: lastToken}}); err != nil {
return err
}
}
select {
case <-changed:
// Let a burst of tokens accumulate so we send one event, not fifty.
select {
case <-time.After(sseCoalesceDelay):
case <-ctx.Done():
return nil
}
case <-time.After(sseKeepAliveInterval):
// Server-streaming gRPC has its own keepalive at the
// transport level (HTTP/2 PINGs); nothing to send here.
// The timer's only job is to re-loop and re-check ctx.Done.
case <-ctx.Done():
return nil
}
}
}
func (s *Server) StopTurn(ctx context.Context, req *agentv1.StopTurnRequest) (*agentv1.StopTurnResponse, error) {
if !s.Svc.StopTurn(req.GetUserId(), req.GetStreamId()) {
return nil, grpcerr.NotFound("stream")
}
return &agentv1.StopTurnResponse{}, nil
}