Skip to content

Develop poc acp#8196

Open
Nabil-Salah wants to merge 5 commits intojaegertracing:mainfrom
Nabil-Salah:develop_poc_acp
Open

Develop poc acp#8196
Nabil-Salah wants to merge 5 commits intojaegertracing:mainfrom
Nabil-Salah:develop_poc_acp

Conversation

@Nabil-Salah
Copy link
Contributor

Which problem is this PR solving?

Description of the changes

  • This pr isn't meant to be merged, it should provide a POC that ACP can work for our case

How was this change tested?

  • it was tested manually by curl requests

Checklist

AI Usage in this PR (choose one)

See AI Usage Policy.

  • None: No AI tools were used in creating this PR
  • Light: AI provided minor assistance (formatting, simple suggestions)
  • Moderate: AI helped with code generation or debugging specific parts
  • Heavy: AI generated most or all of the code changes

Signed-off-by: Nabil-Salah <nabil.salah203@gmail.com>
Signed-off-by: Nabil-Salah <nabil.salah203@gmail.com>
@Nabil-Salah Nabil-Salah requested a review from a team as a code owner March 18, 2026 21:55
Copilot AI review requested due to automatic review settings March 18, 2026 21:55
@dosubot dosubot bot added the feature vote Proposed feature that needs 3+ users interested in it label Mar 18, 2026
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Proof-of-concept wiring for an ACP-based “AI gateway” in Jaeger Query, with a local Python sidecar agent (Gemini-backed) reachable over WebSockets to demonstrate tool-calling (e.g., search_traces) end-to-end.

Changes:

  • Adds a new /api/chat HTTP endpoint in Jaeger Query that bridges ACP to a local WebSocket sidecar.
  • Introduces a Python ACP agent sidecar (Gemini client) plus minimal uv project scaffolding/docs to run it.
  • Adds Go dependencies for ACP and WebSockets; introduces a “dummy trace” shortcut in FindTraces for PoC tool output.

Reviewed changes

Copilot reviewed 9 out of 10 changed files in this pull request and generated 11 comments.

Show a summary per file
File Description
python-sidecar/sidecar.py Implements a WebSocket-bridged ACP agent that calls Gemini and can invoke search_traces.
python-sidecar/pyproject.toml Declares Python-sidecar dependencies (agent-client-protocol, google-genai, websockets).
python-sidecar/main.py Simple asyncio entrypoint wrapper for running the sidecar.
python-sidecar/README.md Local run instructions and a curl-based manual test.
go.mod Adds ACP Go SDK + gorilla/websocket dependencies.
go.sum Adds checksums for the new Go dependencies.
cmd/jaeger/internal/extension/jaegerquery/querysvc/service.go Adds a hardcoded dummy-service branch returning a synthetic trace.
cmd/jaeger/internal/extension/jaegerquery/internal/server.go Registers the new /api/chat route (with basePath handling).
cmd/jaeger/internal/extension/jaegerquery/internal/jaegerai/handler.go New HTTP handler implementing ACP client-side connection and tool plumbing.
Makefile Excludes python-sidecar/ from repo script/lint/license checks.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +156 to +159
if query.TraceQueryParams.ServiceName == "dummy-service" {
yield([]ptrace.Traces{dummyTrace(query.TraceQueryParams.OperationName)}, nil)
return
}
Comment on lines +249 to +253
var req ChatRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
Comment on lines +188 to +197
c.w.Write([]byte(content.Text.Text))
c.flusher.Flush()
}
}
if u.ToolCall != nil {
fmt.Fprintf(c.w, "\n[tool_call] %s\n", u.ToolCall.Title)
c.flusher.Flush()
}
if u.ToolCallUpdate != nil {
fmt.Fprintf(c.w, "\n[tool_result] id=%s status=%s\n", u.ToolCallUpdate.ToolCallId, valueOrUnknown(u.ToolCallUpdate.Status))
if isinstance(message, str):
message = message.encode('utf-8')
client_writer.write(message)
if b'\n' not in message:
Comment on lines +30 to +35
api_key = os.environ.get("GEMINI_API_KEY")

class JaegerSidecarAgent(Agent):
def __init__(self):
super().__init__()
self._conn: Client = None
Comment on lines +1 to +5
import asyncio
import json
import os
import socket
from urllib.parse import quote_plus
Comment on lines +1 to +5
import asyncio

from sidecar import main as sidecar_main


Comment on lines +266 to +274
dialer := websocket.Dialer{HandshakeTimeout: 5 * time.Second}
conn, resp, err := dialer.DialContext(ctx, "ws://localhost:9000", nil)
if resp != nil && resp.Body != nil {
defer resp.Body.Close()
}
if err != nil {
h.Logger.Error("Failed to dial ACP sidecar", zap.Error(err))
http.Error(w, "Failed to connect to agent backend", http.StatusBadGateway)
return
Comment on lines +310 to +332
if err != nil {
fmt.Fprintf(w, "Error initializing agent: %v\n", err)
return
}

sess, err := acpConn.NewSession(acpCtx, acp.NewSessionRequest{
Cwd: "/",
McpServers: []acp.McpServer{},
})
if err != nil {
fmt.Fprintf(w, "Error creating session: %v\n", err)
return
}

// This is blocking until the agent finishes processing the prompt
_, err = acpConn.Prompt(acpCtx, acp.PromptRequest{
SessionId: sess.SessionId,
Prompt: []acp.ContentBlock{acp.TextBlock(req.Prompt)},
})
if err != nil {
fmt.Fprintf(w, "Error starting prompt: %v\n", err)
return
}
-not -path './vendor/*' \
-not -path './idl/*' \
-not -path './jaeger-ui/*' \
-not -path './python-sidecar/*' \
query TraceQueryParams,
) iter.Seq2[[]ptrace.Traces, error] {
return func(yield func([]ptrace.Traces, error) bool) {
if query.TraceQueryParams.ServiceName == "dummy-service" {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

since you are running Jaeger for this test anyway, just refresh the UI a few times for internal traces to be reported under jaeger service. Then you won't need this hack just to test.

Comment on lines +246 to +252
async def main():
async with websockets.serve(handle_websocket, "localhost", 9000):
print("Jaeger ACP Sidecar listening on ws://localhost:9000")
await asyncio.Future()

if __name__ == "__main__":
asyncio.run(main())
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suggest moving these to main.py

return PromptResponse(stop_reason="end_turn")


async def ws_to_client_writer(websocket, client_writer):
Copy link
Member

@yurishkuro yurishkuro Mar 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please move ws / comms functions to a separate file

"Call this tool whenever trace/span lookup data is needed before answering."
)

search_traces_tool = types.Tool(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

since we have Jaeger MCP server, I wonder if the ACP SDK can just interrogate that server for available tools and invoke them instead of you manually re-implementing the plumbing/binding

Copy link
Member

@yurishkuro yurishkuro Mar 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

try this

from google.adk.tools import McpToolset
from google.adk.tools.mcp_tool import StreamableHTTPConnectionParams

# 1. Define the connection to Jaeger MCP server
mcp_config = StreamableHTTPConnectionParams(
    url="https://127.0.0.1:16687/mcp",
)

# 2. Create the toolset (this handles the "automatic construction")
tools = McpToolset(connections=[mcp_config])

then below instead of tools=[search_traces_tool] use tools: tools.get_tools(), and then use tools.call_tool(fc.name, fc.args) instead of manually switching via if name == "search_traces"

Copy link
Contributor Author

@Nabil-Salah Nabil-Salah Mar 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You mean that for the discovery part, I use the MCP server? but for tool calling, I use the current Meta tools
because i thought you wanted the "Mediated" Approach (ACP + MCP).
The Jaeger Go Proxy will implement the Mediated Approach for all tool calls. The remote agent will never directly communicate with Jaeger's internal database or MCP server. but when need a tool it call using the jaeger acp client

Option B: The "Mediated" Approach (ACP + MCP)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not just for discovery but for tool execution too. Right now when the agent tells you it wants to invoke a tool like search traces you wrote a bunch of code to implement that search, but we already have mcp server doing that, so your proxy simply needs to invoke mcp and pass the result back to the agent.


## End-to-End Test

1. Start Jaeger gateway (`jaeger-dev`) in another terminal.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what is Jaeger gateway?

}

// AI Gateway Endpoints
aiHandlerPath := "/api/chat"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
aiHandlerPath := "/api/chat"
aiHandlerPath := "/api/ai/chat"

)

// WsReadWriteCloser wraps a gorilla websocket to implement io.ReadWriteCloser
type WsReadWriteCloser struct {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please move ws/comms related structs to separate files

queryService *querysvc.QueryService
}

func searchTracesToolResult(ctx context.Context, queryService *querysvc.QueryService, query string) string {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

see my comment in sidecar.py - I don't see why we need any special handling for tools since we already have the MCP server.

flusher: flusher,
queryService: h.QueryService,
}
acpConn := acp.NewClientSideConnection(clientImpl, adapter, adapter)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this the connection to the sidecar? please add comments like this

Meta: map[string]any{
"tools": []map[string]string{
{
"name": "search_traces",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not following why tool definition is repeated here.


sess, err := acpConn.NewSession(acpCtx, acp.NewSessionRequest{
Cwd: "/",
McpServers: []acp.McpServer{},
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

architecturally what does having MCP servers here mean?


import websockets

from google import genai
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it seems these SDKs also exist in Go, maybe then we don't need to run a sidecar, just run it inside query service ?

Signed-off-by: Nabil-Salah <nabil.salah203@gmail.com>
Signed-off-by: Nabil-Salah <nabil.salah203@gmail.com>
Copilot AI review requested due to automatic review settings March 21, 2026 01:51
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a proof-of-concept “AI gateway” to Jaeger Query that proxies chat prompts to an ACP sidecar over WebSocket, with the sidecar calling Jaeger MCP tools and using Gemini for responses.

Changes:

  • Adds a new Jaeger Query HTTP endpoint (/api/ai/chat) that streams agent output and tool lifecycle updates.
  • Introduces a Python ACP sidecar (WebSocket server) that bridges Gemini ↔ ACP ↔ Jaeger MCP tools.
  • Adds Go module dependencies for ACP and WebSocket support, plus a dummy trace injection for PoC testing.

Reviewed changes

Copilot reviewed 11 out of 12 changed files in this pull request and generated 15 comments.

Show a summary per file
File Description
cmd/jaeger/internal/extension/jaegerquery/querysvc/service.go Adds “dummy-service” short-circuit that returns a synthetic trace.
cmd/jaeger/internal/extension/jaegerquery/internal/server.go Registers the new AI chat HTTP route in the Query server mux.
cmd/jaeger/internal/extension/jaegerquery/internal/jaegerai/handler.go Implements /api/ai/chat handler that dials the sidecar via WebSocket and runs an ACP session.
cmd/jaeger/internal/extension/jaegerquery/internal/jaegerai/streaming_client.go Implements ACP client callbacks to stream agent text/tool updates to the HTTP response.
cmd/jaeger/internal/extension/jaegerquery/internal/jaegerai/ws_adapter.go Wraps gorilla/websocket as an io.ReadWriteCloser for ACP connection plumbing.
python-sidecar/sidecar.py Implements ACP agent and MCP tool bridge using Gemini + Google ADK, plus WS↔ACP stream bridging.
python-sidecar/main.py Starts the sidecar WebSocket server on localhost:9000.
python-sidecar/pyproject.toml Declares sidecar project metadata and dependencies.
python-sidecar/README.md Documents setup and manual curl testing for the PoC.
go.mod Adds github.com/coder/acp-go-sdk and github.com/gorilla/websocket.
go.sum Adds module sums for new Go dependencies.
Makefile Excludes python-sidecar/ from script formatting/license checks.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +6 to +11
requires-python = ">=3.14"
dependencies = [
"agent-client-protocol>=0.8.1",
"google-adk>=1.10.0",
"google-genai>=1.68.0",
"websockets>=16.0",
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

requires-python is set to ">=3.14", but the code shown only uses features available in older Python 3.x (e.g., 3.10/3.11). Unless 3.14 is required for a specific dependency/runtime feature, consider lowering this to reduce friction for contributors and runtime environments.

Copilot uses AI. Check for mistakes.
Comment on lines +43 to +47
var req ChatRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The request body is decoded without any size limit, so a large POST can cause excessive memory usage. Consider wrapping r.Body with http.MaxBytesReader (and optionally validating req.Prompt length / non-empty) before decoding.

Copilot uses AI. Check for mistakes.
Comment on lines +69 to +72
for tool in adk_tools:
declaration = tool._get_declaration()
if declaration is not None:
function_declarations.append(declaration)
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This relies on a private/underscored API (tool._get_declaration()), which is prone to breaking on dependency upgrades. If google-adk exposes a public way to get function declarations, prefer that; otherwise isolate this behind a small compatibility layer with clear version pinning.

Copilot uses AI. Check for mistakes.
Comment on lines +37 to +58
func (h *ChatHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}

var req ChatRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Bad request", http.StatusBadRequest)
return
}

flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "Streaming unsupported", http.StatusInternalServerError)
return
}

w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")

Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This introduces a new public HTTP endpoint (/api/ai/chat) with streaming behavior and external sidecar dependency, but there are no corresponding tests. Consider adding at least a basic handler test (similar to existing http_handler_test.go patterns) that exercises request validation, sidecar dial failure handling, and streaming output on a mocked websocket/ACP connection.

Copilot uses AI. Check for mistakes.
class JaegerSidecarAgent(Agent):
def __init__(self):
super().__init__()
self._conn: Client = None
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Gemini client is constructed even when GEMINI_API_KEY is unset (api_key=None), so failures will happen later in a less obvious place. Consider validating api_key at startup and returning a clear error to the ACP client (or failing fast) when the environment variable is missing.

Suggested change
self._conn: Client = None
self._conn: Client = None
if not api_key:
raise RuntimeError(
"GEMINI_API_KEY environment variable is not set; cannot initialize Gemini client."
)

Copilot uses AI. Check for mistakes.
3. Send a chat prompt:

```bash
curl -N -X POST http://localhost:16686/api/chat \
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The README's curl example posts to /api/chat, but the server registers the handler at /api/ai/chat. Please update the example (or the route) so users can follow the documented steps successfully.

Suggested change
curl -N -X POST http://localhost:16686/api/chat \
curl -N -X POST http://localhost:16686/api/ai/chat \

Copilot uses AI. Check for mistakes.
Comment on lines +44 to +52
You can start the same server using either entrypoint:

```bash
uv run python sidecar.py
```

or

```bash
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

README suggests uv run python sidecar.py starts the server, but sidecar.py currently only defines handlers/classes and does not start a websocket server in a main block. Either add an entrypoint to sidecar.py or adjust the README to only reference main.py (or a proper console script).

Suggested change
You can start the same server using either entrypoint:
```bash
uv run python sidecar.py
```
or
```bash
Start the sidecar server with:
```bash

Copilot uses AI. Check for mistakes.
-not -path './vendor/*' \
-not -path './idl/*' \
-not -path './jaeger-ui/*' \
-not -path './python-sidecar/*' \
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Excluding python-sidecar from SCRIPTS_SRC means these new .py/.md files won't be covered by the repo's formatting/license checks (make fmt / make lint-license). If this code is intended to live in-repo (even as a PoC), consider keeping it in the lint set and adding the standard license headers instead of opting out.

Suggested change
-not -path './python-sidecar/*' \

Copilot uses AI. Check for mistakes.
Comment on lines +156 to +159
if query.TraceQueryParams.ServiceName == "dummy-service" {
yield([]ptrace.Traces{dummyTrace(query.TraceQueryParams.OperationName)}, nil)
return
}
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This injects a synthetic trace whenever the caller searches for service.name == "dummy-service", which changes production query behavior and could leak into real deployments (unexpected data, confusing UX, test-only behavior in main code path). Please gate this behind an explicit debug/POC configuration flag (or build tag), or move it to a dedicated mock storage/test harness instead of QueryService.FindTraces().

Copilot uses AI. Check for mistakes.
Comment on lines +60 to +61
dialer := websocket.Dialer{HandshakeTimeout: 5 * time.Second}
conn, resp, err := dialer.DialContext(ctx, "ws://localhost:9000", nil)
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The websocket sidecar endpoint is hard-coded to ws://localhost:9000, which makes the feature unusable in non-local deployments and complicates ops (sidecar on different host/port, TLS, etc.). Please make the sidecar URL configurable (e.g., via QueryOptions / env var) and validate it at startup.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature vote Proposed feature that needs 3+ users interested in it

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants