rubyllm-observ 0.5.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 (209) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +778 -0
  3. data/Rakefile +49 -0
  4. data/app/assets/javascripts/observ/application.js +12 -0
  5. data/app/assets/javascripts/observ/controllers/autoscroll_controller.js +33 -0
  6. data/app/assets/javascripts/observ/controllers/chat_form_controller.js +93 -0
  7. data/app/assets/javascripts/observ/controllers/copy_controller.js +43 -0
  8. data/app/assets/javascripts/observ/controllers/dashboard_controller.js +58 -0
  9. data/app/assets/javascripts/observ/controllers/drawer_controller.js +58 -0
  10. data/app/assets/javascripts/observ/controllers/expandable_controller.js +33 -0
  11. data/app/assets/javascripts/observ/controllers/filter_controller.js +36 -0
  12. data/app/assets/javascripts/observ/controllers/index.js +52 -0
  13. data/app/assets/javascripts/observ/controllers/json_viewer_controller.js +260 -0
  14. data/app/assets/javascripts/observ/controllers/message_form_controller.js +58 -0
  15. data/app/assets/javascripts/observ/controllers/prompt_variables_controller.js +64 -0
  16. data/app/assets/javascripts/observ/controllers/text_select_controller.js +14 -0
  17. data/app/assets/stylesheets/observ/_annotations.scss +127 -0
  18. data/app/assets/stylesheets/observ/_card.scss +52 -0
  19. data/app/assets/stylesheets/observ/_chat.scss +156 -0
  20. data/app/assets/stylesheets/observ/_components.scss +460 -0
  21. data/app/assets/stylesheets/observ/_dashboard.scss +40 -0
  22. data/app/assets/stylesheets/observ/_datasets.scss +697 -0
  23. data/app/assets/stylesheets/observ/_drawer.scss +273 -0
  24. data/app/assets/stylesheets/observ/_json_viewer.scss +120 -0
  25. data/app/assets/stylesheets/observ/_layout.scss +256 -0
  26. data/app/assets/stylesheets/observ/_metrics.scss +99 -0
  27. data/app/assets/stylesheets/observ/_observations.scss +160 -0
  28. data/app/assets/stylesheets/observ/_pagination.scss +143 -0
  29. data/app/assets/stylesheets/observ/_prompts.scss +365 -0
  30. data/app/assets/stylesheets/observ/_table.scss +53 -0
  31. data/app/assets/stylesheets/observ/_variables.scss +53 -0
  32. data/app/assets/stylesheets/observ/application.scss +15 -0
  33. data/app/controllers/observ/annotations_controller.rb +144 -0
  34. data/app/controllers/observ/application_controller.rb +8 -0
  35. data/app/controllers/observ/chats_controller.rb +58 -0
  36. data/app/controllers/observ/dashboard_controller.rb +159 -0
  37. data/app/controllers/observ/dataset_items_controller.rb +85 -0
  38. data/app/controllers/observ/dataset_run_items_controller.rb +84 -0
  39. data/app/controllers/observ/dataset_runs_controller.rb +110 -0
  40. data/app/controllers/observ/datasets_controller.rb +74 -0
  41. data/app/controllers/observ/messages_controller.rb +26 -0
  42. data/app/controllers/observ/observations_controller.rb +59 -0
  43. data/app/controllers/observ/prompt_versions_controller.rb +148 -0
  44. data/app/controllers/observ/prompts_controller.rb +205 -0
  45. data/app/controllers/observ/sessions_controller.rb +45 -0
  46. data/app/controllers/observ/traces_controller.rb +86 -0
  47. data/app/forms/observ/prompt_form.rb +96 -0
  48. data/app/helpers/observ/application_helper.rb +9 -0
  49. data/app/helpers/observ/chats_helper.rb +47 -0
  50. data/app/helpers/observ/dashboard_helper.rb +154 -0
  51. data/app/helpers/observ/datasets_helper.rb +62 -0
  52. data/app/helpers/observ/pagination_helper.rb +38 -0
  53. data/app/jobs/observ/application_job.rb +4 -0
  54. data/app/jobs/observ/dataset_runner_job.rb +49 -0
  55. data/app/mailers/observ/application_mailer.rb +6 -0
  56. data/app/models/concerns/observ/agent_phaseable.rb +124 -0
  57. data/app/models/concerns/observ/agent_selectable.rb +50 -0
  58. data/app/models/concerns/observ/chat_enhancements.rb +109 -0
  59. data/app/models/concerns/observ/message_enhancements.rb +31 -0
  60. data/app/models/concerns/observ/observability_instrumentation.rb +124 -0
  61. data/app/models/concerns/observ/prompt_management.rb +320 -0
  62. data/app/models/concerns/observ/trace_association.rb +9 -0
  63. data/app/models/observ/annotation.rb +23 -0
  64. data/app/models/observ/application_record.rb +5 -0
  65. data/app/models/observ/dataset.rb +51 -0
  66. data/app/models/observ/dataset_item.rb +41 -0
  67. data/app/models/observ/dataset_run.rb +104 -0
  68. data/app/models/observ/dataset_run_item.rb +111 -0
  69. data/app/models/observ/generation.rb +56 -0
  70. data/app/models/observ/null_prompt.rb +59 -0
  71. data/app/models/observ/observation.rb +38 -0
  72. data/app/models/observ/prompt.rb +315 -0
  73. data/app/models/observ/score.rb +51 -0
  74. data/app/models/observ/session.rb +131 -0
  75. data/app/models/observ/span.rb +13 -0
  76. data/app/models/observ/trace.rb +135 -0
  77. data/app/presenters/observ/agent_select_presenter.rb +59 -0
  78. data/app/services/observ/agent_executor_service.rb +174 -0
  79. data/app/services/observ/agent_provider.rb +60 -0
  80. data/app/services/observ/agent_selection_service.rb +53 -0
  81. data/app/services/observ/chat_instrumenter.rb +523 -0
  82. data/app/services/observ/dataset_runner_service.rb +153 -0
  83. data/app/services/observ/evaluator_runner_service.rb +58 -0
  84. data/app/services/observ/evaluators/base_evaluator.rb +51 -0
  85. data/app/services/observ/evaluators/contains_evaluator.rb +53 -0
  86. data/app/services/observ/evaluators/exact_match_evaluator.rb +23 -0
  87. data/app/services/observ/evaluators/json_structure_evaluator.rb +44 -0
  88. data/app/services/observ/prompt_manager/cache_statistics.rb +82 -0
  89. data/app/services/observ/prompt_manager/caching.rb +167 -0
  90. data/app/services/observ/prompt_manager/comparison.rb +49 -0
  91. data/app/services/observ/prompt_manager/version_management.rb +96 -0
  92. data/app/services/observ/prompt_manager.rb +40 -0
  93. data/app/services/observ/trace_text_formatter.rb +349 -0
  94. data/app/validators/observ/prompt_config_validator.rb +187 -0
  95. data/app/views/kaminari/_first_page.html.erb +11 -0
  96. data/app/views/kaminari/_gap.html.erb +8 -0
  97. data/app/views/kaminari/_last_page.html.erb +11 -0
  98. data/app/views/kaminari/_next_page.html.erb +11 -0
  99. data/app/views/kaminari/_page.html.erb +12 -0
  100. data/app/views/kaminari/_paginator.html.erb +25 -0
  101. data/app/views/kaminari/_prev_page.html.erb +11 -0
  102. data/app/views/kaminari/observ/_first_page.html.erb +11 -0
  103. data/app/views/kaminari/observ/_gap.html.erb +8 -0
  104. data/app/views/kaminari/observ/_last_page.html.erb +11 -0
  105. data/app/views/kaminari/observ/_next_page.html.erb +11 -0
  106. data/app/views/kaminari/observ/_page.html.erb +12 -0
  107. data/app/views/kaminari/observ/_paginator.html.erb +25 -0
  108. data/app/views/kaminari/observ/_prev_page.html.erb +11 -0
  109. data/app/views/layouts/observ/application.html.erb +88 -0
  110. data/app/views/observ/annotations/_annotation.html.erb +13 -0
  111. data/app/views/observ/annotations/_form.html.erb +28 -0
  112. data/app/views/observ/annotations/index.html.erb +28 -0
  113. data/app/views/observ/annotations/sessions_index.html.erb +48 -0
  114. data/app/views/observ/annotations/traces_index.html.erb +48 -0
  115. data/app/views/observ/chats/_form.html.erb +45 -0
  116. data/app/views/observ/chats/index.html.erb +67 -0
  117. data/app/views/observ/chats/new.html.erb +17 -0
  118. data/app/views/observ/chats/show.html.erb +34 -0
  119. data/app/views/observ/dashboard/index.html.erb +236 -0
  120. data/app/views/observ/dataset_items/_form.html.erb +49 -0
  121. data/app/views/observ/dataset_items/edit.html.erb +18 -0
  122. data/app/views/observ/dataset_items/index.html.erb +95 -0
  123. data/app/views/observ/dataset_items/new.html.erb +18 -0
  124. data/app/views/observ/dataset_run_items/_score_close_drawer.html.erb +4 -0
  125. data/app/views/observ/dataset_run_items/_score_drawer.html.erb +75 -0
  126. data/app/views/observ/dataset_run_items/_score_success.html.erb +29 -0
  127. data/app/views/observ/dataset_run_items/_scores_cell.html.erb +19 -0
  128. data/app/views/observ/dataset_run_items/details_drawer.turbo_stream.erb +80 -0
  129. data/app/views/observ/dataset_run_items/score_drawer.turbo_stream.erb +7 -0
  130. data/app/views/observ/dataset_runs/index.html.erb +108 -0
  131. data/app/views/observ/dataset_runs/new.html.erb +57 -0
  132. data/app/views/observ/dataset_runs/review.html.erb +155 -0
  133. data/app/views/observ/dataset_runs/show.html.erb +166 -0
  134. data/app/views/observ/datasets/_form.html.erb +62 -0
  135. data/app/views/observ/datasets/_items_tab.html.erb +66 -0
  136. data/app/views/observ/datasets/_runs_tab.html.erb +82 -0
  137. data/app/views/observ/datasets/edit.html.erb +32 -0
  138. data/app/views/observ/datasets/index.html.erb +105 -0
  139. data/app/views/observ/datasets/new.html.erb +18 -0
  140. data/app/views/observ/datasets/show.html.erb +67 -0
  141. data/app/views/observ/messages/_content.html.erb +1 -0
  142. data/app/views/observ/messages/_form.html.erb +33 -0
  143. data/app/views/observ/messages/_message.html.erb +14 -0
  144. data/app/views/observ/messages/_tool_calls.html.erb +10 -0
  145. data/app/views/observ/messages/create.turbo_stream.erb +9 -0
  146. data/app/views/observ/observations/index.html.erb +97 -0
  147. data/app/views/observ/observations/show_generation.html.erb +195 -0
  148. data/app/views/observ/observations/show_span.html.erb +93 -0
  149. data/app/views/observ/prompts/_diff_content.html.erb +16 -0
  150. data/app/views/observ/prompts/_form.html.erb +111 -0
  151. data/app/views/observ/prompts/_new_form.html.erb +102 -0
  152. data/app/views/observ/prompts/_prompt_actions.html.erb +4 -0
  153. data/app/views/observ/prompts/_prompt_content_highlighted.html.erb +4 -0
  154. data/app/views/observ/prompts/_version_actions.html.erb +40 -0
  155. data/app/views/observ/prompts/compare.html.erb +155 -0
  156. data/app/views/observ/prompts/edit.html.erb +17 -0
  157. data/app/views/observ/prompts/index.html.erb +108 -0
  158. data/app/views/observ/prompts/new.html.erb +17 -0
  159. data/app/views/observ/prompts/show.html.erb +138 -0
  160. data/app/views/observ/prompts/versions.html.erb +87 -0
  161. data/app/views/observ/sessions/annotations_drawer.turbo_stream.erb +25 -0
  162. data/app/views/observ/sessions/drawer_test.turbo_stream.erb +49 -0
  163. data/app/views/observ/sessions/index.html.erb +91 -0
  164. data/app/views/observ/sessions/show.html.erb +251 -0
  165. data/app/views/observ/traces/add_to_dataset_drawer.turbo_stream.erb +48 -0
  166. data/app/views/observ/traces/annotations_drawer.turbo_stream.erb +25 -0
  167. data/app/views/observ/traces/index.html.erb +87 -0
  168. data/app/views/observ/traces/show.html.erb +285 -0
  169. data/app/views/observ/traces/text_output_drawer.turbo_stream.erb +48 -0
  170. data/app/views/shared/_drawer.html.erb +26 -0
  171. data/config/routes.rb +80 -0
  172. data/db/migrate/001_create_observ_sessions.rb +21 -0
  173. data/db/migrate/002_create_observ_traces.rb +25 -0
  174. data/db/migrate/003_create_observ_observations.rb +42 -0
  175. data/db/migrate/004_add_message_id_to_observ_traces.rb +7 -0
  176. data/db/migrate/005_create_observ_prompts.rb +21 -0
  177. data/db/migrate/006_fix_prompt_config_strings.rb +23 -0
  178. data/db/migrate/007_create_observ_annotations.rb +12 -0
  179. data/db/migrate/009_add_prompt_fields_to_observ_chats.rb +11 -0
  180. data/db/migrate/010_create_observ_datasets.rb +15 -0
  181. data/db/migrate/011_create_observ_dataset_items.rb +17 -0
  182. data/db/migrate/012_create_observ_dataset_runs.rb +22 -0
  183. data/db/migrate/013_create_observ_dataset_run_items.rb +16 -0
  184. data/db/migrate/014_create_observ_scores.rb +26 -0
  185. data/lib/generators/observ/add_phase_tracking/add_phase_tracking_generator.rb +150 -0
  186. data/lib/generators/observ/add_phase_tracking/templates/migration.rb.tt +6 -0
  187. data/lib/generators/observ/install/USAGE +27 -0
  188. data/lib/generators/observ/install/install_generator.rb +270 -0
  189. data/lib/generators/observ/install_chat/install_chat_generator.rb +313 -0
  190. data/lib/generators/observ/install_chat/templates/agents/base_agent.rb.tt +147 -0
  191. data/lib/generators/observ/install_chat/templates/agents/simple_agent.rb.tt +55 -0
  192. data/lib/generators/observ/install_chat/templates/concerns/observ_chat_enhancements.rb.tt +34 -0
  193. data/lib/generators/observ/install_chat/templates/concerns/observ_message_enhancements.rb.tt +18 -0
  194. data/lib/generators/observ/install_chat/templates/initializers/observability.rb.tt +20 -0
  195. data/lib/generators/observ/install_chat/templates/jobs/chat_response_job.rb.tt +56 -0
  196. data/lib/generators/observ/install_chat/templates/migrations/add_agent_class_name.rb.tt +6 -0
  197. data/lib/generators/observ/install_chat/templates/migrations/add_observability_session_id.rb.tt +6 -0
  198. data/lib/generators/observ/install_chat/templates/tools/think_tool.rb.tt +29 -0
  199. data/lib/generators/observ/install_chat/templates/views/messages/_content.html.erb.tt +1 -0
  200. data/lib/observ/asset_installer.rb +130 -0
  201. data/lib/observ/asset_syncer.rb +104 -0
  202. data/lib/observ/configuration.rb +108 -0
  203. data/lib/observ/engine.rb +50 -0
  204. data/lib/observ/index_file_generator.rb +142 -0
  205. data/lib/observ/instrumenter/ruby_llm.rb +6 -0
  206. data/lib/observ/version.rb +3 -0
  207. data/lib/observ.rb +29 -0
  208. data/lib/tasks/observ_tasks.rake +75 -0
  209. metadata +453 -0
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Observ
4
+ class Session < ApplicationRecord
5
+ self.table_name = "observ_sessions"
6
+
7
+ has_many :traces, class_name: "Observ::Trace",
8
+ foreign_key: :observ_session_id, dependent: :destroy, inverse_of: :observ_session
9
+ has_many :annotations, as: :annotatable, dependent: :destroy
10
+
11
+ validates :session_id, presence: true, uniqueness: true
12
+ validates :start_time, presence: true
13
+
14
+ before_validation :set_session_id, on: :create
15
+ before_validation :set_start_time, on: :create
16
+
17
+ def create_trace(name: nil, input: nil, metadata: {}, tags: [])
18
+ traces.create!(
19
+ trace_id: SecureRandom.uuid,
20
+ name: name || "chat_exchange",
21
+ input: input.is_a?(String) ? input : input.to_json,
22
+ metadata: metadata,
23
+ tags: tags,
24
+ user_id: user_id,
25
+ start_time: Time.current
26
+ )
27
+ end
28
+
29
+ def finalize
30
+ update!(end_time: Time.current)
31
+ update_aggregated_metrics
32
+ end
33
+
34
+ def duration_s
35
+ return nil unless end_time
36
+ (end_time - start_time).round(1)
37
+ end
38
+
39
+ def average_llm_latency_ms
40
+ generations = Observ::Generation.joins(trace: :observ_session)
41
+ .where(observ_sessions: { id: id })
42
+ .where.not(end_time: nil)
43
+ return 0 if generations.empty?
44
+
45
+ total_duration = generations.sum do |g|
46
+ ((g.end_time - g.start_time) * 1000).round(2)
47
+ end
48
+ (total_duration / generations.count).round(0)
49
+ end
50
+
51
+ def session_metrics
52
+ if end_time.nil?
53
+ {
54
+ session_id: session_id,
55
+ total_traces: traces.count,
56
+ total_llm_calls: generation_count,
57
+ total_tokens: traces.sum(:total_tokens),
58
+ total_cost: traces.sum(:total_cost).to_f,
59
+ total_llm_duration_ms: calculate_total_llm_duration,
60
+ average_llm_latency_ms: average_llm_latency_ms,
61
+ duration_s: duration_s
62
+ }
63
+ else
64
+ {
65
+ session_id: session_id,
66
+ total_traces: total_traces_count,
67
+ total_llm_calls: total_llm_calls_count,
68
+ total_tokens: total_tokens,
69
+ total_cost: total_cost.to_f,
70
+ total_llm_duration_ms: total_llm_duration_ms,
71
+ average_llm_latency_ms: average_llm_latency_ms,
72
+ duration_s: duration_s
73
+ }
74
+ end
75
+ end
76
+
77
+ def update_aggregated_metrics
78
+ update_columns(
79
+ total_traces_count: traces.count,
80
+ total_llm_calls_count: generation_count,
81
+ total_tokens: traces.sum(:total_tokens),
82
+ total_cost: traces.sum(:total_cost),
83
+ total_llm_duration_ms: calculate_total_llm_duration
84
+ )
85
+ end
86
+
87
+ def update_metadata(new_metadata)
88
+ self.metadata = (self.metadata || {}).merge(new_metadata)
89
+ save
90
+ end
91
+
92
+ def instrument_chat(chat_instance, context: {})
93
+ instrumenter = Observ::ChatInstrumenter.new(
94
+ self,
95
+ chat_instance,
96
+ context: context
97
+ )
98
+ instrumenter.instrument!
99
+ instrumenter
100
+ end
101
+
102
+ def chat
103
+ @chat ||= ::Chat.find_by(observability_session_id: session_id) if defined?(::Chat)
104
+ end
105
+
106
+ private
107
+
108
+ def set_session_id
109
+ self.session_id ||= SecureRandom.uuid
110
+ end
111
+
112
+ def set_start_time
113
+ self.start_time ||= Time.current
114
+ end
115
+
116
+ def generation_count
117
+ Observ::Generation.joins(:trace)
118
+ .where(observ_traces: { observ_session_id: id })
119
+ .count
120
+ end
121
+
122
+ def calculate_total_llm_duration
123
+ Observ::Generation.joins(:trace)
124
+ .where(observ_traces: { observ_session_id: id })
125
+ .where.not(end_time: nil)
126
+ .sum do |g|
127
+ ((g.end_time - g.start_time) * 1000).round(2)
128
+ end || 0
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Observ
4
+ class Span < Observation
5
+ def finalize(output: nil, status_message: nil)
6
+ update!(
7
+ output: output.is_a?(String) ? output : output.to_json,
8
+ end_time: Time.current,
9
+ status_message: status_message
10
+ )
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Observ
4
+ class Trace < ApplicationRecord
5
+ self.table_name = "observ_traces"
6
+
7
+ belongs_to :observ_session, class_name: "Observ::Session", inverse_of: :traces
8
+ has_many :observations, class_name: "Observ::Observation",
9
+ foreign_key: :observ_trace_id, dependent: :destroy, inverse_of: :trace
10
+ belongs_to :message, optional: true
11
+ has_many :annotations, as: :annotatable, dependent: :destroy
12
+
13
+ validates :trace_id, presence: true, uniqueness: true
14
+ validates :start_time, presence: true
15
+
16
+ after_save :update_session_metrics, if: :saved_change_to_total_cost_or_total_tokens?
17
+
18
+ def create_generation(name: "llm_generation", model: nil, metadata: {}, **options)
19
+ observations.create!(
20
+ observation_id: SecureRandom.uuid,
21
+ type: "Observ::Generation",
22
+ name: name,
23
+ model: model,
24
+ metadata: metadata,
25
+ start_time: Time.current,
26
+ **options.slice(:model_parameters, :prompt_name, :prompt_version, :parent_observation_id)
27
+ )
28
+ end
29
+
30
+ def create_span(name:, input: nil, metadata: {}, parent_observation_id: nil)
31
+ observations.create!(
32
+ observation_id: SecureRandom.uuid,
33
+ type: "Observ::Span",
34
+ name: name,
35
+ input: input.is_a?(String) ? input : input.to_json,
36
+ metadata: metadata,
37
+ parent_observation_id: parent_observation_id,
38
+ start_time: Time.current
39
+ )
40
+ end
41
+
42
+ def finalize(output: nil, metadata: {})
43
+ merged_metadata = (self.metadata || {}).merge(metadata)
44
+ update!(
45
+ output: output.is_a?(String) ? output : output.to_json,
46
+ metadata: merged_metadata,
47
+ end_time: Time.current
48
+ )
49
+ update_aggregated_metrics
50
+ end
51
+
52
+ def finalize_with_response(response)
53
+ if response.is_a?(String)
54
+ finalize(output: response)
55
+ return response
56
+ end
57
+
58
+ response_metadata = extract_response_metadata(response)
59
+
60
+ finalize(
61
+ output: response.respond_to?(:content) ? response.content : response.to_s,
62
+ metadata: response_metadata
63
+ )
64
+
65
+ response.respond_to?(:content) ? response.content : response.to_s
66
+ end
67
+
68
+ def duration_ms
69
+ return nil unless end_time
70
+ ((end_time - start_time) * 1000).round(2)
71
+ end
72
+
73
+ def update_aggregated_metrics
74
+ new_total_cost = generations.sum(:cost_usd) || 0.0
75
+
76
+ # Database-agnostic token calculation
77
+ new_total_tokens = generations.sum do |gen|
78
+ gen.usage&.dig("total_tokens") || 0
79
+ end
80
+
81
+ update_columns(
82
+ total_cost: new_total_cost,
83
+ total_tokens: new_total_tokens
84
+ )
85
+ end
86
+
87
+ def generations
88
+ observations.where(type: "Observ::Generation")
89
+ end
90
+
91
+ def spans
92
+ observations.where(type: "Observ::Span")
93
+ end
94
+
95
+ def models_used
96
+ generations.where.not(model: nil).distinct.pluck(:model)
97
+ end
98
+
99
+ private
100
+
101
+ def extract_response_metadata(response)
102
+ metadata = {}
103
+
104
+ metadata[:model_id] = response.model_id if response.respond_to?(:model_id)
105
+ metadata[:input_tokens] = response.input_tokens if response.respond_to?(:input_tokens)
106
+
107
+ if response.respond_to?(:output_tokens)
108
+ metadata[:output_tokens] = response.output_tokens
109
+ metadata[:total_tokens] = (response.input_tokens || 0) + response.output_tokens
110
+ end
111
+
112
+ metadata[:role] = response.role if response.respond_to?(:role)
113
+
114
+ if response.respond_to?(:tool_calls) && response.tool_calls&.any?
115
+ metadata[:tool_calls_count] = response.tool_calls.count
116
+ metadata[:tool_calls] = response.tool_calls.map do |tc|
117
+ {
118
+ name: tc.respond_to?(:name) ? tc.name : nil,
119
+ arguments: tc.respond_to?(:arguments) ? tc.arguments : nil
120
+ }.compact
121
+ end
122
+ end
123
+
124
+ metadata
125
+ end
126
+
127
+ def saved_change_to_total_cost_or_total_tokens?
128
+ saved_change_to_total_cost? || saved_change_to_total_tokens?
129
+ end
130
+
131
+ def update_session_metrics
132
+ observ_session&.update_aggregated_metrics
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,59 @@
1
+ # Presenter for agent selection in the Observ domain
2
+ #
3
+ # This presenter receives agents via dependency injection and formats them
4
+ # for display in select dropdowns. It has NO knowledge of:
5
+ # - How agents are discovered
6
+ # - Where agent files are located
7
+ # - The BaseAgent class hierarchy
8
+ #
9
+ # It only knows about the AgentSelectable interface that agents must implement.
10
+ #
11
+ # Usage with dependency injection (recommended):
12
+ # agents = Observ::AgentProvider.all_agents
13
+ # presenter = Observ::AgentSelectPresenter.new(agents: agents)
14
+ # presenter.options
15
+ # # => [["Default Agent", ""], ["Deep Research", "DeepResearchAgent"], ...]
16
+ #
17
+ # Usage with convenience class method:
18
+ # Observ::AgentSelectPresenter.options
19
+ # # => [["Default Agent", ""], ["Deep Research", "DeepResearchAgent"], ...]
20
+ module Observ
21
+ class AgentSelectPresenter
22
+ attr_reader :agents
23
+
24
+ # Initialize with dependency injection
25
+ # @param agents [Array<Class>] array of agent classes implementing AgentSelectable
26
+ def initialize(agents:)
27
+ @agents = agents
28
+ end
29
+
30
+ # Returns formatted options for Rails select helper
31
+ # Format: [[display_name, identifier], ...]
32
+ # @return [Array<Array<String>>] options array for select dropdown
33
+ def options
34
+ [ default_option ] + agent_options
35
+ end
36
+
37
+ # Convenience class method that injects agents from Observ::AgentProvider
38
+ # Useful when you don't need to filter or transform agents
39
+ # @param agents [Array<Class>] optional array of agents (defaults to Observ::AgentProvider.all_agents)
40
+ # @return [Array<Array<String>>] options array for select dropdown
41
+ def self.options(agents: Observ::AgentProvider.all_agents)
42
+ new(agents: agents).options
43
+ end
44
+
45
+ private
46
+
47
+ # Default option for "no agent selected" state
48
+ # @return [Array<String>] the default option
49
+ def default_option
50
+ [ "Default Agent", "" ]
51
+ end
52
+
53
+ # Maps agents to [display_name, identifier] pairs
54
+ # @return [Array<Array<String>>] agent options
55
+ def agent_options
56
+ agents.map { |agent| [ agent.display_name, agent.agent_identifier ] }
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Observ
4
+ # Generic service for executing any agent against an input
5
+ #
6
+ # This service encapsulates the RubyLLM chat configuration and execution pattern,
7
+ # providing a unified way to run agents for dataset evaluations or other purposes.
8
+ #
9
+ # The service:
10
+ # - Creates a RubyLLM chat with the agent's model
11
+ # - Applies system prompt, schema, and model parameters
12
+ # - Optionally instruments the chat for observability
13
+ # - Handles both simple text input and structured context hashes
14
+ #
15
+ # Usage:
16
+ # # Basic usage
17
+ # executor = Observ::AgentExecutorService.new(LanguageDetectionAgent)
18
+ # result = executor.call("Hello, how are you?")
19
+ #
20
+ # # With observability
21
+ # session = Observ::Session.create!(user_id: "user_123")
22
+ # executor = Observ::AgentExecutorService.new(
23
+ # LanguageDetectionAgent,
24
+ # observability_session: session
25
+ # )
26
+ # result = executor.call("Bonjour!")
27
+ #
28
+ # # With context hash (for agents that implement build_user_prompt)
29
+ # executor = Observ::AgentExecutorService.new(CharacterGeneratorAgent)
30
+ # result = executor.call(genre: "fantasy", title: "Dragon Quest")
31
+ #
32
+ class AgentExecutorService
33
+ class ExecutionError < StandardError; end
34
+ class RubyLLMNotAvailableError < ExecutionError; end
35
+
36
+ attr_reader :agent_class, :observability_session
37
+
38
+ # Initialize the executor
39
+ #
40
+ # @param agent_class [Class] The agent class to execute (must respond to model, system_prompt)
41
+ # @param observability_session [Observ::Session, nil] Optional session for tracing
42
+ # @param context [Hash] Additional context metadata for tracing
43
+ def initialize(agent_class, observability_session: nil, context: {})
44
+ @agent_class = agent_class
45
+ @observability_session = observability_session
46
+ @context = context
47
+
48
+ validate_ruby_llm_available!
49
+ validate_agent_class!
50
+ end
51
+
52
+ # Execute the agent with the given input
53
+ #
54
+ # @param input [String, Hash] The input text or context hash
55
+ # @return [Hash, String] The agent's response (structured if schema defined, string otherwise)
56
+ # @raise [ExecutionError] If the agent execution fails
57
+ def call(input)
58
+ chat = build_chat
59
+ configure_chat(chat)
60
+ instrument_chat(chat) if observability_session
61
+
62
+ user_prompt = build_user_prompt(input)
63
+ response = chat.ask(user_prompt)
64
+
65
+ normalize_response(response.content)
66
+ rescue StandardError => e
67
+ raise ExecutionError, "Agent execution failed: #{e.message}"
68
+ end
69
+
70
+ private
71
+
72
+ def validate_ruby_llm_available!
73
+ return if defined?(RubyLLM)
74
+
75
+ raise RubyLLMNotAvailableError,
76
+ "RubyLLM is not available. Please ensure the ruby_llm gem is installed and configured."
77
+ end
78
+
79
+ def validate_agent_class!
80
+ unless agent_class.respond_to?(:model) && agent_class.respond_to?(:system_prompt)
81
+ raise ArgumentError,
82
+ "Agent class must respond to :model and :system_prompt. Got: #{agent_class.name}"
83
+ end
84
+ end
85
+
86
+ def build_chat
87
+ RubyLLM.chat(model: agent_class.model)
88
+ end
89
+
90
+ def configure_chat(chat)
91
+ # Apply system prompt
92
+ chat.with_instructions(agent_class.system_prompt)
93
+
94
+ # Apply schema for structured output if agent defines one
95
+ if agent_class.respond_to?(:schema) && agent_class.schema
96
+ chat.with_schema(agent_class.schema)
97
+ end
98
+
99
+ # Apply model parameters (temperature, max_tokens, etc.)
100
+ if agent_class.respond_to?(:model_parameters)
101
+ params = agent_class.model_parameters
102
+ chat.with_params(**params) if params.any?
103
+ end
104
+
105
+ chat
106
+ end
107
+
108
+ def instrument_chat(chat)
109
+ return unless observability_session
110
+
111
+ instrumenter = Observ::ChatInstrumenter.new(
112
+ observability_session,
113
+ chat,
114
+ context: default_context.merge(@context)
115
+ )
116
+ instrumenter.instrument!
117
+ end
118
+
119
+ def default_context
120
+ {
121
+ service: "agent_executor",
122
+ agent_class: agent_class
123
+ }
124
+ end
125
+
126
+ # Build the user prompt from input
127
+ #
128
+ # If the agent implements build_user_prompt, use it with the input as context.
129
+ # Otherwise, extract text from the input directly.
130
+ def build_user_prompt(input)
131
+ if agent_class.respond_to?(:build_user_prompt)
132
+ context = input.is_a?(Hash) ? input : { text: input }
133
+ agent_class.build_user_prompt(context)
134
+ else
135
+ extract_text_input(input)
136
+ end
137
+ end
138
+
139
+ def extract_text_input(input)
140
+ case input
141
+ when String
142
+ input
143
+ when Hash
144
+ # Try common keys for text content
145
+ input[:text] || input["text"] ||
146
+ input[:content] || input["content"] ||
147
+ input[:input] || input["input"] ||
148
+ input.to_json
149
+ else
150
+ input.to_s
151
+ end
152
+ end
153
+
154
+ def normalize_response(content)
155
+ case content
156
+ when Hash
157
+ # Symbolize keys for consistent access
158
+ deep_symbolize_keys(content)
159
+ when String
160
+ content
161
+ else
162
+ content.respond_to?(:to_h) ? deep_symbolize_keys(content.to_h) : content
163
+ end
164
+ end
165
+
166
+ def deep_symbolize_keys(hash)
167
+ hash.each_with_object({}) do |(key, value), result|
168
+ new_key = key.respond_to?(:to_sym) ? key.to_sym : key
169
+ new_value = value.is_a?(Hash) ? deep_symbolize_keys(value) : value
170
+ result[new_key] = new_value
171
+ end
172
+ end
173
+ end
174
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Observ
4
+ # Service for discovering and providing available agents
5
+ # This is the ONLY class that knows how to discover agents in the application
6
+ #
7
+ # Responsibilities:
8
+ # - Loading agent files in development mode (via Zeitwerk)
9
+ # - Discovering all agents that implement Observ::AgentSelectable
10
+ # - Sorting and filtering agents
11
+ #
12
+ # The Observ domain queries this service to get available agents,
13
+ # maintaining clean separation between domains.
14
+ #
15
+ # Configuration:
16
+ # You can customize the agent discovery path via configuration:
17
+ #
18
+ # Observ.configure do |config|
19
+ # config.agent_path = Rails.root.join("lib", "my_agents")
20
+ # end
21
+ #
22
+ # Usage:
23
+ # agents = Observ::AgentProvider.all_agents
24
+ # # => [LanguageDetectionAgent, MoodDetectionAgent, ...]
25
+ class AgentProvider
26
+ class << self
27
+ # Returns all available agents that implement the Observ::AgentSelectable interface
28
+ # Agents are sorted alphabetically by display_name
29
+ #
30
+ # @return [Array<Class>] array of agent classes
31
+ def all_agents
32
+ ensure_agents_loaded
33
+
34
+ return [] unless defined?(::BaseAgent)
35
+
36
+ ::BaseAgent.descendants
37
+ .select { |agent_class| agent_class.included_modules.include?(Observ::AgentSelectable) }
38
+ .sort_by(&:display_name)
39
+ end
40
+
41
+ private
42
+
43
+ # Ensures all agent files are loaded in development mode
44
+ # Uses Zeitwerk's eager_load_dir for thread-safe, Rails-idiomatic loading
45
+ # In production, eager loading handles this automatically (this becomes a no-op)
46
+ def ensure_agents_loaded
47
+ return if Rails.application.config.eager_load
48
+
49
+ agent_path = Observ.config.agent_path || default_agent_path
50
+ Rails.autoloaders.main.eager_load_dir(agent_path) if agent_path.exist?
51
+ end
52
+
53
+ # Default path where agents are located
54
+ # @return [Pathname] the default agent path
55
+ def default_agent_path
56
+ Rails.root.join("app", "agents")
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Observ
4
+ # Service for providing agent selection options in the Observ domain
5
+ #
6
+ # This service encapsulates the workflow of:
7
+ # 1. Discovering available agents (via Observ::AgentProvider)
8
+ # 2. Formatting them for UI presentation (via AgentSelectPresenter)
9
+ #
10
+ # This service acts as the single entry point for agent selection,
11
+ # hiding the complexity of agent discovery and presentation from
12
+ # controllers and views.
13
+ #
14
+ # Usage in controllers:
15
+ # @agent_select_options = Observ::AgentSelectionService.options
16
+ #
17
+ # Usage in helpers:
18
+ # def agent_select_options
19
+ # Observ::AgentSelectionService.options
20
+ # end
21
+ #
22
+ # Example output:
23
+ # [
24
+ # ["Default Agent", ""],
25
+ # ["Deep Research", "DeepResearchAgent"],
26
+ # ["Simple Research", "ResearchAgent"]
27
+ # ]
28
+ class AgentSelectionService
29
+ class << self
30
+ # Returns formatted select options for agent selection dropdown
31
+ #
32
+ # This method orchestrates the entire agent selection workflow:
33
+ # - Discovers all available agents
34
+ # - Formats them for use in Rails select helpers
35
+ #
36
+ # @return [Array<Array<String>>] options array for Rails select helper
37
+ # Format: [["Display Name", "ClassName"], ...]
38
+ def options
39
+ AgentSelectPresenter.options(agents: Observ::AgentProvider.all_agents)
40
+ end
41
+
42
+ # Returns all available agents (pass-through to Observ::AgentProvider)
43
+ #
44
+ # Useful when you need the raw agent classes instead of formatted options.
45
+ # For most UI purposes, prefer using .options instead.
46
+ #
47
+ # @return [Array<Class>] array of agent classes implementing Observ::AgentSelectable
48
+ def all_agents
49
+ Observ::AgentProvider.all_agents
50
+ end
51
+ end
52
+ end
53
+ end