agent99 0.0.1

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.
@@ -0,0 +1,87 @@
1
+ # lib/agent99/agent_lifecycle.rb
2
+
3
+ module Agent99::AgentLifecycle
4
+
5
+ # Initializes a new AI agent with the given configuration.
6
+ #
7
+ # @param registry_client [Agent99::RegistryClient] The client for agent registration
8
+ # @param message_client [Agent99::AmqpMessageClient] The client for message handling
9
+ # @param logger [Logger] The logger instance for the agent
10
+ #
11
+ def initialize(registry_client: Agent99::RegistryClient.new,
12
+ message_client: Agent99::AmqpMessageClient.new,
13
+ logger: Logger.new($stdout))
14
+ @payload = nil
15
+ @name = self.class.name
16
+ @capabilities = capabilities
17
+ @id = nil
18
+ @registry_client = registry_client
19
+ @message_client = message_client
20
+ @logger = logger
21
+
22
+ @registry_client.logger = logger
23
+ register
24
+
25
+ @queue = message_client.setup(agent_id: id, logger:)
26
+
27
+ init if respond_to?(:init)
28
+
29
+ setup_signal_handlers
30
+ end
31
+
32
+ # Registers the agent with the registry service.
33
+ #
34
+ # @raise [StandardError] If registration fails
35
+ #
36
+ def register
37
+ @id = registry_client.register(name:, capabilities:)
38
+ logger.info "Registered Agent #{name} with ID: #{id}"
39
+ rescue StandardError => e
40
+ handle_error("Error during registration", e)
41
+ end
42
+
43
+ # Withdraws the agent from the registry service.
44
+ #
45
+ def withdraw
46
+ registry_client.withdraw(@id) if @id
47
+ @id = nil
48
+ end
49
+
50
+
51
+ ################################################
52
+ private
53
+
54
+ # Checks if the agent is currently paused.
55
+ #
56
+ # @return [Boolean] True if the agent is paused, false otherwise
57
+ #
58
+ def paused?
59
+ @paused
60
+ end
61
+
62
+ # Sets up signal handlers for graceful shutdown.
63
+ #
64
+ def setup_signal_handlers
65
+ at_exit { fini }
66
+
67
+ %w[INT TERM QUIT].each do |signal|
68
+ Signal.trap(signal) do
69
+ STDERR.puts "\nReceived #{signal} signal. Initiating graceful shutdown..."
70
+ exit
71
+ end
72
+ end
73
+ end
74
+
75
+
76
+ # Performs cleanup operations when the agent is shutting down.
77
+ #
78
+ def fini
79
+ if id
80
+ queue_name = id
81
+ withdraw
82
+ @message_client&.delete_queue(queue_name)
83
+ else
84
+ logger.warn('fini called with a nil id')
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,117 @@
1
+ # lib/agent99/amqp_message_client.rb
2
+
3
+ require 'bunny'
4
+ require 'json'
5
+ require 'json_schema'
6
+ require 'logger'
7
+
8
+ class Agent99::AmqpMessageClient
9
+ QUEUE_TTL = 60_000 # 60 seconds TTL
10
+ @instance = nil
11
+
12
+ class << self
13
+ def instance
14
+ @instance ||= new
15
+ end
16
+ end
17
+
18
+ attr_accessor :logger, :channel, :exchange
19
+
20
+ def initialize(logger: Logger.new($stdout))
21
+ @connection = create_amqp_connection
22
+ @channel = @connection.create_channel
23
+ @exchange = @channel.default_exchange
24
+ @logger = logger
25
+ end
26
+
27
+ def setup(agent_id:, logger:)
28
+ queue = create_queue(agent_id)
29
+
30
+ # Returning the queue to be used in the Base class
31
+ queue
32
+ end
33
+
34
+ def create_queue(agent_id)
35
+ queue_name = "#{agent_id}"
36
+ @channel.queue(queue_name, expires: QUEUE_TTL)
37
+ end
38
+
39
+ def listen_for_messages(
40
+ queue,
41
+ request_handler:,
42
+ response_handler:,
43
+ control_handler:
44
+ )
45
+ queue.subscribe(block: true) do |delivery_info, properties, body|
46
+ message = JSON.parse(body, symbolize_names: true)
47
+ logger.debug "Received message: #{message.inspect}"
48
+
49
+ type = message.dig(:header, :type)
50
+
51
+ case type
52
+ when "request"
53
+ request_handler.call(message)
54
+ when "response"
55
+ response_handler.call(message)
56
+ when "control"
57
+ control_handler.call(message)
58
+ else
59
+ raise NotImplementedError, "Unsupported message type: #{type}"
60
+ end
61
+ end
62
+ end
63
+
64
+
65
+ def publish(message)
66
+ queue_name = message.dig(:header, :to_uuid)
67
+
68
+ begin
69
+ json_payload = JSON.generate(message)
70
+
71
+ exchange.publish(json_payload, routing_key: queue_name)
72
+
73
+ logger.info "Message published successfully to queue: #{queue_name}"
74
+
75
+ # Return a success status
76
+ { success: true, message: "Message published successfully" }
77
+
78
+ rescue JSON::GeneratorError => e
79
+ logger.error "Failed to convert payload to JSON: #{e.message}"
80
+ { success: false, error: "JSON conversion error: #{e.message}" }
81
+
82
+ rescue Bunny::ConnectionClosedError, Bunny::ChannelAlreadyClosed => e
83
+ logger.error "Failed to publish message: #{e.message}"
84
+ { success: false, error: "Publishing error: #{e.message}" }
85
+
86
+ rescue StandardError => e
87
+ logger.error "Unexpected error while publishing message: #{e.message}"
88
+ { success: false, error: "Unexpected error: #{e.message}" }
89
+ end
90
+ end
91
+
92
+
93
+ def delete_queue(queue_name)
94
+ return logger.warn("Attempted to delete queue with nil name") if queue_name.nil?
95
+
96
+ begin
97
+ queue = @channel.queue(queue_name, passive: true)
98
+ queue.delete
99
+ logger.info "Queue #{queue_name} was deleted"
100
+ rescue Bunny::NotFound
101
+ logger.warn "Queue #{queue_name} not found"
102
+ rescue StandardError => e
103
+ logger.error "Error deleting queue #{queue_name}: #{e.message}"
104
+ end
105
+ end
106
+
107
+
108
+ ################################################
109
+ private
110
+
111
+ def create_amqp_connection
112
+ Bunny.new.tap(&:start)
113
+ rescue Bunny::TCPConnectionFailed, StandardError => e
114
+ logger.error "Failed to connect to AMQP: #{e.message}"
115
+ raise "AMQP Connection Error: #{e.message}. Please check your AMQP server and try again."
116
+ end
117
+ end
@@ -0,0 +1,61 @@
1
+ # lib/agent99/base.rb
2
+
3
+ require 'logger'
4
+ require 'json'
5
+ require 'json_schema'
6
+
7
+ require_relative 'timestamp'
8
+ require_relative 'registry_client'
9
+ require_relative 'amqp_message_client'
10
+ require_relative 'nats_message_client'
11
+
12
+ require_relative 'header_management'
13
+ require_relative 'agent_discovery'
14
+ require_relative 'control_actions'
15
+ require_relative 'agent_lifecycle'
16
+ require_relative 'message_processing'
17
+
18
+ # The Agent99::Base class serves as the foundation for creating AI agents in a distributed system.
19
+ # It provides core functionality for agent registration, message handling, and communication.
20
+ #
21
+ # This class:
22
+ # - Manages agent registration and withdrawal
23
+ # - Handles incoming messages (requests, responses, and control messages)
24
+ # - Provides a framework for defining agent capabilities
25
+ # - Implements error handling and logging
26
+ # - Supports configuration updates and status reporting
27
+ #
28
+ # Subclasses should override specific methods like `receive_request`, `receive_response`,
29
+ # and `capabilities` to define custom behavior for different types of agents.
30
+ #
31
+ class Agent99::Base
32
+ include Agent99::HeaderManagement
33
+ include Agent99::AgentDiscovery
34
+ include Agent99::ControlActions
35
+ include Agent99::AgentLifecycle
36
+ include Agent99::MessageProcessing
37
+
38
+ MESSAGE_TYPES = %w[request response control]
39
+
40
+ CONTROL_HANDLERS = {
41
+ 'shutdown' => :handle_shutdown,
42
+ 'pause' => :handle_pause,
43
+ 'resume' => :handle_resume,
44
+ 'update_config' => :handle_update_config,
45
+ 'status' => :handle_status_request
46
+ }
47
+
48
+ attr_reader :id, :capabilities, :name, :payload, :header, :logger, :queue
49
+ attr_accessor :registry_client, :message_client
50
+
51
+
52
+ ###################################################
53
+ private
54
+
55
+ def handle_error(message, error)
56
+ logger.error "#{message}: #{error.message}"
57
+ logger.debug error.backtrace.join("\n")
58
+ end
59
+ end
60
+
61
+
@@ -0,0 +1,56 @@
1
+ # lib/agent99/control_actions.rb
2
+
3
+
4
+ module Agent99::ControlActions
5
+
6
+
7
+ ################################################
8
+ private
9
+
10
+ # Handles the shutdown control message.
11
+ #
12
+ def handle_shutdown
13
+ logger.info "Received shutdown command. Initiating graceful shutdown..."
14
+ send_control_response("Shutting down")
15
+ fini
16
+ exit(0)
17
+ end
18
+
19
+ # Handles the pause control message.
20
+ #
21
+ def handle_pause
22
+ @paused = true
23
+ logger.info "Agent paused"
24
+ send_control_response("Paused")
25
+ end
26
+
27
+ # Handles the resume control message.
28
+ #
29
+ def handle_resume
30
+ @paused = false
31
+ logger.info "Agent resumed"
32
+ send_control_response("Resumed")
33
+ end
34
+
35
+ # Handles the update_config control message.
36
+ #
37
+ def handle_update_config
38
+ new_config = payload[:config]
39
+ @config = new_config
40
+ logger.info "Configuration updated: #{@config}"
41
+ send_control_response("Configuration updated")
42
+ end
43
+
44
+ # Handles the status request control message.
45
+ #
46
+ def handle_status_request
47
+ status = {
48
+ id: @id,
49
+ name: @name,
50
+ paused: @paused,
51
+ config: @config,
52
+ uptime: (Time.now - @start_time).to_i
53
+ }
54
+ send_control_response("Status", status)
55
+ end
56
+ end
@@ -0,0 +1,24 @@
1
+ # lib/agent99/header_management.rb
2
+
3
+ module Agent99::HeaderManagement
4
+
5
+
6
+ ################################################
7
+ private
8
+
9
+ def header = @payload[:header]
10
+ def to_uuid = header[:to_uuid]
11
+ def from_uuid = header[:from_uuid]
12
+ def event_uuid = header[:event_uuid]
13
+ def timestamp = header[:timestamp]
14
+ def type = header[:type]
15
+
16
+ def return_address
17
+ header.merge(
18
+ to_uuid: from_uuid,
19
+ from_uuid: to_uuid,
20
+ timestamp: Agent99::Timestamp.new.to_i,
21
+ type: 'response'
22
+ )
23
+ end
24
+ end
@@ -0,0 +1,15 @@
1
+ # experiments/agents/header_schema.rb
2
+
3
+ require 'simple_json_schema_builder'
4
+
5
+ require_relative 'timestamp'
6
+
7
+ class Agent99::HeaderSchema < SimpleJsonSchemaBuilder::Base
8
+ object do
9
+ string :from_uuid, required: true, examples: [SecureRandom.uuid]
10
+ string :to_uuid, required: true, examples: [SecureRandom.uuid]
11
+ string :event_uuid, required: true, examples: [SecureRandom.uuid]
12
+ string :type, required: true, examples: %w[request response control]
13
+ integer :timestamp, required: true, examples: [Agent99::Timestamp.new.to_i]
14
+ end
15
+ end
@@ -0,0 +1,155 @@
1
+ # lib/agent99/message_processing.rb
2
+
3
+ module Agent99::MessageProcessing
4
+
5
+
6
+ # Starts the agent's main loop for processing messages.
7
+ #
8
+ def run
9
+ dispatcher
10
+ end
11
+
12
+ ################################################
13
+ private
14
+
15
+ # Main message dispatching loop.
16
+ #
17
+ def dispatcher
18
+ @start_time = Time.now
19
+ @paused = false
20
+ @config = {}
21
+
22
+ message_client.listen_for_messages(
23
+ queue,
24
+ request_handler: ->(message) { process_request(message) unless paused? },
25
+ response_handler: ->(message) { process_response(message) unless paused? },
26
+ control_handler: ->(message) { process_control(message) }
27
+ )
28
+ end
29
+
30
+
31
+ # Processes incoming request messages.
32
+ #
33
+ # @param message [Hash] The incoming message
34
+ #
35
+ def process_request(message)
36
+ @payload = message
37
+ @header = payload[:header]
38
+ return unless validate_schema.empty?
39
+ receive_request
40
+ end
41
+
42
+ # Processes incoming response messages.
43
+ #
44
+ # @param message [Hash] The incoming message
45
+ #
46
+ def process_response(message)
47
+ @payload = message
48
+ receive_response
49
+ end
50
+
51
+ # Processes incoming control messages.
52
+ #
53
+ # @param message [Hash] The incoming message
54
+ #
55
+ def process_control(message)
56
+ @payload = message
57
+ receive_control
58
+ end
59
+
60
+
61
+
62
+ # Handles incoming request messages (to be overridden by subclasses).
63
+ #
64
+ def receive_request
65
+ logger.info "Received request: #{payload}"
66
+ end
67
+
68
+
69
+ # Handles incoming response messages (to be overridden by subclasses).
70
+ #
71
+ def receive_response
72
+ logger.info "Received response: #{payload}"
73
+ end
74
+
75
+
76
+ # Processes incoming control messages.
77
+ #
78
+ # @raise [StandardError] If there's an error processing the control message
79
+ #
80
+ def receive_control
81
+ action = payload[:action]
82
+ handler = CONTROL_HANDLERS[action]
83
+
84
+ if handler
85
+ send(handler)
86
+ else
87
+ logger.warn "Unknown control action: #{action}"
88
+ end
89
+
90
+ rescue StandardError => e
91
+ logger.error "Error processing control message: #{e.message}"
92
+ send_control_response("Error", { error: e.message })
93
+ end
94
+
95
+
96
+ # Sends a response message.
97
+ #
98
+ # @param response [Hash] The response to send
99
+ #
100
+ def send_response(response)
101
+ response[:header] = return_address
102
+ @message_client.publish(response)
103
+ end
104
+
105
+
106
+ # Sends a control response message.
107
+ #
108
+ # @param message [String] The response message
109
+ # @param data [Hash, nil] Additional data to include in the response
110
+ #
111
+ def send_control_response(message, data = nil)
112
+ response = {
113
+ header: return_address.merge(type: 'control'),
114
+ message: message,
115
+ data: data
116
+ }
117
+ @message_client.publish(response)
118
+ end
119
+
120
+ # Validates the incoming message against the defined schema.
121
+ #
122
+ # @return [Array] An array of validation errors, empty if validation succeeds
123
+ #
124
+ def validate_schema
125
+ schema = JsonSchema.parse!(self.class::REQUEST_SCHEMA)
126
+ schema.expand_references!
127
+ validator = JsonSchema::Validator.new(schema)
128
+
129
+ validator.validate(@payload)
130
+ []
131
+
132
+ rescue JsonSchema::ValidationError => e
133
+ handle_error("Validation error", e)
134
+ send_response(type: 'error', errors: e.messages)
135
+ e.messages
136
+ end
137
+
138
+ # Retrieves a field from the payload or returns a default value.
139
+ #
140
+ # @param field [Symbol] The field to retrieve
141
+ # @return [Object] The value of the field or its default
142
+ #
143
+ def get(field)
144
+ payload[field] || default(field)
145
+ end
146
+
147
+ # Returns the default value for a field from the schema.
148
+ #
149
+ # @param field [Symbol] The field to get the default for
150
+ # @return [Object, nil] The default value or nil if not found
151
+ #
152
+ def default(field)
153
+ self.class::REQUEST_SCHEMA.dig(:properties, field, :examples)&.first
154
+ end
155
+ end
@@ -0,0 +1,101 @@
1
+ # lib/agent99/nats_message_client.rb
2
+
3
+ require 'nats/client'
4
+ require 'json'
5
+ require 'logger'
6
+
7
+ class Agent99::NatsMessageClient
8
+ @instance = nil
9
+
10
+ class << self
11
+ def instance
12
+ @instance ||= new
13
+ end
14
+ end
15
+
16
+ attr_accessor :logger, :nats
17
+
18
+ def initialize(logger: Logger.new($stdout))
19
+ @nats = create_nats_connection
20
+ @logger = logger
21
+ end
22
+
23
+ def setup(agent_id:, logger:)
24
+ @logger = logger
25
+ # NATS doesn't require explicit queue creation, so we'll just return the agent_id
26
+ agent_id
27
+ end
28
+
29
+ def listen_for_messages(
30
+ queue,
31
+ request_handler:,
32
+ response_handler:,
33
+ control_handler:
34
+ )
35
+ @nats.subscribe(queue) do |msg|
36
+ message = JSON.parse(msg.data, symbolize_names: true)
37
+ logger.debug "Received message: #{message.inspect}"
38
+
39
+ type = message.dig(:header, :type)
40
+
41
+ case type
42
+ when "request"
43
+ request_handler.call(message)
44
+ when "response"
45
+ response_handler.call(message)
46
+ when "control"
47
+ control_handler.call(message)
48
+ else
49
+ raise NotImplementedError, "Unsupported message type: #{type}"
50
+ end
51
+ end
52
+
53
+ # Keep the connection open
54
+ loop { sleep 1 }
55
+ end
56
+
57
+ def publish(message)
58
+ queue_name = message.dig(:header, :to_uuid)
59
+
60
+ begin
61
+ json_payload = JSON.generate(message)
62
+
63
+ @nats.publish(queue_name, json_payload)
64
+
65
+ logger.info "Message published successfully to queue: #{queue_name}"
66
+
67
+ # Return a success status
68
+ { success: true, message: "Message published successfully" }
69
+
70
+ rescue JSON::GeneratorError => e
71
+ logger.error "Failed to convert payload to JSON: #{e.message}"
72
+ { success: false, error: "JSON conversion error: #{e.message}" }
73
+
74
+ rescue NATS::IO::TimeoutError => e
75
+ logger.error "Failed to publish message: #{e.message}"
76
+ { success: false, error: "Publishing error: #{e.message}" }
77
+
78
+ rescue StandardError => e
79
+ logger.error "Unexpected error while publishing message: #{e.message}"
80
+ { success: false, error: "Unexpected error: #{e.message}" }
81
+ end
82
+ end
83
+
84
+ def delete_queue(queue_name)
85
+ # NATS doesn't have the concept of deleting queues.
86
+ # Subjects are automatically cleaned up when there are no more subscribers.
87
+ logger.info "NATS doesn't require explicit queue deletion. Subject #{queue_name} will be automatically cleaned up."
88
+ end
89
+
90
+
91
+ ################################################
92
+ private
93
+
94
+ def create_nats_connection
95
+ NATS.connect
96
+ rescue NATS::IO::ConnectError => e
97
+ logger.error "Failed to connect to NATS: #{e.message}"
98
+ raise "NATS Connection Error: #{e.message}. Please check your NATS server and try again."
99
+ end
100
+ end
101
+
@@ -0,0 +1,73 @@
1
+ # experiments/agents/agent99/registry_client.rb
2
+
3
+ require 'json'
4
+ require 'net/http'
5
+ require 'uri'
6
+
7
+ class Agent99::RegistryClient
8
+ attr_accessor :logger
9
+
10
+ def initialize(
11
+ base_url: ENV.fetch('REGISTRY_BASE_URL', 'http://localhost:4567'),
12
+ logger: Logger.new($stdout)
13
+ )
14
+ @base_url = base_url
15
+ @logger = logger
16
+ @http_client = Net::HTTP.new(URI.parse(base_url).host, URI.parse(base_url).port)
17
+ end
18
+
19
+ def register(name:, capabilities:)
20
+ request = create_request(:post, "/register", { name: name, capabilities: capabilities })
21
+ @id = send_request(request)
22
+ end
23
+
24
+ def withdraw(id)
25
+ return logger.warn("Agent not registered") unless id
26
+
27
+ request = create_request(:delete, "/withdraw/#{id}")
28
+ send_request(request)
29
+ end
30
+
31
+
32
+ def discover(capability:)
33
+ encoded_capability = URI.encode_www_form_component(capability)
34
+ request = create_request(:get, "/discover?capability=#{encoded_capability}")
35
+ send_request(request)
36
+ end
37
+
38
+ ################################################
39
+ private
40
+
41
+ def create_request(method, path, body = nil)
42
+ request = Object.const_get("Net::HTTP::#{method.capitalize}").new(path, { "Content-Type" => "application/json" })
43
+ request.body = body.to_json if body
44
+ request
45
+ end
46
+
47
+ def send_request(request)
48
+ response = @http_client.request(request)
49
+
50
+ handle_response(response)
51
+ rescue JSON::ParserError => e
52
+ logger.error "JSON parsing error: #{e.message}"
53
+ nil
54
+ rescue StandardError => e
55
+ logger.error "Request error: #{e.message}"
56
+ nil
57
+ end
58
+
59
+ def handle_response(response)
60
+ case response
61
+ when Net::HTTPOK
62
+ JSON.parse(response.body, symbolize_names: true)
63
+ when Net::HTTPCreated
64
+ JSON.parse(response.body, symbolize_names: true)[:uuid]
65
+ when Net::HTTPNoContent
66
+ logger.info "Action completed successfully."
67
+ nil
68
+ else
69
+ logger.error "Error: #{JSON.parse(response.body, symbolize_names: true)[:error]}"
70
+ nil
71
+ end
72
+ end
73
+ end