From 11b89f5e44bdf640b0579d6155cc18537e7595d6 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 22 Dec 2025 13:14:04 +0100 Subject: [PATCH 1/2] Add support for extension methods Change-Id: Ida198f569e84f49da9908471a39998b741c283cf Signed-off-by: Thomas Kosiewski --- README.md | 63 ++++++++++++++++ acp_test.go | 200 +++++++++++++++++++++++++++++++++++++++++++++++++- agent.go | 2 +- client.go | 2 +- connection.go | 5 ++ extensions.go | 103 ++++++++++++++++++++++++++ 6 files changed, 370 insertions(+), 5 deletions(-) create mode 100644 extensions.go diff --git a/README.md b/README.md index a636a52..1d7fdc1 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,69 @@ Helper constructors are provided to reduce boilerplate when working with union t - Tool content: `acp.ToolContent`, `acp.ToolDiffContent`, `acp.ToolTerminalRef`. - Utility: `acp.Ptr[T]` for pointer fields in request/update structs. + +### Extension methods + +ACP supports **extension methods** for custom JSON-RPC methods whose names start with `_`. +Use them to add functionality without conflicting with future ACP versions. + +#### Handling inbound extension methods +Implement `acp.ExtensionMethodHandler` on your Agent or Client. Your handler will be +invoked for any incoming method starting with `_`. + +```go +// HandleExtensionMethod handles ACP extension methods (names starting with "_"). +func (a MyAgent) HandleExtensionMethod(ctx context.Context, method string, params json.RawMessage) (any, error) { + switch method { + case "_example.com/hello": + var p struct { + Name string `json:"name"` + } + if err := json.Unmarshal(params, &p); err != nil { + return nil, err + } + return map[string]any{"greeting": "hello " + p.Name}, nil + default: + return nil, acp.NewMethodNotFound(method) + } +} +``` + +> Note: Per the ACP spec, unknown extension notifications should be ignored. +> This SDK suppresses noisy logs for unhandled extension notifications that return +> “Method not found”. + +#### Calling extension methods +From either side, use `CallExtension` / `NotifyExtension` on the connection. + +```go +raw, err := conn.CallExtension(ctx, "_example.com/hello", map[string]any{"name": "world"}) +if err != nil { + return err +} + +var resp struct { + Greeting string `json:"greeting"` +} +if err := json.Unmarshal(raw, &resp); err != nil { + return err +} + +if err := conn.NotifyExtension(ctx, "_example.com/progress", map[string]any{"pct": 50}); err != nil { + return err +} +``` + +#### Advertising extension support via `_meta` +ACP uses the `_meta` field inside capability objects as the negotiation/advertising +surface for extensions. + +- Client -> Agent: `InitializeRequest.ClientCapabilities.Meta` +- Agent -> Client: `InitializeResponse.AgentCapabilities.Meta` + +Keys `traceparent`, `tracestate`, and `baggage` are reserved in `_meta` for W3C trace +context/OpenTelemetry compatibility. + ### Study a Production Implementation For a complete, production‑ready integration, see the diff --git a/acp_test.go b/acp_test.go index c5a7c37..5ed2160 100644 --- a/acp_test.go +++ b/acp_test.go @@ -1,9 +1,14 @@ package acp import ( + "bytes" "context" + "encoding/json" + "errors" "io" + "log/slog" "slices" + "strings" "sync" "sync/atomic" "testing" @@ -21,8 +26,12 @@ type clientFuncs struct { ReleaseTerminalFunc func(context.Context, ReleaseTerminalRequest) (ReleaseTerminalResponse, error) TerminalOutputFunc func(context.Context, TerminalOutputRequest) (TerminalOutputResponse, error) WaitForTerminalExitFunc func(context.Context, WaitForTerminalExitRequest) (WaitForTerminalExitResponse, error) + + HandleExtensionMethodFunc func(context.Context, string, json.RawMessage) (any, error) } +var _ ExtensionMethodHandler = (*clientFuncs)(nil) + var _ Client = (*clientFuncs)(nil) func (c clientFuncs) WriteTextFile(ctx context.Context, p WriteTextFileRequest) (WriteTextFileResponse, error) { @@ -93,6 +102,13 @@ func (c *clientFuncs) WaitForTerminalExit(ctx context.Context, params WaitForTer return WaitForTerminalExitResponse{}, nil } +func (c clientFuncs) HandleExtensionMethod(ctx context.Context, method string, params json.RawMessage) (any, error) { + if c.HandleExtensionMethodFunc != nil { + return c.HandleExtensionMethodFunc(ctx, method, params) + } + return nil, NewMethodNotFound(method) +} + type agentFuncs struct { InitializeFunc func(context.Context, InitializeRequest) (InitializeResponse, error) NewSessionFunc func(context.Context, NewSessionRequest) (NewSessionResponse, error) @@ -102,12 +118,15 @@ type agentFuncs struct { CancelFunc func(context.Context, CancelNotification) error SetSessionModeFunc func(ctx context.Context, params SetSessionModeRequest) (SetSessionModeResponse, error) SetSessionModelFunc func(ctx context.Context, params SetSessionModelRequest) (SetSessionModelResponse, error) + + HandleExtensionMethodFunc func(context.Context, string, json.RawMessage) (any, error) } var ( - _ Agent = (*agentFuncs)(nil) - _ AgentLoader = (*agentFuncs)(nil) - _ AgentExperimental = (*agentFuncs)(nil) + _ Agent = (*agentFuncs)(nil) + _ AgentLoader = (*agentFuncs)(nil) + _ AgentExperimental = (*agentFuncs)(nil) + _ ExtensionMethodHandler = (*agentFuncs)(nil) ) func (a agentFuncs) Initialize(ctx context.Context, p InitializeRequest) (InitializeResponse, error) { @@ -168,6 +187,13 @@ func (a agentFuncs) SetSessionModel(ctx context.Context, params SetSessionModelR return SetSessionModelResponse{}, nil } +func (a agentFuncs) HandleExtensionMethod(ctx context.Context, method string, params json.RawMessage) (any, error) { + if a.HandleExtensionMethodFunc != nil { + return a.HandleExtensionMethodFunc(ctx, method, params) + } + return nil, NewMethodNotFound(method) +} + // Test bidirectional error handling similar to typescript/acp.test.ts func TestConnectionHandlesErrorsBidirectional(t *testing.T) { ctx := context.Background() @@ -819,3 +845,171 @@ func TestRequestHandlerCanMakeNestedRequest(t *testing.T) { t.Fatalf("prompt failed: %v", err) } } + +type extEchoParams struct { + Msg string `json:"msg"` +} + +type extEchoResult struct { + Msg string `json:"msg"` +} + +type agentNoExtensions struct{} + +func (agentNoExtensions) Authenticate(ctx context.Context, params AuthenticateRequest) (AuthenticateResponse, error) { + return AuthenticateResponse{}, nil +} + +func (agentNoExtensions) Initialize(ctx context.Context, params InitializeRequest) (InitializeResponse, error) { + return InitializeResponse{}, nil +} + +func (agentNoExtensions) Cancel(ctx context.Context, params CancelNotification) error { return nil } + +func (agentNoExtensions) NewSession(ctx context.Context, params NewSessionRequest) (NewSessionResponse, error) { + return NewSessionResponse{}, nil +} + +func (agentNoExtensions) Prompt(ctx context.Context, params PromptRequest) (PromptResponse, error) { + return PromptResponse{}, nil +} + +func (agentNoExtensions) SetSessionMode(ctx context.Context, params SetSessionModeRequest) (SetSessionModeResponse, error) { + return SetSessionModeResponse{}, nil +} + +func TestExtensionMethods_ClientToAgentRequest(t *testing.T) { + c2aR, c2aW := io.Pipe() + a2cR, a2cW := io.Pipe() + + method := "_vendor.test/echo" + + ag := NewAgentSideConnection(agentFuncs{ + HandleExtensionMethodFunc: func(ctx context.Context, gotMethod string, params json.RawMessage) (any, error) { + if gotMethod != method { + return nil, NewInternalError(map[string]any{"expected": method, "got": gotMethod}) + } + var p extEchoParams + if err := json.Unmarshal(params, &p); err != nil { + return nil, err + } + return extEchoResult{Msg: p.Msg}, nil + }, + }, a2cW, c2aR) + + _ = ag + + c := NewClientSideConnection(&clientFuncs{}, c2aW, a2cR) + + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + defer cancel() + + raw, err := c.CallExtension(ctx, method, extEchoParams{Msg: "hi"}) + if err != nil { + t.Fatalf("CallExtension: %v", err) + } + var resp extEchoResult + if err := json.Unmarshal(raw, &resp); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + if resp.Msg != "hi" { + t.Fatalf("unexpected response: %#v", resp) + } +} + +func TestExtensionMethods_UnknownRequest_ReturnsMethodNotFound(t *testing.T) { + c2aR, c2aW := io.Pipe() + a2cR, a2cW := io.Pipe() + + NewAgentSideConnection(agentNoExtensions{}, a2cW, c2aR) + c := NewClientSideConnection(&clientFuncs{}, c2aW, a2cR) + + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + defer cancel() + + _, err := c.CallExtension(ctx, "_vendor.test/missing", extEchoParams{Msg: "hi"}) + if err == nil { + t.Fatalf("expected error") + } + var re *RequestError + if !errors.As(err, &re) { + t.Fatalf("expected *RequestError, got %T: %v", err, err) + } + if re.Code != -32601 { + t.Fatalf("expected -32601 method not found, got %d", re.Code) + } +} + +func TestExtensionMethods_UnknownNotification_DoesNotLog(t *testing.T) { + c2aR, c2aW := io.Pipe() + a2cR, a2cW := io.Pipe() + + done := make(chan struct{}) + + ag := NewAgentSideConnection(agentFuncs{ + HandleExtensionMethodFunc: func(ctx context.Context, method string, params json.RawMessage) (any, error) { + close(done) + return nil, NewMethodNotFound(method) + }, + }, a2cW, c2aR) + + var logBuf bytes.Buffer + ag.SetLogger(slog.New(slog.NewTextHandler(&logBuf, &slog.HandlerOptions{Level: slog.LevelDebug}))) + + c := NewClientSideConnection(&clientFuncs{}, c2aW, a2cR) + + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + defer cancel() + + if err := c.NotifyExtension(ctx, "_vendor.test/notify", map[string]any{"hello": "world"}); err != nil { + t.Fatalf("NotifyExtension: %v", err) + } + + select { + case <-done: + // ok + case <-ctx.Done(): + t.Fatalf("timeout waiting for notification handler") + } + + if strings.Contains(logBuf.String(), "failed to handle notification") { + t.Fatalf("unexpected notification error log: %s", logBuf.String()) + } +} + +func TestExtensionMethods_AgentToClientRequest(t *testing.T) { + c2aR, c2aW := io.Pipe() + a2cR, a2cW := io.Pipe() + + method := "_vendor.test/echo" + + _ = NewClientSideConnection(&clientFuncs{ + HandleExtensionMethodFunc: func(ctx context.Context, gotMethod string, params json.RawMessage) (any, error) { + if gotMethod != method { + return nil, NewInternalError(map[string]any{"expected": method, "got": gotMethod}) + } + var p extEchoParams + if err := json.Unmarshal(params, &p); err != nil { + return nil, err + } + return extEchoResult{Msg: p.Msg}, nil + }, + }, c2aW, a2cR) + + ag := NewAgentSideConnection(agentFuncs{}, a2cW, c2aR) + + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + defer cancel() + + raw, err := ag.CallExtension(ctx, method, extEchoParams{Msg: "hi"}) + if err != nil { + t.Fatalf("CallExtension: %v", err) + } + var resp extEchoResult + if err := json.Unmarshal(raw, &resp); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + if resp.Msg != "hi" { + t.Fatalf("unexpected response: %#v", resp) + } +} diff --git a/agent.go b/agent.go index d561fd5..26efc57 100644 --- a/agent.go +++ b/agent.go @@ -22,7 +22,7 @@ func NewAgentSideConnection(agent Agent, peerInput io.Writer, peerOutput io.Read asc := &AgentSideConnection{} asc.agent = agent asc.sessionCancels = make(map[string]context.CancelFunc) - asc.conn = NewConnection(asc.handle, peerInput, peerOutput) + asc.conn = NewConnection(asc.handleWithExtensions, peerInput, peerOutput) return asc } diff --git a/client.go b/client.go index 3dc04ac..c5faca7 100644 --- a/client.go +++ b/client.go @@ -16,7 +16,7 @@ type ClientSideConnection struct { func NewClientSideConnection(client Client, peerInput io.Writer, peerOutput io.Reader) *ClientSideConnection { csc := &ClientSideConnection{} csc.client = client - csc.conn = NewConnection(csc.handle, peerInput, peerOutput) + csc.conn = NewConnection(csc.handleWithExtensions, peerInput, peerOutput) return csc } diff --git a/connection.go b/connection.go index fc114e5..d632b48 100644 --- a/connection.go +++ b/connection.go @@ -8,6 +8,7 @@ import ( "errors" "io" "log/slog" + "strings" "sync" "sync/atomic" ) @@ -152,6 +153,10 @@ func (c *Connection) handleInbound(req *anyMessage) { if req.ID == nil { // Notification: no response is sent; log handler errors to surface decode failures. if err != nil { + // Per ACP, unknown extension notifications should be ignored. + if err.Code == -32601 && strings.HasPrefix(req.Method, "_") { + return + } c.loggerOrDefault().Error("failed to handle notification", "method", req.Method, "err", err) } return diff --git a/extensions.go b/extensions.go new file mode 100644 index 0000000..9af5983 --- /dev/null +++ b/extensions.go @@ -0,0 +1,103 @@ +package acp + +import ( + "context" + "encoding/json" + "fmt" + "strings" +) + +// ExtensionMethodHandler can be implemented by either an Agent or a Client. +// +// ACP extension methods are JSON-RPC methods whose names begin with "_". +// They provide a stable namespace for custom functionality that is not part +// of the core ACP spec. +// +// If the method is unrecognized, implementations should return NewMethodNotFound(method). +// +// See: https://agentclientprotocol.com/protocol/extensibility#extension-methods +type ExtensionMethodHandler interface { + HandleExtensionMethod(ctx context.Context, method string, params json.RawMessage) (any, error) +} + +func validateExtensionMethodName(method string) error { + if method == "" { + return fmt.Errorf("extension method name must be non-empty") + } + if !strings.HasPrefix(method, "_") { + return fmt.Errorf("extension method name must start with '_' (got %q)", method) + } + return nil +} + +func isExtensionMethodName(method string) bool { + return strings.HasPrefix(method, "_") +} + +func (a *AgentSideConnection) handleWithExtensions(ctx context.Context, method string, params json.RawMessage) (any, *RequestError) { + if isExtensionMethodName(method) { + h, ok := a.agent.(ExtensionMethodHandler) + if !ok { + return nil, NewMethodNotFound(method) + } + resp, err := h.HandleExtensionMethod(ctx, method, params) + if err != nil { + return nil, toReqErr(err) + } + return resp, nil + } + + return a.handle(ctx, method, params) +} + +func (c *ClientSideConnection) handleWithExtensions(ctx context.Context, method string, params json.RawMessage) (any, *RequestError) { + if isExtensionMethodName(method) { + h, ok := c.client.(ExtensionMethodHandler) + if !ok { + return nil, NewMethodNotFound(method) + } + resp, err := h.HandleExtensionMethod(ctx, method, params) + if err != nil { + return nil, toReqErr(err) + } + return resp, nil + } + + return c.handle(ctx, method, params) +} + +// CallExtension sends an ACP extension-method request (method names starting with "_") +// from an agent to its client. +func (c *AgentSideConnection) CallExtension(ctx context.Context, method string, params any) (json.RawMessage, error) { + if err := validateExtensionMethodName(method); err != nil { + return nil, err + } + return SendRequest[json.RawMessage](c.conn, ctx, method, params) +} + +// NotifyExtension sends an ACP extension-method notification (method names starting with "_") +// from an agent to its client. +func (c *AgentSideConnection) NotifyExtension(ctx context.Context, method string, params any) error { + if err := validateExtensionMethodName(method); err != nil { + return err + } + return c.conn.SendNotification(ctx, method, params) +} + +// CallExtension sends an ACP extension-method request (method names starting with "_") +// from a client to its agent. +func (c *ClientSideConnection) CallExtension(ctx context.Context, method string, params any) (json.RawMessage, error) { + if err := validateExtensionMethodName(method); err != nil { + return nil, err + } + return SendRequest[json.RawMessage](c.conn, ctx, method, params) +} + +// NotifyExtension sends an ACP extension-method notification (method names starting with "_") +// from a client to its agent. +func (c *ClientSideConnection) NotifyExtension(ctx context.Context, method string, params any) error { + if err := validateExtensionMethodName(method); err != nil { + return err + } + return c.conn.SendNotification(ctx, method, params) +} From baf0aeabf21f5fce23840f30fc1187031cb6bd60 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 22 Dec 2025 13:27:09 +0100 Subject: [PATCH 2/2] Format README Change-Id: I22441dd4cb63de24bd54ade48dc7d95b656897ea Signed-off-by: Thomas Kosiewski --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 1d7fdc1..7402bdb 100644 --- a/README.md +++ b/README.md @@ -64,13 +64,13 @@ Helper constructors are provided to reduce boilerplate when working with union t - Tool content: `acp.ToolContent`, `acp.ToolDiffContent`, `acp.ToolTerminalRef`. - Utility: `acp.Ptr[T]` for pointer fields in request/update structs. - ### Extension methods ACP supports **extension methods** for custom JSON-RPC methods whose names start with `_`. Use them to add functionality without conflicting with future ACP versions. #### Handling inbound extension methods + Implement `acp.ExtensionMethodHandler` on your Agent or Client. Your handler will be invoked for any incoming method starting with `_`. @@ -97,6 +97,7 @@ func (a MyAgent) HandleExtensionMethod(ctx context.Context, method string, param > “Method not found”. #### Calling extension methods + From either side, use `CallExtension` / `NotifyExtension` on the connection. ```go @@ -118,6 +119,7 @@ if err := conn.NotifyExtension(ctx, "_example.com/progress", map[string]any{"pct ``` #### Advertising extension support via `_meta` + ACP uses the `_meta` field inside capability objects as the negotiation/advertising surface for extensions.