protege 0.1.0.alpha.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.
Files changed (255) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +24 -0
  3. data/Rakefile +12 -0
  4. data/app/assets/builds/protege.css +1128 -0
  5. data/app/assets/javascripts/protege/attachment_picker.js +163 -0
  6. data/app/assets/javascripts/protege/copy.js +80 -0
  7. data/app/assets/javascripts/protege/forms.js +28 -0
  8. data/app/assets/javascripts/protege/keys.js +212 -0
  9. data/app/assets/javascripts/protege/resize.js +149 -0
  10. data/app/assets/javascripts/protege/theme.js +25 -0
  11. data/app/assets/stylesheets/protege/application.css +15 -0
  12. data/app/assets/tailwind/application.css +137 -0
  13. data/app/controllers/concerns/protege/persona_scoped.rb +22 -0
  14. data/app/controllers/concerns/protege/thread_scoped.rb +22 -0
  15. data/app/controllers/protege/access_rules_controller.rb +72 -0
  16. data/app/controllers/protege/application_controller.rb +18 -0
  17. data/app/controllers/protege/archives_controller.rb +52 -0
  18. data/app/controllers/protege/email_domains_controller.rb +72 -0
  19. data/app/controllers/protege/home_controller.rb +15 -0
  20. data/app/controllers/protege/messages_controller.rb +46 -0
  21. data/app/controllers/protege/mta_domains_syncs_controller.rb +22 -0
  22. data/app/controllers/protege/personas_controller.rb +118 -0
  23. data/app/controllers/protege/replies_controller.rb +64 -0
  24. data/app/controllers/protege/responsibilities_controller.rb +82 -0
  25. data/app/controllers/protege/responsibility_runs_controller.rb +46 -0
  26. data/app/controllers/protege/threads_controller.rb +75 -0
  27. data/app/controllers/protege/traces/searches_controller.rb +19 -0
  28. data/app/controllers/protege/traces_controller.rb +52 -0
  29. data/app/helpers/protege/application_helper.rb +30 -0
  30. data/app/helpers/protege/components/badges_helper.rb +59 -0
  31. data/app/helpers/protege/components/buttons_helper.rb +141 -0
  32. data/app/helpers/protege/components/cards_helper.rb +48 -0
  33. data/app/helpers/protege/components/components_helper.rb +30 -0
  34. data/app/helpers/protege/components/dialogs_helper.rb +36 -0
  35. data/app/helpers/protege/components/forms_helper.rb +223 -0
  36. data/app/helpers/protege/components/hotkeys_helper.rb +60 -0
  37. data/app/helpers/protege/components/icons_helper.rb +41 -0
  38. data/app/helpers/protege/components/introspection_helper.rb +162 -0
  39. data/app/helpers/protege/components/layout_helper.rb +169 -0
  40. data/app/helpers/protege/components/menus_helper.rb +68 -0
  41. data/app/helpers/protege/components/pagination_helper.rb +100 -0
  42. data/app/helpers/protege/components/sidebar_helper.rb +211 -0
  43. data/app/helpers/protege/components/tables_helper.rb +59 -0
  44. data/app/helpers/protege/components/typography_helper.rb +64 -0
  45. data/app/helpers/protege/email_domains_helper.rb +22 -0
  46. data/app/helpers/protege/messages_helper.rb +152 -0
  47. data/app/helpers/protege/personas_helper.rb +90 -0
  48. data/app/helpers/protege/responsibilities_helper.rb +63 -0
  49. data/app/helpers/protege/threads_helper.rb +118 -0
  50. data/app/helpers/protege/trace_helper.rb +22 -0
  51. data/app/hooks/protege/event_logger_hook.rb +109 -0
  52. data/app/jobs/protege/application_job.rb +41 -0
  53. data/app/jobs/protege/console_inference_job.rb +57 -0
  54. data/app/jobs/protege/inference_job.rb +60 -0
  55. data/app/jobs/protege/mail_provisioning_job.rb +21 -0
  56. data/app/jobs/protege/responsibility_job.rb +70 -0
  57. data/app/jobs/protege/responsibility_tick_job.rb +19 -0
  58. data/app/mailboxes/protege/agent_mailbox.rb +123 -0
  59. data/app/mailers/protege/application_mailer.rb +65 -0
  60. data/app/models/concerns/protege/broadcastable_email_domain.rb +48 -0
  61. data/app/models/concerns/protege/broadcastable_message.rb +47 -0
  62. data/app/models/concerns/protege/broadcastable_persona.rb +50 -0
  63. data/app/models/concerns/protege/broadcastable_responsibility.rb +51 -0
  64. data/app/models/concerns/protege/broadcastable_thread.rb +55 -0
  65. data/app/models/concerns/protege/tool_scoped.rb +88 -0
  66. data/app/models/protege/access_rule.rb +50 -0
  67. data/app/models/protege/application_record.rb +18 -0
  68. data/app/models/protege/current.rb +23 -0
  69. data/app/models/protege/email_domain.rb +136 -0
  70. data/app/models/protege/email_thread.rb +81 -0
  71. data/app/models/protege/message.rb +401 -0
  72. data/app/models/protege/persona.rb +229 -0
  73. data/app/models/protege/responsibility.rb +98 -0
  74. data/app/models/protege/responsibility_run.rb +68 -0
  75. data/app/models/protege/tool_use.rb +115 -0
  76. data/app/models/protege/trace.rb +71 -0
  77. data/app/providers/protege/open_router_provider.rb +317 -0
  78. data/app/resolvers/README.md +255 -0
  79. data/app/resolvers/protege/load_file_resolver.rb +86 -0
  80. data/app/resolvers/protege/load_record_resolver.rb +80 -0
  81. data/app/resolvers/protege/load_text_resolver.rb +40 -0
  82. data/app/resolvers/protege/thread_history_resolver.rb +116 -0
  83. data/app/services/protege/introspection_tool_call_presentation.rb +134 -0
  84. data/app/services/protege/message_search.rb +114 -0
  85. data/app/services/protege/trace_search.rb +102 -0
  86. data/app/subscribers/protege/introspection_broadcaster.rb +324 -0
  87. data/app/tools/protege/create_file_tool.rb +109 -0
  88. data/app/tools/protege/read_attachment_tool.rb +101 -0
  89. data/app/tools/protege/search_emails_tool.rb +129 -0
  90. data/app/tools/protege/send_email_tool.rb +240 -0
  91. data/app/tools/protege/web_fetch_tool.rb +224 -0
  92. data/app/tools/protege/web_search_tool.rb +131 -0
  93. data/app/views/layouts/protege/application.html.slim +19 -0
  94. data/app/views/protege/access_rules/create.turbo_stream.slim +6 -0
  95. data/app/views/protege/email_domains/_email_domain_sidebar_item.html.slim +4 -0
  96. data/app/views/protege/email_domains/_form.html.slim +10 -0
  97. data/app/views/protege/email_domains/_sidebar.html.slim +11 -0
  98. data/app/views/protege/email_domains/index.html.slim +6 -0
  99. data/app/views/protege/email_domains/new.html.slim +7 -0
  100. data/app/views/protege/email_domains/show.html.slim +36 -0
  101. data/app/views/protege/home/show.html.slim +26 -0
  102. data/app/views/protege/messages/_message.html.slim +19 -0
  103. data/app/views/protege/messages/_sidebar.html.slim +13 -0
  104. data/app/views/protege/messages/index.html.slim +20 -0
  105. data/app/views/protege/personas/_access_rule.html.slim +7 -0
  106. data/app/views/protege/personas/_access_rule_form.html.slim +8 -0
  107. data/app/views/protege/personas/_access_rules.html.slim +19 -0
  108. data/app/views/protege/personas/_actions.html.slim +15 -0
  109. data/app/views/protege/personas/_form.html.slim +29 -0
  110. data/app/views/protege/personas/_persona_sidebar_item.html.slim +5 -0
  111. data/app/views/protege/personas/_sidebar.html.slim +11 -0
  112. data/app/views/protege/personas/edit.html.slim +7 -0
  113. data/app/views/protege/personas/index.html.slim +6 -0
  114. data/app/views/protege/personas/new.html.slim +7 -0
  115. data/app/views/protege/personas/show.html.slim +37 -0
  116. data/app/views/protege/responsibilities/_form.html.slim +21 -0
  117. data/app/views/protege/responsibilities/_responsibility_sidebar_item.html.slim +5 -0
  118. data/app/views/protege/responsibilities/_sidebar.html.slim +11 -0
  119. data/app/views/protege/responsibilities/edit.html.slim +7 -0
  120. data/app/views/protege/responsibilities/index.html.slim +6 -0
  121. data/app/views/protege/responsibilities/new.html.slim +7 -0
  122. data/app/views/protege/responsibilities/show.html.slim +22 -0
  123. data/app/views/protege/responsibility_runs/show.html.slim +13 -0
  124. data/app/views/protege/shared/_confirm_dialog.html.slim +22 -0
  125. data/app/views/protege/shared/_header.html.slim +14 -0
  126. data/app/views/protege/shared/_hotkeys.html.slim +43 -0
  127. data/app/views/protege/threads/_composer_footer.html.slim +15 -0
  128. data/app/views/protege/threads/_introspection.html.slim +7 -0
  129. data/app/views/protege/threads/_reasoning.html.slim +4 -0
  130. data/app/views/protege/threads/_reply_form.html.slim +7 -0
  131. data/app/views/protege/threads/_run_marker.html.slim +3 -0
  132. data/app/views/protege/threads/_sidebar.html.slim +15 -0
  133. data/app/views/protege/threads/_thread.html.slim +6 -0
  134. data/app/views/protege/threads/_tool_call.html.slim +8 -0
  135. data/app/views/protege/threads/index.html.slim +6 -0
  136. data/app/views/protege/threads/new.html.slim +15 -0
  137. data/app/views/protege/threads/show.html.slim +25 -0
  138. data/app/views/protege/traces/_sidebar.html.slim +15 -0
  139. data/app/views/protege/traces/index.html.slim +11 -0
  140. data/app/views/protege/traces/searches/_sidebar.html.slim +13 -0
  141. data/app/views/protege/traces/searches/show.html.slim +22 -0
  142. data/app/views/protege/traces/show.html.slim +28 -0
  143. data/config/routes.rb +33 -0
  144. data/db/migrate/20260520000001_create_protege_tables.rb +105 -0
  145. data/db/migrate/20260706120130_create_protege_traces.rb +30 -0
  146. data/db/migrate/20260707120000_replace_persona_active_with_archived_at.rb +24 -0
  147. data/db/migrate/20260708120000_add_disabled_tool_ids_to_personas.rb +22 -0
  148. data/lib/generators/protege/extension_naming.rb +38 -0
  149. data/lib/generators/protege/hook/hook_generator.rb +33 -0
  150. data/lib/generators/protege/hook/templates/hook.rb.tt +36 -0
  151. data/lib/generators/protege/install/install_generator.rb +170 -0
  152. data/lib/generators/protege/install/templates/initializer.rb.tt +84 -0
  153. data/lib/generators/protege/persona/persona_generator.rb +35 -0
  154. data/lib/generators/protege/persona/templates/persona.rb.tt +31 -0
  155. data/lib/generators/protege/postfix/postfix_generator.rb +74 -0
  156. data/lib/generators/protege/postfix/templates/deploy/mail/Dockerfile +43 -0
  157. data/lib/generators/protege/postfix/templates/deploy/mail/KeyTable +3 -0
  158. data/lib/generators/protege/postfix/templates/deploy/mail/MAIL.md +135 -0
  159. data/lib/generators/protege/postfix/templates/deploy/mail/README.md +56 -0
  160. data/lib/generators/protege/postfix/templates/deploy/mail/SigningTable +3 -0
  161. data/lib/generators/protege/postfix/templates/deploy/mail/TrustedHosts +8 -0
  162. data/lib/generators/protege/postfix/templates/deploy/mail/entrypoint.sh +118 -0
  163. data/lib/generators/protege/postfix/templates/deploy/mail/kamal-accessory.example.yml +32 -0
  164. data/lib/generators/protege/postfix/templates/deploy/mail/main.cf +68 -0
  165. data/lib/generators/protege/postfix/templates/deploy/mail/opendkim.conf +36 -0
  166. data/lib/generators/protege/postfix/templates/deploy/mail/opendmarc.conf +32 -0
  167. data/lib/generators/protege/postfix/templates/deploy/mail/opendmarc.ignore.hosts +9 -0
  168. data/lib/generators/protege/postfix/templates/deploy/mail/policyd-spf.conf +21 -0
  169. data/lib/generators/protege/postfix/templates/deploy/mail/provision.py +182 -0
  170. data/lib/generators/protege/postfix/templates/deploy/mail/relay-to-app.sh +28 -0
  171. data/lib/generators/protege/postfix/templates/github/workflows/build-mail-image.example.yml +50 -0
  172. data/lib/generators/protege/provider/provider_generator.rb +44 -0
  173. data/lib/generators/protege/provider/templates/provider.rb.tt +50 -0
  174. data/lib/generators/protege/resolver/resolver_generator.rb +34 -0
  175. data/lib/generators/protege/resolver/templates/resolver.rb.tt +31 -0
  176. data/lib/generators/protege/tool/templates/tool.rb.tt +50 -0
  177. data/lib/generators/protege/tool/tool_generator.rb +34 -0
  178. data/lib/protege/configuration.rb +171 -0
  179. data/lib/protege/engine.rb +103 -0
  180. data/lib/protege/errors/bad_request_error.rb +8 -0
  181. data/lib/protege/errors/contract_violation_error.rb +8 -0
  182. data/lib/protege/errors/error.rb +30 -0
  183. data/lib/protege/errors/generic_error.rb +7 -0
  184. data/lib/protege/errors/internal_error.rb +8 -0
  185. data/lib/protege/errors/invalid_model_error.rb +8 -0
  186. data/lib/protege/errors/invalid_tool_result_error.rb +9 -0
  187. data/lib/protege/errors/rate_limited_error.rb +8 -0
  188. data/lib/protege/errors/resolver_file_not_found_error.rb +9 -0
  189. data/lib/protege/errors/response_parse_failed_error.rb +8 -0
  190. data/lib/protege/errors/timeout_error.rb +8 -0
  191. data/lib/protege/errors/tool_not_available_error.rb +12 -0
  192. data/lib/protege/errors/tool_not_found_error.rb +9 -0
  193. data/lib/protege/errors/unauthorized_error.rb +8 -0
  194. data/lib/protege/errors/unavailable_error.rb +8 -0
  195. data/lib/protege/errors/unsupported_capability_error.rb +8 -0
  196. data/lib/protege/events/event.rb +98 -0
  197. data/lib/protege/events/inference_chunk_event.rb +17 -0
  198. data/lib/protege/events/inference_completed_event.rb +12 -0
  199. data/lib/protege/events/inference_failed_event.rb +12 -0
  200. data/lib/protege/events/inference_generated_event.rb +19 -0
  201. data/lib/protege/events/inference_max_turns_reached_event.rb +12 -0
  202. data/lib/protege/events/inference_started_event.rb +11 -0
  203. data/lib/protege/events/loop_run_completed_event.rb +11 -0
  204. data/lib/protege/events/loop_run_enqueued_event.rb +12 -0
  205. data/lib/protege/events/loop_run_failed_event.rb +12 -0
  206. data/lib/protege/events/loop_run_started_event.rb +11 -0
  207. data/lib/protege/events/tool_call_completed_event.rb +12 -0
  208. data/lib/protege/events/tool_call_failed_event.rb +12 -0
  209. data/lib/protege/events/tool_call_started_event.rb +11 -0
  210. data/lib/protege/events/tool_calls_received_event.rb +11 -0
  211. data/lib/protege/extensions/hook.rb +13 -0
  212. data/lib/protege/extensions/hook_mixin.rb +90 -0
  213. data/lib/protege/extensions/provider.rb +12 -0
  214. data/lib/protege/extensions/provider_mixin.rb +124 -0
  215. data/lib/protege/extensions/resolver.rb +12 -0
  216. data/lib/protege/extensions/resolver_mixin.rb +58 -0
  217. data/lib/protege/extensions/tool.rb +12 -0
  218. data/lib/protege/extensions/tool_mixin.rb +217 -0
  219. data/lib/protege/gateway/access_control.rb +66 -0
  220. data/lib/protege/gateway/access_policy.rb +94 -0
  221. data/lib/protege/gateway/attachment_policy.rb +115 -0
  222. data/lib/protege/gateway/mail/address.rb +145 -0
  223. data/lib/protege/gateway/mail/attachment.rb +17 -0
  224. data/lib/protege/gateway/mail/message_id.rb +104 -0
  225. data/lib/protege/gateway/mail/outbound.rb +181 -0
  226. data/lib/protege/gateway/mail/references.rb +107 -0
  227. data/lib/protege/gateway.rb +398 -0
  228. data/lib/protege/inference/provider/request.rb +110 -0
  229. data/lib/protege/inference/provider/response.rb +50 -0
  230. data/lib/protege/inference/provider/tool_call.rb +52 -0
  231. data/lib/protege/inference.rb +121 -0
  232. data/lib/protege/loop/schedule.rb +66 -0
  233. data/lib/protege/loop/scheduler.rb +42 -0
  234. data/lib/protege/loop.rb +37 -0
  235. data/lib/protege/orchestrator/context.rb +65 -0
  236. data/lib/protege/orchestrator/harness.rb +305 -0
  237. data/lib/protege/orchestrator/reply_context.rb +21 -0
  238. data/lib/protege/orchestrator/reply_harness.rb +68 -0
  239. data/lib/protege/orchestrator/resolver_chain.rb +93 -0
  240. data/lib/protege/orchestrator/responsibility_context.rb +22 -0
  241. data/lib/protege/orchestrator/responsibility_harness.rb +60 -0
  242. data/lib/protege/orchestrator.rb +37 -0
  243. data/lib/protege/subscribers/hook_dispatcher.rb +140 -0
  244. data/lib/protege/subscribers/tracing.rb +65 -0
  245. data/lib/protege/types/document_part.rb +13 -0
  246. data/lib/protege/types/image_part.rb +11 -0
  247. data/lib/protege/types/model_message.rb +40 -0
  248. data/lib/protege/types/result.rb +113 -0
  249. data/lib/protege/types/stored_file.rb +127 -0
  250. data/lib/protege/types/text_part.rb +8 -0
  251. data/lib/protege/types/tool_result.rb +16 -0
  252. data/lib/protege/version.rb +6 -0
  253. data/lib/protege.rb +47 -0
  254. data/lib/tasks/protege_tasks.rake +16 -0
  255. metadata +494 -0
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # A persona's standing, cron-scheduled duty — the record behind the Loop layer (the "L" of LOGI).
5
+ #
6
+ # Where an inbound email drives the reactive path, a responsibility drives the *proactive* one: on
7
+ # its +schedule+ (a 5-field cron string) the Loop scheduler runs the owning persona's
8
+ # +responsibility_resolvers+ chain, seeded with this responsibility's +instructions+ as the opening user
9
+ # turn. The persona decides what to do from there (typically sending mail via +send_email+).
10
+ #
11
+ # Responsibilities are lean, dashboard-managed records — not a developer subclass; the per-persona
12
+ # resolver chain lives on the +Persona+, so a responsibility is just "which persona, what task, how
13
+ # often." Each execution is tracked by a +ResponsibilityRun+.
14
+ class Responsibility < ApplicationRecord
15
+ self.table_name = 'protege_responsibilities'
16
+
17
+ # ── Concerns ──────────────────────────────────────────────────────────────
18
+
19
+ # Keep the persona's Responsibilities sidebar live (append/replace/remove).
20
+ include BroadcastableResponsibility
21
+
22
+ # ── Associations ──────────────────────────────────────────────────────────
23
+
24
+ # The persona that carries out this duty; its +responsibility_resolvers+ chain assembles context
25
+ # and its identity is the enforced +From+ on any mail the run sends.
26
+ belongs_to :persona, class_name: 'Protege::Persona'
27
+
28
+ # Every execution of this responsibility, newest interesting for the dashboard; destroyed with it.
29
+ has_many :responsibility_runs,
30
+ class_name: 'Protege::ResponsibilityRun',
31
+ dependent: :destroy,
32
+ inverse_of: :responsibility
33
+
34
+ # ── Validations ─────────────────────────────────────────────────────────
35
+
36
+ with_options presence: true do
37
+ validates :name
38
+ validates :instructions
39
+ validates :schedule
40
+ end
41
+
42
+ validate :schedule_must_be_valid_cron
43
+
44
+ # ── Scopes ────────────────────────────────────────────────────────────────
45
+
46
+ default_scope { order(:name) }
47
+
48
+ # Only responsibilities currently enabled to fire.
49
+ scope :active, -> { where(active: true) }
50
+
51
+ # Only responsibilities whose persona is active (not archived) — archiving a persona stops its
52
+ # scheduled runs. The scheduler intersects this with {.active}.
53
+ scope :with_active_persona, -> { joins(:persona).merge(Protege::Persona.active) }
54
+
55
+ # ── Instance methods ────────────────────────────────────────────────────
56
+
57
+ # Whether this responsibility should fire at +time+ — active and its cron matches that minute.
58
+ # The scheduler calls this each tick (over the {.with_active_persona} set, so an archived persona's
59
+ # duties are already excluded); there is no precomputed next-run, only minute matching.
60
+ #
61
+ # @param time [Time] the moment to test (the tick's +Time.current+)
62
+ # @return [Boolean] true when due
63
+ def due?(time)
64
+ active? && Loop.schedule_due?(schedule, at: time)
65
+ end
66
+
67
+ # Enqueue a run of this responsibility. Records a +pending+ +ResponsibilityRun+ now — its
68
+ # +created_at+ marks enqueue time, so the later gap to the run's +started_at+ surfaces a backed-up
69
+ # queue — then hands off to +ResponsibilityJob+, which advances it to running and on to
70
+ # completed/failed. No timestamp is taken here: nothing has run yet (the job stamps +last_run_at+).
71
+ #
72
+ # The run's +correlation_id+ is minted here, at birth, and the worker adopts it — so the whole
73
+ # lifecycle (+loop_run_enqueued+ → +started+ → +completed+/+failed+) and the run row share one trace
74
+ # id. +Protege::Current+ is set so the enqueued event carries it.
75
+ #
76
+ # @return [Protege::ResponsibilityRun] the pending run that was enqueued
77
+ def dispatch!
78
+ run = responsibility_runs.create!(status: :pending, correlation_id: SecureRandom.uuid)
79
+ Protege::Current.correlation_id = run.correlation_id
80
+
81
+ LoopRunEnqueuedEvent.emit(name:, persona_name: persona.name)
82
+ ResponsibilityJob.perform_later(run_id: run.id)
83
+ run
84
+ end
85
+
86
+ private
87
+
88
+ # Reject a schedule that is not a parseable cron expression (presence is validated separately, so
89
+ # a blank value is left to that rule).
90
+ #
91
+ # @return [void]
92
+ def schedule_must_be_valid_cron
93
+ return if schedule.blank?
94
+
95
+ errors.add(:schedule, 'is not a valid cron expression') unless Loop.schedule_valid?(schedule)
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # One execution of a +Responsibility+ — the Loop layer's run-state record and audit trail.
5
+ #
6
+ # The scheduler creates a run +pending+ and enqueues a +ResponsibilityJob+; the job marks it
7
+ # +running+ (stamping +started_at+) and then +completed+ or +failed+ (stamping +finished_at+ and, on
8
+ # failure, the error class and message). The dashboard reads these rows to show a responsibility's
9
+ # recent history.
10
+ class ResponsibilityRun < ApplicationRecord
11
+ self.table_name = 'protege_responsibility_runs'
12
+
13
+ # ── Associations ──────────────────────────────────────────────────────────
14
+
15
+ belongs_to :responsibility,
16
+ class_name: 'Protege::Responsibility',
17
+ inverse_of: :responsibility_runs
18
+
19
+ # Tool calls+results the agent recorded during this scheduled run — an audit trail beside the run's
20
+ # own status; destroyed with the run.
21
+ has_many :tool_uses, as: :source, class_name: 'Protege::ToolUse', dependent: :destroy
22
+
23
+ # ── Status ────────────────────────────────────────────────────────────────
24
+
25
+ # Lifecycle of a run. String-backed (matching +AccessRule+'s enum convention) so the stored value
26
+ # reads plainly in the database and dashboard.
27
+ enum :status, {
28
+ pending: 'pending',
29
+ running: 'running',
30
+ completed: 'completed',
31
+ failed: 'failed'
32
+ }
33
+
34
+ # ── Lifecycle ─────────────────────────────────────────────────────────────
35
+
36
+ # Mark this run as running now: stamp +started_at+, snapshot the current correlation id (minted by
37
+ # the job, so the run row links to the events it emits), and keep the responsibility's denormalised
38
+ # +last_run_at+ fresh (it is running, so the responsibility has just run) — mirroring how +Message+
39
+ # keeps its thread's counters current.
40
+ #
41
+ # @return [void]
42
+ def start!
43
+ now = Time.current
44
+ update!(status: :running, started_at: now, correlation_id: Protege::Current.correlation_id)
45
+ responsibility.update!(last_run_at: now)
46
+ end
47
+
48
+ # Mark this run completed, stamping +finished_at+.
49
+ #
50
+ # @return [void]
51
+ def mark_completed!
52
+ update!(status: :completed, finished_at: Time.current)
53
+ end
54
+
55
+ # Mark this run failed, recording the error class and message and stamping +finished_at+.
56
+ #
57
+ # @param error [Exception] the failure to record
58
+ # @return [void]
59
+ def mark_failed!(error)
60
+ update!(
61
+ status: :failed,
62
+ finished_at: Time.current,
63
+ error_class: error.class.name,
64
+ error_message: error.message
65
+ )
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # One tool call the agent made and the result it got back, recorded during a run so a run's tool
5
+ # activity is durable. During a single run the calls/results live only in the in-memory
6
+ # +Provider::Request+; persisting them here gives two things: a reply run's tool use can be replayed by
7
+ # +ThreadHistoryResolver+ on later turns (as real +:assistant+/+:tool+ messages, so the agent remembers
8
+ # what it searched, fetched, or read), and a scheduled run's tool use is captured for audit beside the
9
+ # +ResponsibilityRun+ status trail.
10
+ #
11
+ # The owning record is polymorphic +source+: a +Protege::Message+ for a reply run (the triggering
12
+ # inbound message) or a +Protege::ResponsibilityRun+ for a scheduled run. A row pairs a call with its
13
+ # result (the tool loop produces them 1:1). +run_id+ groups one harness run, +turn_index+ the
14
+ # tool-calling round within it, and +position+ the order within a round (an assistant turn may request
15
+ # several calls at once). +tool_call_id+ correlates the call to its result when history is replayed.
16
+ # +persona_id+ is denormalized for per-persona queries.
17
+ class ToolUse < ApplicationRecord
18
+ # Upper bound on a stored +arguments+/+result+ JSON payload, so a huge tool result (e.g. a large
19
+ # +web_fetch+ body) can't bloat the row. Replay truncates further still (see +#replay_result+).
20
+ STORED_PAYLOAD_LIMIT = 16_000
21
+
22
+ # Marker appended to any payload clipped for length.
23
+ TRUNCATION_MARKER = '…[truncated]'
24
+
25
+ # ── Associations ────────────────────────────────────────────────────────
26
+
27
+ belongs_to :source, polymorphic: true
28
+ belongs_to :persona, class_name: 'Protege::Persona'
29
+
30
+ # ── Validations ─────────────────────────────────────────────────────────
31
+
32
+ with_options presence: true do
33
+ validates :tool_name
34
+ validates :turn_index
35
+ validates :position
36
+ end
37
+
38
+ # ── Scopes ────────────────────────────────────────────────────────────────
39
+
40
+ # Replay order: by run, then round, then call position within the round.
41
+ scope :in_replay_order, -> { order(:run_id, :turn_index, :position) }
42
+
43
+ # ── Instance methods ──────────────────────────────────────────────────────
44
+
45
+ # Rebuild the provider tool call for structured replay.
46
+ #
47
+ # @return [Protege::Inference::Provider::ToolCall] the call as the model originally emitted it
48
+ def to_provider_tool_call
49
+ Protege::Inference::Provider::ToolCall.new(
50
+ id: tool_call_id,
51
+ name: tool_name,
52
+ input: arguments.present? ? JSON.parse(arguments) : {}
53
+ )
54
+ end
55
+
56
+ # The stored result, truncated for replay so past results never dominate the context window.
57
+ #
58
+ # @param limit [Integer] maximum characters of result to replay
59
+ # @return [String] the (possibly truncated) result JSON text
60
+ def replay_result(limit:)
61
+ self.class.clip(result.to_s, limit:)
62
+ end
63
+
64
+ # ── Class methods ───────────────────────────────────────────────────────
65
+
66
+ class << self
67
+ # Record one tool-calling round: one row per call, paired index-for-index with its result (the
68
+ # tool loop builds +tool_calls+ and +tool_results+ in the same order).
69
+ #
70
+ # **Atomic:** the whole round is written in a single transaction, so a serialization or validation
71
+ # failure on one call rolls the whole round back rather than leaving a half-written round (which
72
+ # would replay as an +:assistant+ with unmatched +tool_calls+ and break the next provider request).
73
+ # The transaction wraps **only these inserts** — it is entered after the tools have run and exits
74
+ # before the next inference call, so no provider request is ever made inside an open transaction.
75
+ #
76
+ # **Idempotent:** rows are keyed by +[source, run_id, turn_index, position]+, so re-recording a
77
+ # round (e.g. when the job retries after a transient provider error, replaying the same +run_id+)
78
+ # finds the existing rows instead of duplicating them.
79
+ #
80
+ # @param source [Protege::Message, Protege::ResponsibilityRun] the run's owning record
81
+ # @param persona [Protege::Persona] the persona handling the run
82
+ # @param run_id [String, nil] id grouping this harness run
83
+ # @param turn_index [Integer] the tool-calling round within the run (0-based)
84
+ # @param tool_calls [Array<Protege::Inference::Provider::ToolCall>] the calls the model requested
85
+ # @param tool_results [Array<Protege::ToolResult>] the paired results
86
+ # @return [Array<Protege::ToolUse>] the recorded rows
87
+ def record_round(source:, persona:, run_id:, turn_index:, tool_calls:, tool_results:)
88
+ transaction do
89
+ tool_calls.each_with_index.map do |call, position|
90
+ result = tool_results[position]
91
+ find_or_create_by!(source:, run_id:, turn_index:, position:) do |use|
92
+ use.persona = persona
93
+ use.tool_call_id = call.id
94
+ use.tool_name = call.name
95
+ use.arguments = clip(call.input.to_json, limit: STORED_PAYLOAD_LIMIT)
96
+ use.result = clip(result.result.to_h.to_json, limit: STORED_PAYLOAD_LIMIT)
97
+ use.succeeded = result.result.success?
98
+ end
99
+ end
100
+ end
101
+ end
102
+
103
+ # Clip a string to +limit+ characters, appending {TRUNCATION_MARKER} when it had to be cut.
104
+ #
105
+ # @param text [String] the text to clip
106
+ # @param limit [Integer] the maximum length before truncation
107
+ # @return [String] the original text, or a truncated copy with the marker
108
+ def clip(text, limit:)
109
+ return text if text.length <= limit
110
+
111
+ "#{text[0, limit - TRUNCATION_MARKER.length]}#{TRUNCATION_MARKER}"
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # A durable, self-contained snapshot of one inference turn (one provider API call), captured for later
5
+ # fine-tuning when tracing is enabled. Each row is a training example in the making: the exact
6
+ # +request+ as sent (the wire conversation — resolved system prompt, history, user turn, tool catalog,
7
+ # attachment bytes inline) paired with the +response+ it produced and the inference settings
8
+ # (+model+ + +settings+) that shaped it, frozen at that instant — unlike +ToolUse+, which stores only
9
+ # enough to *replay* against the *current* prompts.
10
+ #
11
+ # Deliberately **isolated and minimal**: no associations, no foreign keys, and no identity columns
12
+ # (persona/provider) — a trace records only what affects training, plus a human review verdict. The
13
+ # table can be truncated or dropped without touching anything else. +correlation_id+ + +turn_index+
14
+ # group the turns of one run for retry-safe dedup. Written only by the tracing subscriber
15
+ # (+Protege::Subscribers::Tracing+); reviewed and labeled in the dashboard.
16
+ class Trace < ApplicationRecord
17
+ # The request/response/settings snapshots are stored as JSON text (SQLite has no native JSON type),
18
+ # the same coder convention +Message+ uses for its address lists.
19
+ with_options coder: JSON do
20
+ serialize :request
21
+ serialize :response
22
+ serialize :settings
23
+ end
24
+
25
+ # Human review verdict on the turn's quality — the label a fine-tuning corpus is filtered by. Backed
26
+ # by strings (matching the engine's enum convention), defaulting to +unlabeled+ until a reviewer acts.
27
+ enum :label, { unlabeled: 'unlabeled', good: 'good', bad: 'bad', neutral: 'neutral' }, default: 'unlabeled'
28
+
29
+ # ── Scopes ────────────────────────────────────────────────────────────────
30
+ #
31
+ # The label enum supplies the per-verdict scopes (+unlabeled+/+good+/+bad+/+neutral+) the review
32
+ # filter selects from; these add ordering and the "any verdict applied" queue.
33
+
34
+ # Newest first — the review view's default order.
35
+ scope :recent, -> { order(created_at: :desc) }
36
+
37
+ # Turns a human has judged, whatever the verdict (the complement of the enum's +unlabeled+ scope).
38
+ scope :reviewed, -> { where.not(label: 'unlabeled') }
39
+
40
+ # ── Instance methods ──────────────────────────────────────────────────────
41
+
42
+ # Apply a human review verdict — the label plus an optional free-text note — stamping the review time
43
+ # so the turn leaves the {unlabeled} queue.
44
+ #
45
+ # @param label [String, Symbol] one of the {label} values
46
+ # @param annotation [String, nil] a free-text reviewer note
47
+ # @return [void]
48
+ def apply_label(label:, annotation: nil)
49
+ update!(label:, annotation:, reviewed_at: Time.current)
50
+ end
51
+
52
+ class << self
53
+ # Record one turn's snapshot. Idempotent on +[correlation_id, turn_index]+ so a retried job (which
54
+ # replays the same run from turn 0) re-snapshots each turn in place rather than duplicating training
55
+ # rows — the same retry-safety +ToolUse.record_round+ provides. When no correlation id is present
56
+ # (a run kind that carries none), rows can't be keyed for dedup, so each call simply inserts.
57
+ #
58
+ # @param correlation_id [String, nil] the id grouping this run's turns
59
+ # @param turn_index [Integer] this generation's turn index within the run
60
+ # @param attributes [Hash] the snapshot columns (model, settings, request, response)
61
+ # @return [Protege::Trace] the recorded row
62
+ def record(correlation_id:, turn_index:, **attributes)
63
+ return create!(correlation_id:, turn_index:, **attributes) if correlation_id.blank?
64
+
65
+ find_or_create_by!(correlation_id:, turn_index:) do |trace|
66
+ trace.assign_attributes(attributes)
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,317 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'faraday'
4
+ require 'net/http'
5
+ require 'uri'
6
+
7
+ module Protege
8
+ # Built-in OpenRouter provider. Hits OpenRouter's OpenAI-compatible /chat/completions endpoint.
9
+ # Model selection lives in the Request model field so one configured OpenRouter instance
10
+ # dispatches across every model OpenRouter routes to.
11
+ #
12
+ # Plugs into Protege by subclassing +Protege::Provider+ and registering under
13
+ # +protege_id :openrouter+, so it satisfies the +Protege::Inference::Provider+ contract
14
+ # (+generate+ and +generate_stream+) that the harness drives. Activated when
15
+ # +config.provider_id = :openrouter+.
16
+ #
17
+ # Auth and endpoint come from this provider's slice of +config.providers+ — +:api_key+ and
18
+ # +:base_url+ under the +:openrouter+ key — resolved lazily on first request, so construction never
19
+ # touches them and the built-in is harmless when unused. The host sets both in the Protege
20
+ # initializer (typically defaulted to ENV vars there; the provider itself does not read ENV).
21
+ class OpenRouterProvider < Protege::Provider
22
+ protege_id :openrouter
23
+
24
+ # Run a blocking, non-streaming completion.
25
+ #
26
+ # @param request [Hash] the request hash (messages + tools; see the providers doc)
27
+ # @return [Hash] the response hash (content + tool_calls + finish_reason)
28
+ # @raise [Protege::Error] on a non-2xx HTTP status
29
+ def generate(request)
30
+ response = http_client.post('chat/completions') do |req|
31
+ req.body = build_payload(request).to_json
32
+ end
33
+
34
+ raise_for_status(response)
35
+ parse_response(JSON.parse(response.body))
36
+ end
37
+
38
+ # Run a streaming completion over SSE.
39
+ #
40
+ # Streams tokens as they arrive: text deltas are yielded to +block+ as
41
+ # +{ type: :text, content: ... }+ hashes, tool-call deltas are accumulated, and the final
42
+ # assembled response hash is returned once the stream closes.
43
+ #
44
+ # @param request [Hash] the request hash (messages + tools; see the providers doc)
45
+ # @yieldparam chunk [Hash] a +{ type: :text, content: String }+ token chunk
46
+ # @return [Hash] the response hash (content + tool_calls + finish_reason)
47
+ def generate_stream(request, &block)
48
+ payload = build_payload(request).merge(stream: true)
49
+ accumulator = { text_parts: [], tool_calls: {}, finish_reason: nil }
50
+
51
+ stream_chat_completion(payload) do |line|
52
+ process_stream_line(line, accumulator, &block)
53
+ end
54
+
55
+ build_stream_response(accumulator)
56
+ end
57
+
58
+ # The sampling settings this provider applies beyond the model — the same knobs merged into the
59
+ # wire payload — surfaced for tracing. Overrides the mixin default so a trace records the
60
+ # generation settings that shaped the output.
61
+ #
62
+ # @return [Hash] the temperature/max-tokens settings in effect (nils dropped)
63
+ def settings
64
+ { temperature:, max_tokens: max_output_tokens }.compact
65
+ end
66
+
67
+ private
68
+
69
+ # The configured API key, resolved lazily here (never at construction, so the unused built-in
70
+ # is harmless). The host sets it in the initializer — typically defaulted to an ENV var.
71
+ #
72
+ # @return [String] the bearer token
73
+ # @raise [Protege::UnauthorizedError] when no key is configured
74
+ def api_key
75
+ options[:api_key] ||
76
+ raise(Protege::UnauthorizedError,
77
+ 'OpenRouter api_key is not configured — set ' \
78
+ 'config.providers[:openrouter][:api_key] in the Protege initializer')
79
+ end
80
+
81
+ # The configured base URL (set in the initializer, typically defaulted to an ENV var).
82
+ #
83
+ # @return [String] the API base URL
84
+ # @raise [Protege::UnauthorizedError] when no base URL is configured
85
+ def base_url
86
+ options[:base_url] ||
87
+ raise(Protege::UnauthorizedError,
88
+ 'OpenRouter base_url is not configured — set ' \
89
+ 'config.providers[:openrouter][:base_url] in the Protege initializer')
90
+ end
91
+
92
+ # The configured sampling temperature, or +nil+ to defer to the model's own default.
93
+ #
94
+ # @return [Float, nil] the temperature
95
+ def temperature
96
+ options[:temperature]
97
+ end
98
+
99
+ # The configured output-token cap, or +nil+ to defer to the model's own default.
100
+ #
101
+ # @return [Integer, nil] the max output tokens
102
+ def max_output_tokens
103
+ options[:max_output_tokens]
104
+ end
105
+
106
+ # This provider's options Hash from the Protege config, keyed by its own +protege_id+
107
+ # (+:openrouter+). Empty when the host hasn't configured this provider.
108
+ #
109
+ # @return [Hash] the +:openrouter+ slice of +config.providers+ (or +{}+)
110
+ def options
111
+ Protege.configuration.provider_options(self.class.id)
112
+ end
113
+
114
+ # Open the SSE connection and yield each complete response line as it arrives.
115
+ #
116
+ # Owns the Net::HTTP lifecycle and the chunk-to-line buffering so the caller only sees whole,
117
+ # stripped lines.
118
+ #
119
+ # @param payload [Hash] the request body (already merged with +stream: true+)
120
+ # @yieldparam line [String] one stripped line of the SSE response body
121
+ # @return [void]
122
+ def stream_chat_completion(payload)
123
+ uri = URI.join("#{base_url}/", 'chat/completions')
124
+ http = build_stream_http(uri)
125
+ post = build_stream_post(uri, payload)
126
+
127
+ http.request(post) do |response|
128
+ buffer = ''
129
+
130
+ response.read_body do |chunk|
131
+ buffer += chunk
132
+
133
+ while (line_end = buffer.index("\n"))
134
+ yield buffer.slice!(0..line_end).strip
135
+ end
136
+ end
137
+ end
138
+ end
139
+
140
+ # Construct the Net::HTTP transport for an SSE request.
141
+ #
142
+ # @param uri [URI] the fully resolved chat-completions endpoint
143
+ # @return [Net::HTTP] the configured (TLS-aware) transport
144
+ def build_stream_http(uri)
145
+ http = Net::HTTP.new(uri.host, uri.port)
146
+ http.use_ssl = uri.scheme == 'https'
147
+ http
148
+ end
149
+
150
+ # Construct the SSE POST request with auth and streaming headers.
151
+ #
152
+ # @param uri [URI] the endpoint whose path the POST targets
153
+ # @param payload [Hash] the JSON-serializable request body
154
+ # @return [Net::HTTP::Post] the prepared POST request
155
+ def build_stream_post(uri, payload)
156
+ post = Net::HTTP::Post.new(uri.path)
157
+ post['Authorization'] = "Bearer #{api_key}"
158
+ post['Content-Type'] = 'application/json'
159
+ post['Accept'] = 'text/event-stream'
160
+ post.body = payload.to_json
161
+ post
162
+ end
163
+
164
+ # Parse one SSE line and fold its delta into the streaming accumulator.
165
+ #
166
+ # Skips keep-alive blanks, the terminal +[DONE]+ sentinel, and non-+data:+ lines. For a valid
167
+ # delta it records the finish reason (first wins), streams text to +block+, and merges any
168
+ # tool-call fragments.
169
+ #
170
+ # @param line [String] one stripped SSE line
171
+ # @param accumulator [Hash] mutable state with +:text_parts+, +:tool_calls+, +:finish_reason+
172
+ # @yieldparam chunk [Hash] a +{ type: :text, content: String }+ token chunk
173
+ # @return [void]
174
+ def process_stream_line(line, accumulator, &)
175
+ return if line.empty? || line == 'data: [DONE]'
176
+ return unless line.start_with?('data: ')
177
+
178
+ data = JSON.parse(line.delete_prefix('data: '))
179
+ delta = data.dig('choices', 0, 'delta')
180
+ return unless delta
181
+
182
+ accumulator[:finish_reason] ||= data.dig('choices', 0, 'finish_reason')
183
+
184
+ accumulate_text(delta, accumulator, &)
185
+ accumulate_tool_calls(delta, accumulator)
186
+ end
187
+
188
+ # Append a text delta to the accumulator and stream it to the caller.
189
+ #
190
+ # @param delta [Hash] the choice delta from one SSE event
191
+ # @param accumulator [Hash] mutable state whose +:text_parts+ is appended to
192
+ # @yieldparam chunk [Hash] a +{ type: :text, content: String }+ token chunk
193
+ # @return [void]
194
+ def accumulate_text(delta, accumulator)
195
+ return unless delta['content']
196
+
197
+ accumulator[:text_parts] << delta['content']
198
+ yield({ type: :text, content: delta['content'] })
199
+ end
200
+
201
+ # Merge tool-call fragments from a delta into the accumulator by index.
202
+ #
203
+ # Tool calls stream incrementally: name and argument strings arrive in pieces across events
204
+ # and are concatenated per +index+; the id is set once seen.
205
+ #
206
+ # @param delta [Hash] the choice delta from one SSE event
207
+ # @param accumulator [Hash] mutable state whose +:tool_calls+ map is updated
208
+ # @return [void]
209
+ def accumulate_tool_calls(delta, accumulator)
210
+ return unless delta['tool_calls']
211
+
212
+ tool_calls = accumulator[:tool_calls]
213
+
214
+ delta['tool_calls'].each do |tc|
215
+ idx = tc['index']
216
+ tool_calls[idx] ||= { 'id' => nil,
217
+ 'function' => { 'name' => '', 'arguments' => '' } }
218
+ tool_calls[idx]['id'] = tc['id'] if tc['id']
219
+ tool_calls[idx]['function']['name'] += tc.dig('function', 'name').to_s
220
+ tool_calls[idx]['function']['arguments'] += tc.dig('function', 'arguments').to_s
221
+ end
222
+ end
223
+
224
+ # Assemble the final +Response+ from accumulated streaming state.
225
+ #
226
+ # @param accumulator [Hash] state with +:text_parts+, +:tool_calls+, +:finish_reason+
227
+ # @return [Hash] the response hash with joined text, the accumulated tool calls, and finish reason
228
+ def build_stream_response(accumulator)
229
+ {
230
+ content: accumulator[:text_parts].join.presence,
231
+ tool_calls: wire_tool_calls(accumulator[:tool_calls].values),
232
+ finish_reason: accumulator[:finish_reason]
233
+ }
234
+ end
235
+
236
+ # Build (and memoize) the Faraday client for non-streaming requests.
237
+ #
238
+ # @return [Faraday::Connection] the configured HTTP client
239
+ def http_client
240
+ @http_client ||= Faraday.new(url: base_url) do |c|
241
+ c.headers['Authorization'] = "Bearer #{api_key}"
242
+ c.headers['Content-Type'] = 'application/json'
243
+ end
244
+ end
245
+
246
+ # Build the OpenRouter chat-completions body: the standard request hash Protege hands in (messages
247
+ # + tools) plus this provider's model and sampling. Nil fields are dropped. Enforces model presence
248
+ # here — a missing model is a misconfiguration that surfaces on first use, not at construction (the
249
+ # mixin's +#model+ reader is intentionally soft).
250
+ #
251
+ # @param request [Hash] the request hash (messages + tools)
252
+ # @return [Hash] the compacted request payload
253
+ # @raise [Protege::ContractViolationError] when no model is configured
254
+ def build_payload(request)
255
+ model.presence ||
256
+ raise(Protege::ContractViolationError,
257
+ 'OpenRouter model is not configured — set ' \
258
+ 'config.providers[:openrouter][:model] in the Protege initializer')
259
+
260
+ request.merge(
261
+ model:,
262
+ temperature:,
263
+ max_tokens: max_output_tokens
264
+ ).compact
265
+ end
266
+
267
+ # Map a non-2xx HTTP status onto the matching +Protege::Error+.
268
+ #
269
+ # @param response [Faraday::Response] the HTTP response to inspect
270
+ # @return [nil] when the status is 2xx
271
+ # @raise [Protege::Error] the error class matching the status code
272
+ def raise_for_status(response)
273
+ case response.status
274
+ when 200..299 then nil
275
+ when 400 then raise Protege::BadRequestError, response.body
276
+ when 401 then raise Protege::UnauthorizedError, response.body
277
+ when 429 then raise Protege::RateLimitedError, response.body
278
+ when 500..599 then raise Protege::UnavailableError, response.body
279
+ else raise Protege::InternalError,
280
+ "HTTP #{response.status}: #{response.body}"
281
+ end
282
+ end
283
+
284
+ # Parse a non-streaming response body into the response hash Protege expects
285
+ # (+{ content:, tool_calls:, finish_reason: }+ — the OpenAI assistant message + finish reason,
286
+ # symbol-keyed with tool-call arguments left as JSON strings).
287
+ #
288
+ # @param body [Hash] the decoded JSON response body
289
+ # @return [Hash] the response hash
290
+ # @raise [Protege::ResponseParseFailedError] when no choice is present
291
+ def parse_response(body)
292
+ choice = body.dig('choices', 0) || raise(Protege::ResponseParseFailedError, body.to_s)
293
+ message = choice.fetch('message')
294
+
295
+ {
296
+ content: message['content'],
297
+ tool_calls: wire_tool_calls(message['tool_calls']),
298
+ finish_reason: choice['finish_reason']
299
+ }
300
+ end
301
+
302
+ # Normalize the API's (string-keyed) tool_calls into the function-call wire shape Protege parses:
303
+ # +{ id:, type: 'function', function: { name:, arguments: <JSON string> } }+.
304
+ #
305
+ # @param raw [Array<Hash>, nil] the message's +tool_calls+ from the API
306
+ # @return [Array<Hash>] the wire tool calls (empty when none)
307
+ def wire_tool_calls(raw)
308
+ Array(raw).map do |tc|
309
+ {
310
+ id: tc['id'],
311
+ type: 'function',
312
+ function: { name: tc.dig('function', 'name'), arguments: tc.dig('function', 'arguments') }
313
+ }
314
+ end
315
+ end
316
+ end
317
+ end