-
Notifications
You must be signed in to change notification settings - Fork 0
feat(acp): implement Agent Communication Protocol (ACP) server and context-aware tool execution #5
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
mrazza
wants to merge
11
commits into
main
Choose a base branch
from
feat/acp
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+2,721
−93
Draft
Changes from all commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
01cd8c1
feat: start implementing Agent Communication Protocol (ACP) server wi…
mrazza 098ffff
refactor: rename Agent to Session
mrazza 9b7ca3d
feat: add NewSessionRequest/Response types and start session creation…
mrazza 65e566f
feat: implement session management and persistent state handling in A…
mrazza ceeabd9
feat: add support to stream message and thought content back to the c…
mrazza ed5d7c5
feat: implement streaming response support for session turns and add …
mrazza e2decee
refactor: extract streaming chunk processing to new converter module …
mrazza e267f07
feat: support context-aware tool execution where a context object is …
mrazza d718143
fix: content-length is case insensitive
mrazza f59bd52
fix: Improve Tool ctx object unwrapping by hiding the optional anyopa…
mrazza 3a0f94b
feat: gracefully handle errors and report them to the ACP client
mrazza File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| const std = @import("std"); | ||
| const Provider = @import("llm").Provider; | ||
| const SessionConfig = @import("agent").types.SessionConfig; | ||
|
|
||
| const Config = @This(); | ||
|
|
||
| provider: Provider, | ||
| default_session_config: SessionConfig, |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,219 @@ | ||
| const std = @import("std"); | ||
| const agent = @import("agent"); | ||
| const llm = @import("llm"); | ||
| const agent_api = @import("agent_api.zig"); | ||
| const client_api = @import("client_api.zig"); | ||
| const shared_api = @import("shared_api.zig"); | ||
| const converter = @import("converter.zig"); | ||
| const JsonRpcReader = @import("json_rpc/JsonRpcReader.zig"); | ||
| const JsonRpcWriter = @import("json_rpc/JsonRpcWriter.zig"); | ||
| const SessionStorage = @import("SessionStorage.zig"); | ||
| pub const Config = @import("Config.zig"); | ||
|
|
||
| const Io = std.Io; | ||
| const Allocator = std.mem.Allocator; | ||
|
|
||
| pub const AcpProtocolError = error{ | ||
| InvalidJsonRpcVersion, | ||
| MissingId, | ||
| } || std.json.Error; | ||
|
|
||
| const ServerSessionContext = struct { | ||
| session_state: *SessionStorage.SessionState, | ||
| json_rpc_writer: *JsonRpcWriter, | ||
| }; | ||
|
|
||
| const Server = @This(); | ||
|
|
||
| allocator: Allocator, | ||
| io: Io, | ||
| input_reader: *Io.Reader, | ||
| output_writer: *Io.Writer, | ||
| sessions: SessionStorage, | ||
|
|
||
| pub fn init(allocator: Allocator, io: Io, input_reader: *Io.Reader, output_writer: *Io.Writer) Server { | ||
| return .{ | ||
| .allocator = allocator, | ||
| .io = io, | ||
| .input_reader = input_reader, | ||
| .output_writer = output_writer, | ||
| .sessions = .init(allocator), | ||
| }; | ||
| } | ||
|
|
||
| pub fn deinit(self: *Server) void { | ||
| self.sessions.deinit(); | ||
| } | ||
|
|
||
| fn handleTurnUpdate(ctx: ?*anyopaque, chunk: agent.types.StreamingChunk) void { | ||
| const stream_ctx: *ServerSessionContext = @ptrCast(@alignCast(ctx)); | ||
| const notification = converter.streamingChunkToNotification(stream_ctx.session_state.id, chunk) orelse return; | ||
| stream_ctx.json_rpc_writer.writeJsonObject(notification, .{ .use_headers = false }) catch {}; | ||
| } | ||
|
|
||
| fn sendError(self: *Server, allocator: Allocator, id: shared_api.RequestId, code: agent_api.JsonRpcErrorCode, message: []const u8) !void { | ||
| var writer = JsonRpcWriter.init(allocator, self.output_writer); | ||
| defer writer.deinit(); | ||
| try writer.writeJsonObject(agent_api.AgentErrorResponse{ | ||
| .id = id, | ||
| .@"error" = .{ | ||
| .code = code, | ||
| .message = message, | ||
| }, | ||
| }, .{}); | ||
| } | ||
|
|
||
| pub fn run(self: *Server, acp_config: Config) !void { | ||
| var arena = std.heap.ArenaAllocator.init(self.allocator); | ||
| defer arena.deinit(); | ||
| const arena_allocator = arena.allocator(); | ||
|
|
||
| while (true) { | ||
| try self.io.checkCancel(); | ||
| _ = arena.reset(.retain_capacity); | ||
|
|
||
| var json_rpc_reader = JsonRpcReader.init(arena_allocator, self.input_reader); | ||
| defer json_rpc_reader.deinit(); | ||
|
|
||
| const parse_result = json_rpc_reader.readJsonObject(client_api.ClientRequest) catch |err| { | ||
| if (err == error.EndOfStream) return; | ||
| try self.sendError(arena_allocator, .null, .parse_error, "Parse error"); | ||
| continue; | ||
| }; | ||
|
|
||
| const client_request = parse_result.value; | ||
| checkClientRequestValid(client_request) catch |err| { | ||
| const msg = switch (err) { | ||
| AcpProtocolError.InvalidJsonRpcVersion => "Invalid JSON-RPC version (must be 2.0)", | ||
| AcpProtocolError.MissingId => "Missing request ID", | ||
| else => "Invalid request", | ||
| }; | ||
| try self.sendError(arena_allocator, client_request.id, .invalid_request, msg); | ||
| continue; | ||
| }; | ||
|
|
||
| switch (client_request.method) { | ||
| .initialize => { | ||
| const reply: agent_api.AgentResponse = .{ | ||
| .id = client_request.id, | ||
| .result = .{ | ||
| .initialize = .{ | ||
| .protocolVersion = 1, | ||
| .agentCapabilities = null, | ||
| .agentInfo = null, | ||
| .authMethods = {}, | ||
| }, | ||
| }, | ||
| }; | ||
| var json_rpc_writer = JsonRpcWriter.init(arena_allocator, self.output_writer); | ||
| defer json_rpc_writer.deinit(); | ||
|
|
||
| try json_rpc_writer.writeJsonObject(reply, .{}); | ||
| }, | ||
| .session_new => { | ||
| const session_state = self.sessions.createSession(.{ | ||
| self.allocator, | ||
| self.io, | ||
| acp_config.provider, | ||
| acp_config.default_session_config, | ||
| }) catch { | ||
| try self.sendError(arena_allocator, client_request.id, .internal_error, "Failed to create session"); | ||
| continue; | ||
| }; | ||
|
|
||
| const reply: agent_api.AgentResponse = .{ | ||
| .id = client_request.id, | ||
| .result = .{ | ||
| .session_new = .{ | ||
| .sessionId = session_state.id, | ||
| }, | ||
| }, | ||
| }; | ||
| var json_rpc_writer = JsonRpcWriter.init(arena_allocator, self.output_writer); | ||
| defer json_rpc_writer.deinit(); | ||
|
|
||
| try json_rpc_writer.writeJsonObject(reply, .{}); | ||
| }, | ||
| .session_prompt => { | ||
| const session = self.sessions.getSession(client_request.params.session_prompt.sessionId) catch |err| { | ||
| const code: agent_api.JsonRpcErrorCode = if (err == error.SessionNotFound) .session_not_found else .internal_error; | ||
| const msg = if (err == error.SessionNotFound) "Session not found" else "Session retrieval error"; | ||
| try self.sendError(arena_allocator, client_request.id, code, msg); | ||
| continue; | ||
| }; | ||
| var json_rpc_writer = JsonRpcWriter.init(arena_allocator, self.output_writer); | ||
| defer json_rpc_writer.deinit(); | ||
|
|
||
| var ctx: ServerSessionContext = .{ | ||
| .session_state = session, | ||
| .json_rpc_writer = &json_rpc_writer, | ||
| }; | ||
|
|
||
| _ = session.session.executeTurnStreaming(.{ .prompt = client_request.params.session_prompt.prompt[0].text }, handleTurnUpdate, &ctx) catch { | ||
| try self.sendError(arena_allocator, client_request.id, .internal_error, "Failed to execute turn"); | ||
| continue; | ||
| }; | ||
|
|
||
| const reply: agent_api.AgentResponse = .{ | ||
| .id = client_request.id, | ||
| .result = .{ | ||
| .session_prompt = .{ | ||
| .stopReason = agent_api.StopReason.end_turn, | ||
| }, | ||
| }, | ||
| }; | ||
|
|
||
| try json_rpc_writer.writeJsonObject(reply, .{}); | ||
| }, | ||
| .unknown => { | ||
| try self.sendError(arena_allocator, client_request.id, .method_not_found, "Method not found"); | ||
| }, | ||
| } | ||
| } | ||
| } | ||
|
|
||
| fn checkClientRequestValid(request: client_api.ClientRequest) AcpProtocolError!void { | ||
| if (!std.mem.eql(u8, request.jsonrpc, "2.0")) return AcpProtocolError.InvalidJsonRpcVersion; | ||
| if (request.id == .null) return AcpProtocolError.MissingId; | ||
| } | ||
|
|
||
| test "Server error handling - malformed JSON and recovery" { | ||
| const allocator = std.testing.allocator; | ||
|
|
||
| const input = | ||
| \\{ malformed json | ||
| \\{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1}} | ||
| ; | ||
| var reader_buf = std.Io.Reader.fixed(input); | ||
| var buffer = std.Io.Writer.Allocating.init(allocator); | ||
| defer buffer.deinit(); | ||
|
|
||
| var server = Server.init(allocator, std.testing.io, &reader_buf, &buffer.writer); | ||
| defer server.deinit(); | ||
|
|
||
| try server.run(.{ .provider = undefined, .default_session_config = undefined }); | ||
|
|
||
| const output = buffer.written(); | ||
| try std.testing.expect(std.mem.indexOf(u8, output, "-32700") != null); | ||
| try std.testing.expect(std.mem.indexOf(u8, output, "\"protocolVersion\":1") != null); | ||
| } | ||
|
|
||
| test "Server error handling - invalid session ID" { | ||
| const allocator = std.testing.allocator; | ||
|
|
||
| const input = | ||
| \\{"jsonrpc":"2.0","id":1,"method":"session/prompt","params":{"sessionId":"nonexistent","prompt":[{"type":"text","text":"hello"}]}} | ||
| ; | ||
| var reader_buf = std.Io.Reader.fixed(input); | ||
| var buffer = std.Io.Writer.Allocating.init(allocator); | ||
| defer buffer.deinit(); | ||
|
|
||
| var server = Server.init(allocator, std.testing.io, &reader_buf, &buffer.writer); | ||
| defer server.deinit(); | ||
|
|
||
| try server.run(.{ .provider = undefined, .default_session_config = undefined }); | ||
|
|
||
| const output = buffer.written(); | ||
| try std.testing.expect(std.mem.indexOf(u8, output, "-32001") != null); | ||
| try std.testing.expect(std.mem.indexOf(u8, output, "Session not found") != null); | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.