Skip to content

Communication Architecture

1 post with the tag “Communication Architecture”

Deep Integration and Practical Use of StreamJsonRpc in HagiCode

Deep Integration and Practical Use of StreamJsonRpc in HagiCode

Section titled “Deep Integration and Practical Use of StreamJsonRpc in HagiCode”

This article explains in detail how the HagiCode (formerly PCode) project successfully integrated Microsoft’s StreamJsonRpc communication library to replace its original custom JSON-RPC implementation, while also resolving the technical pain points and architectural challenges encountered during the integration process.

StreamJsonRpc is Microsoft’s officially maintained JSON-RPC communication library for .NET and TypeScript, known for its strong type safety, automatic proxy generation, and mature exception handling mechanisms. In the HagiCode project, the team decided to integrate StreamJsonRpc in order to communicate with external AI tools such as iflow CLI and OpenCode CLI through ACP (Agent Communication Protocol), while also eliminating the maintenance cost and potential bugs introduced by the earlier custom JSON-RPC implementation. However, the integration process ran into challenges specific to streaming JSON-RPC, especially when handling proxy target binding and generic parameter recognition.

To address these pain points, we made a bold decision: rebuild the entire build system from the ground up. The impact of that decision may be even bigger than you expect, and I will explain it in detail shortly.

Let me first introduce the “main project” featured in this article.

If you have run into these frustrations during development:

  • Multiple projects and multiple tech stacks make build scripts expensive to maintain
  • CI/CD pipeline configuration is cumbersome, and every change sends you back to the docs
  • Cross-platform compatibility issues keep surfacing
  • You want AI to help you write code, but existing tools are not intelligent enough

Then HagiCode, which we are building, may interest you.

What is HagiCode?

  • An AI-driven intelligent coding assistant
  • Supports multilingual, cross-platform code generation and optimization
  • Includes built-in gamification mechanisms so coding feels less dull

Why mention it here? The StreamJsonRpc integration approach shared in this article is distilled from our practical experience while developing HagiCode. If you find this engineering solution valuable, it probably means our technical taste is pretty solid, and HagiCode itself is worth checking out as well.

Want to learn more?

The current project is in a critical stage of ACP protocol integration and is facing the following technical pain points and architectural challenges:

1. Limitations of the Custom Implementation

Section titled “1. Limitations of the Custom Implementation”

The original JSON-RPC implementation is located in src/HagiCode.ClaudeHelper/AcpImp/ and includes components such as JsonRpcEndpoint and ClientSideConnection. Maintaining this custom codebase is costly, and it lacks the advanced capabilities of a mature library, such as progress reporting and cancellation support.

When attempting to migrate the existing CallbackProxyTarget pattern to StreamJsonRpc, we found that the _rpc.AddLocalRpcTarget(target) method could not recognize targets created through the proxy pattern. Specifically, StreamJsonRpc could not automatically split properties of the generic type T into RPC method parameters, causing the server side to fail when processing method calls initiated by the client.

The existing ClientSideConnection mixes the transport layer (WebSocket/Stdio), protocol layer (JSON-RPC), and business layer (ACP Agent interface), leading to unclear responsibilities. It also suffers from missing method bindings in AcpAgentCallbackRpcAdapter.

The WebSocket transport layer lacks raw JSON content logging, making it difficult to determine during RPC debugging whether a problem originates from serialization or from the network.

To address the problems above, we adopted the following systematic solution, optimizing from three dimensions: architectural refactoring, library integration, and enhanced debugging.

Delete JsonRpcEndpoint.cs, AgentSideConnection.cs, and related custom serialization converters such as JsonRpcMessageJsonConverter.

Introduce the StreamJsonRpc NuGet package and use its JsonRpc class to handle the core communication logic.

Define the IAcpTransport interface to handle both WebSocket and Stdio transport modes in a unified way, ensuring the protocol layer is decoupled from the transport layer.

// Definition of the IAcpTransport interface
public interface IAcpTransport
{
Task SendAsync(string message, CancellationToken cancellationToken = default);
Task<string> ReceiveAsync(CancellationToken cancellationToken = default);
Task CloseAsync(CancellationToken cancellationToken = default);
}
// WebSocket transport implementation
public class WebSocketTransport : IAcpTransport
{
private readonly WebSocket _webSocket;
public WebSocketTransport(WebSocket webSocket)
{
_webSocket = webSocket;
}
// Implement send and receive methods
// ...
}
// Stdio transport implementation
public class StdioTransport : IAcpTransport
{
private readonly StreamReader _reader;
private readonly StreamWriter _writer;
public StdioTransport(StreamReader reader, StreamWriter writer)
{
_reader = reader;
_writer = writer;
}
// Implement send and receive methods
// ...
}

Inspect the existing dynamic proxy generation logic and identify the root cause of why StreamJsonRpc cannot recognize it. In most cases, this happens because the proxy object does not publicly expose the actual method signatures, or it uses parameter types unsupported by StreamJsonRpc.

Split generic properties into explicit RPC method parameters. Instead of relying on dynamic properties, define concrete Request/Response DTOs (data transfer objects) so StreamJsonRpc can correctly recognize method signatures through reflection.

// Original generic property approach
public class CallbackProxyTarget<T>
{
public Func<T, Task> Callback { get; set; }
}
// Refactored concrete method approach
public class ReadTextFileRequest
{
public string FilePath { get; set; }
}
public class ReadTextFileResponse
{
public string Content { get; set; }
}
public interface IAcpAgentCallback
{
Task<ReadTextFileResponse> ReadTextFileAsync(ReadTextFileRequest request);
// Other methods...
}

In some complex scenarios, manually proxying the JsonRpc object and handling RpcConnection can be more flexible than directly adding a local target.

3. Implement Method Binding and Stronger Logging

Section titled “3. Implement Method Binding and Stronger Logging”

Ensure that this component explicitly implements the StreamJsonRpc proxy interface and maps methods defined by the ACP protocol, such as ReadTextFileAsync, to StreamJsonRpc callback handlers.

Intercept and record the raw text of JSON-RPC requests and responses in the WebSocket or Stdio message processing pipeline. Use ILogger to output the raw payload before parsing and after serialization so formatting issues can be diagnosed more easily.

// Transport wrapper with enhanced logging
public class LoggingAcpTransport : IAcpTransport
{
private readonly IAcpTransport _innerTransport;
private readonly ILogger<LoggingAcpTransport> _logger;
public LoggingAcpTransport(IAcpTransport innerTransport, ILogger<LoggingAcpTransport> logger)
{
_innerTransport = innerTransport;
_logger = logger;
}
public async Task SendAsync(string message, CancellationToken cancellationToken = default)
{
_logger.LogTrace("Sending message: {Message}", message);
await _innerTransport.SendAsync(message, cancellationToken);
}
public async Task<string> ReceiveAsync(CancellationToken cancellationToken = default)
{
var message = await _innerTransport.ReceiveAsync(cancellationToken);
_logger.LogTrace("Received message: {Message}", message);
return message;
}
public async Task CloseAsync(CancellationToken cancellationToken = default)
{
_logger.LogDebug("Closing connection");
await _innerTransport.CloseAsync(cancellationToken);
}
}

Wrap the StreamJsonRpc connection and make it responsible for InvokeAsync calls and connection lifecycle management.

public class AcpRpcClient : IDisposable
{
private readonly JsonRpc _rpc;
private readonly IAcpTransport _transport;
public AcpRpcClient(IAcpTransport transport)
{
_transport = transport;
_rpc = new JsonRpc(new StreamRpcTransport(transport));
_rpc.StartListening();
}
public async Task<TResponse> InvokeAsync<TResponse>(string methodName, object parameters)
{
return await _rpc.InvokeAsync<TResponse>(methodName, parameters);
}
public void Dispose()
{
_rpc.Dispose();
_transport.Dispose();
}
// StreamRpcTransport is the StreamJsonRpc adapter for IAcpTransport
private class StreamRpcTransport : IDuplexPipe
{
// Implement the IDuplexPipe interface
// ...
}
}

Protocol Layer (IAcpAgentClient / IAcpAgentCallback)

Section titled “Protocol Layer (IAcpAgentClient / IAcpAgentCallback)”

Define clear client-to-agent and agent-to-client interfaces. Remove the cyclic factory pattern Func<IAcpAgent, IAcpClient> and replace it with dependency injection or direct callback registration.

Based on StreamJsonRpc best practices and project experience, the following are the key recommendations for implementation:

1. Strongly Typed DTOs Are Better Than Dynamic Objects

Section titled “1. Strongly Typed DTOs Are Better Than Dynamic Objects”

The core advantage of StreamJsonRpc lies in strong typing. Do not use dynamic or JObject to pass parameters. Instead, define explicit C# POCO classes as parameters for each RPC method. This not only solves the proxy target recognition issue, but also catches type errors at compile time.

Example: replace the generic properties in CallbackProxyTarget with concrete classes such as ReadTextFileRequest and WriteTextFileRequest.

Use the [JsonRpcMethod] attribute to explicitly specify RPC method names instead of relying on default method-name mapping. This prevents invocation failures caused by naming-style differences such as PascalCase versus camelCase.

public interface IAcpAgentCallback
{
[JsonRpcMethod("readTextFile")]
Task<ReadTextFileResponse> ReadTextFileAsync(ReadTextFileRequest request);
[JsonRpcMethod("writeTextFile")]
Task WriteTextFileAsync(WriteTextFileRequest request);
}

3. Take Advantage of Connection State Callbacks

Section titled “3. Take Advantage of Connection State Callbacks”

StreamJsonRpc provides the JsonRpc.ConnectionLost event. You should absolutely listen for this event to handle unexpected process exits or network disconnections, which is more timely than relying only on Orleans Grain failure detection.

_rpc.ConnectionLost += (sender, e) =>
{
_logger.LogError("RPC connection lost: {Reason}", e.ToString());
// Handle reconnection logic or notify the user
};
  • Trace level: Record the full raw JSON request/response payload.
  • Debug level: Record method call stacks and parameter summaries.
  • Note: Make sure the logs do not include sensitive Authorization tokens or Base64-encoded large-file content.

5. Handle the Special Nature of Streaming Transport

Section titled “5. Handle the Special Nature of Streaming Transport”

StreamJsonRpc natively supports IAsyncEnumerable. When implementing streaming prompt responses for ACP, use IAsyncEnumerable directly instead of custom pagination logic. This can greatly simplify the amount of code needed for streaming processing.

public interface IAcpAgentCallback
{
[JsonRpcMethod("streamText")]
IAsyncEnumerable<string> StreamTextAsync(StreamTextRequest request);
}

Keep ACPSession and ClientSideConnection separate. ACPSession should focus on Orleans state management and business logic, such as message enqueueing, and should use the StreamJsonRpc connection object through composition rather than inheritance.

By comprehensively integrating StreamJsonRpc, the HagiCode project successfully addressed the high maintenance cost, functional limitations, and architectural layering confusion of the original custom implementation. The key improvements include:

  1. Replacing dynamic properties with strongly typed DTOs, improving maintainability and reliability
  2. Implementing transport-layer abstraction and protocol-layer separation, improving architectural clarity
  3. Strengthening logging capabilities to make communication problems easier to diagnose
  4. Introducing streaming support to simplify streaming implementation

These improvements provide HagiCode with a more stable and more efficient communication foundation, allowing it to interact better with external AI tools and laying a solid foundation for future feature expansion.


If this article helped you:


Thank you for reading. If you found this article useful, please click the like button below so more people can discover it.

This content was created with AI-assisted collaboration and reviewed by me, and it reflects my own views and position.