activeagent 0.6.3 → 1.0.0

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.
Files changed (152) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +240 -2
  3. data/README.md +15 -24
  4. data/lib/active_agent/base.rb +389 -39
  5. data/lib/active_agent/concerns/callbacks.rb +251 -0
  6. data/lib/active_agent/concerns/observers.rb +147 -0
  7. data/lib/active_agent/concerns/parameterized.rb +292 -0
  8. data/lib/active_agent/concerns/provider.rb +120 -0
  9. data/lib/active_agent/concerns/queueing.rb +36 -0
  10. data/lib/active_agent/concerns/rescue.rb +64 -0
  11. data/lib/active_agent/concerns/streaming.rb +282 -0
  12. data/lib/active_agent/concerns/tooling.rb +23 -0
  13. data/lib/active_agent/concerns/view.rb +150 -0
  14. data/lib/active_agent/configuration.rb +442 -20
  15. data/lib/active_agent/generation.rb +141 -47
  16. data/lib/active_agent/providers/_base_provider.rb +420 -0
  17. data/lib/active_agent/providers/anthropic/_types.rb +63 -0
  18. data/lib/active_agent/providers/anthropic/options.rb +53 -0
  19. data/lib/active_agent/providers/anthropic/request.rb +163 -0
  20. data/lib/active_agent/providers/anthropic/transforms.rb +353 -0
  21. data/lib/active_agent/providers/anthropic_provider.rb +254 -0
  22. data/lib/active_agent/providers/common/messages/_types.rb +160 -0
  23. data/lib/active_agent/providers/common/messages/assistant.rb +57 -0
  24. data/lib/active_agent/providers/common/messages/base.rb +17 -0
  25. data/lib/active_agent/providers/common/messages/system.rb +20 -0
  26. data/lib/active_agent/providers/common/messages/tool.rb +21 -0
  27. data/lib/active_agent/providers/common/messages/user.rb +20 -0
  28. data/lib/active_agent/providers/common/model.rb +361 -0
  29. data/lib/active_agent/providers/common/response.rb +13 -0
  30. data/lib/active_agent/providers/common/responses/_types.rb +51 -0
  31. data/lib/active_agent/providers/common/responses/base.rb +199 -0
  32. data/lib/active_agent/providers/common/responses/embed.rb +33 -0
  33. data/lib/active_agent/providers/common/responses/format.rb +31 -0
  34. data/lib/active_agent/providers/common/responses/message.rb +3 -0
  35. data/lib/active_agent/providers/common/responses/prompt.rb +42 -0
  36. data/lib/active_agent/providers/common/usage.rb +385 -0
  37. data/lib/active_agent/providers/concerns/exception_handler.rb +72 -0
  38. data/lib/active_agent/providers/concerns/instrumentation.rb +263 -0
  39. data/lib/active_agent/providers/concerns/previewable.rb +150 -0
  40. data/lib/active_agent/providers/log_subscriber.rb +178 -0
  41. data/lib/active_agent/providers/mock/_types.rb +77 -0
  42. data/lib/active_agent/providers/mock/embedding_request.rb +17 -0
  43. data/lib/active_agent/providers/mock/messages/_types.rb +103 -0
  44. data/lib/active_agent/providers/mock/messages/assistant.rb +26 -0
  45. data/lib/active_agent/providers/mock/messages/base.rb +63 -0
  46. data/lib/active_agent/providers/mock/messages/user.rb +18 -0
  47. data/lib/active_agent/providers/mock/options.rb +30 -0
  48. data/lib/active_agent/providers/mock/request.rb +38 -0
  49. data/lib/active_agent/providers/mock_provider.rb +311 -0
  50. data/lib/active_agent/providers/ollama/_types.rb +5 -0
  51. data/lib/active_agent/providers/ollama/chat/_types.rb +44 -0
  52. data/lib/active_agent/providers/ollama/chat/request.rb +249 -0
  53. data/lib/active_agent/providers/ollama/chat/transforms.rb +135 -0
  54. data/lib/active_agent/providers/ollama/embedding/_types.rb +44 -0
  55. data/lib/active_agent/providers/ollama/embedding/request.rb +190 -0
  56. data/lib/active_agent/providers/ollama/embedding/transforms.rb +160 -0
  57. data/lib/active_agent/providers/ollama/options.rb +27 -0
  58. data/lib/active_agent/providers/ollama_provider.rb +94 -0
  59. data/lib/active_agent/providers/open_ai/_base.rb +59 -0
  60. data/lib/active_agent/providers/open_ai/_types.rb +5 -0
  61. data/lib/active_agent/providers/open_ai/chat/_types.rb +56 -0
  62. data/lib/active_agent/providers/open_ai/chat/request.rb +161 -0
  63. data/lib/active_agent/providers/open_ai/chat/transforms.rb +364 -0
  64. data/lib/active_agent/providers/open_ai/chat_provider.rb +219 -0
  65. data/lib/active_agent/providers/open_ai/embedding/_types.rb +56 -0
  66. data/lib/active_agent/providers/open_ai/embedding/request.rb +53 -0
  67. data/lib/active_agent/providers/open_ai/embedding/transforms.rb +88 -0
  68. data/lib/active_agent/providers/open_ai/options.rb +74 -0
  69. data/lib/active_agent/providers/open_ai/responses/_types.rb +44 -0
  70. data/lib/active_agent/providers/open_ai/responses/request.rb +129 -0
  71. data/lib/active_agent/providers/open_ai/responses/transforms.rb +228 -0
  72. data/lib/active_agent/providers/open_ai/responses_provider.rb +200 -0
  73. data/lib/active_agent/providers/open_ai_provider.rb +94 -0
  74. data/lib/active_agent/providers/open_router/_types.rb +71 -0
  75. data/lib/active_agent/providers/open_router/options.rb +141 -0
  76. data/lib/active_agent/providers/open_router/request.rb +249 -0
  77. data/lib/active_agent/providers/open_router/requests/_types.rb +197 -0
  78. data/lib/active_agent/providers/open_router/requests/messages/_types.rb +56 -0
  79. data/lib/active_agent/providers/open_router/requests/messages/content/_types.rb +97 -0
  80. data/lib/active_agent/providers/open_router/requests/messages/content/file.rb +43 -0
  81. data/lib/active_agent/providers/open_router/requests/messages/content/files/_types.rb +61 -0
  82. data/lib/active_agent/providers/open_router/requests/messages/content/files/details.rb +37 -0
  83. data/lib/active_agent/providers/open_router/requests/plugin.rb +41 -0
  84. data/lib/active_agent/providers/open_router/requests/plugins/_types.rb +46 -0
  85. data/lib/active_agent/providers/open_router/requests/plugins/pdf_config.rb +51 -0
  86. data/lib/active_agent/providers/open_router/requests/prediction.rb +34 -0
  87. data/lib/active_agent/providers/open_router/requests/provider_preferences/_types.rb +44 -0
  88. data/lib/active_agent/providers/open_router/requests/provider_preferences/max_price.rb +64 -0
  89. data/lib/active_agent/providers/open_router/requests/provider_preferences.rb +105 -0
  90. data/lib/active_agent/providers/open_router/requests/response_format.rb +77 -0
  91. data/lib/active_agent/providers/open_router/transforms.rb +134 -0
  92. data/lib/active_agent/providers/open_router_provider.rb +62 -0
  93. data/lib/active_agent/providers/openai_provider.rb +2 -0
  94. data/lib/active_agent/providers/openrouter_provider.rb +2 -0
  95. data/lib/active_agent/railtie.rb +8 -6
  96. data/lib/active_agent/schema_generator.rb +333 -166
  97. data/lib/active_agent/version.rb +1 -1
  98. data/lib/active_agent.rb +112 -36
  99. data/lib/generators/active_agent/agent/USAGE +78 -0
  100. data/lib/generators/active_agent/{agent_generator.rb → agent/agent_generator.rb} +14 -4
  101. data/lib/generators/active_agent/install/USAGE +25 -0
  102. data/lib/generators/active_agent/{install_generator.rb → install/install_generator.rb} +1 -19
  103. data/lib/generators/active_agent/templates/agent.rb.tt +7 -3
  104. data/lib/generators/active_agent/templates/application_agent.rb.tt +0 -2
  105. data/lib/generators/erb/agent_generator.rb +31 -16
  106. data/lib/generators/erb/templates/instructions.md.erb.tt +3 -0
  107. data/lib/generators/erb/templates/instructions.md.tt +3 -0
  108. data/lib/generators/erb/templates/instructions.text.tt +1 -0
  109. data/lib/generators/erb/templates/message.md.erb.tt +5 -0
  110. data/lib/generators/erb/templates/schema.json.tt +10 -0
  111. data/lib/generators/test_unit/agent_generator.rb +1 -1
  112. data/lib/generators/test_unit/templates/functional_test.rb.tt +4 -2
  113. metadata +182 -71
  114. data/lib/active_agent/action_prompt/action.rb +0 -13
  115. data/lib/active_agent/action_prompt/base.rb +0 -623
  116. data/lib/active_agent/action_prompt/message.rb +0 -126
  117. data/lib/active_agent/action_prompt/prompt.rb +0 -136
  118. data/lib/active_agent/action_prompt.rb +0 -19
  119. data/lib/active_agent/callbacks.rb +0 -33
  120. data/lib/active_agent/generation_provider/anthropic_provider.rb +0 -163
  121. data/lib/active_agent/generation_provider/base.rb +0 -55
  122. data/lib/active_agent/generation_provider/base_adapter.rb +0 -19
  123. data/lib/active_agent/generation_provider/error_handling.rb +0 -167
  124. data/lib/active_agent/generation_provider/log_subscriber.rb +0 -92
  125. data/lib/active_agent/generation_provider/message_formatting.rb +0 -107
  126. data/lib/active_agent/generation_provider/ollama_provider.rb +0 -66
  127. data/lib/active_agent/generation_provider/open_ai_provider.rb +0 -279
  128. data/lib/active_agent/generation_provider/open_router_provider.rb +0 -385
  129. data/lib/active_agent/generation_provider/parameter_builder.rb +0 -119
  130. data/lib/active_agent/generation_provider/response.rb +0 -75
  131. data/lib/active_agent/generation_provider/responses_adapter.rb +0 -44
  132. data/lib/active_agent/generation_provider/stream_processing.rb +0 -58
  133. data/lib/active_agent/generation_provider/tool_management.rb +0 -142
  134. data/lib/active_agent/generation_provider.rb +0 -67
  135. data/lib/active_agent/log_subscriber.rb +0 -44
  136. data/lib/active_agent/parameterized.rb +0 -75
  137. data/lib/active_agent/prompt_helper.rb +0 -19
  138. data/lib/active_agent/queued_generation.rb +0 -12
  139. data/lib/active_agent/rescuable.rb +0 -34
  140. data/lib/active_agent/sanitizers.rb +0 -40
  141. data/lib/active_agent/streaming.rb +0 -34
  142. data/lib/active_agent/test_case.rb +0 -125
  143. data/lib/generators/USAGE +0 -47
  144. data/lib/generators/active_agent/USAGE +0 -56
  145. data/lib/generators/erb/install_generator.rb +0 -44
  146. data/lib/generators/erb/templates/layout.html.erb.tt +0 -1
  147. data/lib/generators/erb/templates/layout.json.erb.tt +0 -1
  148. data/lib/generators/erb/templates/layout.text.erb.tt +0 -1
  149. data/lib/generators/erb/templates/view.html.erb.tt +0 -5
  150. data/lib/generators/erb/templates/view.json.erb.tt +0 -16
  151. /data/lib/active_agent/{preview.rb → concerns/preview.rb} +0 -0
  152. /data/lib/generators/erb/templates/{view.text.erb.tt → message.text.erb.tt} +0 -0
@@ -1,90 +1,184 @@
1
- # lib/active_agent/generation.rb
2
- require "delegate"
1
+ # frozen_string_literal: true
2
+
3
+ require "active_agent/providers/common/messages/_types"
3
4
 
4
5
  module ActiveAgent
5
- class Generation < Delegator
6
- def initialize(agent_class, action, *args)
7
- @agent_class, @action, @args = agent_class, action, args
8
- @processed_agent = nil
9
- @context = nil
6
+ # Deferred agent action ready for synchronous or asynchronous execution.
7
+ #
8
+ # Returned when calling agent actions. Provides methods to execute immediately
9
+ # or queue for background processing, plus access to prompt properties before execution.
10
+ #
11
+ # @example Synchronous generation
12
+ # generation = MyAgent.with(message: "Hello").greet
13
+ # response = generation.prompt_now
14
+ #
15
+ # @example Asynchronous generation
16
+ # MyAgent.with(message: "Hello").greet.prompt_later(queue: :prompts)
17
+ #
18
+ # @example Accessing prompt properties before generation
19
+ # generation = MyAgent.prompt(message: "Hello")
20
+ # generation.message.content # => "Hello"
21
+ # generation.messages # => [...]
22
+ class Generation
23
+ attr_internal :agent_class, :action_name, :args, :kwargs
24
+
25
+ # @param agent_class [Class]
26
+ # @param action_name [Symbol]
27
+ # @param args [Array]
28
+ # @param kwargs [Hash]
29
+ def initialize(agent_class, action_name, *args, **kwargs)
30
+ self.agent_class, self.action_name, self.args, self.kwargs = agent_class, action_name, args, kwargs
10
31
  end
11
- ruby2_keywords(:initialize)
12
32
 
13
- def __getobj__
14
- @context ||= processed_agent.context
33
+ # @return [Boolean]
34
+ def processed?
35
+ !!@agent
15
36
  end
16
37
 
17
- def __setobj__(context)
18
- @context = context
38
+ # Accesses prompt options by processing the agent if needed.
39
+ #
40
+ # Lazily processes the agent on first access, allowing inspection of
41
+ # prompt properties before executing generation.
42
+ #
43
+ # @return [Hash] with :messages, :actions, and configuration keys
44
+ def prompt_options
45
+ agent.prompt_options
19
46
  end
20
47
 
21
- def context
22
- __getobj__
48
+ # @return [Hash] configuration options excluding messages and actions
49
+ def options
50
+ prompt_options.except(:messages, :actions)
23
51
  end
24
52
 
25
- def processed?
26
- @processed_agent || @context
53
+ def instructions
54
+ agent.prompt_view_instructions(prompt_options[:instructions])
27
55
  end
28
56
 
29
- def generate_later!(options = {})
30
- enqueue_generation :generate_now!, options
57
+ # @return [Array]
58
+ def messages
59
+ prompt_options[:messages] || []
31
60
  end
32
61
 
33
- def generate_later(options = {})
34
- enqueue_generation :generate_now, options
62
+ # Returns the last message with consistent `.content` access.
63
+ #
64
+ # Wraps various message formats (String, Hash, objects) using the common
65
+ # MessageType for uniform access patterns.
66
+ #
67
+ # @return [ActiveAgent::Providers::Common::Messages::Base, nil]
68
+ def message
69
+ last_message = messages.last
70
+ return nil unless last_message
71
+
72
+ message_type.cast(last_message)
35
73
  end
36
74
 
37
- def generate_now!
38
- processed_agent.handle_exceptions do
39
- processed_agent.run_callbacks(:generation) do
40
- processed_agent.perform_generation!
41
- end
42
- end
75
+ # @return [Array]
76
+ def actions
77
+ prompt_options[:actions] || []
43
78
  end
44
79
 
45
- def generate_now
46
- processed_agent.handle_exceptions do
47
- processed_agent.run_callbacks(:generation) do
48
- processed_agent.perform_generation
49
- end
50
- end
80
+ # Executes prompt generation synchronously with immediate processing.
81
+ #
82
+ # @return [ActiveAgent::Providers::Response]
83
+ def prompt_now!
84
+ agent.process_prompt!
85
+ end
86
+ alias generate_now! prompt_now!
87
+
88
+ # Executes prompt generation synchronously.
89
+ #
90
+ # @return [ActiveAgent::Providers::Response]
91
+ def prompt_now
92
+ agent.process_prompt
93
+ end
94
+ alias generate_now prompt_now
95
+
96
+ # Queues for background execution.
97
+ #
98
+ # @param options [Hash] job options (queue, priority, wait, etc.)
99
+ # @return [Object] enqueued job instance
100
+ # @raise [RuntimeError] if agent was accessed before queueing
101
+ def prompt_later(options = {})
102
+ enqueue_generation :prompt_now, options
103
+ end
104
+ alias generate_later prompt_later
105
+
106
+ # Generates a preview of the prompt without executing generation.
107
+ #
108
+ # Processes the agent action and renders the prompt configuration as
109
+ # markdown for debugging and inspection.
110
+ #
111
+ # @return [String] markdown-formatted preview
112
+ def prompt_preview
113
+ agent.preview_prompt
51
114
  end
115
+ alias preview_prompt prompt_preview
52
116
 
117
+ # Executes embedding generation synchronously.
118
+ #
119
+ # @return [ActiveAgent::Providers::Response] embedding response with vector data
53
120
  def embed_now
54
- processed_agent.handle_exceptions do
55
- processed_agent.run_callbacks(:embedding) do
56
- processed_agent.embed
57
- end
58
- end
121
+ agent.process_embed
59
122
  end
60
123
 
124
+ # Queues embedding generation for background execution.
125
+ #
126
+ # @param options [Hash] job options (queue, priority, wait, etc.)
127
+ # @return [Object] enqueued job instance
128
+ # @raise [RuntimeError] if agent was accessed before queueing
61
129
  def embed_later(options = {})
62
130
  enqueue_generation :embed_now, options
63
131
  end
64
132
 
65
133
  private
66
134
 
67
- def processed_agent
68
- @processed_agent ||= @agent_class.new.tap do |agent|
69
- agent.process(@action, *@args)
135
+ # Lazily instantiates and processes the agent instance.
136
+ #
137
+ # Cached after first call.
138
+ #
139
+ # @return [ActiveAgent::Base]
140
+ # @api private
141
+ def agent
142
+ @agent ||= agent_class.new.tap do |agent|
143
+ agent.params = @params
144
+ agent.process(action_name, *args, **kwargs)
70
145
  end
71
146
  end
72
147
 
148
+ # Enqueues for background processing.
149
+ #
150
+ # Prevents enqueuing if the agent has been accessed, as local changes
151
+ # would be lost. Only method arguments are passed to the job, not the
152
+ # agent instance state.
153
+ #
154
+ # @param generation_method [Symbol, String]
155
+ # @param options [Hash]
156
+ # @return [Object] enqueued job
157
+ # @raise [RuntimeError] when agent already processed to prevent data loss
158
+ # @api private
73
159
  def enqueue_generation(generation_method, options = {})
74
160
  if processed?
75
- ::Kernel.raise "You've accessed the context before asking to " \
161
+ ::Kernel.raise "You've accessed the agent before asking to " \
76
162
  "generate it later, so you may have made local changes that would " \
77
163
  "be silently lost if we enqueued a job to generate it. Why? Only " \
78
164
  "the agent method *arguments* are passed with the generation job! " \
79
- "Do not access the context in any way if you mean to generate it " \
80
- "later. Workarounds: 1. don't touch the context before calling " \
81
- "#generate_later, 2. only touch the context *within your agent " \
82
- "method*, or 3. use a custom Active Job instead of #generate_later."
165
+ "Do not access the agent in any way if you mean to generate it " \
166
+ "later. Workarounds: 1. don't touch the agent before calling " \
167
+ "#prompt_later, 2. only touch the agent *within your agent " \
168
+ "method*, or 3. use a custom Active Job instead of #prompt_later."
83
169
  else
84
- @agent_class.generation_job.set(options).perform_later(
85
- @agent_class.name, @action.to_s, generation_method.to_s, args: @args
170
+ agent_class.generation_job.set(options).perform_later(
171
+ agent_class.name, action_name.to_s, generation_method.to_s, args: args, kwargs: kwargs
86
172
  )
87
173
  end
88
174
  end
175
+
176
+ # Lazy-loaded message type instance for casting messages.
177
+ #
178
+ # @return [ActiveAgent::Providers::Common::Messages::Types::MessageType]
179
+ # @api private
180
+ def message_type
181
+ @message_type ||= ActiveAgent::Providers::Common::Messages::Types::MessageType.new
182
+ end
89
183
  end
90
184
  end
@@ -0,0 +1,420 @@
1
+ require "active_support/delegation"
2
+
3
+ require_relative "common/response"
4
+ require_relative "concerns/exception_handler"
5
+ require_relative "concerns/instrumentation"
6
+ require_relative "concerns/previewable"
7
+
8
+ # @private
9
+ GEM_LOADERS = {
10
+ anthropic: [ "anthropic", "~> 1.12", "anthropic" ],
11
+ openai: [ "openai", "~> 0.34", "openai" ]
12
+ }
13
+
14
+ # Requires a provider's gem dependency.
15
+ #
16
+ # @param type [Symbol] provider type (:anthropic, :openai)
17
+ # @param file_name [String] for error context
18
+ # @return [void]
19
+ # @raise [LoadError] when required gem is not installed
20
+ def require_gem!(type, file_name)
21
+ gem_name, requirement, package_name = GEM_LOADERS.fetch(type)
22
+ provider_name = file_name.split("/").last.delete_suffix(".rb").camelize
23
+
24
+ begin
25
+ gem(gem_name, requirement)
26
+ require(package_name)
27
+ rescue LoadError
28
+ raise LoadError, "The '#{gem_name}' gem is required for #{provider_name}. Please add it to your Gemfile and run `bundle install`."
29
+ end
30
+ end
31
+
32
+ module ActiveAgent
33
+ module Providers
34
+ # Orchestrates LLM provider API requests, streaming, and multi-turn tool calling.
35
+ #
36
+ # Each provider (OpenAI, Anthropic, etc.) subclasses this to implement
37
+ # provider-specific API interactions.
38
+ #
39
+ # @abstract Subclasses must implement {#api_prompt_execute},
40
+ # {#process_stream_chunk}, {#process_prompt_finished_extract_messages},
41
+ # and {#process_prompt_finished_extract_function_calls}
42
+ class BaseProvider
43
+ extend ActiveSupport::Delegation
44
+
45
+ include ExceptionHandler
46
+ include Instrumentation
47
+ include Previewable
48
+
49
+ class ProvidersError < StandardError; end
50
+
51
+ attr_internal :options, :context, :trace_id, # Setup
52
+ :request, :message_stack, # Runtime
53
+ :stream_broadcaster, :streaming, # Callback (Streams)
54
+ :tools_function, # Callback (Tools)
55
+ :usage_stack # Usage Tracking
56
+
57
+ # @return [String] e.g., "Anthropic", "OpenAI"
58
+ def self.service_name
59
+ name.split("::").last.delete_suffix("Provider")
60
+ end
61
+
62
+ # @return [String] e.g., "Anthropic", "OpenAI::Chat"
63
+ def self.tag_name
64
+ name.delete_prefix("ActiveAgent::Providers::").delete_suffix("Provider")
65
+ end
66
+
67
+ # @return [Module] e.g., ActiveAgent::Providers::OpenAI
68
+ def self.namespace
69
+ "#{name.deconstantize}::#{service_name}".safe_constantize
70
+ end
71
+
72
+ # @return [Class]
73
+ def self.options_klass
74
+ namespace::Options
75
+ end
76
+
77
+ # @return [ActiveModel::Type::Value] for prompt casting/serialization
78
+ def self.prompt_request_type
79
+ namespace::RequestType.new
80
+ end
81
+
82
+ # @return [ActiveModel::Type::Value] for embedding casting/serialization
83
+ # @raise [NotImplementedError] when provider doesn't support embeddings
84
+ def self.embed_request_type
85
+ fail(NotImplementedError)
86
+ end
87
+
88
+ delegate :service_name, :tag_name, :namespace, :options_klass, :prompt_request_type, :embed_request_type, to: :class
89
+
90
+ # @param kwargs [Hash] configuration and callbacks
91
+ # @option kwargs [Symbol] :service validates against provider's service name
92
+ # @option kwargs [Proc] :stream_broadcaster for streaming events (:open, :update, :close)
93
+ # @option kwargs [Proc] :tools_function to execute tool/function calls
94
+ # @raise [RuntimeError] when service name doesn't match provider
95
+ def initialize(kwargs = {})
96
+ assert_service!(kwargs.delete(:service))
97
+
98
+ configure_exception_handler(
99
+ exception_handler: kwargs.delete(:exception_handler)
100
+ )
101
+
102
+ self.trace_id = kwargs[:trace_id]
103
+ self.stream_broadcaster = kwargs.delete(:stream_broadcaster)
104
+ self.streaming = false
105
+ self.tools_function = kwargs.delete(:tools_function)
106
+ self.options = options_klass.new(kwargs.extract!(*options_klass.keys))
107
+ self.context = kwargs
108
+ self.message_stack = []
109
+ self.usage_stack = []
110
+ end
111
+
112
+ # Generates prompt preview without executing the API call.
113
+ #
114
+ # @return [String] markdown-formatted preview
115
+ def preview
116
+ self.request = prompt_request_type.cast(context.except(:trace_id))
117
+ preview_prompt
118
+ end
119
+
120
+ # Executes prompt request with error handling and instrumentation.
121
+ #
122
+ # @return [ActiveAgent::Providers::Common::PromptResponse]
123
+ def prompt
124
+ self.request = prompt_request_type.cast(context.except(:trace_id))
125
+
126
+ instrument("prompt.active_agent") do |payload|
127
+ response = resolve_prompt
128
+ instrumentation_prompt_payload(payload, request, response)
129
+
130
+ response
131
+ end
132
+ end
133
+
134
+ # Executes embedding request with error handling and instrumentation.
135
+ #
136
+ # @return [ActiveAgent::Providers::Common::EmbedResponse]
137
+ def embed
138
+ self.request = embed_request_type.cast(context.except(:trace_id))
139
+
140
+ instrument("embed.active_agent") do |payload|
141
+ response = resolve_embed
142
+ instrumentation_embed_payload(payload, request, response)
143
+
144
+ response
145
+ end
146
+ end
147
+
148
+ protected
149
+
150
+ # @param name [String, nil]
151
+ # @raise [RuntimeError] when service name doesn't match provider
152
+ def assert_service!(name)
153
+ fail "Unexpected Service Name: #{name} != #{service_name}" if name && name != service_name
154
+ end
155
+
156
+ # @param name [String]
157
+ # @param payload [Hash]
158
+ # @yield block to instrument
159
+ # @return [Object] block result
160
+ def instrument(name, payload = {}, &block)
161
+ full_payload = { provider: service_name, provider_module: tag_name, trace_id: }.merge(payload)
162
+ ActiveSupport::Notifications.instrument(name, full_payload, &block)
163
+ end
164
+
165
+ # Orchestrates complete prompt request lifecycle.
166
+ #
167
+ # Handles recursive tool/function calling until completion.
168
+ #
169
+ # @return [ActiveAgent::Providers::Common::PromptResponse]
170
+ def resolve_prompt
171
+ api_parameters = api_request_build(prepare_prompt_request, prompt_request_type)
172
+ api_response = instrument("prompt.provider.active_agent") do |payload|
173
+ raw_response = with_exception_handling { api_prompt_execute(api_parameters) }
174
+
175
+ # Instrumentation Context Building
176
+ # Normalize response for instrumentation (providers may return gem objects)
177
+ normalized_response = api_response_normalize(raw_response)
178
+ common_response = Common::PromptResponse.new(raw_response: normalized_response)
179
+ instrumentation_prompt_payload(payload, self.request, common_response)
180
+ usage_stack.push(common_response.usage) if common_response&.usage
181
+
182
+ raw_response
183
+ end
184
+
185
+ process_prompt_finished(api_response)
186
+ end
187
+
188
+ # Orchestrates complete embedding request lifecycle.
189
+ #
190
+ # @return [ActiveAgent::Providers::Common::EmbedResponse]
191
+ def resolve_embed
192
+ api_parameters = api_request_build(self.request, embed_request_type)
193
+ api_response = instrument("embed.provider.active_agent") do |payload|
194
+ raw_response = with_exception_handling { api_embed_execute(api_parameters) }
195
+
196
+ # Instrumentation Context Building
197
+ common_response = Common::EmbedResponse.new(raw_response:)
198
+ instrumentation_embed_payload(payload, self.request, common_response)
199
+
200
+ raw_response
201
+ end
202
+
203
+ process_embed_finished(api_response)
204
+ end
205
+
206
+ # Prepares request for next iteration in multi-turn conversation.
207
+ #
208
+ # Appends accumulated messages and resets buffer for next cycle.
209
+ #
210
+ # @return [Request]
211
+ def prepare_prompt_request
212
+ self.request.messages = [ *request.messages, *message_stack ]
213
+ self.message_stack = []
214
+
215
+ self.request
216
+ end
217
+
218
+ # @param request [Request]
219
+ # @param request_type [ActiveModel::Type::Value] for serialization
220
+ # @return [Hash] API request parameters
221
+ def api_request_build(request, request_type)
222
+ parameters = request_type.serialize(request)
223
+ parameters[:stream] = process_stream if request.try(:stream)
224
+
225
+ if options.extra_headers.present?
226
+ parameters[:request_options] = { extra_headers: options.extra_headers }.deep_merge(parameters[:request_options] || {})
227
+ end
228
+
229
+ parameters
230
+ end
231
+
232
+ # @return [Proc] for each response chunk
233
+ def process_stream
234
+ proc do |api_response_chunk|
235
+ process_stream_chunk(api_response_chunk)
236
+ end
237
+ end
238
+
239
+ # Executes prompt request against provider's API.
240
+ #
241
+ # @abstract
242
+ # @param parameters [Hash]
243
+ # @return [Object] provider-specific API response
244
+ # @raise [NotImplementedError]
245
+ def api_prompt_execute(parameters)
246
+ unless parameters[:stream]
247
+ api_prompt_executer.create(**parameters)
248
+ else
249
+ api_prompt_executer.stream(**parameters.except(:stream)).each(&parameters[:stream])
250
+ nil
251
+ end
252
+ end
253
+
254
+ # Returns provider-specific API executer for prompt requests.
255
+ #
256
+ # Since all currently implemented providers use stainless gems, subclasses
257
+ # only need to override endpoint selection.
258
+ #
259
+ # @abstract
260
+ # @return [Object] provider-specific API client
261
+ # @raise [NotImplementedError]
262
+ def api_prompt_executer
263
+ fail NotImplementedError, "Subclass expected to implement"
264
+ end
265
+
266
+ # Normalizes API response for instrumentation.
267
+ #
268
+ # Providers that return gem objects (like Anthropic::Models::Message) should
269
+ # override this to convert to a hash so usage data can be extracted.
270
+ # By default, returns the response as-is (for providers returning hashes).
271
+ #
272
+ # @param api_response [Object] provider-specific API response
273
+ # @return [Hash, Object] normalized response (preferably hash)
274
+ def api_response_normalize(api_response)
275
+ api_response
276
+ end
277
+
278
+ # Executes embedding request against provider's API.
279
+ #
280
+ # @abstract
281
+ # @param request_parameters [Hash]
282
+ # @return [Object] provider-specific embedding response
283
+ # @raise [NotImplementedError]
284
+ def api_embed_execute(request_parameters)
285
+ fail NotImplementedError, "Subclass expected to implement"
286
+ end
287
+
288
+ # Processes a single streaming response chunk.
289
+ #
290
+ # @abstract
291
+ # @param api_response_chunk [Object] provider-specific chunk format
292
+ # @raise [NotImplementedError]
293
+ def process_stream_chunk(api_response_chunk)
294
+ fail NotImplementedError, "Subclass expected to implement"
295
+ end
296
+
297
+ # Broadcasts stream open event.
298
+ #
299
+ # Fires once per request cycle, even during multi-turn tool calling.
300
+ #
301
+ # @return [void]
302
+ def broadcast_stream_open
303
+ return if streaming
304
+ self.streaming = true
305
+
306
+ instrument("stream_open.active_agent")
307
+ stream_broadcaster.call(nil, nil, :open)
308
+ end
309
+
310
+ # Broadcasts stream update with message content delta.
311
+ #
312
+ # @param message [Hash, Object]
313
+ # @param delta [String, nil]
314
+ # @return [void]
315
+ def broadcast_stream_update(message, delta = nil)
316
+ stream_broadcaster.call(message, delta, :update)
317
+ end
318
+
319
+ # Broadcasts stream close event.
320
+ #
321
+ # Fires once per request cycle, even during multi-turn tool calling.
322
+ #
323
+ # @return [void]
324
+ def broadcast_stream_close
325
+ return unless streaming
326
+ self.streaming = false
327
+
328
+ instrument("stream_close.active_agent")
329
+ stream_broadcaster.call(message_stack.last, nil, :close)
330
+ end
331
+
332
+ # Processes completed API response and handles tool calling recursion.
333
+ #
334
+ # Extracts messages and function calls. If tools were invoked,
335
+ # executes them and recursively continues until completion.
336
+ #
337
+ # @param api_response [Object, nil] provider-specific response
338
+ # @return [Common::PromptResponse, nil]
339
+ def process_prompt_finished(api_response = nil)
340
+ if (api_messages = process_prompt_finished_extract_messages(api_response))
341
+ message_stack.push(*api_messages)
342
+ end
343
+
344
+ if (tool_calls = process_prompt_finished_extract_function_calls)&.any?
345
+ process_function_calls(tool_calls)
346
+ resolve_prompt
347
+ else
348
+
349
+ # During a multi iteration process, we will internally open/close the stream
350
+ # with the provider, but this should all look like one big stream to the agents
351
+ # as they continue to work.
352
+ broadcast_stream_close
353
+
354
+ # To convert the messages into common format we first need to merge the current
355
+ # stack and then cast them to the provider type, so we can cast them out to common.
356
+ messages = prompt_request_type.cast(
357
+ messages: [ *request.messages, *message_stack ]
358
+ ).messages
359
+
360
+ # Create response object with usage_stack array for multi-turn cumulative tracking.
361
+ # This will returned as it closes up the recursive stack
362
+ Common::PromptResponse.new(
363
+ context:,
364
+ format: request.response_format,
365
+ messages:,
366
+ raw_request: prompt_request_type.serialize(request),
367
+ raw_response: api_response,
368
+ usages: usage_stack
369
+ )
370
+ end
371
+ end
372
+
373
+ # @abstract
374
+ # @param api_response [Object]
375
+ # @return [Array<Message>, nil]
376
+ # @raise [NotImplementedError]
377
+ def process_prompt_finished_extract_messages(api_response)
378
+ fail NotImplementedError, "Subclass expected to implement"
379
+ end
380
+
381
+ # @abstract
382
+ # @return [Array<Hash>, nil]
383
+ # @raise [NotImplementedError]
384
+ def process_prompt_finished_extract_function_calls
385
+ fail NotImplementedError, "Subclass expected to implement"
386
+ end
387
+
388
+ # @param api_response [Hash]
389
+ # @return [Common::EmbedResponse]
390
+ def process_embed_finished(api_response)
391
+ Common::EmbedResponse.new(
392
+ context:,
393
+ raw_request: embed_request_type.serialize(request),
394
+ raw_response: api_response,
395
+ data: process_embed_finished_data(api_response)
396
+ )
397
+ end
398
+
399
+ # Extracts embedding vectors from API response.
400
+ #
401
+ # Handles both list and single embedding response formats:
402
+ # - List: `{ "data": [{ "embedding": [...] }] }`
403
+ # - Single: `{ "embedding": [...] }`
404
+ #
405
+ # @param api_response [Hash]
406
+ # @return [Array<Hash>] embedding objects with :index, :object, :embedding keys
407
+ # @raise [RuntimeError] when response format is unexpected
408
+ def process_embed_finished_data(api_response)
409
+ case (type = api_response[:object].to_sym)
410
+ when :list
411
+ api_response[:data]
412
+ when :embedding
413
+ [ { index: 0 }.merge(api_response.slice(:index, :object, :embedding)) ]
414
+ else
415
+ fail "Unexpected Embed Object Type: #{type}"
416
+ end
417
+ end
418
+ end
419
+ end
420
+ end