actionagent 1.8.0 → 1.9.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 (168) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/builds/action_agent.css +1 -1
  3. data/app/assets/builds/action_agent.js +76 -73
  4. data/app/assets/builds/action_agent_recorder.js +78 -0
  5. data/app/assets/builds/action_agent_replay.js +226 -0
  6. data/app/controllers/action_agent/api/agent_runs_controller.rb +3 -1
  7. data/app/controllers/action_agent/api/agents_controller.rb +1 -0
  8. data/app/controllers/action_agent/api/api_keys_controller.rb +12 -2
  9. data/app/controllers/action_agent/api/base_controller.rb +63 -2
  10. data/app/controllers/action_agent/api/claude_logins_controller.rb +93 -0
  11. data/app/controllers/action_agent/api/code_sessions_controller.rb +71 -12
  12. data/app/controllers/action_agent/api/dashboard_assistant_controller.rb +2 -2
  13. data/app/controllers/action_agent/api/draft_pull_requests_controller.rb +248 -0
  14. data/app/controllers/action_agent/api/evaluation_fixes_controller.rb +27 -0
  15. data/app/controllers/action_agent/api/evaluations_controller.rb +58 -7
  16. data/app/controllers/action_agent/api/explorations_controller.rb +213 -0
  17. data/app/controllers/action_agent/api/github_app_manifests_controller.rb +125 -0
  18. data/app/controllers/action_agent/api/github_connections_controller.rb +40 -2
  19. data/app/controllers/action_agent/api/github_installations_controller.rb +280 -0
  20. data/app/controllers/action_agent/api/input_requests_controller.rb +77 -0
  21. data/app/controllers/action_agent/api/mcp_controller.rb +46 -12
  22. data/app/controllers/action_agent/api/members_controller.rb +36 -0
  23. data/app/controllers/action_agent/api/project_explorations_controller.rb +80 -0
  24. data/app/controllers/action_agent/api/project_install_pull_requests_controller.rb +305 -0
  25. data/app/controllers/action_agent/api/project_secrets_controller.rb +96 -0
  26. data/app/controllers/action_agent/api/project_setup_controller.rb +151 -0
  27. data/app/controllers/action_agent/api/project_sign_ins_controller.rb +112 -0
  28. data/app/controllers/action_agent/api/projects_controller.rb +512 -0
  29. data/app/controllers/action_agent/api/provider_keys_controller.rb +176 -24
  30. data/app/controllers/action_agent/api/provider_models_controller.rb +37 -15
  31. data/app/controllers/action_agent/api/recording_event_ingest_controller.rb +67 -0
  32. data/app/controllers/action_agent/api/sandbox_browser_tickets_controller.rb +50 -0
  33. data/app/controllers/action_agent/api/sandbox_browsers_controller.rb +65 -0
  34. data/app/controllers/action_agent/api/sandboxes_controller.rb +148 -8
  35. data/app/controllers/action_agent/api/scenario_catalog_entries_controller.rb +188 -0
  36. data/app/controllers/action_agent/api/scenario_catalogs_controller.rb +302 -0
  37. data/app/controllers/action_agent/api/session_recordings_controller.rb +165 -34
  38. data/app/controllers/action_agent/api/sessions_controller.rb +111 -0
  39. data/app/controllers/action_agent/api/telemetry_keys_controller.rb +21 -0
  40. data/app/controllers/action_agent/dashboard_controller.rb +17 -6
  41. data/app/controllers/action_agent/session_player_controller.rb +58 -0
  42. data/app/controllers/concerns/action_agent/api/agent_serialization.rb +1 -0
  43. data/app/controllers/concerns/action_agent/api/mcp_dashboard_tools.rb +627 -20
  44. data/app/controllers/concerns/action_agent/api/project_browser.rb +46 -0
  45. data/app/controllers/concerns/action_agent/api/project_secret_authorization.rb +57 -0
  46. data/app/jobs/action_agent/agent_execution_job.rb +9 -39
  47. data/app/jobs/action_agent/agent_resume_job.rb +84 -0
  48. data/app/jobs/action_agent/code_session_job.rb +39 -10
  49. data/app/jobs/action_agent/draft_pull_request_job.rb +43 -0
  50. data/app/jobs/action_agent/exploration_job.rb +80 -0
  51. data/app/jobs/action_agent/input_request_expiry_job.rb +22 -0
  52. data/app/jobs/action_agent/project_evaluation_job.rb +59 -0
  53. data/app/jobs/action_agent/project_setup_job.rb +21 -0
  54. data/app/jobs/action_agent/sandbox_cleanup_job.rb +20 -3
  55. data/app/jobs/action_agent/sandbox_provision_job.rb +77 -10
  56. data/app/jobs/action_agent/sandbox_run_job.rb +9 -37
  57. data/app/jobs/action_agent/verify_evaluation_fix_job.rb +37 -0
  58. data/app/models/action_agent/agent.rb +72 -26
  59. data/app/models/action_agent/agent_run.rb +134 -23
  60. data/app/models/action_agent/agent_template.rb +35 -1
  61. data/app/models/action_agent/api_key.rb +14 -0
  62. data/app/models/action_agent/catalog_scenario.rb +75 -0
  63. data/app/models/action_agent/code_session.rb +21 -5
  64. data/app/models/action_agent/draft_pull_request.rb +131 -0
  65. data/app/models/action_agent/evaluation.rb +148 -0
  66. data/app/models/action_agent/evaluation_run.rb +143 -53
  67. data/app/models/action_agent/evaluation_scenario_result.rb +15 -3
  68. data/app/models/action_agent/exploration.rb +847 -0
  69. data/app/models/action_agent/github_installation.rb +159 -0
  70. data/app/models/action_agent/input_request.rb +291 -0
  71. data/app/models/action_agent/model_pricing.rb +214 -34
  72. data/app/models/action_agent/project.rb +789 -0
  73. data/app/models/action_agent/project_secret.rb +339 -0
  74. data/app/models/action_agent/provider_key.rb +207 -4
  75. data/app/models/action_agent/recording_event.rb +145 -0
  76. data/app/models/action_agent/recording_snapshot.rb +7 -7
  77. data/app/models/action_agent/sandbox_session.rb +300 -16
  78. data/app/models/action_agent/scenario_catalog.rb +172 -0
  79. data/app/models/action_agent/scenario_product.rb +57 -0
  80. data/app/models/action_agent/scenario_set.rb +138 -0
  81. data/app/models/action_agent/session_recording.rb +121 -14
  82. data/app/models/action_agent/telemetry_trace.rb +74 -19
  83. data/app/models/concerns/action_agent/ownable.rb +84 -13
  84. data/app/serializers/action_agent/evaluation_serializer.rb +41 -10
  85. data/app/serializers/action_agent/input_request_serializer.rb +47 -0
  86. data/app/serializers/action_agent/scenario_catalog_serializer.rb +23 -0
  87. data/app/serializers/action_agent/session_summary_serializer.rb +146 -0
  88. data/app/services/action_agent/agent_execution_service.rb +450 -72
  89. data/app/services/action_agent/agent_scorecard.rb +50 -21
  90. data/app/services/action_agent/agent_sync.rb +5 -2
  91. data/app/services/action_agent/agent_tool_roster.rb +19 -4
  92. data/app/services/action_agent/agent_toolbox.rb +215 -9
  93. data/app/services/action_agent/browser_live_ticket.rb +78 -0
  94. data/app/services/action_agent/browser_sidecar.rb +213 -0
  95. data/app/services/action_agent/browser_sign_in.rb +163 -0
  96. data/app/services/action_agent/browser_tool_redaction.rb +92 -0
  97. data/app/services/action_agent/claude_code_auth.rb +70 -4
  98. data/app/services/action_agent/codex_session_events.rb +37 -0
  99. data/app/services/action_agent/dashboard_assistant_service.rb +24 -10
  100. data/app/services/action_agent/draft_pull_request_publisher.rb +719 -0
  101. data/app/services/action_agent/evaluation_fix.rb +101 -0
  102. data/app/services/action_agent/evaluation_fix_comparison.rb +66 -0
  103. data/app/services/action_agent/evaluation_report_import.rb +84 -5
  104. data/app/services/action_agent/evaluation_run_cost.rb +609 -0
  105. data/app/services/action_agent/evaluation_runner_service.rb +29 -10
  106. data/app/services/action_agent/evaluation_standing.rb +152 -0
  107. data/app/services/action_agent/explorer_execution_service.rb +517 -0
  108. data/app/services/action_agent/github_client.rb +405 -64
  109. data/app/services/action_agent/live_updates.rb +31 -0
  110. data/app/services/action_agent/local_sandbox_backend.rb +1263 -102
  111. data/app/services/action_agent/local_sandbox_claude_login.rb +151 -0
  112. data/app/services/action_agent/mcp_client.rb +6 -2
  113. data/app/services/action_agent/mcp_recording_middleware.rb +104 -145
  114. data/app/services/action_agent/mcp_tool_dispatcher.rb +36 -13
  115. data/app/services/action_agent/mock_sandbox_backend.rb +3 -1
  116. data/app/services/action_agent/playwright_mcp_client.rb +7 -0
  117. data/app/services/action_agent/project_boot_fix.rb +309 -0
  118. data/app/services/action_agent/project_capabilities.rb +200 -0
  119. data/app/services/action_agent/project_install_pull_request.rb +225 -0
  120. data/app/services/action_agent/project_preflight.rb +178 -0
  121. data/app/services/action_agent/project_secret_discovery.rb +142 -0
  122. data/app/services/action_agent/project_setup.rb +429 -0
  123. data/app/services/action_agent/provider_credentials.rb +102 -0
  124. data/app/services/action_agent/recording_event_ingest.rb +181 -0
  125. data/app/services/action_agent/runtime_tool_roster.rb +52 -0
  126. data/app/services/action_agent/sample_data/conference_ticket.rb +516 -0
  127. data/app/services/action_agent/sandbox_boot_spec.rb +558 -0
  128. data/app/services/action_agent/sandbox_browser.rb +299 -0
  129. data/app/services/action_agent/sandbox_browser_driver.rb +85 -0
  130. data/app/services/action_agent/sandbox_credential_paths.rb +26 -0
  131. data/app/services/action_agent/sandbox_mail.rb +103 -0
  132. data/app/services/action_agent/sandbox_manifest.rb +166 -13
  133. data/app/services/action_agent/sandbox_orchestrator.rb +290 -23
  134. data/app/services/action_agent/sandbox_patch.rb +253 -0
  135. data/app/services/action_agent/scenario_catalog_import.rb +255 -0
  136. data/app/services/action_agent/scenario_catalog_repository_import.rb +81 -0
  137. data/app/services/action_agent/scenario_evaluation_runner.rb +121 -17
  138. data/app/services/action_agent/secret_scrubber.rb +33 -4
  139. data/app/services/action_agent/session_index.rb +270 -0
  140. data/app/services/action_agent/session_recording_service.rb +11 -0
  141. data/app/services/action_agent/session_timeline.rb +505 -0
  142. data/app/views/action_agent/github_app_manifests/created.html.erb +55 -0
  143. data/app/views/action_agent/session_player/show.html.erb +19 -0
  144. data/config/routes.rb +153 -2
  145. data/lib/action_agent/claude_login_process.rb +107 -0
  146. data/lib/action_agent/engine.rb +55 -5
  147. data/lib/action_agent/secret_requests.rb +80 -0
  148. data/lib/action_agent/version.rb +1 -1
  149. data/lib/action_agent.rb +513 -22
  150. data/lib/generators/action_agent/install_generator.rb +45 -5
  151. data/lib/generators/action_agent/templates/action_agent.rb.erb +161 -0
  152. data/lib/generators/action_agent/templates/add_code_session_runner.rb.erb +9 -0
  153. data/lib/generators/action_agent/templates/migrations/015_add_provider_key_scope.rb.erb +72 -0
  154. data/lib/generators/action_agent/templates/migrations/016_create_active_agent_input_requests.rb.erb +85 -0
  155. data/lib/generators/action_agent/templates/migrations/017_create_active_agent_recording_events.rb.erb +57 -0
  156. data/lib/generators/action_agent/templates/migrations/018_add_browser_to_active_agent_sandbox_sessions.rb.erb +25 -0
  157. data/lib/generators/action_agent/templates/migrations/019_create_active_agent_projects.rb.erb +79 -0
  158. data/lib/generators/action_agent/templates/migrations/020_add_github_app_installations.rb.erb +62 -0
  159. data/lib/generators/action_agent/templates/migrations/021_create_active_agent_explorations.rb.erb +63 -0
  160. data/lib/generators/action_agent/templates/migrations/023_create_active_agent_draft_pull_requests.rb.erb +75 -0
  161. data/lib/generators/action_agent/templates/migrations/024_add_kind_to_active_agent_project_secrets.rb.erb +14 -0
  162. data/lib/generators/action_agent/templates/migrations/025_create_active_agent_scenario_catalogs.rb.erb +110 -0
  163. data/lib/generators/action_agent/templates/migrations/026_add_evaluation_fixes_to_code_sessions.rb.erb +21 -0
  164. data/lib/tasks/action_agent.rake +33 -0
  165. data/lib/tasks/browser.rake +22 -0
  166. data/lib/tasks/sandbox.rake +1 -1
  167. data/lib/tasks/scenario_catalogs.rake +71 -0
  168. metadata +100 -2
@@ -0,0 +1,253 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "zlib"
5
+
6
+ module ActionAgent
7
+ # Builds diffs and patches from file contents, in Ruby and with no git
8
+ # process: the dialog's preview of a draft pull request, and the patch a
9
+ # user downloads when publishing is not available (see
10
+ # DraftPullRequestPublisher).
11
+ #
12
+ # A change is a Hash:
13
+ #
14
+ # path: relative to the repository root
15
+ # status: "added", "modified" or "deleted"
16
+ # base_mode: the mode before ("100644" or "100755"); nil when added
17
+ # mode: the mode after; nil when deleted
18
+ # base_content: the bytes before; nil when added
19
+ # content: the bytes after; nil when deleted
20
+ #
21
+ # Text is diffed line by line with three lines of context. A binary file
22
+ # (one with a NUL byte in its first 8000, as git decides) is written as a
23
+ # GIT binary patch holding the new content whole, which `git apply` takes.
24
+ module SandboxPatch
25
+ CONTEXT_LINES = 3
26
+ # Past this many differing lines between two versions, a file's diff
27
+ # replaces every line rather than searching for the shortest edit.
28
+ MAX_EDIT_DISTANCE = 2_000
29
+ NULL_ID = "0" * 40
30
+ BINARY_PROBE_BYTES = 8_000
31
+ # The characters git's base-85 encoding uses, in order.
32
+ BASE85 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz!#$%&()*+-;<=>?@^_`{|}~"
33
+ # The author `git am` records for a downloaded patch. The domain is
34
+ # reserved, so nothing is ever sent there.
35
+ PATCH_AUTHOR = "ActiveAgent sandbox <sandbox@activeagent.invalid>"
36
+ LINE_PREFIX = { equal: " ".b, delete: "-".b, insert: "+".b }.freeze
37
+
38
+ module_function
39
+
40
+ # Whether git would treat +content+ as binary.
41
+ def binary?(content)
42
+ content.to_s.b.byteslice(0, BINARY_PROBE_BYTES).include?("\0")
43
+ end
44
+
45
+ # The id git gives +content+ as a blob.
46
+ def blob_id(content)
47
+ content = content.to_s.b
48
+ Digest::SHA1.hexdigest("blob #{content.bytesize}\0".b + content)
49
+ end
50
+
51
+ # +change+ as `git diff` prints one file: the header, mode lines, the
52
+ # index line and the hunks. Binary-encoded.
53
+ def file_diff(change)
54
+ path = change.fetch(:path)
55
+ before = change[:status] == "added" ? nil : change[:base_content].to_s.b
56
+ after = change[:status] == "deleted" ? nil : change[:content].to_s.b
57
+ base_mode = change[:base_mode] || (before && change[:mode]) || "100644"
58
+ mode = change[:mode] || base_mode
59
+
60
+ out = String.new(encoding: Encoding::BINARY)
61
+ out << "diff --git ".b << quote_path("a/#{path}") << " ".b << quote_path("b/#{path}") << "\n".b
62
+ if before.nil?
63
+ out << "new file mode #{mode}\n"
64
+ elsif after.nil?
65
+ out << "deleted file mode #{base_mode}\n"
66
+ elsif base_mode != mode
67
+ out << "old mode #{base_mode}\nnew mode #{mode}\n"
68
+ end
69
+
70
+ old_id = before ? blob_id(before) : NULL_ID
71
+ new_id = after ? blob_id(after) : NULL_ID
72
+ return out if old_id == new_id
73
+
74
+ out << "index #{old_id}..#{new_id}#{" #{mode}" if before && after && base_mode == mode}\n"
75
+ if binary?(before) || binary?(after)
76
+ out << "GIT binary patch\n" << binary_literal(after.to_s) << "\n"
77
+ elsif !(before.to_s.empty? && after.to_s.empty?)
78
+ out << "--- ".b << (before ? quote_path("a/#{path}") : "/dev/null".b) << "\n".b
79
+ out << "+++ ".b << (after ? quote_path("b/#{path}") : "/dev/null".b) << "\n".b
80
+ out << hunks(lines(before), lines(after))
81
+ end
82
+ out
83
+ end
84
+
85
+ # A patch of every change, as `git format-patch` writes one commit:
86
+ # `git am` applies it as a commit titled +subject+, and `git apply`
87
+ # applies its changes to a checkout of +base_commit+.
88
+ def format_patch(changes, subject:, base_commit:, body: nil, date: Time.now.utc)
89
+ out = String.new(encoding: Encoding::BINARY)
90
+ out << "From #{base_commit} Mon Sep 17 00:00:00 2001\n".b
91
+ out << "From: #{PATCH_AUTHOR}\n".b
92
+ out << "Date: #{date.rfc2822}\n".b
93
+ out << "Subject: [PATCH] #{subject.to_s.gsub(/\s+/, ' ').strip}\n\n".b
94
+ out << "#{body.to_s.strip}\n\n".b if body.present?
95
+ out << "---\n\n".b
96
+ changes.each { |change| out << file_diff(change) }
97
+ out << "-- \nActiveAgent\n".b
98
+ end
99
+
100
+ # +path+ as git writes it in a header: quoted, with C escapes, when it
101
+ # holds a quote, a backslash, a control character or a non-ASCII byte.
102
+ def quote_path(path)
103
+ bytes = path.b
104
+ return bytes unless bytes.match?(/["\\\x00-\x1f\x7f-\xff]/n)
105
+
106
+ escaped = bytes.each_byte.map do |byte|
107
+ case byte
108
+ when 0x07 then "\\a"
109
+ when 0x08 then "\\b"
110
+ when 0x09 then "\\t"
111
+ when 0x0a then "\\n"
112
+ when 0x0b then "\\v"
113
+ when 0x0c then "\\f"
114
+ when 0x0d then "\\r"
115
+ when 0x22 then "\\\""
116
+ when 0x5c then "\\\\"
117
+ when 0x00..0x1f, 0x7f..0xff then format("\\%03o", byte)
118
+ else byte.chr
119
+ end
120
+ end
121
+ "\"#{escaped.join}\"".b
122
+ end
123
+
124
+ # +content+ split into lines, each keeping its newline.
125
+ def lines(content)
126
+ content.to_s.b.split(/(?<=\n)/n)
127
+ end
128
+
129
+ # The unified hunks that turn +old_lines+ into +new_lines+.
130
+ def hunks(old_lines, new_lines)
131
+ edits = line_edits(old_lines, new_lines)
132
+ changed = edits.each_index.reject { |index| edits[index].first == :equal }
133
+ return "".b if changed.empty?
134
+
135
+ groups = changed.slice_when { |a, b| b - a > CONTEXT_LINES * 2 + 1 }.to_a
136
+ groups.each_with_object(String.new(encoding: Encoding::BINARY)) do |group, out|
137
+ first = [ group.first - CONTEXT_LINES, 0 ].max
138
+ last = [ group.last + CONTEXT_LINES, edits.size - 1 ].min
139
+ old_before = edits[0...first].count { |op, _| op != :insert }
140
+ new_before = edits[0...first].count { |op, _| op != :delete }
141
+ window = edits[first..last]
142
+ old_count = window.count { |op, _| op != :insert }
143
+ new_count = window.count { |op, _| op != :delete }
144
+
145
+ out << "@@ -#{range(old_before, old_count)} +#{range(new_before, new_count)} @@\n".b
146
+ window.each do |op, line|
147
+ out << LINE_PREFIX.fetch(op) << line
148
+ out << "\n\\n".b unless line.end_with?("\n")
149
+ end
150
+ end
151
+ end
152
+
153
+ # "start,count" for a hunk that starts after +before+ lines. An empty
154
+ # side names the line it would follow.
155
+ def range(before, count)
156
+ "#{count.zero? ? before : before + 1},#{count}"
157
+ end
158
+
159
+ # The edit script from +a+ to +b+, as [op, line] pairs with op :equal,
160
+ # :delete or :insert: the shortest one (Myers' algorithm) between the
161
+ # lines the two do not share at either end, or a whole replacement of
162
+ # those lines past MAX_EDIT_DISTANCE.
163
+ def line_edits(a, b)
164
+ prefix = 0
165
+ prefix += 1 while prefix < a.size && prefix < b.size && a[prefix] == b[prefix]
166
+ suffix = 0
167
+ suffix += 1 while suffix < a.size - prefix && suffix < b.size - prefix && a[-1 - suffix] == b[-1 - suffix]
168
+ a_middle = a[prefix...(a.size - suffix)]
169
+ b_middle = b[prefix...(b.size - suffix)]
170
+
171
+ middle = shortest_edits(a_middle, b_middle) ||
172
+ (a_middle.map { |line| [ :delete, line ] } + b_middle.map { |line| [ :insert, line ] })
173
+ a.first(prefix).map { |line| [ :equal, line ] } + middle + a.last(suffix).map { |line| [ :equal, line ] }
174
+ end
175
+
176
+ # Myers' O(ND) shortest edit script, or nil when it is longer than
177
+ # MAX_EDIT_DISTANCE. Each round's furthest-reaching paths are kept for
178
+ # the walk back, as only the slice of diagonals that round could reach.
179
+ def shortest_edits(a, b)
180
+ n = a.size
181
+ m = b.size
182
+ return [] if n.zero? && m.zero?
183
+
184
+ max = n + m
185
+ offset = max + 1
186
+ furthest = Array.new((2 * max) + 3, 0)
187
+ trace = []
188
+ found = (0..[ max, MAX_EDIT_DISTANCE ].min).any? do |d|
189
+ trace << furthest[(offset - d - 1)..(offset + d + 1)]
190
+ (-d..d).step(2).any? do |k|
191
+ x = k == -d || (k != d && furthest[offset + k - 1] < furthest[offset + k + 1]) ? furthest[offset + k + 1] : furthest[offset + k - 1] + 1
192
+ y = x - k
193
+ while x < n && y < m && a[x] == b[y]
194
+ x += 1
195
+ y += 1
196
+ end
197
+ furthest[offset + k] = x
198
+ x >= n && y >= m
199
+ end
200
+ end
201
+ return nil unless found
202
+
203
+ walk_back(trace, a, b)
204
+ end
205
+
206
+ def walk_back(trace, a, b)
207
+ x = a.size
208
+ y = b.size
209
+ edits = []
210
+ trace.each_with_index.reverse_each do |snapshot, d|
211
+ at = ->(k) { snapshot[k + d + 1] }
212
+ k = x - y
213
+ previous_k = k == -d || (k != d && at.call(k - 1) < at.call(k + 1)) ? k + 1 : k - 1
214
+ previous_x = at.call(previous_k)
215
+ previous_y = previous_x - previous_k
216
+ while x > previous_x && y > previous_y
217
+ x -= 1
218
+ y -= 1
219
+ edits << [ :equal, a[x] ]
220
+ end
221
+ if d.positive?
222
+ edits << (x == previous_x ? [ :insert, b[previous_y] ] : [ :delete, a[previous_x] ])
223
+ end
224
+ x = previous_x
225
+ y = previous_y
226
+ end
227
+ edits.reverse
228
+ end
229
+
230
+ # +content+ whole, deflated and base-85 encoded the way a GIT binary
231
+ # patch's "literal" hunk carries it.
232
+ def binary_literal(content)
233
+ deflated = Zlib::Deflate.deflate(content.b)
234
+ out = "literal #{content.bytesize}\n".b
235
+ deflated.bytes.each_slice(52) do |chunk|
236
+ length = chunk.size <= 26 ? ("A".ord + chunk.size - 1) : ("a".ord + chunk.size - 27)
237
+ out << length.chr << encode85(chunk) << "\n"
238
+ end
239
+ out
240
+ end
241
+
242
+ def encode85(bytes)
243
+ bytes.each_slice(4).map do |group|
244
+ value = group.each_with_index.sum { |byte, index| byte << (24 - (8 * index)) }
245
+ digits = Array.new(5) do
246
+ value, digit = value.divmod(85)
247
+ BASE85[digit]
248
+ end
249
+ digits.reverse.join
250
+ end.join
251
+ end
252
+ end
253
+ end
@@ -0,0 +1,255 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ # Imports a scenario catalog document into the owner's records: products,
5
+ # sets and scenarios are matched by key. In "replace" mode (the default)
6
+ # the document is the truth, so what it leaves out is removed and
7
+ # importing it twice changes nothing; in "merge" mode what it leaves out
8
+ # is kept, so an export of one set or a few scenarios can be imported back
9
+ # into the whole catalog. The catalog keeps the canonical document and its
10
+ # digest, and is written to Active Storage when the host has it.
11
+ #
12
+ # ScenarioCatalogImport.new(owner: account, document: yaml, agents: ActionAgent.agents_for(account)).call
13
+ #
14
+ # #preview answers what #call would change, by key, without writing.
15
+ #
16
+ # A product's `agent:` is resolved among +agents+ by name, and a project
17
+ # among +projects+ by the product's `repository` or `project` entry, so a
18
+ # catalog written for a repository finds the project that boots it.
19
+ class ScenarioCatalogImport
20
+ # Raised for a document that cannot be imported, with the reason a
21
+ # person can act on.
22
+ class Invalid < StandardError; end
23
+
24
+ MODES = %w[replace merge].freeze
25
+ # The changes #preview lists; the counts cover all of them.
26
+ PREVIEW_LIMIT = 500
27
+
28
+ attr_reader :catalog, :source_path
29
+
30
+ def initialize(owner:, document:, catalog: nil, name: nil, source_kind: "upload", source_path: nil, agents: nil, projects: nil, mode: "replace")
31
+ @owner = owner
32
+ @document = document.to_s
33
+ @catalog = catalog
34
+ @name = name
35
+ @source_kind = source_kind
36
+ @source_path = source_path
37
+ @agents = agents
38
+ @projects = projects
39
+ @mode = mode.to_s.presence || "replace"
40
+ raise Invalid, "The import mode is replace or merge" unless MODES.include?(@mode)
41
+ end
42
+
43
+ # @return [ScenarioCatalog] the imported catalog
44
+ def call
45
+ parsed = parse
46
+
47
+ ScenarioCatalog.transaction do
48
+ @catalog ||= find_or_initialize(parsed.key)
49
+ merging = merge? && @catalog.persisted?
50
+ @catalog.assign_attributes(
51
+ key: parsed.key, source_kind: @source_kind, source_path: @source_path,
52
+ name: merging ? (parsed_field(parsed, "name") || @catalog.name) : parsed.name,
53
+ description: merging ? (parsed.description || @catalog.description) : parsed.description,
54
+ metadata: merging ? @catalog.metadata.merge(parsed.metadata) : parsed.metadata,
55
+ document: parsed.to_yaml, digest: parsed.digest
56
+ )
57
+ @catalog.save!
58
+ replace_products(parsed)
59
+ if merging
60
+ @catalog.refresh_document!
61
+ if @catalog.scenario_count > ScenarioCatalog::MAX_SCENARIOS
62
+ raise Invalid, "The catalog would hold more than #{ScenarioCatalog::MAX_SCENARIOS} scenarios"
63
+ end
64
+ end
65
+ end
66
+ @catalog.sync_to_storage!
67
+ @catalog
68
+ rescue ActiveAgent::Evals::Catalog::InvalidDocument => e
69
+ raise Invalid, e.message
70
+ rescue ActiveRecord::RecordInvalid => e
71
+ raise Invalid, e.record.errors.full_messages.to_sentence
72
+ end
73
+
74
+ # What #call would do, without writing: the catalog it imports into
75
+ # (created or updated), counts by action, and the changes (up to
76
+ # PREVIEW_LIMIT), each { level: product|set|scenario, path, action }
77
+ # where action is create, update (the document changes a record by
78
+ # that key), unchanged, remove (replace mode drops a record the document
79
+ # leaves out) or keep (merge mode keeps it).
80
+ #
81
+ # @return [Hash]
82
+ def preview
83
+ parsed = parse
84
+ existing = @catalog || ScenarioCatalog.for_owner(@owner).find_by(key: parsed.key)
85
+ before = existing ? existing.to_catalog.to_h : {}
86
+ after = parsed.to_h
87
+ changes = []
88
+ diff_level(changes, "product", "", list(before["products"]), list(after["products"]), "sets") do |path, old_product, new_product|
89
+ diff_level(changes, "set", path, list(old_product["sets"]), list(new_product["sets"]), "scenarios") do |set_path, old_set, new_set|
90
+ diff_level(changes, "scenario", set_path, list(old_set["scenarios"]), list(new_set["scenarios"]), nil)
91
+ end
92
+ end
93
+
94
+ counts = Hash.new(0)
95
+ changes.each { |change| counts[change[:action]] += 1 }
96
+ {
97
+ mode: @mode,
98
+ catalog: {
99
+ id: existing&.id, key: parsed.key, name: parsed.name,
100
+ action: existing.nil? ? "create" : (catalog_fields(before) == catalog_fields(after) ? "unchanged" : "update")
101
+ },
102
+ scenario_count: parsed.scenario_count,
103
+ counts: counts,
104
+ changes: changes.reject { |change| change[:action] == "unchanged" }.first(PREVIEW_LIMIT)
105
+ }
106
+ rescue ActiveAgent::Evals::Catalog::InvalidDocument => e
107
+ raise Invalid, e.message
108
+ end
109
+
110
+ private
111
+
112
+ def merge?
113
+ @mode == "merge"
114
+ end
115
+
116
+ def parse
117
+ if @document.bytesize > ScenarioCatalog::MAX_BYTES
118
+ raise Invalid, "The catalog is larger than #{ScenarioCatalog::MAX_BYTES / 1.megabyte} MB"
119
+ end
120
+
121
+ parsed = ActiveAgent::Evals::Catalog.parse(@document, name: @name)
122
+ if parsed.scenario_count > ScenarioCatalog::MAX_SCENARIOS
123
+ raise Invalid, "The catalog holds #{parsed.scenario_count} scenarios; the limit is #{ScenarioCatalog::MAX_SCENARIOS}"
124
+ end
125
+
126
+ parsed
127
+ end
128
+
129
+ # The document's own name, rather than the key the parser falls back to.
130
+ def parsed_field(parsed, field)
131
+ value = parsed.to_h[field]
132
+ value unless field == "name" && value == parsed.key
133
+ end
134
+
135
+ def list(value)
136
+ Array(value).select { |entry| entry.is_a?(Hash) }
137
+ end
138
+
139
+ def catalog_fields(document)
140
+ document.except("products")
141
+ end
142
+
143
+ # Appends the changes between +old_entries+ and +new_entries+ (matched
144
+ # by key) to +changes+, comparing each entry without its +children+, and
145
+ # yields the pairs present in the document so their children are
146
+ # compared too.
147
+ def diff_level(changes, level, parent_path, old_entries, new_entries, children)
148
+ old_by_key = old_entries.index_by { |entry| entry["key"].to_s }
149
+ new_entries.each do |entry|
150
+ key = entry["key"].to_s
151
+ path = parent_path.empty? ? key : "#{parent_path}/#{key}"
152
+ previous = old_by_key.delete(key)
153
+ action =
154
+ if previous.nil? then "create"
155
+ elsif children ? previous.except(children) == entry.except(children) : previous == entry then "unchanged"
156
+ else "update"
157
+ end
158
+ changes << { level: level, path: path, action: action }
159
+ yield(path, previous || {}, entry) if children && block_given?
160
+ end
161
+ old_by_key.each_key do |key|
162
+ path = parent_path.empty? ? key : "#{parent_path}/#{key}"
163
+ changes << { level: level, path: path, action: merge? ? "keep" : "remove" }
164
+ end
165
+ end
166
+
167
+ def find_or_initialize(key)
168
+ catalog = ScenarioCatalog.for_owner(@owner).find_or_initialize_by(key: key)
169
+ catalog.owner = @owner if catalog.new_record? && @owner
170
+ catalog
171
+ end
172
+
173
+ def replace_products(parsed)
174
+ @catalog.products.where.not(key: parsed.products.map(&:key)).destroy_all unless merge?
175
+ offset = merge? ? next_position(@catalog.products) : 0
176
+
177
+ parsed.products.each do |entry|
178
+ product = @catalog.products.find_or_initialize_by(key: entry.key)
179
+ product.assign_attributes(
180
+ name: entry.name, description: entry.description, agent_name: entry.agent,
181
+ metadata: entry.metadata, agent: resolve_agent(entry) || (merge? ? product.agent : nil),
182
+ project: resolve_project(entry) || (merge? ? product.project : nil)
183
+ )
184
+ product.position = position_for(product, entry.position, offset)
185
+ product.save!
186
+ replace_sets(product, entry)
187
+ end
188
+ end
189
+
190
+ def replace_sets(product, entry)
191
+ product.sets.where.not(key: entry.sets.map(&:key)).destroy_all unless merge?
192
+ offset = merge? ? next_position(product.sets) : 0
193
+
194
+ entry.sets.each do |set_entry|
195
+ set = product.sets.find_or_initialize_by(key: set_entry.key)
196
+ set.assign_attributes(
197
+ name: set_entry.name, description: set_entry.description, judge: set_entry.judge,
198
+ criteria: set_entry.criteria, metadata: set_entry.metadata
199
+ )
200
+ set.position = position_for(set, set_entry.position, offset)
201
+ set.save!
202
+ replace_scenarios(set, set_entry)
203
+ end
204
+ end
205
+
206
+ def replace_scenarios(set, set_entry)
207
+ set.scenarios.where.not(key: set_entry.scenarios.map(&:key)).destroy_all unless merge?
208
+ offset = merge? ? next_position(set.scenarios) : 0
209
+
210
+ set_entry.scenarios.each do |scenario|
211
+ record = set.scenarios.find_or_initialize_by(key: scenario.key)
212
+ record.assign_attributes(
213
+ prompt: scenario.prompt, notes: scenario.notes, expectations: scenario.expectations,
214
+ tags: scenario.tags, params: scenario.params, production_only: scenario.production_only?
215
+ )
216
+ record.position = position_for(record, scenario.position, offset)
217
+ record.save!
218
+ end
219
+ end
220
+
221
+ # Replace mode takes the document's order. Merge mode keeps a record's
222
+ # place and puts a new one after the records already there.
223
+ def position_for(record, position, offset)
224
+ return position unless merge?
225
+
226
+ record.persisted? ? record.position : offset + position.to_i
227
+ end
228
+
229
+ def next_position(relation)
230
+ (relation.maximum(:position) || -1) + 1
231
+ end
232
+
233
+ # The owner's agent the product's `agent:` names, kept as the product's
234
+ # agent; a name that matches nothing stays as agent_name alone.
235
+ def resolve_agent(entry)
236
+ return nil if entry.agent.blank? || @agents.nil?
237
+
238
+ @agents.find_by(name: entry.agent)
239
+ end
240
+
241
+ # The project the product's `project:` (a project id or name) or
242
+ # `repository:` (owner/name) names among +projects+.
243
+ def resolve_project(entry)
244
+ return nil if @projects.nil?
245
+
246
+ reference = entry.metadata["project"]
247
+ repository = entry.metadata["repository"]
248
+ if reference.present?
249
+ reference.to_s.match?(/\A\d+\z/) ? @projects.find_by(id: reference) : @projects.find_by(name: reference.to_s)
250
+ elsif repository.present?
251
+ @projects.find_by(repository: repository.to_s)
252
+ end
253
+ end
254
+ end
255
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ # Imports the scenario catalogs a connected repository keeps, read through
5
+ # GitHub's contents API at a ref: every `.yml` under +path+
6
+ # (`.activeagents/evals` by default, where the install pull request writes
7
+ # a project's suite), or the one file +path+ names. Each file is a
8
+ # catalog; its source is recorded as "owner/name@ref:path", so a re-import
9
+ # of the same ref finds it again, and a pull request's branch can carry
10
+ # the scenarios that accept it.
11
+ class ScenarioCatalogRepositoryImport
12
+ DEFAULT_PATH = ".activeagents/evals"
13
+ MAX_FILES = 50
14
+ EXTENSIONS = %w[.yml .yaml].freeze
15
+
16
+ def initialize(owner:, connection:, repository:, ref: nil, path: nil, agents: nil, projects: nil, mode: "replace")
17
+ @owner = owner
18
+ @connection = connection
19
+ @repository = repository.to_s
20
+ @ref = ref.to_s.presence
21
+ @path = path.to_s.presence || DEFAULT_PATH
22
+ @agents = agents
23
+ @projects = projects
24
+ @mode = mode
25
+ end
26
+
27
+ # @return [Array<ScenarioCatalog>] the imported catalogs, one per file
28
+ def call
29
+ imports.map(&:call)
30
+ end
31
+
32
+ # What #call would change, one ScenarioCatalogImport#preview per file,
33
+ # each with the file's source_path.
34
+ # @return [Array<Hash>]
35
+ def preview
36
+ imports.map { |import| import.preview.merge(source_path: import.source_path) }
37
+ end
38
+
39
+ private
40
+
41
+ # One ScenarioCatalogImport per catalog file at the ref.
42
+ def imports
43
+ client = @connection.client
44
+ ref = @ref || default_branch(client)
45
+ files = catalog_files(client, ref)
46
+ if files.empty?
47
+ raise ScenarioCatalogImport::Invalid, "#{@repository} at #{ref} has no catalog at #{@path}: " \
48
+ "add a .yml file there, or name the file to read"
49
+ end
50
+
51
+ files.first(MAX_FILES).filter_map do |file_path|
52
+ content = client.file(@repository, file_path, ref: ref)
53
+ next if content.nil?
54
+
55
+ ScenarioCatalogImport.new(
56
+ owner: @owner, document: content, name: File.basename(file_path, File.extname(file_path)),
57
+ source_kind: "repository", source_path: "#{@repository}@#{ref}:#{file_path}",
58
+ agents: @agents, projects: @projects, mode: @mode
59
+ )
60
+ end
61
+ rescue GithubClient::Error => e
62
+ raise ScenarioCatalogImport::Invalid, "GitHub refused reading #{@repository}: #{e.message}"
63
+ end
64
+
65
+ def default_branch(client)
66
+ @connection.repository(@repository)&.dig("default_branch").presence || client.repository(@repository)["default_branch"]
67
+ end
68
+
69
+ # The .yml files at @path: the file itself, or the files directly under
70
+ # the directory, in name order.
71
+ def catalog_files(client, ref)
72
+ return [ @path ] if EXTENSIONS.include?(File.extname(@path))
73
+
74
+ directory = @path.chomp("/")
75
+ client.tree(@repository, ref: ref)[:paths]
76
+ .map { |entry| entry[:path] }
77
+ .select { |entry| entry.start_with?("#{directory}/") && EXTENSIONS.include?(File.extname(entry)) && !entry.delete_prefix("#{directory}/").include?("/") }
78
+ .sort
79
+ end
80
+ end
81
+ end