acp_sdk_async 0.3.1 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 311898f67ba8c2701c3edcebe315120d09f5b445d5bc32b7c31719d4973f9b7c
4
- data.tar.gz: d94e9ce7d8da1bb16017c77ee562b87fe8492f5ea60467042a97aa20f4c33493
3
+ metadata.gz: 71071f49bdb82be3e735ea3d15e8cd3b7969ef24308f576a1b48c88064793e15
4
+ data.tar.gz: 647de2c4fabcbac7d7f19c4f7d078559f377d26ba29455c6559933be6c72089f
5
5
  SHA512:
6
- metadata.gz: 0c65a6d278fd08ce6e9fb08cbda659c1eb3fd5805a43f345514ef6cb516534a76c89acb1aa8fc1d99d2fcde0ee92453db1268144884dd87097ba7b1892857f3a
7
- data.tar.gz: 014db843ff5839284074e89f56acdb18eacdc8de8387d678892b345b56bd1392fe4232637fb4506623d0c8b2f02e37347114859de8ea44db7381345e3a30ed8a
6
+ metadata.gz: b2aedef677d4253e0ee4586bc993ab3b75392b60e985a2d9ac2c25158969fa24b6bfd40fd6f845afb2bba02a1735726fc0f42efcbf4da5360cf821515c70be2a
7
+ data.tar.gz: 8555c782250e074c7c300771f718da95c5cae4492f3a16ff4ed437ea30e80d2a05274216c5c2b73e63fdf4c2647ddb65b81ad0f411aa7b58ba17f2b75c27083e
data/CHANGELOG.md CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.3.2]
6
+
7
+ ### Added
8
+
9
+ - `examples/duet.rb`: runnable in-process agent↔client demo over
10
+ in-memory transports (covered by `test/examples_test.rb`).
11
+ - Windows inherited-env allowlist for spawned agents
12
+ (`APPDATA`, `PATHEXT`, …); process management itself stays POSIX-only.
13
+ Untested: there is no Windows CI yet.
14
+
15
+ ### Fixed
16
+
17
+ - `NdjsonTransport` caps inbound lines at 50MB by default
18
+ (`max_line_bytes:`): over-long lines are skipped instead of
19
+ ballooning memory. Lines are reassembled in 64KB chunks.
20
+
5
21
  ## [0.3.1]
6
22
 
7
23
  ### Fixed
data/README.md CHANGED
@@ -122,7 +122,11 @@ block.to_h # => { "type" => "text", "text" => "hi" }
122
122
 
123
123
  ## Lower level
124
124
 
125
- `ACP::Connection` is the transport-agnostic JSON-RPC layer (requests with optional timeouts, ordered notification delivery, observers for tracing traffic). `ACP::NdjsonTransport` wraps a pair of IO objects; `ACP::MemoryTransport.pair` gives two in-memory ends for tests.
125
+ `ACP::Connection` is the transport-agnostic JSON-RPC layer (requests with optional timeouts, ordered notification delivery, observers for tracing traffic). `ACP::NdjsonTransport` wraps a pair of IO objects; `ACP::MemoryTransport.pair` gives two in-memory ends for tests. Inbound lines are capped at 50MB by default (`max_line_bytes:`) so a rogue peer cannot exhaust memory.
126
+
127
+ Spawning and signal handling (`ACP::Stdio.spawn_agent`, `AgentProcess#kill`) target POSIX platforms. The Windows inherited-environment allowlist ships untested — there is no Windows CI yet.
128
+
129
+ See `examples/duet.rb` for a runnable in-process agent↔client demo (`ruby -Ilib examples/duet.rb "hello"`).
126
130
 
127
131
  Logging goes through `ACP.logger` (a `Logger`, `WARN` level by default); assign your own to integrate with the host application.
128
132
 
data/examples/duet.rb ADDED
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ # In-process agent<->client demo over in-memory transports (no subprocess).
4
+ #
5
+ # Run with:
6
+ # ruby -Ilib examples/duet.rb ["message 1" "message 2" ...]
7
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
8
+ require "acp_sdk_async"
9
+
10
+ S = ACP::Schema
11
+
12
+ # Minimal agent: echoes prompts back with a "Client sent:" preamble.
13
+ class DuetAgent
14
+ def on_connect(connection)
15
+ @connection = connection
16
+ end
17
+
18
+ def initialize_acp(request)
19
+ S::InitializeResponse.new(
20
+ protocol_version: request.protocol_version,
21
+ agent_info: S::Implementation.new(name: "duet-agent", version: "0.1.0")
22
+ )
23
+ end
24
+
25
+ def new_session(_request)
26
+ S::NewSessionResponse.new(session_id: "duet-1")
27
+ end
28
+
29
+ def prompt(request)
30
+ text = request.prompt.map { |block| block.respond_to?(:text) ? block.text : "" }.join
31
+ @connection.session_update(
32
+ session_id: request.session_id,
33
+ update: S::AgentMessageChunk.new(content: S::TextContentBlock.new(text: "Client sent:"))
34
+ )
35
+ @connection.session_update(
36
+ session_id: request.session_id,
37
+ update: S::AgentMessageChunk.new(content: S::TextContentBlock.new(text: text))
38
+ )
39
+ S::PromptResponse.new(stop_reason: "end_turn")
40
+ end
41
+
42
+ def cancel(_notification); end
43
+ end
44
+
45
+ # Minimal client: prints agent message chunks.
46
+ class DuetClient
47
+ def session_update(notification)
48
+ update = notification.update
49
+ return unless update.is_a?(S::AgentMessageChunk) && update.content.is_a?(S::TextContentBlock)
50
+
51
+ puts "| Agent: #{update.content.text}"
52
+ end
53
+ end
54
+
55
+ Sync do
56
+ client_transport, agent_transport = ACP.memory_transport_pair
57
+ agent_conn = ACP::Agent::Connection.new(DuetAgent.new, agent_transport)
58
+ client_conn = ACP::Client::Connection.new(DuetClient.new, client_transport)
59
+ agent_conn.start
60
+ client_conn.start
61
+
62
+ begin
63
+ client_conn.initialize_agent(
64
+ client_info: S::Implementation.new(name: "duet-client", version: "0.1.0")
65
+ )
66
+ session = client_conn.new_session(cwd: Dir.pwd)
67
+
68
+ messages = ARGV.empty? ? ["Hello, agent!"] : ARGV
69
+ messages.each do |text|
70
+ puts "> #{text}"
71
+ client_conn.prompt(
72
+ session_id: session.session_id,
73
+ prompt: [S::TextContentBlock.new(text: text)]
74
+ )
75
+ end
76
+ ensure
77
+ client_conn.close
78
+ agent_conn.close
79
+ end
80
+ end
@@ -29,4 +29,8 @@ module ACP
29
29
  class ConnectionError < Error; end
30
30
 
31
31
  class TimeoutError < Error; end
32
+
33
+ # Raised when a single NDJSON line exceeds the transport's cap.
34
+ # Never escapes NdjsonTransport: receive_message skips the line.
35
+ class LineTooLongError < Error; end
32
36
  end
data/lib/acp/stdio.rb CHANGED
@@ -7,10 +7,15 @@ require_relative "wait"
7
7
  module ACP
8
8
  module Stdio
9
9
  DEFAULT_INHERITED_ENV = %w[HOME LOGNAME PATH SHELL TERM USER].freeze
10
+ WINDOWS_INHERITED_ENV = %w[
11
+ APPDATA HOMEDRIVE HOMEPATH LOCALAPPDATA PATH PATHEXT
12
+ PROCESSOR_ARCHITECTURE SYSTEMDRIVE SYSTEMROOT TEMP USERNAME USERPROFILE
13
+ ].freeze
10
14
 
11
15
  def self.default_environment
16
+ keys = Gem.win_platform? ? WINDOWS_INHERITED_ENV : DEFAULT_INHERITED_ENV
12
17
  env = {}
13
- DEFAULT_INHERITED_ENV.each do |key|
18
+ keys.each do |key|
14
19
  value = ENV.fetch(key, nil)
15
20
  next if value.nil? || value.start_with?("()")
16
21
 
data/lib/acp/transport.rb CHANGED
@@ -26,12 +26,20 @@ module ACP
26
26
  class NdjsonTransport
27
27
  include Transport
28
28
 
29
+ # Parity with the reference 50MB stdio buffer: inbound lines are
30
+ # reassembled in bounded chunks and a line beyond the cap is skipped,
31
+ # so a rogue peer cannot exhaust memory via stdout. (The reference
32
+ # accepts such lines; we deliberately reject them instead.)
33
+ DEFAULT_MAX_LINE_BYTES = 50 * 1024 * 1024
34
+ READ_CHUNK_BYTES = 64 * 1024
35
+
29
36
  attr_reader :input, :output
30
37
 
31
- def initialize(input, output, receive_timeout: nil)
38
+ def initialize(input, output, receive_timeout: nil, max_line_bytes: DEFAULT_MAX_LINE_BYTES)
32
39
  @input = input
33
40
  @output = output
34
41
  @receive_timeout = receive_timeout
42
+ @max_line_bytes = max_line_bytes
35
43
  @write_mutex = Mutex.new
36
44
  @closed = false
37
45
  @output.sync = true if @output.respond_to?(:sync=)
@@ -55,7 +63,12 @@ module ACP
55
63
 
56
64
  def receive_message
57
65
  loop do
58
- line = read_line
66
+ begin
67
+ line = read_line
68
+ rescue LineTooLongError => e
69
+ ACP.logger.warn("acp: skipping over-long line: #{e.message}")
70
+ next
71
+ end
59
72
  return nil if line.nil?
60
73
 
61
74
  line = line.scrub unless line.valid_encoding?
@@ -84,22 +97,54 @@ module ACP
84
97
  private
85
98
 
86
99
  def read_line
87
- return @input.gets unless @receive_timeout
100
+ return read_line_chunks unless @receive_timeout
88
101
 
89
102
  if Wait.async?
90
103
  begin
91
- ::Async::Task.current.with_timeout(@receive_timeout) { @input.gets }
104
+ ::Async::Task.current.with_timeout(@receive_timeout) { read_line_chunks }
92
105
  rescue ::Async::TimeoutError
93
106
  raise TimeoutError, "No message received within #{@receive_timeout}s"
94
107
  end
95
108
  elsif @input.respond_to?(:wait_readable)
96
109
  raise TimeoutError, "No message received within #{@receive_timeout}s" unless @input.wait_readable(@receive_timeout)
97
110
 
98
- @input.gets
111
+ read_line_chunks
99
112
  else
100
113
  raise TimeoutError, "No message received within #{@receive_timeout}s" unless IO.select([@input], nil, nil, @receive_timeout)
101
114
 
102
- @input.gets
115
+ read_line_chunks
116
+ end
117
+ end
118
+
119
+ # Reads one "\n"-terminated line in bounded chunks so a giant line never
120
+ # sits in memory twice. Returns nil on EOF (or the trailing partial line,
121
+ # like a short read). Raises LineTooLongError after discarding the rest
122
+ # of an over-long line to keep framing in sync.
123
+ def read_line_chunks
124
+ buffer = nil
125
+ loop do
126
+ chunk = @input.gets("\n", READ_CHUNK_BYTES)
127
+ if chunk.nil?
128
+ return nil if buffer.nil? || buffer.empty?
129
+
130
+ return buffer
131
+ end
132
+ buffer = chunk.dup.clear if buffer.nil?
133
+ buffer << chunk
134
+ if buffer.bytesize > @max_line_bytes
135
+ discard_line_rest(chunk)
136
+ raise LineTooLongError, "line exceeds #{@max_line_bytes} bytes"
137
+ end
138
+ return buffer if chunk.end_with?("\n")
139
+ end
140
+ end
141
+
142
+ def discard_line_rest(last_chunk)
143
+ return if last_chunk.end_with?("\n")
144
+
145
+ loop do
146
+ chunk = @input.gets("\n", READ_CHUNK_BYTES)
147
+ return if chunk.nil? || chunk.end_with?("\n")
103
148
  end
104
149
  end
105
150
  end
data/lib/acp/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ACP
4
- VERSION = "0.3.1"
4
+ VERSION = "0.3.2"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: acp_sdk_async
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 0.3.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - nutsoriginal
@@ -46,6 +46,7 @@ files:
46
46
  - CHANGELOG.md
47
47
  - LICENSE
48
48
  - README.md
49
+ - examples/duet.rb
49
50
  - lib/acp/agent.rb
50
51
  - lib/acp/client.rb
51
52
  - lib/acp/connection.rb