solid_agent 0.0.0 → 0.2.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 (103) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +68 -0
  3. data/LICENSE +21 -0
  4. data/README.md +321 -0
  5. data/Rakefile +32 -0
  6. data/docs/agent-md-spec.md +803 -0
  7. data/docs/parser-design.md +1369 -0
  8. data/docs/registry-api.md +882 -0
  9. data/examples/README.md +60 -0
  10. data/examples/manifests/changelog_writer.agent.md +81 -0
  11. data/examples/manifests/usage.rb +96 -0
  12. data/examples/memory_handoff/app/agents/researcher_agent.rb +36 -0
  13. data/examples/memory_handoff/app/agents/writer_agent.rb +41 -0
  14. data/examples/memory_handoff/usage.rb +45 -0
  15. data/examples/persistent_conversation/app/agents/support_agent.rb +59 -0
  16. data/examples/persistent_conversation/app/controllers/support_conversations_controller.rb +24 -0
  17. data/examples/persistent_conversation/app/views/agents/support/instructions.md.erb +8 -0
  18. data/examples/persistent_conversation/usage.rb +51 -0
  19. data/examples/reasoning/app/agents/analysis_agent.rb +52 -0
  20. data/examples/reasoning/usage.rb +52 -0
  21. data/examples/run_tracking/app/agents/report_agent.rb +30 -0
  22. data/examples/run_tracking/app/controllers/agent_runs_controller.rb +43 -0
  23. data/examples/run_tracking/app/jobs/document_analysis_job.rb +17 -0
  24. data/examples/run_tracking/app/services/document_analysis_run.rb +68 -0
  25. data/examples/run_tracking/usage.rb +85 -0
  26. data/examples/tool_streaming/app/agents/browser_agent.rb +65 -0
  27. data/examples/tool_streaming/app/channels/tool_status_channel.rb +24 -0
  28. data/examples/tool_streaming/app/views/browser_agent/tools/fetch_url.json.erb +15 -0
  29. data/examples/tool_streaming/usage.rb +47 -0
  30. data/lib/generators/solid_agent/agent/agent_generator.rb +95 -0
  31. data/lib/generators/solid_agent/agent/templates/action.text.erb +10 -0
  32. data/lib/generators/solid_agent/agent/templates/agent.rb.erb +93 -0
  33. data/lib/generators/solid_agent/context/context_generator.rb +124 -0
  34. data/lib/generators/solid_agent/context/templates/context_model.rb.erb +134 -0
  35. data/lib/generators/solid_agent/context/templates/create_context.rb.erb +32 -0
  36. data/lib/generators/solid_agent/context/templates/create_generations.rb.erb +46 -0
  37. data/lib/generators/solid_agent/context/templates/create_messages.rb.erb +37 -0
  38. data/lib/generators/solid_agent/context/templates/generation_model.rb.erb +51 -0
  39. data/lib/generators/solid_agent/context/templates/message_model.rb.erb +47 -0
  40. data/lib/generators/solid_agent/install/install_generator.rb +92 -0
  41. data/lib/generators/solid_agent/install/templates/agent_context.rb.erb +171 -0
  42. data/lib/generators/solid_agent/install/templates/agent_generation.rb.erb +76 -0
  43. data/lib/generators/solid_agent/install/templates/agent_memory.rb.erb +51 -0
  44. data/lib/generators/solid_agent/install/templates/agent_memory_entry.rb.erb +12 -0
  45. data/lib/generators/solid_agent/install/templates/agent_message.rb.erb +76 -0
  46. data/lib/generators/solid_agent/install/templates/agent_run.rb.erb +122 -0
  47. data/lib/generators/solid_agent/install/templates/create_agent_contexts.rb.erb +32 -0
  48. data/lib/generators/solid_agent/install/templates/create_agent_generations.rb.erb +51 -0
  49. data/lib/generators/solid_agent/install/templates/create_agent_memories.rb.erb +35 -0
  50. data/lib/generators/solid_agent/install/templates/create_agent_messages.rb.erb +38 -0
  51. data/lib/generators/solid_agent/install/templates/create_agent_runs.rb.erb +46 -0
  52. data/lib/generators/solid_agent/install/templates/initializer.rb.erb +51 -0
  53. data/lib/generators/solid_agent/manifest/manifest_generator.rb +209 -0
  54. data/lib/generators/solid_agent/manifest/templates/agent.md.erb +39 -0
  55. data/lib/generators/solid_agent/manifest/templates/prompt.erb +13 -0
  56. data/lib/generators/solid_agent/reasons/reasons_generator.rb +83 -0
  57. data/lib/generators/solid_agent/reasons/templates/add_reasoning_columns.rb.erb +12 -0
  58. data/lib/generators/solid_agent/tool/templates/tool.json.erb +19 -0
  59. data/lib/generators/solid_agent/tool/tool_generator.rb +117 -0
  60. data/lib/solid_agent/agent_manifest/agent_builder.rb +323 -0
  61. data/lib/solid_agent/agent_manifest/errors.rb +26 -0
  62. data/lib/solid_agent/agent_manifest/exporter_registry.rb +117 -0
  63. data/lib/solid_agent/agent_manifest/exporters/agent_md_exporter.rb +115 -0
  64. data/lib/solid_agent/agent_manifest/exporters/base_exporter.rb +152 -0
  65. data/lib/solid_agent/agent_manifest/exporters/crewai_exporter.rb +125 -0
  66. data/lib/solid_agent/agent_manifest/exporters/dotprompt_exporter.rb +92 -0
  67. data/lib/solid_agent/agent_manifest/input_schema.rb +154 -0
  68. data/lib/solid_agent/agent_manifest/manifest.rb +306 -0
  69. data/lib/solid_agent/agent_manifest/parser_registry.rb +185 -0
  70. data/lib/solid_agent/agent_manifest/parsers/agent_md_parser.rb +87 -0
  71. data/lib/solid_agent/agent_manifest/parsers/base_parser.rb +223 -0
  72. data/lib/solid_agent/agent_manifest/parsers/crewai_parser.rb +201 -0
  73. data/lib/solid_agent/agent_manifest/parsers/dotprompt_parser.rb +122 -0
  74. data/lib/solid_agent/agent_manifest/parsers/github_prompt_parser.rb +143 -0
  75. data/lib/solid_agent/agent_manifest/picoschema.rb +254 -0
  76. data/lib/solid_agent/agent_manifest/registry/auth.rb +103 -0
  77. data/lib/solid_agent/agent_manifest/registry/client.rb +384 -0
  78. data/lib/solid_agent/agent_manifest/resource.rb +103 -0
  79. data/lib/solid_agent/agent_manifest/tool.rb +160 -0
  80. data/lib/solid_agent/agent_manifest/validator.rb +368 -0
  81. data/lib/solid_agent/agent_manifest.rb +381 -0
  82. data/lib/solid_agent/engine.rb +16 -0
  83. data/lib/solid_agent/has_context.rb +670 -0
  84. data/lib/solid_agent/has_memory.rb +136 -0
  85. data/lib/solid_agent/has_reasons.rb +230 -0
  86. data/lib/solid_agent/has_tools.rb +257 -0
  87. data/lib/solid_agent/model_naming.rb +42 -0
  88. data/lib/solid_agent/model_pricing.rb +93 -0
  89. data/lib/solid_agent/reasonable/reason.rb +205 -0
  90. data/lib/solid_agent/reasonable.rb +181 -0
  91. data/lib/solid_agent/records/agent.rb +520 -0
  92. data/lib/solid_agent/records/agent_run.rb +520 -0
  93. data/lib/solid_agent/records/agent_template.rb +142 -0
  94. data/lib/solid_agent/records/agent_version.rb +141 -0
  95. data/lib/solid_agent/records/ownable.rb +130 -0
  96. data/lib/solid_agent/records.rb +152 -0
  97. data/lib/solid_agent/run_fingerprint.rb +51 -0
  98. data/lib/solid_agent/streams_tool_updates.rb +178 -0
  99. data/lib/solid_agent/tool_cache.rb +91 -0
  100. data/lib/solid_agent/version.rb +5 -0
  101. data/lib/solid_agent.rb +95 -0
  102. data/sig/solid_agent.rbs +4 -0
  103. metadata +174 -14
@@ -0,0 +1,384 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+ require "fileutils"
7
+
8
+ module SolidAgent
9
+ module AgentManifest
10
+ module Registry
11
+ # Client provides HTTP API access to the ActiveAgents registry.
12
+ #
13
+ # @example Search for agents
14
+ # client = Registry::Client.new
15
+ # results = client.search(q: "research", tags: ["assistant"])
16
+ #
17
+ # @example Download an agent
18
+ # client.download("@anthropic/research-assistant", path: "./agents/")
19
+ #
20
+ # @example Publish an agent
21
+ # client = Registry::Client.new(token: "aa_live_xxx")
22
+ # client.publish("./research-assistant.agent.md")
23
+ #
24
+ class Client
25
+ BASE_URL = "https://api.activeagents.ai/v1"
26
+
27
+ attr_reader :base_url, :token
28
+
29
+ # Initialize a new client
30
+ #
31
+ # @param token [String, nil] Auth token (uses Auth.token if nil)
32
+ # @param base_url [String] API base URL
33
+ def initialize(token: nil, base_url: BASE_URL)
34
+ @token = token || Auth.token
35
+ @base_url = base_url
36
+ end
37
+
38
+ # === Discovery Methods ===
39
+
40
+ # Search for agents
41
+ #
42
+ # @param q [String, nil] Search query
43
+ # @param tags [Array<String>, nil] Filter by tags
44
+ # @param framework [String, nil] Filter by framework (e.g., "activeagent", "crewai")
45
+ # @param author [String, nil] Filter by author
46
+ # @param sort [String] Sort order ("popular", "recent", "name")
47
+ # @param page [Integer] Page number
48
+ # @param per_page [Integer] Results per page
49
+ # @return [Hash] Search results with :agents, :total, :page keys
50
+ def search(q: nil, tags: nil, framework: nil, author: nil, sort: "popular", page: 1, per_page: 20)
51
+ params = {
52
+ q: q,
53
+ tags: tags&.join(","),
54
+ framework: framework,
55
+ author: author,
56
+ sort: sort,
57
+ page: page,
58
+ per_page: per_page
59
+ }.compact
60
+
61
+ get("/agents", params)
62
+ end
63
+
64
+ # Get an agent by name
65
+ #
66
+ # @param name [String] Agent name (e.g., "@anthropic/research-assistant")
67
+ # @param version [String, nil] Specific version (latest if nil)
68
+ # @return [Hash] Agent metadata
69
+ def get(name, version: nil)
70
+ path = version ? "/agents/#{encode_name(name)}/versions/#{version}" : "/agents/#{encode_name(name)}"
71
+ get_request(path)
72
+ end
73
+
74
+ # List all versions of an agent
75
+ #
76
+ # @param name [String] Agent name
77
+ # @return [Array<Hash>] Version list
78
+ def versions(name)
79
+ get_request("/agents/#{encode_name(name)}/versions")
80
+ end
81
+
82
+ # === Download Methods ===
83
+
84
+ # Download an agent manifest
85
+ #
86
+ # @param name [String] Agent name
87
+ # @param version [String, nil] Specific version
88
+ # @param path [String, nil] Download directory (current dir if nil)
89
+ # @param format [Symbol] Output format (:agent_md, :dotprompt, etc.)
90
+ # @return [String] Path to downloaded file
91
+ def download(name, version: nil, path: nil, format: :agent_md)
92
+ # Get agent metadata
93
+ agent = get(name, version: version)
94
+
95
+ # Determine output path
96
+ output_dir = path || Dir.pwd
97
+ FileUtils.mkdir_p(output_dir)
98
+
99
+ filename = agent_filename(agent, format)
100
+ output_path = File.join(output_dir, filename)
101
+
102
+ # Download content
103
+ content_url = agent["download_url"] || agent.dig("links", "download")
104
+ if content_url
105
+ content = fetch_content(content_url)
106
+ else
107
+ # Fetch from content endpoint
108
+ content = get_request("/agents/#{encode_name(name)}/content", accept: "text/markdown")
109
+ end
110
+
111
+ # Convert format if needed
112
+ source_format = agent["format"]&.to_sym || :agent_md
113
+ if format != source_format
114
+ manifest = ParserRegistry.parse_string(content, format: source_format)
115
+ content = ExporterRegistry.export(manifest, format)
116
+ end
117
+
118
+ File.write(output_path, content, encoding: "UTF-8")
119
+ output_path
120
+ end
121
+
122
+ # Download a specific file from an agent package
123
+ #
124
+ # @param name [String] Agent name
125
+ # @param file_path [String] Path within the package
126
+ # @param version [String, nil] Specific version
127
+ # @return [String] File content
128
+ def download_file(name, file_path, version: nil)
129
+ path = "/agents/#{encode_name(name)}/files/#{file_path}"
130
+ path += "?version=#{version}" if version
131
+ get_request(path, accept: "*/*")
132
+ end
133
+
134
+ # === Publishing Methods ===
135
+
136
+ # Publish an agent manifest
137
+ #
138
+ # @param path [String] Path to manifest file
139
+ # @param tag [String] Version tag ("latest", "beta", etc.)
140
+ # @param scope [String, nil] Organization scope
141
+ # @param access [String] Access level ("public", "private")
142
+ # @return [Hash] Published agent info
143
+ def publish(path, tag: "latest", scope: nil, access: "public")
144
+ require_auth!
145
+
146
+ manifest = AgentManifest.parse(path)
147
+ AgentManifest.validate!(manifest, strict: true)
148
+
149
+ content = File.read(path, encoding: "UTF-8")
150
+ format = ParserRegistry.detect_format(path)
151
+
152
+ body = {
153
+ name: scope ? "#{scope}/#{manifest.name}" : manifest.name,
154
+ version: manifest.version,
155
+ tag: tag,
156
+ access: access,
157
+ format: format,
158
+ content: content,
159
+ metadata: {
160
+ description: manifest.description,
161
+ tags: manifest.tags,
162
+ model: manifest.model,
163
+ author: manifest.author,
164
+ license: manifest.license,
165
+ repository: manifest.repository
166
+ }
167
+ }
168
+
169
+ post("/agents", body)
170
+ end
171
+
172
+ # Deprecate a version
173
+ #
174
+ # @param name [String] Agent name
175
+ # @param version [String] Version to deprecate
176
+ # @param message [String] Deprecation message
177
+ # @return [Hash] Updated version info
178
+ def deprecate(name, version, message:)
179
+ require_auth!
180
+ patch("/agents/#{encode_name(name)}/versions/#{version}", { deprecated: true, deprecation_message: message })
181
+ end
182
+
183
+ # Unpublish a version
184
+ #
185
+ # @param name [String] Agent name
186
+ # @param version [String] Version to remove
187
+ # @return [Boolean]
188
+ def unpublish(name, version)
189
+ require_auth!
190
+ delete("/agents/#{encode_name(name)}/versions/#{version}")
191
+ true
192
+ end
193
+
194
+ # === User Actions ===
195
+
196
+ # Star an agent
197
+ #
198
+ # @param name [String] Agent name
199
+ # @return [Boolean]
200
+ def star(name)
201
+ require_auth!
202
+ post("/agents/#{encode_name(name)}/star", {})
203
+ true
204
+ end
205
+
206
+ # Unstar an agent
207
+ #
208
+ # @param name [String] Agent name
209
+ # @return [Boolean]
210
+ def unstar(name)
211
+ require_auth!
212
+ delete("/agents/#{encode_name(name)}/star")
213
+ true
214
+ end
215
+
216
+ # Fork an agent
217
+ #
218
+ # @param name [String] Source agent name
219
+ # @param new_name [String] Name for the fork
220
+ # @param scope [String, nil] Organization scope
221
+ # @return [Hash] Forked agent info
222
+ def fork(name, new_name:, scope: nil)
223
+ require_auth!
224
+ post("/agents/#{encode_name(name)}/fork", { new_name: new_name, scope: scope })
225
+ end
226
+
227
+ # === Sandbox Methods ===
228
+
229
+ # Run an agent in the sandbox
230
+ #
231
+ # @param name [String] Agent name
232
+ # @param input [Hash] Input parameters
233
+ # @param model [String, nil] Override model
234
+ # @param stream [Boolean] Enable streaming
235
+ # @return [Hash] Run result or job info
236
+ def run(name, input:, model: nil, stream: false)
237
+ require_auth!
238
+ body = { input: input, model: model, stream: stream }.compact
239
+ post("/agents/#{encode_name(name)}/run", body)
240
+ end
241
+
242
+ # Get status of a sandbox run
243
+ #
244
+ # @param run_id [String] Run ID
245
+ # @return [Hash] Run status
246
+ def run_status(run_id)
247
+ require_auth!
248
+ get_request("/runs/#{run_id}")
249
+ end
250
+
251
+ # === Account Methods ===
252
+
253
+ # Get current user info
254
+ #
255
+ # @return [Hash] User info
256
+ def me
257
+ require_auth!
258
+ get_request("/me")
259
+ end
260
+
261
+ # Get user's starred agents
262
+ #
263
+ # @return [Array<Hash>] Starred agents
264
+ def my_stars
265
+ require_auth!
266
+ get_request("/me/stars")
267
+ end
268
+
269
+ # Get user's published agents
270
+ #
271
+ # @return [Array<Hash>] Published agents
272
+ def my_agents
273
+ require_auth!
274
+ get_request("/me/agents")
275
+ end
276
+
277
+ private
278
+
279
+ def require_auth!
280
+ raise RegistryError, "Authentication required" unless token
281
+ end
282
+
283
+ def encode_name(name)
284
+ # Handle scoped names: @scope/name -> @scope%2Fname
285
+ URI.encode_www_form_component(name)
286
+ end
287
+
288
+ def agent_filename(agent, format)
289
+ name = agent["name"].to_s.split("/").last
290
+ extension = case format
291
+ when :agent_md then ".agent.md"
292
+ when :dotprompt then ".prompt"
293
+ when :crewai then ".yaml"
294
+ when :github_prompt then ".prompt.md"
295
+ else ".agent.md"
296
+ end
297
+ "#{name}#{extension}"
298
+ end
299
+
300
+ def get_request(path, params = {}, accept: "application/json")
301
+ uri = build_uri(path, params.except(:accept))
302
+ request = Net::HTTP::Get.new(uri)
303
+ request["Accept"] = accept
304
+ execute(request)
305
+ end
306
+
307
+ def post(path, body)
308
+ uri = build_uri(path)
309
+ request = Net::HTTP::Post.new(uri)
310
+ request["Content-Type"] = "application/json"
311
+ request.body = body.to_json
312
+ execute(request)
313
+ end
314
+
315
+ def patch(path, body)
316
+ uri = build_uri(path)
317
+ request = Net::HTTP::Patch.new(uri)
318
+ request["Content-Type"] = "application/json"
319
+ request.body = body.to_json
320
+ execute(request)
321
+ end
322
+
323
+ def delete(path)
324
+ uri = build_uri(path)
325
+ request = Net::HTTP::Delete.new(uri)
326
+ execute(request)
327
+ end
328
+
329
+ def build_uri(path, params = {})
330
+ uri = URI.parse("#{base_url}#{path}")
331
+ uri.query = URI.encode_www_form(params) if params.any?
332
+ uri
333
+ end
334
+
335
+ def execute(request)
336
+ # Add auth header if available
337
+ if token
338
+ request["Authorization"] = "Bearer #{token}"
339
+ end
340
+
341
+ request["User-Agent"] = "SolidAgent/#{SolidAgent::VERSION}"
342
+
343
+ uri = request.uri
344
+ http = Net::HTTP.new(uri.host, uri.port)
345
+ http.use_ssl = uri.scheme == "https"
346
+ http.open_timeout = 10
347
+ http.read_timeout = 30
348
+
349
+ response = http.request(request)
350
+
351
+ case response
352
+ when Net::HTTPSuccess
353
+ parse_response(response)
354
+ when Net::HTTPUnauthorized
355
+ raise RegistryError, "Authentication failed. Please login again."
356
+ when Net::HTTPForbidden
357
+ raise RegistryError, "Access denied"
358
+ when Net::HTTPNotFound
359
+ raise RegistryError, "Resource not found"
360
+ when Net::HTTPUnprocessableEntity
361
+ error_body = parse_response(response) rescue {}
362
+ message = error_body["error"] || error_body["message"] || "Validation failed"
363
+ raise ValidationError, message
364
+ else
365
+ error_body = parse_response(response) rescue {}
366
+ message = error_body["error"] || error_body["message"] || "Request failed: #{response.code}"
367
+ raise RegistryError, message
368
+ end
369
+ end
370
+
371
+ def parse_response(response)
372
+ return response.body unless response["Content-Type"]&.include?("application/json")
373
+
374
+ JSON.parse(response.body)
375
+ end
376
+
377
+ def fetch_content(url)
378
+ uri = URI.parse(url)
379
+ Net::HTTP.get(uri)
380
+ end
381
+ end
382
+ end
383
+ end
384
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidAgent
4
+ module AgentManifest
5
+ # Resource represents an external data source that an agent can access.
6
+ #
7
+ # Resources follow MCP (Model Context Protocol) conventions and can represent
8
+ # files, APIs, databases, or other data sources.
9
+ #
10
+ # @example File resource
11
+ # resource = Resource.new(
12
+ # name: "company_docs",
13
+ # description: "Internal company documentation",
14
+ # uri: "file:///docs/**/*.md",
15
+ # mime_type: "text/markdown"
16
+ # )
17
+ #
18
+ # @example API resource
19
+ # resource = Resource.new(
20
+ # name: "api_spec",
21
+ # description: "OpenAPI specification",
22
+ # uri: "https://api.example.com/openapi.json",
23
+ # mime_type: "application/json"
24
+ # )
25
+ #
26
+ class Resource
27
+ # @return [String] Resource identifier
28
+ attr_accessor :name
29
+
30
+ # @return [String, nil] Human-readable description
31
+ attr_accessor :description
32
+
33
+ # @return [String] URI pattern or URL
34
+ attr_accessor :uri
35
+
36
+ # @return [String, nil] MIME type of the resource content
37
+ attr_accessor :mime_type
38
+
39
+ def initialize(attributes = {})
40
+ attributes.each do |key, value|
41
+ setter = "#{key}="
42
+ send(setter, value) if respond_to?(setter)
43
+ end
44
+ end
45
+
46
+ # Convert to hash representation
47
+ #
48
+ # @return [Hash]
49
+ def to_h
50
+ {
51
+ name: name,
52
+ description: description,
53
+ uri: uri,
54
+ mimeType: mime_type
55
+ }.compact
56
+ end
57
+
58
+ # Create from hash (flexible key formats)
59
+ #
60
+ # @param data [Hash]
61
+ # @return [Resource]
62
+ def self.from_hash(data)
63
+ new(
64
+ name: data["name"] || data[:name],
65
+ description: data["description"] || data[:description],
66
+ uri: data["uri"] || data[:uri],
67
+ mime_type: data["mimeType"] || data["mime_type"] || data[:mime_type] || data[:mimeType]
68
+ )
69
+ end
70
+
71
+ # Check if this is a file resource
72
+ #
73
+ # @return [Boolean]
74
+ def file?
75
+ uri&.start_with?("file://")
76
+ end
77
+
78
+ # Check if this is an HTTP resource
79
+ #
80
+ # @return [Boolean]
81
+ def http?
82
+ uri&.match?(%r{\Ahttps?://})
83
+ end
84
+
85
+ # Check if resource is valid
86
+ #
87
+ # @return [Boolean]
88
+ def valid?
89
+ name.present? && uri.present?
90
+ end
91
+
92
+ # Validation errors
93
+ #
94
+ # @return [Array<String>]
95
+ def validation_errors
96
+ errors = []
97
+ errors << "Resource must have a name" if name.blank?
98
+ errors << "Resource must have a uri" if uri.blank?
99
+ errors
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidAgent
4
+ module AgentManifest
5
+ # Tool represents a function/tool that an agent can invoke.
6
+ #
7
+ # Tools follow the MCP (Model Context Protocol) conventions for maximum
8
+ # portability across different LLM providers and frameworks.
9
+ #
10
+ # @example Inline tool definition
11
+ # tool = Tool.new(
12
+ # name: "search",
13
+ # description: "Search the web for information",
14
+ # input_schema: {
15
+ # "type" => "object",
16
+ # "properties" => {
17
+ # "query" => { "type" => "string", "description" => "Search query" }
18
+ # },
19
+ # "required" => ["query"]
20
+ # }
21
+ # )
22
+ #
23
+ # @example Tool reference
24
+ # tool = Tool.new(ref: "@activeagents/web-tools/search")
25
+ #
26
+ class Tool
27
+ # @return [String, nil] Tool name (required for inline definitions)
28
+ attr_accessor :name
29
+
30
+ # @return [String, nil] Human-readable description
31
+ attr_accessor :description
32
+
33
+ # @return [Hash, nil] JSON Schema for tool input parameters
34
+ attr_accessor :input_schema
35
+
36
+ # @return [String, nil] Reference to external tool (e.g., "$ref" or package reference)
37
+ attr_accessor :ref
38
+
39
+ def initialize(attributes = {})
40
+ attributes.each do |key, value|
41
+ setter = "#{key}="
42
+ send(setter, value) if respond_to?(setter)
43
+ end
44
+ end
45
+
46
+ # Check if this tool is a reference to an external tool
47
+ #
48
+ # @return [Boolean]
49
+ def reference?
50
+ ref.present?
51
+ end
52
+
53
+ # Check if this tool has an inline definition
54
+ #
55
+ # @return [Boolean]
56
+ def inline?
57
+ !reference? && name.present?
58
+ end
59
+
60
+ # Convert to hash representation
61
+ #
62
+ # @return [Hash]
63
+ def to_h
64
+ if reference?
65
+ { "$ref" => ref }
66
+ else
67
+ {
68
+ name: name,
69
+ description: description,
70
+ inputSchema: input_schema
71
+ }.compact
72
+ end
73
+ end
74
+
75
+ # Convert to MCP-compatible JSON string
76
+ #
77
+ # @return [String] JSON representation following MCP tool format
78
+ def to_mcp_json
79
+ {
80
+ name: name,
81
+ description: description,
82
+ inputSchema: input_schema
83
+ }.compact.to_json
84
+ end
85
+
86
+ # Convert to HasTools::ToolBuilder compatible schema
87
+ #
88
+ # This format is compatible with OpenAI's function calling API
89
+ # and SolidAgent's existing HasTools concern.
90
+ #
91
+ # @return [Hash]
92
+ def to_tool_builder_schema
93
+ {
94
+ type: "function",
95
+ name: name,
96
+ description: description,
97
+ parameters: input_schema || { type: "object", properties: {} }
98
+ }
99
+ end
100
+
101
+ # Create a Tool from a HasTools::ToolBuilder schema
102
+ #
103
+ # @param schema [Hash] Schema from ToolBuilder
104
+ # @return [Tool]
105
+ def self.from_tool_builder(schema)
106
+ new(
107
+ name: schema[:name] || schema["name"],
108
+ description: schema[:description] || schema["description"],
109
+ input_schema: schema[:parameters] || schema["parameters"]
110
+ )
111
+ end
112
+
113
+ # Create a Tool from a hash (flexible key formats)
114
+ #
115
+ # @param data [Hash] Tool data with various key formats
116
+ # @return [Tool]
117
+ def self.from_hash(data)
118
+ return new(ref: data["$ref"]) if data["$ref"]
119
+
120
+ new(
121
+ name: data["name"] || data[:name],
122
+ description: data["description"] || data[:description],
123
+ input_schema: data["inputSchema"] || data["input_schema"] || data[:input_schema] || data[:inputSchema]
124
+ )
125
+ end
126
+
127
+ # Get list of required parameters
128
+ #
129
+ # @return [Array<String>]
130
+ def required_parameters
131
+ input_schema&.dig("required") || input_schema&.dig(:required) || []
132
+ end
133
+
134
+ # Get all parameter names
135
+ #
136
+ # @return [Array<String>]
137
+ def parameter_names
138
+ properties = input_schema&.dig("properties") || input_schema&.dig(:properties) || {}
139
+ properties.keys.map(&:to_s)
140
+ end
141
+
142
+ # Check if tool is valid (has required fields)
143
+ #
144
+ # @return [Boolean]
145
+ def valid?
146
+ reference? || name.present?
147
+ end
148
+
149
+ # Validation errors
150
+ #
151
+ # @return [Array<String>]
152
+ def validation_errors
153
+ errors = []
154
+ errors << "Tool must have a name or $ref" unless valid?
155
+ errors << "Tool description is recommended" if inline? && description.blank?
156
+ errors
157
+ end
158
+ end
159
+ end
160
+ end