From d50af9ea1b5d99e7585bf7182f8b7ece239d7c32 Mon Sep 17 00:00:00 2001 From: hackerli Date: Thu, 18 Sep 2025 14:26:56 +0800 Subject: [PATCH 01/31] feat: support agui --- examples/agui/README.md | 8 + examples/agui/bubbletea/README.md | 59 +++++ examples/agui/bubbletea/client/main.go | 292 +++++++++++++++++++++++++ examples/agui/bubbletea/server/main.go | 82 +++++++ examples/agui/go.mod | 67 ++++++ examples/agui/go.sum | 134 ++++++++++++ server/agui/event/event.go | 227 +++++++++++++++++++ server/agui/go.mod | 37 ++++ server/agui/go.sum | 71 ++++++ server/agui/options.go | 28 +++ server/agui/runner/input.go | 224 +++++++++++++++++++ server/agui/runner/runner.go | 94 ++++++++ server/agui/server.go | 62 ++++++ server/agui/service/service.go | 12 + server/agui/service/sse/options.go | 31 +++ server/agui/service/sse/sse.go | 88 ++++++++ 16 files changed, 1516 insertions(+) create mode 100644 examples/agui/README.md create mode 100644 examples/agui/bubbletea/README.md create mode 100644 examples/agui/bubbletea/client/main.go create mode 100644 examples/agui/bubbletea/server/main.go create mode 100644 examples/agui/go.mod create mode 100644 examples/agui/go.sum create mode 100644 server/agui/event/event.go create mode 100644 server/agui/go.mod create mode 100644 server/agui/go.sum create mode 100644 server/agui/options.go create mode 100644 server/agui/runner/input.go create mode 100644 server/agui/runner/runner.go create mode 100644 server/agui/server.go create mode 100644 server/agui/service/service.go create mode 100644 server/agui/service/sse/options.go create mode 100644 server/agui/service/sse/sse.go diff --git a/examples/agui/README.md b/examples/agui/README.md new file mode 100644 index 000000000..a90c7057d --- /dev/null +++ b/examples/agui/README.md @@ -0,0 +1,8 @@ +# AG-UI Examples + +This folder collects runnable demos that showcase how to integrate the +`tRPC-Agent-Go` AG-UI server and various clients. + +- [`bubbletea/`](bubbletea/) – Terminal chat experience with an AG-UI SSE server using `llmagent` and a minimal CLI client. + +Each subdirectory contains its own instructions. diff --git a/examples/agui/bubbletea/README.md b/examples/agui/bubbletea/README.md new file mode 100644 index 000000000..e832393a1 --- /dev/null +++ b/examples/agui/bubbletea/README.md @@ -0,0 +1,59 @@ +# Bubbletea AG-UI Demo + +This example pairs a minimal AG-UI SSE server powered by `llmagent` with a +lightweight terminal client. The demo streams real model responses via the +AG-UI protocol. + +## Layout + +- `server/` – AG-UI SSE server backed by an OpenAI-compatible LLM agent. +- `client/` – Simple terminal client that reads a prompt, streams AG-UI events, + and prints the assistant output. + +## Prerequisites + +- Go 1.24+ +- An OpenAI-compatible API key (export `OPENAI_API_KEY`). + +## Run the server + +```bash +export OPENAI_API_KEY=sk-... +cd examples/agui +GO111MODULE=on go run ./bubbletea/server/cmd +``` + +The server listens on `http://localhost:8080/agui/run` by default. Adjust the +model name in `server/cmd/main.go` if you prefer a different backend. + +## Run the client + +In a second terminal: + +```bash +cd examples/agui +GO111MODULE=on go run ./bubbletea/client +``` + +Type a message and press Enter. The client streams AG-UI events from the server +and prints the assistant response. Press Enter on an empty line to exit. + +Example session: + +``` +╭──────────────────────────────────────────────────────────────╮ +│ │ +│ Simple AG-UI Client. Press Ctrl+C to quit. │ +│ You> calculate 1.25^0.42 │ +│ Agent> [RUN_STARTED] │ +│ Agent> [TOOL_CALL_START] tool call 'calculator' started, │ +│ Agent> [TOOL_CALL_ARGS] tool args: {"a":1.25,"b":0.42,"operation":"power"} +│ Agent> [TOOL_CALL_END] tool call completed, id: call_00_... │ +│ Agent> [TOOL_CALL_RESULT] tool result: {"result":1.09825...}│ +│ Agent> [TEXT_MESSAGE_START] │ +│ Agent> [TEXT_MESSAGE_CONTENT] The result of 1.25^0.42 is... │ +│ Agent> [TEXT_MESSAGE_END] │ +│ Agent> [RUN_FINISHED] │ +│ │ +╰──────────────────────────────────────────────────────────────╯ +``` diff --git a/examples/agui/bubbletea/client/main.go b/examples/agui/bubbletea/client/main.go new file mode 100644 index 000000000..89d7abbb4 --- /dev/null +++ b/examples/agui/bubbletea/client/main.go @@ -0,0 +1,292 @@ +package main + +import ( + "context" + "flag" + "fmt" + "log" + "strings" + "time" + + "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/client/sse" + "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/core/events" + "github.com/charmbracelet/bubbles/spinner" + "github.com/charmbracelet/bubbles/textinput" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/sirupsen/logrus" +) + +var ( + docStyle = lipgloss.NewStyle().Margin(1, 2) + headerStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("205")).Bold(true) + bodyStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).Padding(1, 2) + inputStyle = lipgloss.NewStyle().MarginTop(1) +) + +const headerText = "AG-UI Demo" + +func main() { + endpoint := flag.String("endpoint", "http://localhost:8080/agui/run", "AG-UI SSE endpoint") + flag.Parse() + + if _, err := tea.NewProgram(initialModel(*endpoint), tea.WithAltScreen()).Run(); err != nil { + log.Fatalf("bubbletea program failed: %v", err) + } +} + +type model struct { + endpoint string + history []string + input textinput.Model + viewport viewport.Model + spinner spinner.Model + busy bool + ready bool +} + +func initialModel(endpoint string) model { + input := textinput.New() + input.Placeholder = "Ask something..." + input.Prompt = "You> " + input.Focus() + + spin := spinner.New() + spin.Spinner = spinner.Dot + + m := model{ + endpoint: endpoint, + history: []string{"Simple AG-UI Client. Press Ctrl+C to quit."}, + input: input, + spinner: spin, + } + return m +} + +func (m model) Init() tea.Cmd { + return m.spinner.Tick +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.ready = true + m.configureViewport(msg.Width, msg.Height) + m.refreshViewport() + return m, nil + + case tea.KeyMsg: + switch msg.Type { + case tea.KeyCtrlC, tea.KeyEsc: + return m, tea.Quit + case tea.KeyEnter: + trimmed := strings.TrimSpace(m.input.Value()) + if trimmed == "" || m.busy { + return m, nil + } + m.input.Reset() + m.busy = true + m.history = append(m.history, fmt.Sprintf("You> %s", trimmed)) + m.refreshViewport() + return m, tea.Batch(m.spinner.Tick, startChatCmd(trimmed, m.endpoint)) + default: + if !m.busy { + var cmd tea.Cmd + m.input, cmd = m.input.Update(msg) + return m, cmd + } + return m, nil + } + + case chatResultMsg: + m.history = append(m.history, msg.lines...) + m.busy = false + m.refreshViewport() + m.input.Focus() + return m, nil + + case errMsg: + m.history = append(m.history, fmt.Sprintf("Error: %v", msg.error)) + m.busy = false + m.refreshViewport() + m.input.Focus() + return m, nil + + case spinner.TickMsg: + if !m.busy { + return m, nil + } + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd + } + + if !m.busy { + var cmd tea.Cmd + m.input, cmd = m.input.Update(msg) + return m, cmd + } + return m, nil +} + +func (m model) View() string { + if !m.ready { + return "Loading..." + } + + header := headerStyle.Render(headerText) + bodyFrameWidth, bodyFrameHeight := bodyStyle.GetFrameSize() + bodyWidth := m.viewport.Width + bodyFrameWidth + bodyHeight := m.viewport.Height + bodyFrameHeight + body := bodyStyle.Width(bodyWidth).Height(bodyHeight).Render(m.viewport.View()) + + inputView := m.input.View() + if m.busy { + spin := lipgloss.NewStyle().Foreground(lipgloss.Color("63")).Render(m.spinner.View()) + inputView += " " + spin + } + + content := lipgloss.JoinVertical(lipgloss.Left, header, body, inputStyle.Render(inputView)) + return docStyle.Render(content) +} + +func (m *model) refreshViewport() { + if !m.ready { + return + } + content := strings.Join(m.history, "\n") + m.viewport.SetContent(content) + m.viewport.GotoBottom() +} + +func (m *model) configureViewport(width, height int) { + hFrameDoc, vFrameDoc := docStyle.GetFrameSize() + hFrameBody, vFrameBody := bodyStyle.GetFrameSize() + _, vFrameInput := inputStyle.GetFrameSize() + headerHeight := lipgloss.Height(headerStyle.Render(headerText)) + inputHeight := 1 + vFrameInput + + viewportWidth := width - hFrameDoc - hFrameBody + if viewportWidth < 20 { + viewportWidth = 20 + } + + viewportHeight := height - vFrameDoc - vFrameBody - headerHeight - inputHeight + if viewportHeight < 5 { + viewportHeight = 5 + } + + if m.viewport.Width != viewportWidth || m.viewport.Height != viewportHeight { + m.viewport = viewport.New(viewportWidth, viewportHeight) + } else { + m.viewport.Width = viewportWidth + m.viewport.Height = viewportHeight + } + m.input.Width = viewportWidth +} + +type chatResultMsg struct{ lines []string } +type errMsg struct{ error } + +func startChatCmd(prompt, endpoint string) tea.Cmd { + return func() tea.Msg { + lines, err := fetchResponse(prompt, endpoint) + if err != nil { + return errMsg{err} + } + return chatResultMsg{lines: lines} + } +} + +func fetchResponse(prompt, endpoint string) ([]string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + logger := logrus.New() + logger.SetLevel(logrus.FatalLevel) + + client := sse.NewClient(sse.Config{ + Endpoint: endpoint, + ConnectTimeout: 30 * time.Second, + ReadTimeout: 5 * time.Minute, + BufferSize: 100, + Logger: logger, + }) + defer client.Close() + + payload := map[string]any{ + "threadId": "demo-thread", + "runId": fmt.Sprintf("run-%d", time.Now().UnixNano()), + "messages": []map[string]any{{"role": "user", "content": prompt}}, + } + + frames, errCh, err := client.Stream(sse.StreamOptions{Context: ctx, Payload: payload}) + if err != nil { + return nil, fmt.Errorf("failed to start SSE stream: %w", err) + } + + var collected []events.Event + for { + select { + case frame, ok := <-frames: + if !ok { + return renderEvents(collected), nil + } + evt, err := events.EventFromJSON(frame.Data) + if err != nil { + return nil, fmt.Errorf("parse event: %w", err) + } + collected = append(collected, evt) + case err, ok := <-errCh: + if ok && err != nil { + return nil, err + } + case <-ctx.Done(): + return nil, ctx.Err() + } + } +} + +func renderEvents(evts []events.Event) []string { + var output []string + for _, evt := range evts { + output = append(output, formatEvent(evt)...) + } + if len(output) == 0 { + output = append(output, "Bot> (no response)") + } + return output +} + +func formatEvent(evt events.Event) []string { + label := fmt.Sprintf("[%s]", evt.Type()) + + switch e := evt.(type) { + case *events.RunStartedEvent: + return []string{fmt.Sprintf("Agent> %s", label)} + case *events.RunFinishedEvent: + return []string{fmt.Sprintf("Agent> %s", label)} + case *events.RunErrorEvent: + return []string{fmt.Sprintf("Agent> %s: %s", label, e.Message)} + case *events.TextMessageStartEvent: + return []string{fmt.Sprintf("Agent> %s", label)} + case *events.TextMessageContentEvent: + if strings.TrimSpace(e.Delta) == "" { + return nil + } + return []string{fmt.Sprintf("Agent> %s %s", label, e.Delta)} + case *events.TextMessageEndEvent: + return []string{fmt.Sprintf("Agent> %s", label)} + case *events.ToolCallStartEvent: + return []string{fmt.Sprintf("Agent> %s tool call '%s' started, id: %s", label, e.ToolCallName, e.ToolCallID)} + case *events.ToolCallArgsEvent: + return []string{fmt.Sprintf("Agent> %s tool args: %s", label, e.Delta)} + case *events.ToolCallEndEvent: + return []string{fmt.Sprintf("Agent> %s tool call completed, id: %s", label, e.ToolCallID)} + case *events.ToolCallResultEvent: + return []string{fmt.Sprintf("Agent> %s tool result: %s", label, e.Content)} + default: + return nil + } +} diff --git a/examples/agui/bubbletea/server/main.go b/examples/agui/bubbletea/server/main.go new file mode 100644 index 000000000..e83a02828 --- /dev/null +++ b/examples/agui/bubbletea/server/main.go @@ -0,0 +1,82 @@ +package main + +import ( + "context" + "fmt" + "log" + "math" + + "trpc.group/trpc-go/trpc-agent-go/agent/llmagent" + "trpc.group/trpc-go/trpc-agent-go/model" + "trpc.group/trpc-go/trpc-agent-go/model/openai" + "trpc.group/trpc-go/trpc-agent-go/server/agui" + "trpc.group/trpc-go/trpc-agent-go/tool" + "trpc.group/trpc-go/trpc-agent-go/tool/function" +) + +func main() { + modelInstance := openai.New("deepseek-chat") + generationConfig := model.GenerationConfig{ + MaxTokens: intPtr(512), + Temperature: floatPtr(0.7), + Stream: false, + } + calculatorTool := function.NewFunctionTool( + calculator, + function.WithName("calculator"), + function.WithDescription("A calculator tool, you can use it to calculate the result of the operation. "+ + "a is the first number, b is the second number, "+ + "the operation can be add, subtract, multiply, divide, power."), + ) + agent := llmagent.New( + "agui-agent", + llmagent.WithTools([]tool.Tool{calculatorTool}), + llmagent.WithModel(modelInstance), + llmagent.WithGenerationConfig(generationConfig), + llmagent.WithInstruction("You are a helpful assistant."), + ) + server, err := agui.New(agent) + if err != nil { + log.Fatalf("failed to create AG-UI server: %v", err) + } + if err := server.Serve(context.Background()); err != nil { + log.Fatalf("server stopped with error: %v", err) + } +} + +func calculator(ctx context.Context, args calculatorArgs) (calculatorResult, error) { + var result float64 + switch args.Operation { + case "add", "+": + result = args.A + args.B + case "subtract", "-": + result = args.A - args.B + case "multiply", "*": + result = args.A * args.B + case "divide", "/": + result = args.A / args.B + case "power", "^": + result = math.Pow(args.A, args.B) + default: + return calculatorResult{Result: 0}, fmt.Errorf("invalid operation: %s", args.Operation) + } + return calculatorResult{Result: result}, nil +} + +type calculatorArgs struct { + Operation string `json:"operation" description:"add, subtract, multiply, divide, power"` + A float64 `json:"a" description:"First number"` + B float64 `json:"b" description:"Second number"` +} + +type calculatorResult struct { + Result float64 `json:"result"` +} + +func intPtr(i int) *int { + return &i +} + +func floatPtr(f float64) *float64 { + return &f +} diff --git a/examples/agui/go.mod b/examples/agui/go.mod new file mode 100644 index 000000000..440869b48 --- /dev/null +++ b/examples/agui/go.mod @@ -0,0 +1,67 @@ +module trpc.group/trpc-go/trpc-agent-go/examples/agui + +go 1.24.4 + +replace github.com/ag-ui-protocol/ag-ui/sdks/community/go => github.com/Flash-LHR/ag-ui/sdks/community/go v0.0.0-20250918062220-1d05c81e442a + +replace trpc.group/trpc-go/trpc-agent-go => ../../ + +replace trpc.group/trpc-go/trpc-agent-go/server/agui => ../../server/agui + +require ( + github.com/ag-ui-protocol/ag-ui/sdks/community/go v0.0.0-00010101000000-000000000000 + github.com/charmbracelet/bubbles v0.21.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/sirupsen/logrus v1.9.3 + trpc.group/trpc-go/trpc-agent-go v0.2.0 + trpc.group/trpc-go/trpc-agent-go/server/agui v0.0.0-00010101000000-000000000000 +) + +require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/openai/openai-go v1.12.0 // indirect + github.com/panjf2000/ants/v2 v2.9.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/tidwall/gjson v1.14.4 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + go.opentelemetry.io/otel v1.29.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.29.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.29.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.29.0 // indirect + go.opentelemetry.io/otel/metric v1.29.0 // indirect + go.opentelemetry.io/otel/sdk v1.29.0 // indirect + go.opentelemetry.io/otel/trace v1.29.0 // indirect + go.opentelemetry.io/proto/otlp v1.3.1 // indirect + go.uber.org/multierr v1.10.0 // indirect + go.uber.org/zap v1.27.0 // indirect + golang.org/x/net v0.34.0 // indirect + golang.org/x/sys v0.36.0 // indirect + golang.org/x/text v0.21.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20240822170219-fc7c04adadcd // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240822170219-fc7c04adadcd // indirect + google.golang.org/grpc v1.65.0 // indirect + google.golang.org/protobuf v1.34.2 // indirect + trpc.group/trpc-go/trpc-a2a-go v0.2.4-0.20250904070130-981d83483333 // indirect +) diff --git a/examples/agui/go.sum b/examples/agui/go.sum new file mode 100644 index 000000000..739214c6f --- /dev/null +++ b/examples/agui/go.sum @@ -0,0 +1,134 @@ +github.com/Flash-LHR/ag-ui/sdks/community/go v0.0.0-20250918062220-1d05c81e442a h1:09jFEqvgNKp6xEqonncNilnqqEXCJvORzEh5ktWqoPw= +github.com/Flash-LHR/ag-ui/sdks/community/go v0.0.0-20250918062220-1d05c81e442a/go.mod h1:ERAMOexUee4AIuoxksuuGoEcHl3aqLwaazjGwlR9ZCI= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= +github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 h1:asbCHRVmodnJTuQ3qamDwqVOIjwqUPTYmYuemVOx+Ys= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0/go.mod h1:ggCgvZ2r7uOoQjOyu2Y1NhHmEPPzzuhWgcza5M1Ji1I= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/openai/openai-go v1.12.0 h1:NBQCnXzqOTv5wsgNC36PrFEiskGfO5wccfCWDo9S1U0= +github.com/openai/openai-go v1.12.0/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y= +github.com/panjf2000/ants/v2 v2.9.0 h1:SztCLkVxBRigbg+vt0S5QvF5vxAbxbKt09/YfAJ0tEo= +github.com/panjf2000/ants/v2 v2.9.0/go.mod h1:7ZxyxsqE4vvW0M7LSD8aI3cKwgFhBHbxnlN8mDqHa1I= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM= +github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw= +go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.29.0 h1:dIIDULZJpgdiHz5tXrTgKIMLkus6jEFa7x5SOKcyR7E= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.29.0/go.mod h1:jlRVBe7+Z1wyxFSUs48L6OBQZ5JwH2Hg/Vbl+t9rAgI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.29.0 h1:nSiV3s7wiCam610XcLbYOmMfJxB9gO4uK3Xgv5gmTgg= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.29.0/go.mod h1:hKn/e/Nmd19/x1gvIHwtOwVWM+VhuITSWip3JUDghj0= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.29.0 h1:JAv0Jwtl01UFiyWZEMiJZBiTlv5A50zNs8lsthXqIio= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.29.0/go.mod h1:QNKLmUEAq2QUbPQUfvw4fmv0bgbK7UlOSFCnXyfvSNc= +go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc= +go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8= +go.opentelemetry.io/otel/sdk v1.29.0 h1:vkqKjk7gwhS8VaWb0POZKmIEDimRCMsopNYnriHyryo= +go.opentelemetry.io/otel/sdk v1.29.0/go.mod h1:pM8Dx5WKnvxLCb+8lG1PRNIDxu9g9b9g59Qr7hfAAok= +go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4= +go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ= +go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0= +go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= +golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= +golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +google.golang.org/genproto/googleapis/api v0.0.0-20240822170219-fc7c04adadcd h1:BBOTEWLuuEGQy9n1y9MhVJ9Qt0BDu21X8qZs71/uPZo= +google.golang.org/genproto/googleapis/api v0.0.0-20240822170219-fc7c04adadcd/go.mod h1:fO8wJzT2zbQbAjbIoos1285VfEIYKDDY+Dt+WpTkh6g= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240822170219-fc7c04adadcd h1:6TEm2ZxXoQmFWFlt1vNxvVOa1Q0dXFQD1m/rYjXmS0E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240822170219-fc7c04adadcd/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU= +google.golang.org/grpc v1.65.0 h1:bs/cUb4lp1G5iImFFd3u5ixQzweKizoZJAwBNLR42lc= +google.golang.org/grpc v1.65.0/go.mod h1:WgYC2ypjlB0EiQi6wdKixMqukr6lBc0Vo+oOgjrM5ZQ= +google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= +google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +trpc.group/trpc-go/trpc-a2a-go v0.2.4-0.20250904070130-981d83483333 h1:Als3R0+WZSm+bkDVkt5ATElgRixuGRY7iBSEJXBq2XM= +trpc.group/trpc-go/trpc-a2a-go v0.2.4-0.20250904070130-981d83483333/go.mod h1:Gtytau9Uoc3oPo/dpHvKit+tQn9Qlk5XFG1RiZTGqfk= diff --git a/server/agui/event/event.go b/server/agui/event/event.go new file mode 100644 index 000000000..98525b29f --- /dev/null +++ b/server/agui/event/event.go @@ -0,0 +1,227 @@ +// +// Tencent is pleased to support the open source community by making trpc-agent-go available. +// +// Copyright (C) 2025 Tencent. All rights reserved. +// +// trpc-agent-go is licensed under the Apache License Version 2.0. +// +// + +// Package event provides the event translation from runner events to AG-UI events. +package event + +import ( + "encoding/json" + "strings" + + aguievents "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/core/events" + agentevent "trpc.group/trpc-go/trpc-agent-go/event" + "trpc.group/trpc-go/trpc-agent-go/model" +) + +// Translator converts runner events into AG-UI events. +type Translator interface { + FromRunnerEvent(evt *agentevent.Event) []aguievents.Event + Finalize() []aguievents.Event +} + +// defaultTranslator maps runner events into AG-UI event sequence. +type defaultTranslator struct { + threadID string + runID string + messageID string + messageActive bool + toolCalls map[string]string +} + +// NewTranslator returns the default translator implementation. +func NewTranslator(threadID, runID string) Translator { + return &defaultTranslator{ + threadID: threadID, + runID: runID, + toolCalls: make(map[string]string), + } +} + +// FromRunnerEvent converts one runner event into zero or more AG-UI events. +func (t *defaultTranslator) FromRunnerEvent(evt *agentevent.Event) []aguievents.Event { + if evt == nil { + return nil + } + var out []aguievents.Event + if snapshot := t.stateSnapshotEvent(evt); snapshot != nil { + out = append(out, snapshot) + } + if evt.Response != nil { + resp := evt.Response + if resp.Error != nil { + out = append(out, aguievents.NewRunErrorEvent(resp.Error.Message, aguievents.WithRunID(t.runID))) + return out + } + if resp.IsToolCallResponse() { + out = append(out, t.translateToolCall(resp)...) + return out + } + if resp.IsToolResultResponse() { + out = append(out, t.translateToolResult(resp)...) + return out + } + if delta := extractTextFromResponse(resp); delta != "" { + out = append(out, t.emitAssistantText(delta, resp.IsPartial)...) + if !resp.IsPartial { + out = append(out, t.finishMessage()...) + } + return out + } + } + return out +} + +// Finalize emits any trailing events required to finish the stream. +func (t *defaultTranslator) Finalize() []aguievents.Event { + return t.finishMessage() +} + +func (t *defaultTranslator) emitAssistantText(delta string, partial bool) []aguievents.Event { + if strings.TrimSpace(delta) == "" { + return nil + } + var events []aguievents.Event + if !t.messageActive { + t.messageID = aguievents.GenerateMessageID() + start := aguievents.NewTextMessageStartEvent(t.messageID, aguievents.WithRole("assistant")) + events = append(events, start) + t.messageActive = true + } + events = append(events, aguievents.NewTextMessageContentEvent(t.messageID, delta)) + if !partial { + events = append(events, t.finishMessage()...) + } + return events +} + +func (t *defaultTranslator) finishMessage() []aguievents.Event { + if !t.messageActive || t.messageID == "" { + return nil + } + end := aguievents.NewTextMessageEndEvent(t.messageID) + t.messageActive = false + t.messageID = "" + return []aguievents.Event{end} +} + +func (t *defaultTranslator) stateSnapshotEvent(evt *agentevent.Event) aguievents.Event { + if len(evt.StateDelta) == 0 { + return nil + } + snapshot := make(map[string]any, len(evt.StateDelta)) + for key, val := range evt.StateDelta { + if len(val) == 0 { + snapshot[key] = "" + continue + } + var decoded any + if err := json.Unmarshal(val, &decoded); err == nil { + snapshot[key] = decoded + continue + } + snapshot[key] = string(val) + } + return aguievents.NewStateSnapshotEvent(snapshot) +} + +func (t *defaultTranslator) translateToolCall(resp *model.Response) []aguievents.Event { + var events []aguievents.Event + for _, choice := range resp.Choices { + calls := choice.Delta.ToolCalls + if len(calls) == 0 { + calls = choice.Message.ToolCalls + } + for _, tc := range calls { + id := tc.ID + if id == "" { + id = aguievents.GenerateToolCallID() + } + name := tc.Function.Name + if name == "" { + name = "tool" + } + if _, exists := t.toolCalls[id]; !exists { + events = append(events, aguievents.NewToolCallStartEvent(id, name)) + t.toolCalls[id] = name + } + if len(tc.Function.Arguments) > 0 { + if args := formatArguments(tc.Function.Arguments); args != "" { + events = append(events, aguievents.NewToolCallArgsEvent(id, args)) + } + } + if !resp.IsPartial { + events = append(events, aguievents.NewToolCallEndEvent(id)) + delete(t.toolCalls, id) + } + } + } + return events +} + +func (t *defaultTranslator) translateToolResult(resp *model.Response) []aguievents.Event { + var events []aguievents.Event + for _, choice := range resp.Choices { + msg := choice.Message + if msg.ToolID == "" { + continue + } + content := textFromMessage(msg) + if content == "" { + continue + } + messageID := msg.ToolID + if messageID == "" { + messageID = aguievents.GenerateMessageID() + } + events = append(events, aguievents.NewToolCallResultEvent(messageID, msg.ToolID, content)) + } + return events +} + +func formatArguments(raw []byte) string { + if len(raw) == 0 { + return "" + } + var obj any + if err := json.Unmarshal(raw, &obj); err == nil { + if pretty, err := json.Marshal(obj); err == nil { + return string(pretty) + } + } + return string(raw) +} + +func extractTextFromResponse(resp *model.Response) string { + if resp == nil || len(resp.Choices) == 0 { + return "" + } + choice := resp.Choices[0] + if resp.IsPartial { + if msg := textFromMessage(choice.Delta); msg != "" { + return msg + } + } + return textFromMessage(choice.Message) +} + +func textFromMessage(msg model.Message) string { + if msg.Content != "" { + return msg.Content + } + if len(msg.ContentParts) == 0 { + return "" + } + var builder strings.Builder + for _, part := range msg.ContentParts { + if part.Text != nil { + builder.WriteString(*part.Text) + } + } + return builder.String() +} diff --git a/server/agui/go.mod b/server/agui/go.mod new file mode 100644 index 000000000..34562bd33 --- /dev/null +++ b/server/agui/go.mod @@ -0,0 +1,37 @@ +module trpc.group/trpc-go/trpc-agent-go/server/agui + +go 1.24.4 + +replace github.com/ag-ui-protocol/ag-ui/sdks/community/go => github.com/Flash-LHR/ag-ui/sdks/community/go v0.0.0-20250918062220-1d05c81e442a + +require ( + github.com/ag-ui-protocol/ag-ui/sdks/community/go v0.0.0-00010101000000-000000000000 + trpc.group/trpc-go/trpc-agent-go v0.2.0 +) + +require ( + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect + go.opentelemetry.io/otel v1.29.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.29.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.29.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.29.0 // indirect + go.opentelemetry.io/otel/metric v1.29.0 // indirect + go.opentelemetry.io/otel/sdk v1.29.0 // indirect + go.opentelemetry.io/otel/trace v1.29.0 // indirect + go.opentelemetry.io/proto/otlp v1.3.1 // indirect + go.uber.org/multierr v1.10.0 // indirect + go.uber.org/zap v1.27.0 // indirect + golang.org/x/net v0.34.0 // indirect + golang.org/x/sys v0.30.0 // indirect + golang.org/x/text v0.21.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20240822170219-fc7c04adadcd // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240822170219-fc7c04adadcd // indirect + google.golang.org/grpc v1.65.0 // indirect + google.golang.org/protobuf v1.34.2 // indirect + trpc.group/trpc-go/trpc-a2a-go v0.2.4-0.20250904070130-981d83483333 // indirect +) diff --git a/server/agui/go.sum b/server/agui/go.sum new file mode 100644 index 000000000..498819e9e --- /dev/null +++ b/server/agui/go.sum @@ -0,0 +1,71 @@ +github.com/Flash-LHR/ag-ui/sdks/community/go v0.0.0-20250918062220-1d05c81e442a h1:09jFEqvgNKp6xEqonncNilnqqEXCJvORzEh5ktWqoPw= +github.com/Flash-LHR/ag-ui/sdks/community/go v0.0.0-20250918062220-1d05c81e442a/go.mod h1:ERAMOexUee4AIuoxksuuGoEcHl3aqLwaazjGwlR9ZCI= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 h1:asbCHRVmodnJTuQ3qamDwqVOIjwqUPTYmYuemVOx+Ys= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0/go.mod h1:ggCgvZ2r7uOoQjOyu2Y1NhHmEPPzzuhWgcza5M1Ji1I= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw= +go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.29.0 h1:dIIDULZJpgdiHz5tXrTgKIMLkus6jEFa7x5SOKcyR7E= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.29.0/go.mod h1:jlRVBe7+Z1wyxFSUs48L6OBQZ5JwH2Hg/Vbl+t9rAgI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.29.0 h1:nSiV3s7wiCam610XcLbYOmMfJxB9gO4uK3Xgv5gmTgg= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.29.0/go.mod h1:hKn/e/Nmd19/x1gvIHwtOwVWM+VhuITSWip3JUDghj0= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.29.0 h1:JAv0Jwtl01UFiyWZEMiJZBiTlv5A50zNs8lsthXqIio= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.29.0/go.mod h1:QNKLmUEAq2QUbPQUfvw4fmv0bgbK7UlOSFCnXyfvSNc= +go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc= +go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8= +go.opentelemetry.io/otel/sdk v1.29.0 h1:vkqKjk7gwhS8VaWb0POZKmIEDimRCMsopNYnriHyryo= +go.opentelemetry.io/otel/sdk v1.29.0/go.mod h1:pM8Dx5WKnvxLCb+8lG1PRNIDxu9g9b9g59Qr7hfAAok= +go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4= +go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ= +go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0= +go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= +golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +google.golang.org/genproto/googleapis/api v0.0.0-20240822170219-fc7c04adadcd h1:BBOTEWLuuEGQy9n1y9MhVJ9Qt0BDu21X8qZs71/uPZo= +google.golang.org/genproto/googleapis/api v0.0.0-20240822170219-fc7c04adadcd/go.mod h1:fO8wJzT2zbQbAjbIoos1285VfEIYKDDY+Dt+WpTkh6g= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240822170219-fc7c04adadcd h1:6TEm2ZxXoQmFWFlt1vNxvVOa1Q0dXFQD1m/rYjXmS0E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240822170219-fc7c04adadcd/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU= +google.golang.org/grpc v1.65.0 h1:bs/cUb4lp1G5iImFFd3u5ixQzweKizoZJAwBNLR42lc= +google.golang.org/grpc v1.65.0/go.mod h1:WgYC2ypjlB0EiQi6wdKixMqukr6lBc0Vo+oOgjrM5ZQ= +google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= +google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +trpc.group/trpc-go/trpc-a2a-go v0.2.4-0.20250904070130-981d83483333 h1:Als3R0+WZSm+bkDVkt5ATElgRixuGRY7iBSEJXBq2XM= +trpc.group/trpc-go/trpc-a2a-go v0.2.4-0.20250904070130-981d83483333/go.mod h1:Gtytau9Uoc3oPo/dpHvKit+tQn9Qlk5XFG1RiZTGqfk= +trpc.group/trpc-go/trpc-agent-go v0.2.0 h1:XDuv+s783DbFPhzM4vgM6yjPw6W76FfMlcy0a/9jR9U= +trpc.group/trpc-go/trpc-agent-go v0.2.0/go.mod h1:8jAphCIcoi0LE7X3J4mxVQr29LEu+zT3oQr0xJ+hub4= diff --git a/server/agui/options.go b/server/agui/options.go new file mode 100644 index 000000000..924bf1ed6 --- /dev/null +++ b/server/agui/options.go @@ -0,0 +1,28 @@ +package agui + +import ( + "trpc.group/trpc-go/trpc-agent-go/server/agui/service" + "trpc.group/trpc-go/trpc-agent-go/session" +) + +// Option customizes server behaviour. +type Option func(*options) + +type options struct { + service service.Service + sessionService session.Service +} + +// WithService replaces the default transport service. +func WithService(s service.Service) Option { + return func(o *options) { + o.service = s + } +} + +// WithSessionService replaces the default session service implementation. +func WithSessionService(svc session.Service) Option { + return func(o *options) { + o.sessionService = svc + } +} diff --git a/server/agui/runner/input.go b/server/agui/runner/input.go new file mode 100644 index 000000000..0ff894f14 --- /dev/null +++ b/server/agui/runner/input.go @@ -0,0 +1,224 @@ +// +// Tencent is pleased to support the open source community by making trpc-agent-go available. +// +// Copyright (C) 2025 Tencent. All rights reserved. +// +// trpc-agent-go is licensed under the Apache License Version 2.0. +// +// + +package runner + +import ( + "encoding/json" + "errors" + "io" + "strings" + + "trpc.group/trpc-go/trpc-agent-go/model" +) + +// NOTE: This file should be removed when the AG-UI Go SDK exposes the official structure. + +// RunAgentInput captures the parameters for an AG-UI run request. +// NOTE: This type should be removed when the AG-UI Go SDK exposes the official structure. +type RunAgentInput struct { + ThreadID string + RunID string + Messages []model.Message + State map[string]any + ForwardedProps map[string]any +} + +// UserID returns the derived user identifier. +// NOTE: This function should be removed when the AG-UI Go SDK exposes the official structure. +func (in *RunAgentInput) UserID() string { + return in.ThreadID +} + +// SessionID returns the derived session identifier. +// NOTE: This function should be removed when the AG-UI Go SDK exposes the official structure. +func (in *RunAgentInput) SessionID() string { + return in.RunID +} + +// LatestUserMessage returns the most recent user message with content. +// NOTE: This function should be removed when the AG-UI Go SDK exposes the official structure. +func (in *RunAgentInput) LatestUserMessage() (model.Message, bool) { + for i := len(in.Messages) - 1; i >= 0; i-- { + msg := in.Messages[i] + if msg.Role == model.RoleUser && (msg.Content != "" || len(msg.ContentParts) > 0) { + return msg, true + } + } + return model.Message{}, false +} + +// DecodeRunAgentInput decodes a RunAgentInput from an HTTP request body. +// NOTE: This function should be removed when the AG-UI Go SDK exposes the official structure. +func DecodeRunAgentInput(r io.Reader) (*RunAgentInput, error) { + var raw map[string]json.RawMessage + dec := json.NewDecoder(r) + dec.DisallowUnknownFields() + if err := dec.Decode(&raw); err != nil { + return nil, errors.New("agui: decode request failed: " + err.Error()) + } + + input := &RunAgentInput{ + ThreadID: readStringRaw(raw, "threadId", "thread_id"), + RunID: readStringRaw(raw, "runId", "run_id"), + State: decodeMap(raw, "state"), + ForwardedProps: decodeMap(raw, "forwardedProps", "forwarded_props"), + } + + if msgRaw, ok := raw["messages"]; ok { + messages, err := decodeMessages(msgRaw) + if err != nil { + return nil, err + } + input.Messages = messages + } else { + return nil, errors.New("agui: messages field is required") + } + + if _, ok := input.LatestUserMessage(); !ok { + return nil, errors.New("agui: at least one user message with content is required") + } + + return input, nil +} + +// NOTE: This function should be removed when the AG-UI Go SDK exposes the official structure. +func readStringRaw(raw map[string]json.RawMessage, keys ...string) string { + for _, key := range keys { + if data, ok := raw[key]; ok { + var s string + if err := json.Unmarshal(data, &s); err == nil { + return s + } + } + } + return "" +} + +// NOTE: This function should be removed when the AG-UI Go SDK exposes the official structure. +func decodeMap(raw map[string]json.RawMessage, keys ...string) map[string]any { + for _, key := range keys { + if data, ok := raw[key]; ok && len(data) > 0 { + var m map[string]any + if err := json.Unmarshal(data, &m); err == nil { + return m + } + } + } + return nil +} + +// NOTE: This function should be removed when the AG-UI Go SDK exposes the official structure. +func decodeMessages(data json.RawMessage) ([]model.Message, error) { + var rawMsgs []map[string]json.RawMessage + if err := json.Unmarshal(data, &rawMsgs); err != nil { + return nil, errors.New("agui: messages must be an array of objects: " + err.Error()) + } + + msgs := make([]model.Message, 0, len(rawMsgs)) + for _, item := range rawMsgs { + role := strings.ToLower(readStringRaw(item, "role")) + if role == "" { + return nil, errors.New("agui: message.role is required") + } + + msg := model.Message{Role: model.Role(role)} + if !msg.Role.IsValid() { + return nil, errors.New("agui: unsupported message role " + role) + } + + msg.Content = readStringRaw(item, "content") + if msg.Content == "" { + msg.Content = decodeTextParts(item, "content", "parts", "contentParts") + } + if msg.Content == "" { + msg.Content = readStringRaw(item, "delta") + } + + if msg.Role == model.RoleTool { + msg.ToolID = readStringRaw(item, "toolId", "tool_id") + msg.ToolName = readStringRaw(item, "toolName", "tool_name") + } + + if toolCallsRaw, ok := findRaw(item, "toolCalls", "tool_calls"); ok { + msg.ToolCalls = decodeToolCalls(toolCallsRaw) + } + + msgs = append(msgs, msg) + } + + return msgs, nil +} + +// NOTE: This function should be removed when the AG-UI Go SDK exposes the official structure. +func decodeTextParts(item map[string]json.RawMessage, keys ...string) string { + for _, key := range keys { + if data, ok := item[key]; ok { + var parts []map[string]any + if err := json.Unmarshal(data, &parts); err == nil { + var builder strings.Builder + for _, part := range parts { + typeVal, _ := part["type"].(string) + if strings.ToLower(typeVal) != "text" { + continue + } + if text, ok := part["text"].(string); ok { + builder.WriteString(text) + } + } + if builder.Len() > 0 { + return builder.String() + } + } + } + } + return "" +} + +// NOTE: This function should be removed when the AG-UI Go SDK exposes the official structure. +func findRaw(item map[string]json.RawMessage, keys ...string) (json.RawMessage, bool) { + for _, key := range keys { + if data, ok := item[key]; ok { + return data, true + } + } + return nil, false +} + +// NOTE: This function should be removed when the AG-UI Go SDK exposes the official structure. +func decodeToolCalls(data json.RawMessage) []model.ToolCall { + var rawCalls []map[string]any + if err := json.Unmarshal(data, &rawCalls); err != nil { + return nil + } + + toolCalls := make([]model.ToolCall, 0, len(rawCalls)) + for _, call := range rawCalls { + var tc model.ToolCall + tc.Type = "function" + if id, ok := call["id"].(string); ok { + tc.ID = id + } + if t, ok := call["type"].(string); ok && t != "" { + tc.Type = t + } + if fn, ok := call["function"].(map[string]any); ok { + if name, ok := fn["name"].(string); ok { + tc.Function.Name = name + } + if args, ok := fn["arguments"]; ok { + if encoded, err := json.Marshal(args); err == nil { + tc.Function.Arguments = encoded + } + } + } + toolCalls = append(toolCalls, tc) + } + return toolCalls +} diff --git a/server/agui/runner/runner.go b/server/agui/runner/runner.go new file mode 100644 index 000000000..239a294f7 --- /dev/null +++ b/server/agui/runner/runner.go @@ -0,0 +1,94 @@ +// +// Tencent is pleased to support the open source community by making trpc-agent-go available. +// +// Copyright (C) 2025 Tencent. All rights reserved. +// +// trpc-agent-go is licensed under the Apache License Version 2.0. +// +// + +// Package runner provides the AG-UI runner implementation. +package runner + +import ( + "context" + "errors" + + "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/core/events" + trunner "trpc.group/trpc-go/trpc-agent-go/runner" + aguievent "trpc.group/trpc-go/trpc-agent-go/server/agui/event" +) + +// Runner is the interface for running agents. +type Runner interface { + Run(ctx context.Context, input *RunAgentInput) (<-chan events.Event, error) +} + +// New wraps a runner.Runner with AG-UI specific translation logic. +func New(r trunner.Runner) Runner { + return &runner{ + runner: r, + translatorFactory: aguievent.NewTranslator, + } +} + +// runner is the AG-UI runner implementation. +type runner struct { + runner trunner.Runner + translatorFactory func(threadID, runID string) aguievent.Translator +} + +// Run executes one run and streams translated events back. +func (r *runner) Run(ctx context.Context, input *RunAgentInput) (<-chan events.Event, error) { + if input == nil { + return nil, errors.New("agui: run input cannot be nil") + } + if r.runner == nil { + return nil, errors.New("agui: runner is nil") + } + events := make(chan events.Event) + go r.run(ctx, input, events) + return events, nil +} + +func (r *runner) run(ctx context.Context, input *RunAgentInput, out chan<- events.Event) { + defer close(out) + threadID := input.ThreadID + runID := input.RunID + out <- events.NewRunStartedEvent(threadID, runID) + msgs := input.Messages + if len(msgs) == 0 { + out <- events.NewRunErrorEvent("no messages provided", events.WithRunID(runID)) + return + } + if _, ok := input.LatestUserMessage(); !ok { + out <- events.NewRunErrorEvent("no user message found", events.WithRunID(runID)) + return + } + ctx, cancel := context.WithCancel(ctx) + defer cancel() + ch, err := r.runner.Run(ctx, threadID, runID, input.Messages[0]) + if err != nil { + out <- events.NewRunErrorEvent(err.Error(), events.WithRunID(runID)) + return + } + translator := r.translatorFactory(threadID, runID) + for { + select { + case <-ctx.Done(): + out <- events.NewRunErrorEvent(ctx.Err().Error(), events.WithRunID(runID)) + return + case evt, ok := <-ch: + if !ok { + for _, fin := range translator.Finalize() { + out <- fin + } + out <- events.NewRunFinishedEvent(threadID, runID) + return + } + for _, translated := range translator.FromRunnerEvent(evt) { + out <- translated + } + } + } +} diff --git a/server/agui/server.go b/server/agui/server.go new file mode 100644 index 000000000..b43239475 --- /dev/null +++ b/server/agui/server.go @@ -0,0 +1,62 @@ +package agui + +import ( + "context" + "errors" + + "trpc.group/trpc-go/trpc-agent-go/agent" + "trpc.group/trpc-go/trpc-agent-go/runner" + aguirunner "trpc.group/trpc-go/trpc-agent-go/server/agui/runner" + "trpc.group/trpc-go/trpc-agent-go/server/agui/service" + "trpc.group/trpc-go/trpc-agent-go/server/agui/service/sse" + "trpc.group/trpc-go/trpc-agent-go/session" + "trpc.group/trpc-go/trpc-agent-go/session/inmemory" +) + +// Server wires the agent, session service, and transport together. +type Server struct { + agent agent.Agent + sessionService session.Service + service service.Service +} + +// New creates a server instance ready to accept AG-UI requests. +func New(agent agent.Agent, opt ...Option) (*Server, error) { + if agent == nil { + return nil, errors.New("agui: agent must not be nil") + } + var opts options + for _, o := range opt { + o(&opts) + } + sessionService := opts.sessionService + if sessionService == nil { + sessionService = inmemory.NewSessionService() + } + service := opts.service + if service == nil { + runner := runner.NewRunner( + agent.Info().Name, + agent, + runner.WithSessionService(sessionService), + ) + aguiRunner := aguirunner.New(runner) + service = sse.New(sse.WithRunner(aguiRunner)) + } + server := &Server{ + agent: agent, + sessionService: sessionService, + service: service, + } + return server, nil +} + +// Serve starts the service. +func (s *Server) Serve(ctx context.Context) error { + return s.service.Serve(ctx) +} + +// Close stops the service. +func (s *Server) Close(ctx context.Context) error { + return s.service.Close(ctx) +} diff --git a/server/agui/service/service.go b/server/agui/service/service.go new file mode 100644 index 000000000..5d8c9a4af --- /dev/null +++ b/server/agui/service/service.go @@ -0,0 +1,12 @@ +package service + +import "context" + +// Service captures the minimal lifecycle hooks an AG-UI transport must implement. +type Service interface { + // Serve starts the transport and blocks until it stops or ctx is cancelled. + Serve(ctx context.Context) error + + // Close should gracefully release transport resources; it must be idempotent. + Close(ctx context.Context) error +} diff --git a/server/agui/service/sse/options.go b/server/agui/service/sse/options.go new file mode 100644 index 000000000..e05ef1244 --- /dev/null +++ b/server/agui/service/sse/options.go @@ -0,0 +1,31 @@ +package sse + +import ( + "trpc.group/trpc-go/trpc-agent-go/server/agui/runner" +) + +// Option is a function that configures a Service. +type Option func(*Service) + +// WithAddress sets the listening address. +func WithAddress(addr string) Option { + return func(s *Service) { + s.addr = addr + } +} + +// WithPath sets the request path. +func WithPath(path string) Option { + return func(s *Service) { + s.path = path + } +} + +// WithRunner sets the runner responsible for executing requests. +func WithRunner(r runner.Runner) Option { + return func(s *Service) { + if r != nil { + s.runner = r + } + } +} diff --git a/server/agui/service/sse/sse.go b/server/agui/service/sse/sse.go new file mode 100644 index 000000000..ae38ba6ec --- /dev/null +++ b/server/agui/service/sse/sse.go @@ -0,0 +1,88 @@ +package sse + +import ( + "context" + "errors" + "net/http" + + aguisse "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/encoding/sse" + "trpc.group/trpc-go/trpc-agent-go/server/agui/runner" +) + +// Service is a SSE service implementation. +type Service struct { + addr string + path string + writer *aguisse.SSEWriter + runner runner.Runner + httpServer *http.Server +} + +// New creates a new SSE service. +func New(opts ...Option) *Service { + s := &Service{ + addr: ":8080", + path: "/agui/run", + writer: aguisse.NewSSEWriter(), + } + for _, opt := range opts { + opt(s) + } + return s +} + +// Serve start the SSE service and listen on the address. +func (s *Service) Serve(ctx context.Context) error { + mux := http.NewServeMux() + mux.HandleFunc(s.path, s.handle) + s.httpServer = &http.Server{Addr: s.addr, Handler: mux} + err := s.httpServer.ListenAndServe() + if errors.Is(err, http.ErrServerClosed) { + return nil + } + return err +} + +// Close stops the SSE service. +func (s *Service) Close(ctx context.Context) error { + if s.httpServer == nil { + return errors.New("http server not running") + } + return s.httpServer.Shutdown(ctx) +} + +func (s *Service) handle(w http.ResponseWriter, r *http.Request) { + if s.runner == nil { + http.Error(w, "runner not configured", http.StatusInternalServerError) + return + } + defer r.Body.Close() + input, err := runner.DecodeRunAgentInput(r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + eventsCh, err := s.runner.Run(r.Context(), input) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("Transfer-Encoding", "chunked") + ctx := r.Context() + for { + select { + case <-ctx.Done(): + return + case evt, ok := <-eventsCh: + if !ok { + return + } + if err := s.writer.WriteEvent(ctx, w, evt); err != nil { + return + } + } + } +} From c4ec52f7fc4a0af588f49caa6bddd3b0e82752f2 Mon Sep 17 00:00:00 2001 From: hackerli Date: Fri, 19 Sep 2025 11:48:45 +0800 Subject: [PATCH 02/31] refactor --- event/event.go | 6 +- examples/agui/README.md | 27 +- examples/agui/bubbletea/README.md | 59 ---- examples/agui/bubbletea/client/main.go | 292 ------------------ examples/agui/client/README.md | 7 + examples/agui/client/bubbletea/README.md | 54 ++++ examples/agui/client/bubbletea/agui.go | 158 ++++++++++ examples/agui/client/bubbletea/main.go | 20 ++ examples/agui/client/bubbletea/ui.go | 221 +++++++++++++ examples/agui/go.mod | 2 +- examples/agui/go.sum | 4 +- examples/agui/server/README.md | 8 + examples/agui/server/default/README.md | 14 + .../server => server/default}/main.go | 11 +- server/agui/{server.go => agui.go} | 33 +- server/agui/event/event.go | 229 ++------------ server/agui/event/translator.go | 118 +++++++ server/agui/go.mod | 2 +- server/agui/go.sum | 4 +- server/agui/options.go | 62 +++- server/agui/runner/input.go | 224 -------------- server/agui/runner/options.go | 47 +++ server/agui/runner/runner.go | 95 +++--- server/agui/sdk/sdk.go | 40 +++ server/agui/service/service.go | 17 +- server/agui/service/sse/options.go | 43 ++- server/agui/service/sse/sse.go | 57 ++-- 27 files changed, 955 insertions(+), 899 deletions(-) delete mode 100644 examples/agui/bubbletea/README.md delete mode 100644 examples/agui/bubbletea/client/main.go create mode 100644 examples/agui/client/README.md create mode 100644 examples/agui/client/bubbletea/README.md create mode 100644 examples/agui/client/bubbletea/agui.go create mode 100644 examples/agui/client/bubbletea/main.go create mode 100644 examples/agui/client/bubbletea/ui.go create mode 100644 examples/agui/server/README.md create mode 100644 examples/agui/server/default/README.md rename examples/agui/{bubbletea/server => server/default}/main.go (90%) rename server/agui/{server.go => agui.go} (58%) create mode 100644 server/agui/event/translator.go delete mode 100644 server/agui/runner/input.go create mode 100644 server/agui/runner/options.go create mode 100644 server/agui/sdk/sdk.go diff --git a/event/event.go b/event/event.go index 5f4827894..6874a94a8 100644 --- a/event/event.go +++ b/event/event.go @@ -229,9 +229,9 @@ func EmitEventWithTimeout(ctx context.Context, ch chan<- *Event, select { case ch <- e: log.Debugf("EmitEventWithTimeout: event sent, event: %+v", *e) - case <-ctx.Done(): - log.Warnf("EmitEventWithTimeout: context cancelled, event: %+v", *e) - return ctx.Err() + // case <-ctx.Done(): + // log.Warnf("EmitEventWithTimeout: context cancelled, event: %+v", *e) + // return ctx.Err() } return nil } diff --git a/examples/agui/README.md b/examples/agui/README.md index a90c7057d..c81d7407a 100644 --- a/examples/agui/README.md +++ b/examples/agui/README.md @@ -1,8 +1,27 @@ # AG-UI Examples -This folder collects runnable demos that showcase how to integrate the -`tRPC-Agent-Go` AG-UI server and various clients. +This folder collects runnable demos that showcase how to integrate the `tRPC-Agent-Go` AG-UI server and various clients. -- [`bubbletea/`](bubbletea/) – Terminal chat experience with an AG-UI SSE server using `llmagent` and a minimal CLI client. +- [`client/`](client/) – Client-side samples. +- [`server/`](server/) – Server-side samples. -Each subdirectory contains its own instructions. +## Quick Start + +1. Start the default AG-UI server: + + ```bash + go run ./server/default + ``` + +2. In another terminal start the Bubble Tea client: + + ```bash + go run ./client/bubbletea/main.go + ``` + +3. Ask a question such as `calculate 1.2+3.5` and watch the live event stream in + the terminal. A full transcript example is documented in + [`client/bubbletea/README.md`](client/bubbletea/README.md). + +See the individual README files under `client/` and `server/` for more background +and configuration options. diff --git a/examples/agui/bubbletea/README.md b/examples/agui/bubbletea/README.md deleted file mode 100644 index e832393a1..000000000 --- a/examples/agui/bubbletea/README.md +++ /dev/null @@ -1,59 +0,0 @@ -# Bubbletea AG-UI Demo - -This example pairs a minimal AG-UI SSE server powered by `llmagent` with a -lightweight terminal client. The demo streams real model responses via the -AG-UI protocol. - -## Layout - -- `server/` – AG-UI SSE server backed by an OpenAI-compatible LLM agent. -- `client/` – Simple terminal client that reads a prompt, streams AG-UI events, - and prints the assistant output. - -## Prerequisites - -- Go 1.24+ -- An OpenAI-compatible API key (export `OPENAI_API_KEY`). - -## Run the server - -```bash -export OPENAI_API_KEY=sk-... -cd examples/agui -GO111MODULE=on go run ./bubbletea/server/cmd -``` - -The server listens on `http://localhost:8080/agui/run` by default. Adjust the -model name in `server/cmd/main.go` if you prefer a different backend. - -## Run the client - -In a second terminal: - -```bash -cd examples/agui -GO111MODULE=on go run ./bubbletea/client -``` - -Type a message and press Enter. The client streams AG-UI events from the server -and prints the assistant response. Press Enter on an empty line to exit. - -Example session: - -``` -╭──────────────────────────────────────────────────────────────╮ -│ │ -│ Simple AG-UI Client. Press Ctrl+C to quit. │ -│ You> calculate 1.25^0.42 │ -│ Agent> [RUN_STARTED] │ -│ Agent> [TOOL_CALL_START] tool call 'calculator' started, │ -│ Agent> [TOOL_CALL_ARGS] tool args: {"a":1.25,"b":0.42,"operation":"power"} -│ Agent> [TOOL_CALL_END] tool call completed, id: call_00_... │ -│ Agent> [TOOL_CALL_RESULT] tool result: {"result":1.09825...}│ -│ Agent> [TEXT_MESSAGE_START] │ -│ Agent> [TEXT_MESSAGE_CONTENT] The result of 1.25^0.42 is... │ -│ Agent> [TEXT_MESSAGE_END] │ -│ Agent> [RUN_FINISHED] │ -│ │ -╰──────────────────────────────────────────────────────────────╯ -``` diff --git a/examples/agui/bubbletea/client/main.go b/examples/agui/bubbletea/client/main.go deleted file mode 100644 index 89d7abbb4..000000000 --- a/examples/agui/bubbletea/client/main.go +++ /dev/null @@ -1,292 +0,0 @@ -package main - -import ( - "context" - "flag" - "fmt" - "log" - "strings" - "time" - - "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/client/sse" - "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/core/events" - "github.com/charmbracelet/bubbles/spinner" - "github.com/charmbracelet/bubbles/textinput" - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/sirupsen/logrus" -) - -var ( - docStyle = lipgloss.NewStyle().Margin(1, 2) - headerStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("205")).Bold(true) - bodyStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).Padding(1, 2) - inputStyle = lipgloss.NewStyle().MarginTop(1) -) - -const headerText = "AG-UI Demo" - -func main() { - endpoint := flag.String("endpoint", "http://localhost:8080/agui/run", "AG-UI SSE endpoint") - flag.Parse() - - if _, err := tea.NewProgram(initialModel(*endpoint), tea.WithAltScreen()).Run(); err != nil { - log.Fatalf("bubbletea program failed: %v", err) - } -} - -type model struct { - endpoint string - history []string - input textinput.Model - viewport viewport.Model - spinner spinner.Model - busy bool - ready bool -} - -func initialModel(endpoint string) model { - input := textinput.New() - input.Placeholder = "Ask something..." - input.Prompt = "You> " - input.Focus() - - spin := spinner.New() - spin.Spinner = spinner.Dot - - m := model{ - endpoint: endpoint, - history: []string{"Simple AG-UI Client. Press Ctrl+C to quit."}, - input: input, - spinner: spin, - } - return m -} - -func (m model) Init() tea.Cmd { - return m.spinner.Tick -} - -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - m.ready = true - m.configureViewport(msg.Width, msg.Height) - m.refreshViewport() - return m, nil - - case tea.KeyMsg: - switch msg.Type { - case tea.KeyCtrlC, tea.KeyEsc: - return m, tea.Quit - case tea.KeyEnter: - trimmed := strings.TrimSpace(m.input.Value()) - if trimmed == "" || m.busy { - return m, nil - } - m.input.Reset() - m.busy = true - m.history = append(m.history, fmt.Sprintf("You> %s", trimmed)) - m.refreshViewport() - return m, tea.Batch(m.spinner.Tick, startChatCmd(trimmed, m.endpoint)) - default: - if !m.busy { - var cmd tea.Cmd - m.input, cmd = m.input.Update(msg) - return m, cmd - } - return m, nil - } - - case chatResultMsg: - m.history = append(m.history, msg.lines...) - m.busy = false - m.refreshViewport() - m.input.Focus() - return m, nil - - case errMsg: - m.history = append(m.history, fmt.Sprintf("Error: %v", msg.error)) - m.busy = false - m.refreshViewport() - m.input.Focus() - return m, nil - - case spinner.TickMsg: - if !m.busy { - return m, nil - } - var cmd tea.Cmd - m.spinner, cmd = m.spinner.Update(msg) - return m, cmd - } - - if !m.busy { - var cmd tea.Cmd - m.input, cmd = m.input.Update(msg) - return m, cmd - } - return m, nil -} - -func (m model) View() string { - if !m.ready { - return "Loading..." - } - - header := headerStyle.Render(headerText) - bodyFrameWidth, bodyFrameHeight := bodyStyle.GetFrameSize() - bodyWidth := m.viewport.Width + bodyFrameWidth - bodyHeight := m.viewport.Height + bodyFrameHeight - body := bodyStyle.Width(bodyWidth).Height(bodyHeight).Render(m.viewport.View()) - - inputView := m.input.View() - if m.busy { - spin := lipgloss.NewStyle().Foreground(lipgloss.Color("63")).Render(m.spinner.View()) - inputView += " " + spin - } - - content := lipgloss.JoinVertical(lipgloss.Left, header, body, inputStyle.Render(inputView)) - return docStyle.Render(content) -} - -func (m *model) refreshViewport() { - if !m.ready { - return - } - content := strings.Join(m.history, "\n") - m.viewport.SetContent(content) - m.viewport.GotoBottom() -} - -func (m *model) configureViewport(width, height int) { - hFrameDoc, vFrameDoc := docStyle.GetFrameSize() - hFrameBody, vFrameBody := bodyStyle.GetFrameSize() - _, vFrameInput := inputStyle.GetFrameSize() - headerHeight := lipgloss.Height(headerStyle.Render(headerText)) - inputHeight := 1 + vFrameInput - - viewportWidth := width - hFrameDoc - hFrameBody - if viewportWidth < 20 { - viewportWidth = 20 - } - - viewportHeight := height - vFrameDoc - vFrameBody - headerHeight - inputHeight - if viewportHeight < 5 { - viewportHeight = 5 - } - - if m.viewport.Width != viewportWidth || m.viewport.Height != viewportHeight { - m.viewport = viewport.New(viewportWidth, viewportHeight) - } else { - m.viewport.Width = viewportWidth - m.viewport.Height = viewportHeight - } - m.input.Width = viewportWidth -} - -type chatResultMsg struct{ lines []string } -type errMsg struct{ error } - -func startChatCmd(prompt, endpoint string) tea.Cmd { - return func() tea.Msg { - lines, err := fetchResponse(prompt, endpoint) - if err != nil { - return errMsg{err} - } - return chatResultMsg{lines: lines} - } -} - -func fetchResponse(prompt, endpoint string) ([]string, error) { - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) - defer cancel() - - logger := logrus.New() - logger.SetLevel(logrus.FatalLevel) - - client := sse.NewClient(sse.Config{ - Endpoint: endpoint, - ConnectTimeout: 30 * time.Second, - ReadTimeout: 5 * time.Minute, - BufferSize: 100, - Logger: logger, - }) - defer client.Close() - - payload := map[string]any{ - "threadId": "demo-thread", - "runId": fmt.Sprintf("run-%d", time.Now().UnixNano()), - "messages": []map[string]any{{"role": "user", "content": prompt}}, - } - - frames, errCh, err := client.Stream(sse.StreamOptions{Context: ctx, Payload: payload}) - if err != nil { - return nil, fmt.Errorf("failed to start SSE stream: %w", err) - } - - var collected []events.Event - for { - select { - case frame, ok := <-frames: - if !ok { - return renderEvents(collected), nil - } - evt, err := events.EventFromJSON(frame.Data) - if err != nil { - return nil, fmt.Errorf("parse event: %w", err) - } - collected = append(collected, evt) - case err, ok := <-errCh: - if ok && err != nil { - return nil, err - } - case <-ctx.Done(): - return nil, ctx.Err() - } - } -} - -func renderEvents(evts []events.Event) []string { - var output []string - for _, evt := range evts { - output = append(output, formatEvent(evt)...) - } - if len(output) == 0 { - output = append(output, "Bot> (no response)") - } - return output -} - -func formatEvent(evt events.Event) []string { - label := fmt.Sprintf("[%s]", evt.Type()) - - switch e := evt.(type) { - case *events.RunStartedEvent: - return []string{fmt.Sprintf("Agent> %s", label)} - case *events.RunFinishedEvent: - return []string{fmt.Sprintf("Agent> %s", label)} - case *events.RunErrorEvent: - return []string{fmt.Sprintf("Agent> %s: %s", label, e.Message)} - case *events.TextMessageStartEvent: - return []string{fmt.Sprintf("Agent> %s", label)} - case *events.TextMessageContentEvent: - if strings.TrimSpace(e.Delta) == "" { - return nil - } - return []string{fmt.Sprintf("Agent> %s %s", label, e.Delta)} - case *events.TextMessageEndEvent: - return []string{fmt.Sprintf("Agent> %s", label)} - case *events.ToolCallStartEvent: - return []string{fmt.Sprintf("Agent> %s tool call '%s' started, id: %s", label, e.ToolCallName, e.ToolCallID)} - case *events.ToolCallArgsEvent: - return []string{fmt.Sprintf("Agent> %s tool args: %s", label, e.Delta)} - case *events.ToolCallEndEvent: - return []string{fmt.Sprintf("Agent> %s tool call completed, id: %s", label, e.ToolCallID)} - case *events.ToolCallResultEvent: - return []string{fmt.Sprintf("Agent> %s tool result: %s", label, e.Content)} - default: - return nil - } -} diff --git a/examples/agui/client/README.md b/examples/agui/client/README.md new file mode 100644 index 000000000..3d8f865c3 --- /dev/null +++ b/examples/agui/client/README.md @@ -0,0 +1,7 @@ +# AG-UI Clients + +This directory collects runnable clients that can talk to the AG-UI SSE server examples. + +## Available Clients + +- [bubbletea/](bubbletea/) – Terminal interface built with Bubble Tea. Streams events as they arrive so you can watch agent reasoning in real time. diff --git a/examples/agui/client/bubbletea/README.md b/examples/agui/client/bubbletea/README.md new file mode 100644 index 000000000..5baf1bc8b --- /dev/null +++ b/examples/agui/client/bubbletea/README.md @@ -0,0 +1,54 @@ +# Bubble Tea AG-UI Client + +This sample uses [Bubble Tea](https://github.com/charmbracelet/bubbletea) to present a terminal chat UI that consumes the AG-UI SSE stream exposed by the example server. Events are rendered as soon as they arrive so you can watch the agent reason step by step. + +## Run the Client + +From `examples/agui`: + +```bash +go run ./client/bubbletea/ +``` + +You can customise the endpoint with `--endpoint` if the server is hosted elsewhere. + +## Sample Output + +The client streams every AG-UI event. Submitting `calculate 1.2+3.5` produces output like the following (truncated IDs for clarity): + +```text +Simple AG-UI Client. Press Ctrl+C to quit. +You> calculate 1.2+3.5 +Agent> [RUN_STARTED] +Agent> [TEXT_MESSAGE_START] +Agent> [TEXT_MESSAGE_CONTENT] 我来 +Agent> [TEXT_MESSAGE_CONTENT] 帮 +Agent> [TEXT_MESSAGE_CONTENT] 您 +Agent> [TEXT_MESSAGE_CONTENT] 计算 +Agent> [TEXT_MESSAGE_CONTENT] 1 +Agent> [TEXT_MESSAGE_CONTENT] . +Agent> [TEXT_MESSAGE_CONTENT] 2 +Agent> [TEXT_MESSAGE_CONTENT] + +Agent> [TEXT_MESSAGE_CONTENT] 3 +Agent> [TEXT_MESSAGE_CONTENT] . +Agent> [TEXT_MESSAGE_CONTENT] 5 +Agent> [TEXT_MESSAGE_CONTENT] 。 +Agent> [TOOL_CALL_START] tool call 'calculator' started, id: call_... +Agent> [TOOL_CALL_ARGS] tool args: {"a":1.2,"b":3.5,"operation":"plus"} +Agent> [TOOL_CALL_END] tool call completed, id: call_... +Agent> [TOOL_CALL_RESULT] tool result: {"result":4.7} +Agent> [TEXT_MESSAGE_START] +Agent> [TEXT_MESSAGE_CONTENT] 1 +Agent> [TEXT_MESSAGE_CONTENT] . +Agent> [TEXT_MESSAGE_CONTENT] 2 +Agent> [TEXT_MESSAGE_CONTENT] + +Agent> [TEXT_MESSAGE_CONTENT] 3 +Agent> [TEXT_MESSAGE_CONTENT] . +Agent> [TEXT_MESSAGE_CONTENT] 5 +Agent> [TEXT_MESSAGE_CONTENT] = +Agent> [TEXT_MESSAGE_CONTENT] 4 +Agent> [TEXT_MESSAGE_CONTENT] . +Agent> [TEXT_MESSAGE_CONTENT] 7 +Agent> [TEXT_MESSAGE_END] +Agent> [RUN_FINISHED] +``` diff --git a/examples/agui/client/bubbletea/agui.go b/examples/agui/client/bubbletea/agui.go new file mode 100644 index 000000000..33eedc0b1 --- /dev/null +++ b/examples/agui/client/bubbletea/agui.go @@ -0,0 +1,158 @@ +package main + +import ( + "context" + "fmt" + "strings" + "sync" + "time" + + "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/client/sse" + "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/core/events" + tea "github.com/charmbracelet/bubbletea" + "github.com/sirupsen/logrus" +) + +type errMsg struct { + error +} + +type chatStreamReadyMsg struct { + stream *chatStream +} + +type chatStreamEventMsg struct { + stream *chatStream + lines []string +} + +type chatStreamFinishedMsg struct { + stream *chatStream +} + +func startChatCmd(prompt, endpoint string) tea.Cmd { + return func() tea.Msg { + stream, err := openChatStream(prompt, endpoint) + if err != nil { + return errMsg{err} + } + return chatStreamReadyMsg{stream: stream} + } +} + +type chatStream struct { + ctx context.Context + cancel context.CancelFunc + client *sse.Client + frames <-chan sse.Frame + errCh <-chan error + once sync.Once +} + +func (s *chatStream) Close() { + if s == nil { + return + } + s.once.Do(func() { + s.cancel() + s.client.Close() + }) +} + +func openChatStream(prompt, endpoint string) (*chatStream, error) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + logger := logrus.New() + logger.SetLevel(logrus.FatalLevel) + client := sse.NewClient(sse.Config{ + Endpoint: endpoint, + ConnectTimeout: 30 * time.Second, + ReadTimeout: 5 * time.Minute, + BufferSize: 100, + Logger: logger, + }) + payload := map[string]any{ + "threadId": "demo-thread", + "runId": fmt.Sprintf("run-%d", time.Now().UnixNano()), + "messages": []map[string]any{{"role": "user", "content": prompt}}, + } + frames, errCh, err := client.Stream(sse.StreamOptions{Context: ctx, Payload: payload}) + if err != nil { + cancel() + client.Close() + return nil, fmt.Errorf("failed to start SSE stream: %w", err) + } + return &chatStream{ + ctx: ctx, + cancel: cancel, + client: client, + frames: frames, + errCh: errCh, + }, nil +} + +func readNextEventCmd(stream *chatStream) tea.Cmd { + if stream == nil { + return nil + } + return func() tea.Msg { + for { + select { + case frame, ok := <-stream.frames: + if !ok { + stream.Close() + return chatStreamFinishedMsg{stream: stream} + } + evt, err := events.EventFromJSON(frame.Data) + if err != nil { + stream.Close() + return fmt.Errorf("parse event: %w", err) + } + lines := formatEvent(evt) + if len(lines) == 0 { + continue + } + return chatStreamEventMsg{stream: stream, lines: lines} + case err, ok := <-stream.errCh: + if !ok || err == nil { + continue + } + stream.Close() + return errMsg{err} + case <-stream.ctx.Done(): + stream.Close() + return errMsg{stream.ctx.Err()} + } + } + } +} + +func formatEvent(evt events.Event) []string { + label := fmt.Sprintf("[%s]", evt.Type()) + switch e := evt.(type) { + case *events.RunStartedEvent: + return []string{fmt.Sprintf("Agent> %s", label)} + case *events.RunFinishedEvent: + return []string{fmt.Sprintf("Agent> %s", label)} + case *events.RunErrorEvent: + return []string{fmt.Sprintf("Agent> %s: %s", label, e.Message)} + case *events.TextMessageStartEvent: + return []string{fmt.Sprintf("Agent> %s", label)} + case *events.TextMessageContentEvent: + if strings.TrimSpace(e.Delta) == "" { + return nil + } + return []string{fmt.Sprintf("Agent> %s %s", label, e.Delta)} + case *events.TextMessageEndEvent: + return []string{fmt.Sprintf("Agent> %s", label)} + case *events.ToolCallStartEvent: + return []string{fmt.Sprintf("Agent> %s tool call '%s' started, id: %s", label, e.ToolCallName, e.ToolCallID)} + case *events.ToolCallArgsEvent: + return []string{fmt.Sprintf("Agent> %s tool args: %s", label, e.Delta)} + case *events.ToolCallEndEvent: + return []string{fmt.Sprintf("Agent> %s tool call completed, id: %s", label, e.ToolCallID)} + case *events.ToolCallResultEvent: + return []string{fmt.Sprintf("Agent> %s tool result: %s", label, e.Content)} + default: + return nil + } +} diff --git a/examples/agui/client/bubbletea/main.go b/examples/agui/client/bubbletea/main.go new file mode 100644 index 000000000..d945f5d4e --- /dev/null +++ b/examples/agui/client/bubbletea/main.go @@ -0,0 +1,20 @@ +package main + +import ( + "flag" + "log" + + tea "github.com/charmbracelet/bubbletea" +) + +func main() { + endpoint := flag.String("endpoint", "http://localhost:8080/agui", "AG-UI SSE endpoint") + flag.Parse() + + if _, err := tea.NewProgram( + initialModel(*endpoint), + tea.WithAltScreen(), + ).Run(); err != nil { + log.Fatalf("bubbletea program failed: %v", err) + } +} diff --git a/examples/agui/client/bubbletea/ui.go b/examples/agui/client/bubbletea/ui.go new file mode 100644 index 000000000..4f3e3cc8b --- /dev/null +++ b/examples/agui/client/bubbletea/ui.go @@ -0,0 +1,221 @@ +package main + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/spinner" + "github.com/charmbracelet/bubbles/textinput" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +var ( + docStyle = lipgloss.NewStyle().Margin(1, 2) + headerStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("205")).Bold(true) + bodyStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).Padding(1, 2) + inputStyle = lipgloss.NewStyle().MarginTop(1) +) + +const headerText = "AG-UI Demo" + +type model struct { + endpoint string + history []string + input textinput.Model + viewport viewport.Model + spinner spinner.Model + busy bool + ready bool + stream *chatStream + streamHasOutput bool +} + +func initialModel(endpoint string) tea.Model { + input := textinput.New() + input.Placeholder = "Ask something..." + input.Prompt = "You> " + input.Focus() + + spin := spinner.New() + spin.Spinner = spinner.Dot + + m := model{ + endpoint: endpoint, + history: []string{"Simple AG-UI Client. Press Ctrl+C to quit."}, + input: input, + spinner: spin, + } + return m +} + +func (m model) Init() tea.Cmd { + return m.spinner.Tick +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.ready = true + m.configureViewport(msg.Width, msg.Height) + m.refreshViewport() + return m, nil + + case tea.KeyMsg: + switch msg.Type { + case tea.KeyCtrlC, tea.KeyEsc: + return m, tea.Quit + case tea.KeyEnter: + trimmed := strings.TrimSpace(m.input.Value()) + if trimmed == "" || m.busy { + return m, nil + } + m.input.Reset() + m.busy = true + if m.stream != nil { + m.stream.Close() + } + m.stream = nil + m.streamHasOutput = false + m.history = append(m.history, fmt.Sprintf("You> %s", trimmed)) + m.refreshViewport() + return m, tea.Batch(m.spinner.Tick, startChatCmd(trimmed, m.endpoint)) + default: + var cmds []tea.Cmd + var viewportCmd tea.Cmd + m.viewport, viewportCmd = m.viewport.Update(msg) + if viewportCmd != nil { + cmds = append(cmds, viewportCmd) + } + if !m.busy { + var inputCmd tea.Cmd + m.input, inputCmd = m.input.Update(msg) + if inputCmd != nil { + cmds = append(cmds, inputCmd) + } + } + if len(cmds) == 0 { + return m, nil + } + return m, tea.Batch(cmds...) + } + + case chatStreamReadyMsg: + if msg.stream == nil { + return m, nil + } + m.stream = msg.stream + m.streamHasOutput = false + return m, readNextEventCmd(msg.stream) + + case chatStreamEventMsg: + if msg.stream == nil || m.stream != msg.stream { + return m, nil + } + if len(msg.lines) > 0 { + m.history = append(m.history, msg.lines...) + m.streamHasOutput = true + m.refreshViewport() + } + return m, readNextEventCmd(msg.stream) + + case chatStreamFinishedMsg: + if msg.stream == nil || m.stream != msg.stream { + return m, nil + } + msg.stream.Close() + m.stream = nil + m.busy = false + if !m.streamHasOutput { + m.history = append(m.history, "Bot> (no response)") + } + m.streamHasOutput = false + m.refreshViewport() + m.input.Focus() + return m, nil + + case errMsg: + if m.stream != nil { + m.stream.Close() + m.stream = nil + } + m.streamHasOutput = false + m.history = append(m.history, fmt.Sprintf("Error: %v", msg.error)) + m.busy = false + m.refreshViewport() + m.input.Focus() + return m, nil + + case spinner.TickMsg: + if !m.busy { + return m, nil + } + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd + } + + if !m.busy { + var cmd tea.Cmd + m.input, cmd = m.input.Update(msg) + return m, cmd + } + return m, nil +} + +func (m model) View() string { + if !m.ready { + return "Loading..." + } + + header := headerStyle.Render(headerText) + bodyFrameWidth, bodyFrameHeight := bodyStyle.GetFrameSize() + bodyWidth := m.viewport.Width + bodyFrameWidth + bodyHeight := m.viewport.Height + bodyFrameHeight + body := bodyStyle.Width(bodyWidth).Height(bodyHeight).Render(m.viewport.View()) + + inputView := m.input.View() + if m.busy { + spin := lipgloss.NewStyle().Foreground(lipgloss.Color("63")).Render(m.spinner.View()) + inputView += " " + spin + } + + content := lipgloss.JoinVertical(lipgloss.Left, header, body, inputStyle.Render(inputView)) + return docStyle.Render(content) +} + +func (m *model) refreshViewport() { + if !m.ready { + return + } + content := strings.Join(m.history, "\n") + m.viewport.SetContent(content) + m.viewport.GotoBottom() +} + +func (m *model) configureViewport(width, height int) { + hFrameDoc, vFrameDoc := docStyle.GetFrameSize() + hFrameBody, vFrameBody := bodyStyle.GetFrameSize() + _, vFrameInput := inputStyle.GetFrameSize() + headerHeight := lipgloss.Height(headerStyle.Render(headerText)) + inputHeight := 1 + vFrameInput + + viewportWidth := width - hFrameDoc - hFrameBody + if viewportWidth < 20 { + viewportWidth = 20 + } + + viewportHeight := height - vFrameDoc - vFrameBody - headerHeight - inputHeight + if viewportHeight < 5 { + viewportHeight = 5 + } + + if m.viewport.Width != viewportWidth || m.viewport.Height != viewportHeight { + m.viewport = viewport.New(viewportWidth, viewportHeight) + } else { + m.viewport.Width = viewportWidth + m.viewport.Height = viewportHeight + } + m.input.Width = viewportWidth +} diff --git a/examples/agui/go.mod b/examples/agui/go.mod index 440869b48..ff40b976c 100644 --- a/examples/agui/go.mod +++ b/examples/agui/go.mod @@ -2,7 +2,7 @@ module trpc.group/trpc-go/trpc-agent-go/examples/agui go 1.24.4 -replace github.com/ag-ui-protocol/ag-ui/sdks/community/go => github.com/Flash-LHR/ag-ui/sdks/community/go v0.0.0-20250918062220-1d05c81e442a +replace github.com/ag-ui-protocol/ag-ui/sdks/community/go => github.com/Flash-LHR/ag-ui/sdks/community/go v0.0.0-20250919021155-c1b3471899c8 replace trpc.group/trpc-go/trpc-agent-go => ../../ diff --git a/examples/agui/go.sum b/examples/agui/go.sum index 739214c6f..bca148133 100644 --- a/examples/agui/go.sum +++ b/examples/agui/go.sum @@ -1,5 +1,5 @@ -github.com/Flash-LHR/ag-ui/sdks/community/go v0.0.0-20250918062220-1d05c81e442a h1:09jFEqvgNKp6xEqonncNilnqqEXCJvORzEh5ktWqoPw= -github.com/Flash-LHR/ag-ui/sdks/community/go v0.0.0-20250918062220-1d05c81e442a/go.mod h1:ERAMOexUee4AIuoxksuuGoEcHl3aqLwaazjGwlR9ZCI= +github.com/Flash-LHR/ag-ui/sdks/community/go v0.0.0-20250919021155-c1b3471899c8 h1:3ZcB9AXuWmx/hFCptPv/DcY+zmwkruRTJREXSQjBHhM= +github.com/Flash-LHR/ag-ui/sdks/community/go v0.0.0-20250919021155-c1b3471899c8/go.mod h1:ERAMOexUee4AIuoxksuuGoEcHl3aqLwaazjGwlR9ZCI= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= diff --git a/examples/agui/server/README.md b/examples/agui/server/README.md new file mode 100644 index 000000000..71a67ab74 --- /dev/null +++ b/examples/agui/server/README.md @@ -0,0 +1,8 @@ +# AG-UI Servers + +This directory shows AG-UI servers that can talk to the AG-UI client examples. + +## Available Servers + +- [`default/`](default/) – Minimal AG-UI server that wires the `tRPC-Agent-Go` runner. + diff --git a/examples/agui/server/default/README.md b/examples/agui/server/default/README.md new file mode 100644 index 000000000..3081e9b7c --- /dev/null +++ b/examples/agui/server/default/README.md @@ -0,0 +1,14 @@ +# Default AG-UI Server + +This example exposes a minimal AG-UI SSE endpoint backed by the `tRPC-Agent-Go` runner. It is intended to be used alongside the Bubble Tea client in `../client/bubbletea`. + +## Run + +From the `examples/agui` module: + +```bash +# Start the server on http://localhost:8080/agui +go run ./server/default +``` + +The server prints startup logs showing the bound address and the registered runner. diff --git a/examples/agui/bubbletea/server/main.go b/examples/agui/server/default/main.go similarity index 90% rename from examples/agui/bubbletea/server/main.go rename to examples/agui/server/default/main.go index e83a02828..7b762a5f6 100644 --- a/examples/agui/bubbletea/server/main.go +++ b/examples/agui/server/default/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "flag" "fmt" "log" "math" @@ -14,12 +15,18 @@ import ( "trpc.group/trpc-go/trpc-agent-go/tool/function" ) +var ( + modelName = flag.String("model", "deepseek-chat", "Model to use") + isStream = flag.Bool("stream", true, "Whether to stream the response") +) + func main() { - modelInstance := openai.New("deepseek-chat") + flag.Parse() + modelInstance := openai.New(*modelName) generationConfig := model.GenerationConfig{ MaxTokens: intPtr(512), Temperature: floatPtr(0.7), - Stream: false, + Stream: *isStream, } calculatorTool := function.NewFunctionTool( calculator, diff --git a/server/agui/server.go b/server/agui/agui.go similarity index 58% rename from server/agui/server.go rename to server/agui/agui.go index b43239475..f1d677729 100644 --- a/server/agui/server.go +++ b/server/agui/agui.go @@ -1,3 +1,13 @@ +// +// Tencent is pleased to support the open source community by making trpc-agent-go available. +// +// Copyright (C) 2025 Tencent. All rights reserved. +// +// trpc-agent-go is licensed under the Apache License Version 2.0. +// +// + +// Package agui provides the ability to communicate with the front end through the AG-UI protocol. package agui import ( @@ -5,6 +15,7 @@ import ( "errors" "trpc.group/trpc-go/trpc-agent-go/agent" + "trpc.group/trpc-go/trpc-agent-go/log" "trpc.group/trpc-go/trpc-agent-go/runner" aguirunner "trpc.group/trpc-go/trpc-agent-go/server/agui/runner" "trpc.group/trpc-go/trpc-agent-go/server/agui/service" @@ -13,22 +24,21 @@ import ( "trpc.group/trpc-go/trpc-agent-go/session/inmemory" ) -// Server wires the agent, session service, and transport together. +// Server provides AG-UI server. type Server struct { + address string + path string agent agent.Agent sessionService session.Service service service.Service } -// New creates a server instance ready to accept AG-UI requests. +// New creates a AG-UI server instance. func New(agent agent.Agent, opt ...Option) (*Server, error) { if agent == nil { return nil, errors.New("agui: agent must not be nil") } - var opts options - for _, o := range opt { - o(&opts) - } + opts := newOptions(opt...) sessionService := opts.sessionService if sessionService == nil { sessionService = inmemory.NewSessionService() @@ -40,10 +50,12 @@ func New(agent agent.Agent, opt ...Option) (*Server, error) { agent, runner.WithSessionService(sessionService), ) - aguiRunner := aguirunner.New(runner) - service = sse.New(sse.WithRunner(aguiRunner)) + aguiRunner := aguirunner.New(runner, opts.runnerOptions...) + service = sse.New(aguiRunner, sse.WithAddress(opts.address), sse.WithPath(opts.path)) } server := &Server{ + address: opts.address, + path: opts.path, agent: agent, sessionService: sessionService, service: service, @@ -51,12 +63,13 @@ func New(agent agent.Agent, opt ...Option) (*Server, error) { return server, nil } -// Serve starts the service. +// Serve starts the server. func (s *Server) Serve(ctx context.Context) error { + log.Infof("AG-UI: serving agent %q on %s%s", s.agent.Info().Name, s.address, s.path) return s.service.Serve(ctx) } -// Close stops the service. +// Close stops the server. func (s *Server) Close(ctx context.Context) error { return s.service.Close(ctx) } diff --git a/server/agui/event/event.go b/server/agui/event/event.go index 98525b29f..7d5226514 100644 --- a/server/agui/event/event.go +++ b/server/agui/event/event.go @@ -7,221 +7,52 @@ // // -// Package event provides the event translation from runner events to AG-UI events. +// Package event provides the event bridge from runner events to AG-UI events. package event import ( - "encoding/json" - "strings" - - aguievents "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/core/events" - agentevent "trpc.group/trpc-go/trpc-agent-go/event" - "trpc.group/trpc-go/trpc-agent-go/model" + "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/core/events" + "trpc.group/trpc-go/trpc-agent-go/event" ) -// Translator converts runner events into AG-UI events. -type Translator interface { - FromRunnerEvent(evt *agentevent.Event) []aguievents.Event - Finalize() []aguievents.Event +// Bridge is the event bridge from trpc-agent-go events to AG-UI events. +type Bridge interface { + // NewRunStartedEvent creates a new run started event. + NewRunStartedEvent() events.Event + // NewRunErrorEvent creates a new run error event. + NewRunErrorEvent(errorMessage string) events.Event + // NewRunFinishedEvent creates a new run finished event. + NewRunFinishedEvent() events.Event + // Translate translates a trpc-agent-go event to AG-UI events. + Translate(event *event.Event) ([]events.Event, error) } -// defaultTranslator maps runner events into AG-UI event sequence. -type defaultTranslator struct { +// bridge is the default implementation of the Bridge. +type bridge struct { threadID string runID string - messageID string - messageActive bool - toolCalls map[string]string + lastMessageID string } -// NewTranslator returns the default translator implementation. -func NewTranslator(threadID, runID string) Translator { - return &defaultTranslator{ - threadID: threadID, - runID: runID, - toolCalls: make(map[string]string), +// NewBridge creates a new event bridge. +func NewBridge(threadID, runID string) Bridge { + return &bridge{ + threadID: threadID, + runID: runID, } } -// FromRunnerEvent converts one runner event into zero or more AG-UI events. -func (t *defaultTranslator) FromRunnerEvent(evt *agentevent.Event) []aguievents.Event { - if evt == nil { - return nil - } - var out []aguievents.Event - if snapshot := t.stateSnapshotEvent(evt); snapshot != nil { - out = append(out, snapshot) - } - if evt.Response != nil { - resp := evt.Response - if resp.Error != nil { - out = append(out, aguievents.NewRunErrorEvent(resp.Error.Message, aguievents.WithRunID(t.runID))) - return out - } - if resp.IsToolCallResponse() { - out = append(out, t.translateToolCall(resp)...) - return out - } - if resp.IsToolResultResponse() { - out = append(out, t.translateToolResult(resp)...) - return out - } - if delta := extractTextFromResponse(resp); delta != "" { - out = append(out, t.emitAssistantText(delta, resp.IsPartial)...) - if !resp.IsPartial { - out = append(out, t.finishMessage()...) - } - return out - } - } - return out +// NewRunStartedEvent creates a new run started event. +func (m *bridge) NewRunStartedEvent() events.Event { + return events.NewRunStartedEvent(m.threadID, m.runID) } -// Finalize emits any trailing events required to finish the stream. -func (t *defaultTranslator) Finalize() []aguievents.Event { - return t.finishMessage() -} - -func (t *defaultTranslator) emitAssistantText(delta string, partial bool) []aguievents.Event { - if strings.TrimSpace(delta) == "" { - return nil - } - var events []aguievents.Event - if !t.messageActive { - t.messageID = aguievents.GenerateMessageID() - start := aguievents.NewTextMessageStartEvent(t.messageID, aguievents.WithRole("assistant")) - events = append(events, start) - t.messageActive = true - } - events = append(events, aguievents.NewTextMessageContentEvent(t.messageID, delta)) - if !partial { - events = append(events, t.finishMessage()...) - } - return events +// NewRunErrorEvent creates a new run error event. +func (m *bridge) NewRunErrorEvent(errorMessage string) events.Event { + return events.NewRunErrorEvent(errorMessage, events.WithRunID(m.runID)) } -func (t *defaultTranslator) finishMessage() []aguievents.Event { - if !t.messageActive || t.messageID == "" { - return nil - } - end := aguievents.NewTextMessageEndEvent(t.messageID) - t.messageActive = false - t.messageID = "" - return []aguievents.Event{end} -} - -func (t *defaultTranslator) stateSnapshotEvent(evt *agentevent.Event) aguievents.Event { - if len(evt.StateDelta) == 0 { - return nil - } - snapshot := make(map[string]any, len(evt.StateDelta)) - for key, val := range evt.StateDelta { - if len(val) == 0 { - snapshot[key] = "" - continue - } - var decoded any - if err := json.Unmarshal(val, &decoded); err == nil { - snapshot[key] = decoded - continue - } - snapshot[key] = string(val) - } - return aguievents.NewStateSnapshotEvent(snapshot) -} - -func (t *defaultTranslator) translateToolCall(resp *model.Response) []aguievents.Event { - var events []aguievents.Event - for _, choice := range resp.Choices { - calls := choice.Delta.ToolCalls - if len(calls) == 0 { - calls = choice.Message.ToolCalls - } - for _, tc := range calls { - id := tc.ID - if id == "" { - id = aguievents.GenerateToolCallID() - } - name := tc.Function.Name - if name == "" { - name = "tool" - } - if _, exists := t.toolCalls[id]; !exists { - events = append(events, aguievents.NewToolCallStartEvent(id, name)) - t.toolCalls[id] = name - } - if len(tc.Function.Arguments) > 0 { - if args := formatArguments(tc.Function.Arguments); args != "" { - events = append(events, aguievents.NewToolCallArgsEvent(id, args)) - } - } - if !resp.IsPartial { - events = append(events, aguievents.NewToolCallEndEvent(id)) - delete(t.toolCalls, id) - } - } - } - return events -} - -func (t *defaultTranslator) translateToolResult(resp *model.Response) []aguievents.Event { - var events []aguievents.Event - for _, choice := range resp.Choices { - msg := choice.Message - if msg.ToolID == "" { - continue - } - content := textFromMessage(msg) - if content == "" { - continue - } - messageID := msg.ToolID - if messageID == "" { - messageID = aguievents.GenerateMessageID() - } - events = append(events, aguievents.NewToolCallResultEvent(messageID, msg.ToolID, content)) - } - return events -} - -func formatArguments(raw []byte) string { - if len(raw) == 0 { - return "" - } - var obj any - if err := json.Unmarshal(raw, &obj); err == nil { - if pretty, err := json.Marshal(obj); err == nil { - return string(pretty) - } - } - return string(raw) -} - -func extractTextFromResponse(resp *model.Response) string { - if resp == nil || len(resp.Choices) == 0 { - return "" - } - choice := resp.Choices[0] - if resp.IsPartial { - if msg := textFromMessage(choice.Delta); msg != "" { - return msg - } - } - return textFromMessage(choice.Message) -} - -func textFromMessage(msg model.Message) string { - if msg.Content != "" { - return msg.Content - } - if len(msg.ContentParts) == 0 { - return "" - } - var builder strings.Builder - for _, part := range msg.ContentParts { - if part.Text != nil { - builder.WriteString(*part.Text) - } - } - return builder.String() +// NewRunFinishedEvent creates a new run finished event. +func (m *bridge) NewRunFinishedEvent() events.Event { + return events.NewRunFinishedEvent(m.threadID, m.runID) } diff --git a/server/agui/event/translator.go b/server/agui/event/translator.go new file mode 100644 index 000000000..80c822a81 --- /dev/null +++ b/server/agui/event/translator.go @@ -0,0 +1,118 @@ +// +// Tencent is pleased to support the open source community by making trpc-agent-go available. +// +// Copyright (C) 2025 Tencent. All rights reserved. +// +// trpc-agent-go is licensed under the Apache License Version 2.0. +// +// + +package event + +import ( + "encoding/json" + "errors" + + aguievents "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/core/events" + agentevent "trpc.group/trpc-go/trpc-agent-go/event" + "trpc.group/trpc-go/trpc-agent-go/model" +) + +// Translate translates one trpc-agent-go event into zero or more AG-UI events. +func (b *bridge) Translate(event *agentevent.Event) ([]aguievents.Event, error) { + if event == nil { + return nil, errors.New("event is nil") + } + if event.Response != nil { + rsp := event.Response + if rsp.Error != nil { + return []aguievents.Event{b.NewRunErrorEvent(rsp.Error.Message)}, nil + } + if rsp.IsToolCallResponse() { + return b.toolCallEvent(rsp) + } + if rsp.IsToolResultResponse() { + return b.toolResultEvent(rsp) + } + if rsp.IsFinalResponse() { + return []aguievents.Event{aguievents.NewTextMessageEndEvent(rsp.ID), b.NewRunFinishedEvent()}, nil + } + return b.textMessageEvent(rsp) + } + return nil, nil +} + +// textMessageEvent translates a text message trpc-agent-go event to AG-UI events. +func (b *bridge) textMessageEvent(rsp *model.Response) ([]aguievents.Event, error) { + if rsp == nil || len(rsp.Choices) == 0 { + return nil, nil + } + var events []aguievents.Event + // Different message ID means a new message. + if b.lastMessageID != rsp.ID { + b.lastMessageID = rsp.ID + switch rsp.Object { + case model.ObjectTypeChatCompletionChunk: + events = append(events, aguievents.NewTextMessageStartEvent(rsp.ID, aguievents.WithRole("assistant"))) + case model.ObjectTypeChatCompletion: + return nil, errors.New("non-streaming response is not supported, waiting for agui sdk support") + default: + return nil, errors.New("invalid response object") + } + } + // Streaming response. + switch rsp.Object { + // Streaming chunk. + case model.ObjectTypeChatCompletionChunk: + if rsp.Choices[0].Delta.Content != "" { + events = append(events, aguievents.NewTextMessageContentEvent(rsp.ID, rsp.Choices[0].Delta.Content)) + } + // For streaming response, don't need to emit final completion event. + // It means the response is ended. + case model.ObjectTypeChatCompletion: + events = append(events, aguievents.NewTextMessageEndEvent(rsp.ID)) + default: + return nil, errors.New("invalid response object") + } + return events, nil +} + +// toolCallEvent translates a tool call trpc-agent-go event to AG-UI events. +func (b *bridge) toolCallEvent(rsp *model.Response) ([]aguievents.Event, error) { + var events []aguievents.Event + toolCall := rsp.Choices[0].Message.ToolCalls[0] + // Tool call start event. + var startOpt []aguievents.ToolCallStartOption + startOpt = append(startOpt, aguievents.WithParentMessageID(rsp.ID)) + events = append(events, aguievents.NewToolCallStartEvent(toolCall.ID, toolCall.Function.Name, startOpt...)) + // Tool call arguments event. + toolCallArguments := formatToolCallArguments(toolCall.Function.Arguments) + events = append(events, aguievents.NewToolCallArgsEvent(toolCall.ID, toolCallArguments)) + b.lastMessageID = rsp.ID + return events, nil +} + +// toolResultEvent translates a tool result trpc-agent-go event to AG-UI events. +func (b *bridge) toolResultEvent(rsp *model.Response) ([]aguievents.Event, error) { + var events []aguievents.Event + choice := rsp.Choices[0] + // Tool call end event. + events = append(events, aguievents.NewToolCallEndEvent(choice.Message.ToolID)) + // Tool call result event. + events = append(events, aguievents.NewToolCallResultEvent(b.lastMessageID, choice.Message.ToolID, choice.Message.Content)) + return events, nil +} + +// formatToolCallArguments formats a tool call arguments event to a string. +func formatToolCallArguments(arguments []byte) string { + if len(arguments) == 0 { + return "" + } + var obj any + if err := json.Unmarshal(arguments, &obj); err == nil { + if pretty, err := json.Marshal(obj); err == nil { + return string(pretty) + } + } + return string(arguments) +} diff --git a/server/agui/go.mod b/server/agui/go.mod index 34562bd33..4329989e5 100644 --- a/server/agui/go.mod +++ b/server/agui/go.mod @@ -2,7 +2,7 @@ module trpc.group/trpc-go/trpc-agent-go/server/agui go 1.24.4 -replace github.com/ag-ui-protocol/ag-ui/sdks/community/go => github.com/Flash-LHR/ag-ui/sdks/community/go v0.0.0-20250918062220-1d05c81e442a +replace github.com/ag-ui-protocol/ag-ui/sdks/community/go => github.com/Flash-LHR/ag-ui/sdks/community/go v0.0.0-20250919021155-c1b3471899c8 require ( github.com/ag-ui-protocol/ag-ui/sdks/community/go v0.0.0-00010101000000-000000000000 diff --git a/server/agui/go.sum b/server/agui/go.sum index 498819e9e..527c44dab 100644 --- a/server/agui/go.sum +++ b/server/agui/go.sum @@ -1,5 +1,5 @@ -github.com/Flash-LHR/ag-ui/sdks/community/go v0.0.0-20250918062220-1d05c81e442a h1:09jFEqvgNKp6xEqonncNilnqqEXCJvORzEh5ktWqoPw= -github.com/Flash-LHR/ag-ui/sdks/community/go v0.0.0-20250918062220-1d05c81e442a/go.mod h1:ERAMOexUee4AIuoxksuuGoEcHl3aqLwaazjGwlR9ZCI= +github.com/Flash-LHR/ag-ui/sdks/community/go v0.0.0-20250919021155-c1b3471899c8 h1:3ZcB9AXuWmx/hFCptPv/DcY+zmwkruRTJREXSQjBHhM= +github.com/Flash-LHR/ag-ui/sdks/community/go v0.0.0-20250919021155-c1b3471899c8/go.mod h1:ERAMOexUee4AIuoxksuuGoEcHl3aqLwaazjGwlR9ZCI= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= diff --git a/server/agui/options.go b/server/agui/options.go index 924bf1ed6..c2c13c9b5 100644 --- a/server/agui/options.go +++ b/server/agui/options.go @@ -1,28 +1,82 @@ +// +// Tencent is pleased to support the open source community by making trpc-agent-go available. +// +// Copyright (C) 2025 Tencent. All rights reserved. +// +// trpc-agent-go is licensed under the Apache License Version 2.0. +// +// + package agui import ( + aguirunner "trpc.group/trpc-go/trpc-agent-go/server/agui/runner" "trpc.group/trpc-go/trpc-agent-go/server/agui/service" "trpc.group/trpc-go/trpc-agent-go/session" ) -// Option customizes server behaviour. -type Option func(*options) +const ( + // DefaultAddress is the default listen address. + DefaultAddress = "0.0.0.0:8080" + // DefaultPath is the default HTTP path the SSE handler is mounted on. + DefaultPath = "/agui" +) +// options holds the options for the AG-UI server. type options struct { + address string + path string service service.Service sessionService session.Service + runnerOptions []aguirunner.Option } -// WithService replaces the default transport service. +// newOptions creates a new options instance. +func newOptions(opt ...Option) *options { + opts := &options{ + address: DefaultAddress, + path: DefaultPath, + } + for _, o := range opt { + o(opts) + } + return opts +} + +// Option is a function that configures the options. +type Option func(*options) + +// WithService sets the service. func WithService(s service.Service) Option { return func(o *options) { o.service = s } } -// WithSessionService replaces the default session service implementation. +// WithSessionService sets the session service. func WithSessionService(svc session.Service) Option { return func(o *options) { o.sessionService = svc } } + +// WithRunnerOptions sets the runner options. +func WithRunnerOptions(runnerOpts ...aguirunner.Option) Option { + return func(o *options) { + o.runnerOptions = append(o.runnerOptions, runnerOpts...) + } +} + +// WithAddress sets the address for service listening. +func WithAddress(addr string) Option { + return func(o *options) { + o.address = addr + } +} + +// WithPath sets the path for service listening. +func WithPath(path string) Option { + return func(o *options) { + o.path = path + } +} diff --git a/server/agui/runner/input.go b/server/agui/runner/input.go deleted file mode 100644 index 0ff894f14..000000000 --- a/server/agui/runner/input.go +++ /dev/null @@ -1,224 +0,0 @@ -// -// Tencent is pleased to support the open source community by making trpc-agent-go available. -// -// Copyright (C) 2025 Tencent. All rights reserved. -// -// trpc-agent-go is licensed under the Apache License Version 2.0. -// -// - -package runner - -import ( - "encoding/json" - "errors" - "io" - "strings" - - "trpc.group/trpc-go/trpc-agent-go/model" -) - -// NOTE: This file should be removed when the AG-UI Go SDK exposes the official structure. - -// RunAgentInput captures the parameters for an AG-UI run request. -// NOTE: This type should be removed when the AG-UI Go SDK exposes the official structure. -type RunAgentInput struct { - ThreadID string - RunID string - Messages []model.Message - State map[string]any - ForwardedProps map[string]any -} - -// UserID returns the derived user identifier. -// NOTE: This function should be removed when the AG-UI Go SDK exposes the official structure. -func (in *RunAgentInput) UserID() string { - return in.ThreadID -} - -// SessionID returns the derived session identifier. -// NOTE: This function should be removed when the AG-UI Go SDK exposes the official structure. -func (in *RunAgentInput) SessionID() string { - return in.RunID -} - -// LatestUserMessage returns the most recent user message with content. -// NOTE: This function should be removed when the AG-UI Go SDK exposes the official structure. -func (in *RunAgentInput) LatestUserMessage() (model.Message, bool) { - for i := len(in.Messages) - 1; i >= 0; i-- { - msg := in.Messages[i] - if msg.Role == model.RoleUser && (msg.Content != "" || len(msg.ContentParts) > 0) { - return msg, true - } - } - return model.Message{}, false -} - -// DecodeRunAgentInput decodes a RunAgentInput from an HTTP request body. -// NOTE: This function should be removed when the AG-UI Go SDK exposes the official structure. -func DecodeRunAgentInput(r io.Reader) (*RunAgentInput, error) { - var raw map[string]json.RawMessage - dec := json.NewDecoder(r) - dec.DisallowUnknownFields() - if err := dec.Decode(&raw); err != nil { - return nil, errors.New("agui: decode request failed: " + err.Error()) - } - - input := &RunAgentInput{ - ThreadID: readStringRaw(raw, "threadId", "thread_id"), - RunID: readStringRaw(raw, "runId", "run_id"), - State: decodeMap(raw, "state"), - ForwardedProps: decodeMap(raw, "forwardedProps", "forwarded_props"), - } - - if msgRaw, ok := raw["messages"]; ok { - messages, err := decodeMessages(msgRaw) - if err != nil { - return nil, err - } - input.Messages = messages - } else { - return nil, errors.New("agui: messages field is required") - } - - if _, ok := input.LatestUserMessage(); !ok { - return nil, errors.New("agui: at least one user message with content is required") - } - - return input, nil -} - -// NOTE: This function should be removed when the AG-UI Go SDK exposes the official structure. -func readStringRaw(raw map[string]json.RawMessage, keys ...string) string { - for _, key := range keys { - if data, ok := raw[key]; ok { - var s string - if err := json.Unmarshal(data, &s); err == nil { - return s - } - } - } - return "" -} - -// NOTE: This function should be removed when the AG-UI Go SDK exposes the official structure. -func decodeMap(raw map[string]json.RawMessage, keys ...string) map[string]any { - for _, key := range keys { - if data, ok := raw[key]; ok && len(data) > 0 { - var m map[string]any - if err := json.Unmarshal(data, &m); err == nil { - return m - } - } - } - return nil -} - -// NOTE: This function should be removed when the AG-UI Go SDK exposes the official structure. -func decodeMessages(data json.RawMessage) ([]model.Message, error) { - var rawMsgs []map[string]json.RawMessage - if err := json.Unmarshal(data, &rawMsgs); err != nil { - return nil, errors.New("agui: messages must be an array of objects: " + err.Error()) - } - - msgs := make([]model.Message, 0, len(rawMsgs)) - for _, item := range rawMsgs { - role := strings.ToLower(readStringRaw(item, "role")) - if role == "" { - return nil, errors.New("agui: message.role is required") - } - - msg := model.Message{Role: model.Role(role)} - if !msg.Role.IsValid() { - return nil, errors.New("agui: unsupported message role " + role) - } - - msg.Content = readStringRaw(item, "content") - if msg.Content == "" { - msg.Content = decodeTextParts(item, "content", "parts", "contentParts") - } - if msg.Content == "" { - msg.Content = readStringRaw(item, "delta") - } - - if msg.Role == model.RoleTool { - msg.ToolID = readStringRaw(item, "toolId", "tool_id") - msg.ToolName = readStringRaw(item, "toolName", "tool_name") - } - - if toolCallsRaw, ok := findRaw(item, "toolCalls", "tool_calls"); ok { - msg.ToolCalls = decodeToolCalls(toolCallsRaw) - } - - msgs = append(msgs, msg) - } - - return msgs, nil -} - -// NOTE: This function should be removed when the AG-UI Go SDK exposes the official structure. -func decodeTextParts(item map[string]json.RawMessage, keys ...string) string { - for _, key := range keys { - if data, ok := item[key]; ok { - var parts []map[string]any - if err := json.Unmarshal(data, &parts); err == nil { - var builder strings.Builder - for _, part := range parts { - typeVal, _ := part["type"].(string) - if strings.ToLower(typeVal) != "text" { - continue - } - if text, ok := part["text"].(string); ok { - builder.WriteString(text) - } - } - if builder.Len() > 0 { - return builder.String() - } - } - } - } - return "" -} - -// NOTE: This function should be removed when the AG-UI Go SDK exposes the official structure. -func findRaw(item map[string]json.RawMessage, keys ...string) (json.RawMessage, bool) { - for _, key := range keys { - if data, ok := item[key]; ok { - return data, true - } - } - return nil, false -} - -// NOTE: This function should be removed when the AG-UI Go SDK exposes the official structure. -func decodeToolCalls(data json.RawMessage) []model.ToolCall { - var rawCalls []map[string]any - if err := json.Unmarshal(data, &rawCalls); err != nil { - return nil - } - - toolCalls := make([]model.ToolCall, 0, len(rawCalls)) - for _, call := range rawCalls { - var tc model.ToolCall - tc.Type = "function" - if id, ok := call["id"].(string); ok { - tc.ID = id - } - if t, ok := call["type"].(string); ok && t != "" { - tc.Type = t - } - if fn, ok := call["function"].(map[string]any); ok { - if name, ok := fn["name"].(string); ok { - tc.Function.Name = name - } - if args, ok := fn["arguments"]; ok { - if encoded, err := json.Marshal(args); err == nil { - tc.Function.Arguments = encoded - } - } - } - toolCalls = append(toolCalls, tc) - } - return toolCalls -} diff --git a/server/agui/runner/options.go b/server/agui/runner/options.go new file mode 100644 index 000000000..3d3af47c4 --- /dev/null +++ b/server/agui/runner/options.go @@ -0,0 +1,47 @@ +// +// Tencent is pleased to support the open source community by making trpc-agent-go available. +// +// Copyright (C) 2025 Tencent. All rights reserved. +// +// trpc-agent-go is licensed under the Apache License Version 2.0. +// +// + +package runner + +import ( + "context" + + "trpc.group/trpc-go/trpc-agent-go/server/agui/sdk" +) + +// options holds the options for the runner. +type options struct { + userIDResolver UserIDResolver +} + +// newOptions creates a new options instance. +func newOptions(opt ...Option) *options { + opts := &options{ + userIDResolver: func(ctx context.Context, input *sdk.RunAgentInput) (string, error) { + return "user", nil + }, + } + for _, o := range opt { + o(opts) + } + return opts +} + +// Option is a function that configures the options. +type Option func(*options) + +// UserIDResolver is a function that derives the user identifier for an AG-UI run. +type UserIDResolver func(ctx context.Context, input *sdk.RunAgentInput) (string, error) + +// WithUserIDResolver sets the user ID resolver. +func WithUserIDResolver(resolver UserIDResolver) Option { + return func(r *options) { + r.userIDResolver = resolver + } +} diff --git a/server/agui/runner/runner.go b/server/agui/runner/runner.go index 239a294f7..68ade8db7 100644 --- a/server/agui/runner/runner.go +++ b/server/agui/runner/runner.go @@ -7,88 +7,87 @@ // // -// Package runner provides the AG-UI runner implementation. +// Package runner wraps a trpc-agent-go runner and translates it to AG-UI events. package runner import ( "context" "errors" + "fmt" "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/core/events" + "trpc.group/trpc-go/trpc-agent-go/model" trunner "trpc.group/trpc-go/trpc-agent-go/runner" - aguievent "trpc.group/trpc-go/trpc-agent-go/server/agui/event" + "trpc.group/trpc-go/trpc-agent-go/server/agui/event" + "trpc.group/trpc-go/trpc-agent-go/server/agui/sdk" ) -// Runner is the interface for running agents. +// Runner executes AG-UI runs and emits AG-UI events. type Runner interface { - Run(ctx context.Context, input *RunAgentInput) (<-chan events.Event, error) + // Run starts processing one AG-UI run request and returns a channel of AG-UI events. + Run(ctx context.Context, runAgentInput *sdk.RunAgentInput) (<-chan events.Event, error) } -// New wraps a runner.Runner with AG-UI specific translation logic. -func New(r trunner.Runner) Runner { - return &runner{ - runner: r, - translatorFactory: aguievent.NewTranslator, +// New wraps a trpc-agent-go runner with AG-UI specific translation logic. +func New(r trunner.Runner, opt ...Option) Runner { + opts := newOptions(opt...) + run := &runner{ + runner: r, + userIDResolver: opts.userIDResolver, } + return run } -// runner is the AG-UI runner implementation. +// runner is the default implementation of the Runner. type runner struct { - runner trunner.Runner - translatorFactory func(threadID, runID string) aguievent.Translator + runner trunner.Runner + userIDResolver UserIDResolver } -// Run executes one run and streams translated events back. -func (r *runner) Run(ctx context.Context, input *RunAgentInput) (<-chan events.Event, error) { - if input == nil { - return nil, errors.New("agui: run input cannot be nil") - } +// Run starts processing one AG-UI run request and returns a channel of AG-UI events. +func (r *runner) Run(ctx context.Context, runAgentInput *sdk.RunAgentInput) (<-chan events.Event, error) { if r.runner == nil { return nil, errors.New("agui: runner is nil") } + if runAgentInput == nil { + return nil, errors.New("agui: run input cannot be nil") + } events := make(chan events.Event) - go r.run(ctx, input, events) + go r.run(ctx, runAgentInput, events) return events, nil } -func (r *runner) run(ctx context.Context, input *RunAgentInput, out chan<- events.Event) { - defer close(out) - threadID := input.ThreadID - runID := input.RunID - out <- events.NewRunStartedEvent(threadID, runID) - msgs := input.Messages - if len(msgs) == 0 { - out <- events.NewRunErrorEvent("no messages provided", events.WithRunID(runID)) +func (r *runner) run(ctx context.Context, runAgentInput *sdk.RunAgentInput, events chan<- events.Event) { + defer close(events) + bridge := event.NewBridge(runAgentInput.ThreadID, runAgentInput.RunID) + events <- bridge.NewRunStartedEvent() + if len(runAgentInput.Messages) == 0 { + events <- bridge.NewRunErrorEvent("no messages provided") return } - if _, ok := input.LatestUserMessage(); !ok { - out <- events.NewRunErrorEvent("no user message found", events.WithRunID(runID)) + userID, err := r.userIDResolver(ctx, runAgentInput) + if err != nil { + events <- bridge.NewRunErrorEvent(fmt.Sprintf("resolve user ID: %v", err)) + return + } + userMessage := runAgentInput.Messages[len(runAgentInput.Messages)-1] + if userMessage.Role != model.RoleUser { + events <- bridge.NewRunErrorEvent("last message is not a user message") return } - ctx, cancel := context.WithCancel(ctx) - defer cancel() - ch, err := r.runner.Run(ctx, threadID, runID, input.Messages[0]) + ch, err := r.runner.Run(ctx, userID, runAgentInput.ThreadID, userMessage) if err != nil { - out <- events.NewRunErrorEvent(err.Error(), events.WithRunID(runID)) + events <- bridge.NewRunErrorEvent(fmt.Sprintf("run agent: %v", err)) return } - translator := r.translatorFactory(threadID, runID) - for { - select { - case <-ctx.Done(): - out <- events.NewRunErrorEvent(ctx.Err().Error(), events.WithRunID(runID)) + for event := range ch { + aguiEvents, err := bridge.Translate(event) + if err != nil { + events <- bridge.NewRunErrorEvent(fmt.Sprintf("translate event: %v", err)) return - case evt, ok := <-ch: - if !ok { - for _, fin := range translator.Finalize() { - out <- fin - } - out <- events.NewRunFinishedEvent(threadID, runID) - return - } - for _, translated := range translator.FromRunnerEvent(evt) { - out <- translated - } + } + for _, aguiEvent := range aguiEvents { + events <- aguiEvent } } } diff --git a/server/agui/sdk/sdk.go b/server/agui/sdk/sdk.go new file mode 100644 index 000000000..b1e559355 --- /dev/null +++ b/server/agui/sdk/sdk.go @@ -0,0 +1,40 @@ +// +// Tencent is pleased to support the open source community by making trpc-agent-go available. +// +// Copyright (C) 2025 Tencent. All rights reserved. +// +// trpc-agent-go is licensed under the Apache License Version 2.0. +// +// + +// Package sdk is a placeholder for the AG-UI Go SDK. +package sdk + +import ( + "encoding/json" + "io" + + "trpc.group/trpc-go/trpc-agent-go/model" +) + +// NOTE: This file should be removed when the AG-UI Go SDK exposes the official structure. + +// RunAgentInput captures the parameters for an AG-UI run request. +// NOTE: This type should be removed when the AG-UI Go SDK exposes the official structure. +type RunAgentInput struct { + ThreadID string `json:"threadId"` + RunID string `json:"runId"` + Messages []model.Message `json:"messages"` + State map[string]any `json:"state"` + ForwardedProps map[string]any `json:"forwardedProps"` +} + +// DecodeRunAgentInput deserialises an AG-UI run request payload. +func DecodeRunAgentInput(r io.Reader) (*RunAgentInput, error) { + var input RunAgentInput + dec := json.NewDecoder(r) + if err := dec.Decode(&input); err != nil { + return nil, err + } + return &input, nil +} diff --git a/server/agui/service/service.go b/server/agui/service/service.go index 5d8c9a4af..d2a0f13d9 100644 --- a/server/agui/service/service.go +++ b/server/agui/service/service.go @@ -1,12 +1,21 @@ +// +// Tencent is pleased to support the open source community by making trpc-agent-go available. +// +// Copyright (C) 2025 Tencent. All rights reserved. +// +// trpc-agent-go is licensed under the Apache License Version 2.0. +// +// + +// Package service defines Service interface for AG-UI services. package service import "context" -// Service captures the minimal lifecycle hooks an AG-UI transport must implement. +// Service is the interface for AG-UI services. type Service interface { - // Serve starts the transport and blocks until it stops or ctx is cancelled. + // Serve starts the service. Serve(ctx context.Context) error - - // Close should gracefully release transport resources; it must be idempotent. + // Close stops the service. Close(ctx context.Context) error } diff --git a/server/agui/service/sse/options.go b/server/agui/service/sse/options.go index e05ef1244..9cc33f5e3 100644 --- a/server/agui/service/sse/options.go +++ b/server/agui/service/sse/options.go @@ -1,31 +1,42 @@ +// +// Tencent is pleased to support the open source community by making trpc-agent-go available. +// +// Copyright (C) 2025 Tencent. All rights reserved. +// +// trpc-agent-go is licensed under the Apache License Version 2.0. +// +// + package sse -import ( - "trpc.group/trpc-go/trpc-agent-go/server/agui/runner" -) +// options holds the options for the SSE service. +type options struct { + addr string + path string +} + +// newOptions creates a new options instance. +func newOptions(opt ...Option) *options { + opts := &options{} + for _, o := range opt { + o(opts) + } + return opts +} -// Option is a function that configures a Service. -type Option func(*Service) +// Option is a function that configures the options. +type Option func(*options) // WithAddress sets the listening address. func WithAddress(addr string) Option { - return func(s *Service) { + return func(s *options) { s.addr = addr } } // WithPath sets the request path. func WithPath(path string) Option { - return func(s *Service) { + return func(s *options) { s.path = path } } - -// WithRunner sets the runner responsible for executing requests. -func WithRunner(r runner.Runner) Option { - return func(s *Service) { - if r != nil { - s.runner = r - } - } -} diff --git a/server/agui/service/sse/sse.go b/server/agui/service/sse/sse.go index ae38ba6ec..e9d2649e9 100644 --- a/server/agui/service/sse/sse.go +++ b/server/agui/service/sse/sse.go @@ -1,3 +1,13 @@ +// +// Tencent is pleased to support the open source community by making trpc-agent-go available. +// +// Copyright (C) 2025 Tencent. All rights reserved. +// +// trpc-agent-go is licensed under the Apache License Version 2.0. +// +// + +// Package sse provides SSE service implementation. package sse import ( @@ -7,10 +17,12 @@ import ( aguisse "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/encoding/sse" "trpc.group/trpc-go/trpc-agent-go/server/agui/runner" + "trpc.group/trpc-go/trpc-agent-go/server/agui/sdk" + "trpc.group/trpc-go/trpc-agent-go/server/agui/service" ) -// Service is a SSE service implementation. -type Service struct { +// sseService is a SSE service implementation. +type sseService struct { addr string path string writer *aguisse.SSEWriter @@ -19,20 +31,19 @@ type Service struct { } // New creates a new SSE service. -func New(opts ...Option) *Service { - s := &Service{ - addr: ":8080", - path: "/agui/run", +func New(runner runner.Runner, opt ...Option) service.Service { + opts := newOptions(opt...) + s := &sseService{ + addr: opts.addr, + path: opts.path, + runner: runner, writer: aguisse.NewSSEWriter(), } - for _, opt := range opts { - opt(s) - } return s } -// Serve start the SSE service and listen on the address. -func (s *Service) Serve(ctx context.Context) error { +// Serve starts the SSE service and listens on the address. +func (s *sseService) Serve(ctx context.Context) error { mux := http.NewServeMux() mux.HandleFunc(s.path, s.handle) s.httpServer = &http.Server{Addr: s.addr, Handler: mux} @@ -44,25 +55,25 @@ func (s *Service) Serve(ctx context.Context) error { } // Close stops the SSE service. -func (s *Service) Close(ctx context.Context) error { +func (s *sseService) Close(ctx context.Context) error { if s.httpServer == nil { return errors.New("http server not running") } return s.httpServer.Shutdown(ctx) } -func (s *Service) handle(w http.ResponseWriter, r *http.Request) { +// handle handles an AG-UI run request. +func (s *sseService) handle(w http.ResponseWriter, r *http.Request) { if s.runner == nil { http.Error(w, "runner not configured", http.StatusInternalServerError) return } - defer r.Body.Close() - input, err := runner.DecodeRunAgentInput(r.Body) + runAgentInput, err := sdk.DecodeRunAgentInput(r.Body) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } - eventsCh, err := s.runner.Run(r.Context(), input) + eventsCh, err := s.runner.Run(r.Context(), runAgentInput) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -70,19 +81,9 @@ func (s *Service) handle(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") - w.Header().Set("Transfer-Encoding", "chunked") - ctx := r.Context() - for { - select { - case <-ctx.Done(): + for event := range eventsCh { + if err := s.writer.WriteEvent(r.Context(), w, event); err != nil { return - case evt, ok := <-eventsCh: - if !ok { - return - } - if err := s.writer.WriteEvent(ctx, w, evt); err != nil { - return - } } } } From 067afd59040a2a924165fd2acbef390753c3ac27 Mon Sep 17 00:00:00 2001 From: hackerli Date: Fri, 19 Sep 2025 12:02:45 +0800 Subject: [PATCH 03/31] fix --- event/event.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/event/event.go b/event/event.go index 4403016ba..e70bb14a1 100644 --- a/event/event.go +++ b/event/event.go @@ -235,9 +235,9 @@ func EmitEventWithTimeout(ctx context.Context, ch chan<- *Event, select { case ch <- e: log.Debugf("EmitEventWithTimeout: event sent, event: %+v", *e) - // case <-ctx.Done(): - // log.Warnf("EmitEventWithTimeout: context cancelled, event: %+v", *e) - // return ctx.Err() + case <-ctx.Done(): + log.Warnf("EmitEventWithTimeout: context cancelled, event: %+v", *e) + return ctx.Err() } return nil } From b83f997358eaa03abb9e90253f5fd0603be992ce Mon Sep 17 00:00:00 2001 From: hackerli Date: Mon, 22 Sep 2025 17:13:50 +0800 Subject: [PATCH 04/31] feat: support DefaultNewService --- server/agui/agui.go | 42 +++++++++++------------- server/agui/service/{sse => }/options.go | 29 ++++++---------- server/agui/service/sse/sse.go | 32 ++++++++++-------- 3 files changed, 47 insertions(+), 56 deletions(-) rename server/agui/service/{sse => }/options.go (54%) diff --git a/server/agui/agui.go b/server/agui/agui.go index f1d677729..1ff7a3dc1 100644 --- a/server/agui/agui.go +++ b/server/agui/agui.go @@ -20,17 +20,18 @@ import ( aguirunner "trpc.group/trpc-go/trpc-agent-go/server/agui/runner" "trpc.group/trpc-go/trpc-agent-go/server/agui/service" "trpc.group/trpc-go/trpc-agent-go/server/agui/service/sse" - "trpc.group/trpc-go/trpc-agent-go/session" "trpc.group/trpc-go/trpc-agent-go/session/inmemory" ) +// DefaultNewService is the default function to create a new service. +var DefaultNewService = sse.New + // Server provides AG-UI server. type Server struct { - address string - path string - agent agent.Agent - sessionService session.Service - service service.Service + address string + path string + agent agent.Agent + service service.Service } // New creates a AG-UI server instance. @@ -39,26 +40,21 @@ func New(agent agent.Agent, opt ...Option) (*Server, error) { return nil, errors.New("agui: agent must not be nil") } opts := newOptions(opt...) - sessionService := opts.sessionService - if sessionService == nil { - sessionService = inmemory.NewSessionService() - } - service := opts.service - if service == nil { - runner := runner.NewRunner( - agent.Info().Name, - agent, - runner.WithSessionService(sessionService), - ) + aguiService := opts.service + if aguiService == nil { + sessionService := opts.sessionService + if sessionService == nil { + sessionService = inmemory.NewSessionService() + } + runner := runner.NewRunner(agent.Info().Name, agent, runner.WithSessionService(sessionService)) aguiRunner := aguirunner.New(runner, opts.runnerOptions...) - service = sse.New(aguiRunner, sse.WithAddress(opts.address), sse.WithPath(opts.path)) + aguiService = DefaultNewService(aguiRunner, service.WithAddress(opts.address), service.WithPath(opts.path)) } server := &Server{ - address: opts.address, - path: opts.path, - agent: agent, - sessionService: sessionService, - service: service, + address: opts.address, + path: opts.path, + agent: agent, + service: aguiService, } return server, nil } diff --git a/server/agui/service/sse/options.go b/server/agui/service/options.go similarity index 54% rename from server/agui/service/sse/options.go rename to server/agui/service/options.go index 9cc33f5e3..16c1badcb 100644 --- a/server/agui/service/sse/options.go +++ b/server/agui/service/options.go @@ -7,36 +7,27 @@ // // -package sse +package service -// options holds the options for the SSE service. -type options struct { - addr string - path string -} - -// newOptions creates a new options instance. -func newOptions(opt ...Option) *options { - opts := &options{} - for _, o := range opt { - o(opts) - } - return opts +// Options holds the options for the SSE service. +type Options struct { + Address string // Address is the listening address. + Path string // Path is the request url path. } // Option is a function that configures the options. -type Option func(*options) +type Option func(*Options) // WithAddress sets the listening address. func WithAddress(addr string) Option { - return func(s *options) { - s.addr = addr + return func(s *Options) { + s.Address = addr } } // WithPath sets the request path. func WithPath(path string) Option { - return func(s *options) { - s.path = path + return func(s *Options) { + s.Path = path } } diff --git a/server/agui/service/sse/sse.go b/server/agui/service/sse/sse.go index e9d2649e9..99542e2e9 100644 --- a/server/agui/service/sse/sse.go +++ b/server/agui/service/sse/sse.go @@ -21,9 +21,9 @@ import ( "trpc.group/trpc-go/trpc-agent-go/server/agui/service" ) -// sseService is a SSE service implementation. -type sseService struct { - addr string +// sse is a SSE service implementation. +type sse struct { + address string path string writer *aguisse.SSEWriter runner runner.Runner @@ -31,22 +31,25 @@ type sseService struct { } // New creates a new SSE service. -func New(runner runner.Runner, opt ...Option) service.Service { - opts := newOptions(opt...) - s := &sseService{ - addr: opts.addr, - path: opts.path, - runner: runner, - writer: aguisse.NewSSEWriter(), +func New(runner runner.Runner, opt ...service.Option) service.Service { + opts := service.Options{} + for _, o := range opt { + o(&opts) + } + s := &sse{ + address: opts.Address, + path: opts.Path, + runner: runner, + writer: aguisse.NewSSEWriter(), } return s } // Serve starts the SSE service and listens on the address. -func (s *sseService) Serve(ctx context.Context) error { +func (s *sse) Serve(ctx context.Context) error { mux := http.NewServeMux() mux.HandleFunc(s.path, s.handle) - s.httpServer = &http.Server{Addr: s.addr, Handler: mux} + s.httpServer = &http.Server{Addr: s.address, Handler: mux} err := s.httpServer.ListenAndServe() if errors.Is(err, http.ErrServerClosed) { return nil @@ -55,7 +58,7 @@ func (s *sseService) Serve(ctx context.Context) error { } // Close stops the SSE service. -func (s *sseService) Close(ctx context.Context) error { +func (s *sse) Close(ctx context.Context) error { if s.httpServer == nil { return errors.New("http server not running") } @@ -63,7 +66,7 @@ func (s *sseService) Close(ctx context.Context) error { } // handle handles an AG-UI run request. -func (s *sseService) handle(w http.ResponseWriter, r *http.Request) { +func (s *sse) handle(w http.ResponseWriter, r *http.Request) { if s.runner == nil { http.Error(w, "runner not configured", http.StatusInternalServerError) return @@ -81,6 +84,7 @@ func (s *sseService) handle(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") + w.Header().Set("Access-Control-Allow-Origin", "*") for event := range eventsCh { if err := s.writer.WriteEvent(r.Context(), w, event); err != nil { return From ba011eb3de00226eb58da31035d446c136d9af96 Mon Sep 17 00:00:00 2001 From: hackerli Date: Thu, 25 Sep 2025 16:28:12 +0800 Subject: [PATCH 05/31] feat: adapter --- server/agui/{sdk/sdk.go => adapter/adapter.go} | 13 +++++-------- server/agui/runner/options.go | 6 +++--- server/agui/runner/runner.go | 8 ++++---- server/agui/service/sse/sse.go | 4 ++-- 4 files changed, 14 insertions(+), 17 deletions(-) rename server/agui/{sdk/sdk.go => adapter/adapter.go} (62%) diff --git a/server/agui/sdk/sdk.go b/server/agui/adapter/adapter.go similarity index 62% rename from server/agui/sdk/sdk.go rename to server/agui/adapter/adapter.go index b1e559355..12cc052c7 100644 --- a/server/agui/sdk/sdk.go +++ b/server/agui/adapter/adapter.go @@ -7,8 +7,8 @@ // // -// Package sdk is a placeholder for the AG-UI Go SDK. -package sdk +// Package adapter provides the adapter for the AG-UI SDK. +package adapter import ( "encoding/json" @@ -17,10 +17,7 @@ import ( "trpc.group/trpc-go/trpc-agent-go/model" ) -// NOTE: This file should be removed when the AG-UI Go SDK exposes the official structure. - -// RunAgentInput captures the parameters for an AG-UI run request. -// NOTE: This type should be removed when the AG-UI Go SDK exposes the official structure. +// RunAgentInput represents the parameters for an AG-UI run request. type RunAgentInput struct { ThreadID string `json:"threadId"` RunID string `json:"runId"` @@ -29,8 +26,8 @@ type RunAgentInput struct { ForwardedProps map[string]any `json:"forwardedProps"` } -// DecodeRunAgentInput deserialises an AG-UI run request payload. -func DecodeRunAgentInput(r io.Reader) (*RunAgentInput, error) { +// RunAgentInputFromReader parses an AG-UI run request payload from a reader. +func RunAgentInputFromReader(r io.Reader) (*RunAgentInput, error) { var input RunAgentInput dec := json.NewDecoder(r) if err := dec.Decode(&input); err != nil { diff --git a/server/agui/runner/options.go b/server/agui/runner/options.go index 3d3af47c4..0533f134c 100644 --- a/server/agui/runner/options.go +++ b/server/agui/runner/options.go @@ -12,7 +12,7 @@ package runner import ( "context" - "trpc.group/trpc-go/trpc-agent-go/server/agui/sdk" + "trpc.group/trpc-go/trpc-agent-go/server/agui/adapter" ) // options holds the options for the runner. @@ -23,7 +23,7 @@ type options struct { // newOptions creates a new options instance. func newOptions(opt ...Option) *options { opts := &options{ - userIDResolver: func(ctx context.Context, input *sdk.RunAgentInput) (string, error) { + userIDResolver: func(ctx context.Context, input *adapter.RunAgentInput) (string, error) { return "user", nil }, } @@ -37,7 +37,7 @@ func newOptions(opt ...Option) *options { type Option func(*options) // UserIDResolver is a function that derives the user identifier for an AG-UI run. -type UserIDResolver func(ctx context.Context, input *sdk.RunAgentInput) (string, error) +type UserIDResolver func(ctx context.Context, input *adapter.RunAgentInput) (string, error) // WithUserIDResolver sets the user ID resolver. func WithUserIDResolver(resolver UserIDResolver) Option { diff --git a/server/agui/runner/runner.go b/server/agui/runner/runner.go index 68ade8db7..0c97d6675 100644 --- a/server/agui/runner/runner.go +++ b/server/agui/runner/runner.go @@ -18,14 +18,14 @@ import ( "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/core/events" "trpc.group/trpc-go/trpc-agent-go/model" trunner "trpc.group/trpc-go/trpc-agent-go/runner" + "trpc.group/trpc-go/trpc-agent-go/server/agui/adapter" "trpc.group/trpc-go/trpc-agent-go/server/agui/event" - "trpc.group/trpc-go/trpc-agent-go/server/agui/sdk" ) // Runner executes AG-UI runs and emits AG-UI events. type Runner interface { // Run starts processing one AG-UI run request and returns a channel of AG-UI events. - Run(ctx context.Context, runAgentInput *sdk.RunAgentInput) (<-chan events.Event, error) + Run(ctx context.Context, runAgentInput *adapter.RunAgentInput) (<-chan events.Event, error) } // New wraps a trpc-agent-go runner with AG-UI specific translation logic. @@ -45,7 +45,7 @@ type runner struct { } // Run starts processing one AG-UI run request and returns a channel of AG-UI events. -func (r *runner) Run(ctx context.Context, runAgentInput *sdk.RunAgentInput) (<-chan events.Event, error) { +func (r *runner) Run(ctx context.Context, runAgentInput *adapter.RunAgentInput) (<-chan events.Event, error) { if r.runner == nil { return nil, errors.New("agui: runner is nil") } @@ -57,7 +57,7 @@ func (r *runner) Run(ctx context.Context, runAgentInput *sdk.RunAgentInput) (<-c return events, nil } -func (r *runner) run(ctx context.Context, runAgentInput *sdk.RunAgentInput, events chan<- events.Event) { +func (r *runner) run(ctx context.Context, runAgentInput *adapter.RunAgentInput, events chan<- events.Event) { defer close(events) bridge := event.NewBridge(runAgentInput.ThreadID, runAgentInput.RunID) events <- bridge.NewRunStartedEvent() diff --git a/server/agui/service/sse/sse.go b/server/agui/service/sse/sse.go index 99542e2e9..274f87a83 100644 --- a/server/agui/service/sse/sse.go +++ b/server/agui/service/sse/sse.go @@ -16,8 +16,8 @@ import ( "net/http" aguisse "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/encoding/sse" + "trpc.group/trpc-go/trpc-agent-go/server/agui/adapter" "trpc.group/trpc-go/trpc-agent-go/server/agui/runner" - "trpc.group/trpc-go/trpc-agent-go/server/agui/sdk" "trpc.group/trpc-go/trpc-agent-go/server/agui/service" ) @@ -71,7 +71,7 @@ func (s *sse) handle(w http.ResponseWriter, r *http.Request) { http.Error(w, "runner not configured", http.StatusInternalServerError) return } - runAgentInput, err := sdk.DecodeRunAgentInput(r.Body) + runAgentInput, err := adapter.RunAgentInputFromReader(r.Body) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return From dc7c5af9aba11564208ee5b6b661e0b3dffda81b Mon Sep 17 00:00:00 2001 From: hackerli Date: Thu, 25 Sep 2025 17:17:32 +0800 Subject: [PATCH 06/31] feat: support non-stream message --- server/agui/event/translator.go | 49 ++++++++++++++++++++------------- 1 file changed, 30 insertions(+), 19 deletions(-) diff --git a/server/agui/event/translator.go b/server/agui/event/translator.go index 80c822a81..6a48aeb40 100644 --- a/server/agui/event/translator.go +++ b/server/agui/event/translator.go @@ -20,26 +20,27 @@ import ( // Translate translates one trpc-agent-go event into zero or more AG-UI events. func (b *bridge) Translate(event *agentevent.Event) ([]aguievents.Event, error) { - if event == nil { + if event == nil || event.Response == nil { return nil, errors.New("event is nil") } - if event.Response != nil { - rsp := event.Response - if rsp.Error != nil { - return []aguievents.Event{b.NewRunErrorEvent(rsp.Error.Message)}, nil - } - if rsp.IsToolCallResponse() { - return b.toolCallEvent(rsp) - } - if rsp.IsToolResultResponse() { - return b.toolResultEvent(rsp) - } - if rsp.IsFinalResponse() { - return []aguievents.Event{aguievents.NewTextMessageEndEvent(rsp.ID), b.NewRunFinishedEvent()}, nil - } - return b.textMessageEvent(rsp) + rsp := event.Response + if rsp.Error != nil { + return []aguievents.Event{b.NewRunErrorEvent(rsp.Error.Message)}, nil + } + if rsp.IsToolCallResponse() { + return b.toolCallEvent(rsp) } - return nil, nil + if rsp.IsToolResultResponse() { + return b.toolResultEvent(rsp) + } + events, err := b.textMessageEvent(rsp) + if err != nil { + return nil, err + } + if rsp.IsFinalResponse() { + events = append(events, b.NewRunFinishedEvent()) + } + return events, nil } // textMessageEvent translates a text message trpc-agent-go event to AG-UI events. @@ -53,9 +54,19 @@ func (b *bridge) textMessageEvent(rsp *model.Response) ([]aguievents.Event, erro b.lastMessageID = rsp.ID switch rsp.Object { case model.ObjectTypeChatCompletionChunk: - events = append(events, aguievents.NewTextMessageStartEvent(rsp.ID, aguievents.WithRole("assistant"))) + role := rsp.Choices[0].Delta.Role.String() + events = append(events, aguievents.NewTextMessageStartEvent(rsp.ID, aguievents.WithRole(role))) case model.ObjectTypeChatCompletion: - return nil, errors.New("non-streaming response is not supported, waiting for agui sdk support") + if rsp.Choices[0].Message.Content == "" { + return nil, nil + } + role := rsp.Choices[0].Message.Role.String() + events = append(events, + aguievents.NewTextMessageStartEvent(rsp.ID, aguievents.WithRole(role)), + aguievents.NewTextMessageContentEvent(rsp.ID, rsp.Choices[0].Message.Content), + aguievents.NewTextMessageEndEvent(rsp.ID), + ) + return events, nil default: return nil, errors.New("invalid response object") } From b939e0092bc5f6f01bcd41b6a4d7310ccfafb9e3 Mon Sep 17 00:00:00 2001 From: hackerli Date: Thu, 25 Sep 2025 20:10:39 +0800 Subject: [PATCH 07/31] fix --- examples/agui/README.md | 19 ++++++++----------- examples/agui/client/bubbletea/agui.go | 10 +++++++++- examples/agui/client/bubbletea/main.go | 9 ++++++++- examples/agui/client/bubbletea/ui.go | 8 ++++++++ examples/agui/server/default/main.go | 12 +++++++++++- server/agui/event/translator.go | 24 ++++++++++++++++++------ 6 files changed, 62 insertions(+), 20 deletions(-) diff --git a/examples/agui/README.md b/examples/agui/README.md index c81d7407a..b1c000ba3 100644 --- a/examples/agui/README.md +++ b/examples/agui/README.md @@ -9,19 +9,16 @@ This folder collects runnable demos that showcase how to integrate the `tRPC-Age 1. Start the default AG-UI server: - ```bash - go run ./server/default - ``` +```bash +go run ./server/default +``` 2. In another terminal start the Bubble Tea client: - ```bash - go run ./client/bubbletea/main.go - ``` +```bash +go run ./client/bubbletea/main.go +``` -3. Ask a question such as `calculate 1.2+3.5` and watch the live event stream in - the terminal. A full transcript example is documented in - [`client/bubbletea/README.md`](client/bubbletea/README.md). +3. Ask a question such as `calculate 1.2+3.5` and watch the live event stream in the terminal. A full transcript example is documented in [`client/bubbletea/README.md`](client/bubbletea/README.md). -See the individual README files under `client/` and `server/` for more background -and configuration options. +See the individual README files under `client/` and `server/` for more background and configuration options. diff --git a/examples/agui/client/bubbletea/agui.go b/examples/agui/client/bubbletea/agui.go index 33eedc0b1..a905cb412 100644 --- a/examples/agui/client/bubbletea/agui.go +++ b/examples/agui/client/bubbletea/agui.go @@ -1,3 +1,11 @@ +// Tencent is pleased to support the open source community by making trpc-agent-go available. +// +// Copyright (C) 2025 Tencent. All rights reserved. +// +// trpc-agent-go is licensed under the Apache License Version 2.0. +// +// + package main import ( @@ -105,7 +113,7 @@ func readNextEventCmd(stream *chatStream) tea.Cmd { evt, err := events.EventFromJSON(frame.Data) if err != nil { stream.Close() - return fmt.Errorf("parse event: %w", err) + return errMsg{fmt.Errorf("parse event: %w", err)} } lines := formatEvent(evt) if len(lines) == 0 { diff --git a/examples/agui/client/bubbletea/main.go b/examples/agui/client/bubbletea/main.go index d945f5d4e..772d2de3d 100644 --- a/examples/agui/client/bubbletea/main.go +++ b/examples/agui/client/bubbletea/main.go @@ -1,3 +1,11 @@ +// Tencent is pleased to support the open source community by making trpc-agent-go available. +// +// Copyright (C) 2025 Tencent. All rights reserved. +// +// trpc-agent-go is licensed under the Apache License Version 2.0. +// +// + package main import ( @@ -10,7 +18,6 @@ import ( func main() { endpoint := flag.String("endpoint", "http://localhost:8080/agui", "AG-UI SSE endpoint") flag.Parse() - if _, err := tea.NewProgram( initialModel(*endpoint), tea.WithAltScreen(), diff --git a/examples/agui/client/bubbletea/ui.go b/examples/agui/client/bubbletea/ui.go index 4f3e3cc8b..f69275612 100644 --- a/examples/agui/client/bubbletea/ui.go +++ b/examples/agui/client/bubbletea/ui.go @@ -1,3 +1,11 @@ +// Tencent is pleased to support the open source community by making trpc-agent-go available. +// +// Copyright (C) 2025 Tencent. All rights reserved. +// +// trpc-agent-go is licensed under the Apache License Version 2.0. +// +// + package main import ( diff --git a/examples/agui/server/default/main.go b/examples/agui/server/default/main.go index 7b762a5f6..495885db1 100644 --- a/examples/agui/server/default/main.go +++ b/examples/agui/server/default/main.go @@ -1,3 +1,11 @@ +// Tencent is pleased to support the open source community by making trpc-agent-go available. +// +// Copyright (C) 2025 Tencent. All rights reserved. +// +// trpc-agent-go is licensed under the Apache License Version 2.0. +// +// + package main import ( @@ -18,6 +26,8 @@ import ( var ( modelName = flag.String("model", "deepseek-chat", "Model to use") isStream = flag.Bool("stream", true, "Whether to stream the response") + address = flag.String("address", "127.0.0.1:8080", "Listen address") + path = flag.String("path", "/agui", "HTTP path") ) func main() { @@ -42,7 +52,7 @@ func main() { llmagent.WithGenerationConfig(generationConfig), llmagent.WithInstruction("You are a helpful assistant."), ) - server, err := agui.New(agent) + server, err := agui.New(agent, agui.WithAddress(*address), agui.WithPath(*path)) if err != nil { log.Fatalf("failed to create AG-UI server: %v", err) } diff --git a/server/agui/event/translator.go b/server/agui/event/translator.go index 6a48aeb40..cd1fa083c 100644 --- a/server/agui/event/translator.go +++ b/server/agui/event/translator.go @@ -27,15 +27,27 @@ func (b *bridge) Translate(event *agentevent.Event) ([]aguievents.Event, error) if rsp.Error != nil { return []aguievents.Event{b.NewRunErrorEvent(rsp.Error.Message)}, nil } + events := []aguievents.Event{} + if rsp.Object == model.ObjectTypeChatCompletionChunk || rsp.Object == model.ObjectTypeChatCompletion { + textMessageEvents, err := b.textMessageEvent(rsp) + if err != nil { + return nil, err + } + events = append(events, textMessageEvents...) + } if rsp.IsToolCallResponse() { - return b.toolCallEvent(rsp) + toolCallEvents, err := b.toolCallEvent(rsp) + if err != nil { + return nil, err + } + events = append(events, toolCallEvents...) } if rsp.IsToolResultResponse() { - return b.toolResultEvent(rsp) - } - events, err := b.textMessageEvent(rsp) - if err != nil { - return nil, err + toolResultEvents, err := b.toolResultEvent(rsp) + if err != nil { + return nil, err + } + events = append(events, toolResultEvents...) } if rsp.IsFinalResponse() { events = append(events, b.NewRunFinishedEvent()) From dad2ebf554fa4a815dd471953aab48a6d6fc18f5 Mon Sep 17 00:00:00 2001 From: hackerli Date: Thu, 25 Sep 2025 20:10:54 +0800 Subject: [PATCH 08/31] example: add copilokit --- examples/agui/client/copilotkit/README.md | 62 + .../copilotkit/app/api/copilotkit/route.ts | 40 + .../agui/client/copilotkit/app/globals.css | 197 + .../agui/client/copilotkit/app/layout.tsx | 35 + examples/agui/client/copilotkit/app/page.tsx | 275 + examples/agui/client/copilotkit/next-env.d.ts | 14 + .../agui/client/copilotkit/next.config.mjs | 19 + examples/agui/client/copilotkit/package.json | 28 + .../agui/client/copilotkit/pnpm-lock.yaml | 9563 +++++++++++++++++ examples/agui/client/copilotkit/tsconfig.json | 40 + 10 files changed, 10273 insertions(+) create mode 100644 examples/agui/client/copilotkit/README.md create mode 100644 examples/agui/client/copilotkit/app/api/copilotkit/route.ts create mode 100644 examples/agui/client/copilotkit/app/globals.css create mode 100644 examples/agui/client/copilotkit/app/layout.tsx create mode 100644 examples/agui/client/copilotkit/app/page.tsx create mode 100644 examples/agui/client/copilotkit/next-env.d.ts create mode 100644 examples/agui/client/copilotkit/next.config.mjs create mode 100644 examples/agui/client/copilotkit/package.json create mode 100644 examples/agui/client/copilotkit/pnpm-lock.yaml create mode 100644 examples/agui/client/copilotkit/tsconfig.json diff --git a/examples/agui/client/copilotkit/README.md b/examples/agui/client/copilotkit/README.md new file mode 100644 index 000000000..cdf9af31b --- /dev/null +++ b/examples/agui/client/copilotkit/README.md @@ -0,0 +1,62 @@ +# CopilotKit Front-End for the AG-UI Server + +This example shows how to pair the Go-based AG-UI server with a React front-end +built on [CopilotKit](https://docs.copilotkit.ai/). The UI streams Server-Sent +Events from the AG-UI endpoint using the `@ag-ui/client` HTTP agent and renders +an assistant sidebar provided by CopilotKit. + +> The example lives in `ui/`, while a full copy of the upstream CopilotKit +> repository is available under `CopilotKit/` for reference and advanced usage. + +## Prerequisites + +- Node.js 18+ and pnpm (or npm/yarn) +- Go 1.21+ +- Access to an LLM model supported by the AG-UI server setup (see + `../server/default`) + +## 1. Launch the AG-UI server + +```bash +cd support-agui/trpc-agent-go/examples/agui/server/default +GOOGLE_API_KEY=... go run . +``` + +By default the server listens on `http://127.0.0.1:8080/agui`. Adjust +`--address`, `--path`, or model flags if required. + +## 2. Start the CopilotKit client + +```bash +cd support-agui/trpc-agent-go/examples/agui/client/copilotkit/ui +pnpm install # or npm install +pnpm dev # or npm run dev +``` + +If you need to pin dependencies manually, make sure to use a published +`@ag-ui/client` version (the example uses `^0.0.38`). + +Available environment variables before `pnpm dev`: + +- `AG_UI_ENDPOINT`: override the AG-UI endpoint URL (defaults to + `http://127.0.0.1:8080/agui`). +- `AG_UI_TOKEN`: optional bearer token forwarded as an `Authorization` header. + +Open `http://localhost:3000` and start chatting with the full-screen assistant +UI. The input shows the placeholder `Calculate 2*(10+11), first explain the +idea, then calculate, and finally give the conclusion.`—press Enter to run that +scenario or type your own request. Tool calls and their results appear inline +inside the chat transcript. + +## How it works + +- `ui/app/api/copilotkit/route.ts` instantiates a `CopilotRuntime` and registers + a single AG-UI agent via `new HttpAgent({ url: ... })`. +- The front-end wraps the App Router layout in `` and renders a + `CopilotChat`, filling the page with a streaming AG-UI conversation. +- `ui/app/page.tsx` overrides `RenderMessage` so AG-UI tool invocations and + outputs show up inline with the assistant replies. + +Use this scaffold as a starting point for richer CopilotKit integrations, or +explore the full upstream examples in `CopilotKit/examples/` for more advanced +patterns such as shared state or tool-driven UI. diff --git a/examples/agui/client/copilotkit/app/api/copilotkit/route.ts b/examples/agui/client/copilotkit/app/api/copilotkit/route.ts new file mode 100644 index 000000000..40ab972db --- /dev/null +++ b/examples/agui/client/copilotkit/app/api/copilotkit/route.ts @@ -0,0 +1,40 @@ +// +// Tencent is pleased to support the open source community by making trpc-agent-go available. +// +// Copyright (C) 2025 Tencent. All rights reserved. +// +// trpc-agent-go is licensed under the Apache License Version 2.0. +// +// + +import { NextRequest } from "next/server"; +import { + CopilotRuntime, + ExperimentalEmptyAdapter, + copilotRuntimeNextJSAppRouterEndpoint, +} from "@copilotkit/runtime"; +import { HttpAgent } from "@ag-ui/client"; + +const runtime = new CopilotRuntime({ + agents: { + "agui-demo": new HttpAgent({ + agentId: "agui-demo", + description: "AG-UI agent hosted by the Go evaluation server", + threadId: "demo-thread", + url: process.env.AG_UI_ENDPOINT ?? "http://127.0.0.1:8080/agui", + headers: process.env.AG_UI_TOKEN + ? { Authorization: `Bearer ${process.env.AG_UI_TOKEN}` } + : undefined, + }), + }, +}); + +const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({ + runtime, + serviceAdapter: new ExperimentalEmptyAdapter(), + endpoint: "/api/copilotkit", +}); + +export async function POST(request: NextRequest) { + return handleRequest(request); +} diff --git a/examples/agui/client/copilotkit/app/globals.css b/examples/agui/client/copilotkit/app/globals.css new file mode 100644 index 000000000..ca45f4f74 --- /dev/null +++ b/examples/agui/client/copilotkit/app/globals.css @@ -0,0 +1,197 @@ +/* +// Tencent is pleased to support the open source community by making trpc-agent-go available. +// +// Copyright (C) 2025 Tencent. All rights reserved. +// +// trpc-agent-go is licensed under the Apache License Version 2.0. +// +*/ + +:root { + color-scheme: dark; + --copilot-kit-background-color: #141414; + --copilot-kit-primary-color: #024a76; + --copilot-kit-contrast-color: #ffffff; + --copilot-kit-secondary-contrast-color: #ffffff; + --copilot-kit-separator-color: rgba(255, 255, 255, 0.12); +} + +html, +body { + margin: 0; + font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + background: #141414; + color: #ffffff; + min-height: 100vh; + height: 100%; + overflow: hidden; +} + +main.agui-chat { + min-height: 100vh; + height: 100vh; + display: flex; + align-items: stretch; + justify-content: center; + padding: 0; + box-sizing: border-box; +} + +.agui-chat__panel { + flex: 1; + margin: 0; + background: #141414; + border: 0; + border-radius: 0; + box-shadow: none; + min-height: 100vh; +} + +.copilotKitChat, +.copilotKitMessages, +.copilotKitMessagesContainer, +.copilotKitMessagesFooter { + background: #141414 !important; +} + +.tool-message { + margin: 0.75rem 0 0.75rem 1.5rem; + padding: 0.75rem 0.95rem; + border-radius: 12px; + background: #303030; + border-left: 3px solid #4c6f8e; + color: #ffffff; + font-size: 0.95rem; + display: inline-block; + max-width: min(680px, 80%); + width: fit-content; +} + +.tool-message__label { + display: block; + font-size: 0.85rem; + letter-spacing: 0.01em; + text-transform: uppercase; + color: rgba(129, 212, 250, 0.9); + margin-bottom: 0.5rem; +} + +.tool-message__body { + margin: 0; + font-size: 0.85rem; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; + font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; + color: #bae6fd; + background: transparent; +} + +@media (max-width: 768px) { + .agui-chat__panel { + border-radius: 0; + border-width: 0; + } + + .tool-message { + margin: 0.75rem 0; + max-width: 100%; + } +} + + +.copilotKitMessage { + color: #ffffff; + padding: 0.9rem 1.1rem; + border-radius: 18px; + max-width: min(720px, 85%); + line-height: 1.6; + box-shadow: 0 14px 28px rgba(0, 0, 0, 0.22); +} + +.agui-chat .copilotKitMessage.copilotKitUserMessage { + background: #024a76; + margin-left: auto; + margin-right: 1.5rem; + color: #ffffff; +} + +.agui-chat .copilotKitMessage.copilotKitAssistantMessage { + background: #303030; + margin-right: auto; + margin-left: 1.5rem; + padding: 1rem 1.2rem; + max-width: min(760px, 90%); + color: #ffffff; +} + +.agui-chat .copilotKitMessage.copilotKitAssistantMessage * { + color: inherit; +} + +.agui-chat .copilotKitMessageControls { + margin-top: 0.75rem; + display: flex; + gap: 0.35rem; +} + +.agui-chat .copilotKitMessageControlButton { + background: rgba(255, 255, 255, 0.1); + border: none; + border-radius: 999px; + color: #ffffff; + width: 30px; + height: 30px; + display: flex; + align-items: center; + justify-content: center; +} + +.agui-chat .copilotKitMessageControlButton:hover { + background: rgba(255, 255, 255, 0.22); +} + +.copilotKitInputContainer { + background: #141414; + border-top: 1px solid rgba(255, 255, 255, 0.06); + padding: 1rem 1.5rem; + display: flex; + justify-content: center; +} + +.copilotKitInput { + background: #1d1d1d; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 18px; + padding: 0.5rem 0.75rem; + display: flex; + align-items: stretch; + gap: 0.75rem; + width: min(720px, 95%); + box-sizing: border-box; +} + +.copilotKitInput textarea { + flex: 1; + background: transparent; + border: none; + color: #ffffff; + font-size: 0.95rem; + line-height: 1.6; + font-family: inherit; + padding: 0.35rem 0.1rem; + align-self: stretch; + min-height: 1.8rem; +} + +.copilotKitInput textarea::placeholder { + color: rgba(255, 255, 255, 0.5); +} + +.tool-message__label { + color: #ffffff; +} + +.tool-message__body { + color: #ffffff; +} diff --git a/examples/agui/client/copilotkit/app/layout.tsx b/examples/agui/client/copilotkit/app/layout.tsx new file mode 100644 index 000000000..2b576c711 --- /dev/null +++ b/examples/agui/client/copilotkit/app/layout.tsx @@ -0,0 +1,35 @@ +// +// Tencent is pleased to support the open source community by making trpc-agent-go available. +// +// Copyright (C) 2025 Tencent. All rights reserved. +// +// trpc-agent-go is licensed under the Apache License Version 2.0. +// +// + +import type { Metadata } from "next"; +import { CopilotKit } from "@copilotkit/react-core"; + +import "@copilotkit/react-ui/styles.css"; +import "./globals.css"; + +export const metadata: Metadata = { + title: "AG-UI CopilotKit Demo", + description: "Minimal CopilotKit front-end that streams AG-UI events from a Go agent server.", +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + + {children} + + + + ); +} diff --git a/examples/agui/client/copilotkit/app/page.tsx b/examples/agui/client/copilotkit/app/page.tsx new file mode 100644 index 000000000..afa4a1ff1 --- /dev/null +++ b/examples/agui/client/copilotkit/app/page.tsx @@ -0,0 +1,275 @@ +// +// Tencent is pleased to support the open source community by making trpc-agent-go available. +// +// Copyright (C) 2025 Tencent. All rights reserved. +// +// trpc-agent-go is licensed under the Apache License Version 2.0. +// +// + +"use client"; + +import { Fragment, useLayoutEffect, useRef, useState } from "react"; +import type { InputProps, RenderMessageProps } from "@copilotkit/react-ui"; +import { + AssistantMessage as DefaultAssistantMessage, + CopilotChat, + ImageRenderer as DefaultImageRenderer, + UserMessage as DefaultUserMessage, + useChatContext, +} from "@copilotkit/react-ui"; + +const DEFAULT_PROMPT = "Calculate 2*(10+11), first explain the idea, then calculate, and finally give the conclusion."; + +const PromptInput = ({ + inProgress, + onSend, + isVisible = false, + onStop, + hideStopButton = false, +}: InputProps) => { + const context = useChatContext(); + const textareaRef = useRef(null); + const [text, setText] = useState(""); + const [isComposing, setIsComposing] = useState(false); + + const adjustHeight = () => { + const textarea = textareaRef.current; + if (!textarea) { + return; + } + const styles = window.getComputedStyle(textarea); + const lineHeight = parseFloat(styles.lineHeight || "20"); + const paddingTop = parseFloat(styles.paddingTop || "0"); + const paddingBottom = parseFloat(styles.paddingBottom || "0"); + const baseHeight = lineHeight + paddingTop + paddingBottom; + + textarea.style.height = "auto"; + const value = textarea.value; + if (value.trim() === "") { + textarea.style.height = `${baseHeight}px`; + textarea.style.overflowY = "hidden"; + return; + } + + textarea.style.height = `${Math.max(textarea.scrollHeight, baseHeight)}px`; + textarea.style.overflowY = "auto"; + }; + + useLayoutEffect(() => { + adjustHeight(); + }, [text]); + + useLayoutEffect(() => { + adjustHeight(); + }, [isVisible]); + + useLayoutEffect(() => { + if (textareaRef.current) { + textareaRef.current.focus(); + // ensure consistent initial height after focus + adjustHeight(); + } + }, []); + + const handleDivClick = (event: React.MouseEvent) => { + const target = event.target as HTMLElement; + if (target.closest("button")) return; + if (target.tagName === "TEXTAREA") return; + textareaRef.current?.focus(); + }; + + const send = () => { + if (inProgress) { + return; + } + const trimmed = text.trim(); + const payload = trimmed.length > 0 ? text : DEFAULT_PROMPT; + onSend(payload); + setText(""); + textareaRef.current?.focus(); + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === "Enter" && !event.shiftKey && !isComposing) { + event.preventDefault(); + if (inProgress && !hideStopButton) { + onStop?.(); + } else { + send(); + } + } + }; + + return ( +
+
+