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 +4 -4
- data/CHANGELOG.md +16 -0
- data/README.md +5 -1
- data/examples/duet.rb +80 -0
- data/lib/acp/exceptions.rb +4 -0
- data/lib/acp/stdio.rb +6 -1
- data/lib/acp/transport.rb +51 -6
- data/lib/acp/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 71071f49bdb82be3e735ea3d15e8cd3b7969ef24308f576a1b48c88064793e15
|
|
4
|
+
data.tar.gz: 647de2c4fabcbac7d7f19c4f7d078559f377d26ba29455c6559933be6c72089f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/lib/acp/exceptions.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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) {
|
|
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
|
-
|
|
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
|
-
|
|
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
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.
|
|
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
|