robot_lab 0.0.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 (153) hide show
  1. checksums.yaml +7 -0
  2. data/.envrc +1 -0
  3. data/.github/workflows/deploy-github-pages.yml +52 -0
  4. data/.github/workflows/deploy-yard-docs.yml +52 -0
  5. data/CHANGELOG.md +55 -0
  6. data/COMMITS.md +196 -0
  7. data/LICENSE.txt +21 -0
  8. data/README.md +332 -0
  9. data/Rakefile +67 -0
  10. data/docs/api/adapters/anthropic.md +121 -0
  11. data/docs/api/adapters/gemini.md +133 -0
  12. data/docs/api/adapters/index.md +104 -0
  13. data/docs/api/adapters/openai.md +134 -0
  14. data/docs/api/core/index.md +113 -0
  15. data/docs/api/core/memory.md +314 -0
  16. data/docs/api/core/network.md +291 -0
  17. data/docs/api/core/robot.md +273 -0
  18. data/docs/api/core/state.md +273 -0
  19. data/docs/api/core/tool.md +353 -0
  20. data/docs/api/history/active-record-adapter.md +195 -0
  21. data/docs/api/history/config.md +191 -0
  22. data/docs/api/history/index.md +132 -0
  23. data/docs/api/history/thread-manager.md +144 -0
  24. data/docs/api/index.md +82 -0
  25. data/docs/api/mcp/client.md +221 -0
  26. data/docs/api/mcp/index.md +111 -0
  27. data/docs/api/mcp/server.md +225 -0
  28. data/docs/api/mcp/transports.md +264 -0
  29. data/docs/api/messages/index.md +67 -0
  30. data/docs/api/messages/text-message.md +102 -0
  31. data/docs/api/messages/tool-call-message.md +144 -0
  32. data/docs/api/messages/tool-result-message.md +154 -0
  33. data/docs/api/messages/user-message.md +171 -0
  34. data/docs/api/streaming/context.md +174 -0
  35. data/docs/api/streaming/events.md +237 -0
  36. data/docs/api/streaming/index.md +108 -0
  37. data/docs/architecture/core-concepts.md +243 -0
  38. data/docs/architecture/index.md +138 -0
  39. data/docs/architecture/message-flow.md +320 -0
  40. data/docs/architecture/network-orchestration.md +216 -0
  41. data/docs/architecture/robot-execution.md +243 -0
  42. data/docs/architecture/state-management.md +323 -0
  43. data/docs/assets/css/custom.css +56 -0
  44. data/docs/assets/images/robot_lab.jpg +0 -0
  45. data/docs/concepts.md +216 -0
  46. data/docs/examples/basic-chat.md +193 -0
  47. data/docs/examples/index.md +129 -0
  48. data/docs/examples/mcp-server.md +290 -0
  49. data/docs/examples/multi-robot-network.md +312 -0
  50. data/docs/examples/rails-application.md +420 -0
  51. data/docs/examples/tool-usage.md +310 -0
  52. data/docs/getting-started/configuration.md +230 -0
  53. data/docs/getting-started/index.md +56 -0
  54. data/docs/getting-started/installation.md +179 -0
  55. data/docs/getting-started/quick-start.md +203 -0
  56. data/docs/guides/building-robots.md +376 -0
  57. data/docs/guides/creating-networks.md +366 -0
  58. data/docs/guides/history.md +359 -0
  59. data/docs/guides/index.md +68 -0
  60. data/docs/guides/mcp-integration.md +356 -0
  61. data/docs/guides/memory.md +309 -0
  62. data/docs/guides/rails-integration.md +432 -0
  63. data/docs/guides/streaming.md +314 -0
  64. data/docs/guides/using-tools.md +394 -0
  65. data/docs/index.md +160 -0
  66. data/examples/01_simple_robot.rb +38 -0
  67. data/examples/02_tools.rb +106 -0
  68. data/examples/03_network.rb +103 -0
  69. data/examples/04_mcp.rb +219 -0
  70. data/examples/05_streaming.rb +124 -0
  71. data/examples/06_prompt_templates.rb +324 -0
  72. data/examples/07_network_memory.rb +329 -0
  73. data/examples/prompts/assistant/system.txt.erb +2 -0
  74. data/examples/prompts/assistant/user.txt.erb +1 -0
  75. data/examples/prompts/billing/system.txt.erb +7 -0
  76. data/examples/prompts/billing/user.txt.erb +1 -0
  77. data/examples/prompts/classifier/system.txt.erb +4 -0
  78. data/examples/prompts/classifier/user.txt.erb +1 -0
  79. data/examples/prompts/entity_extractor/system.txt.erb +11 -0
  80. data/examples/prompts/entity_extractor/user.txt.erb +3 -0
  81. data/examples/prompts/escalation/system.txt.erb +35 -0
  82. data/examples/prompts/escalation/user.txt.erb +34 -0
  83. data/examples/prompts/general/system.txt.erb +4 -0
  84. data/examples/prompts/general/user.txt.erb +1 -0
  85. data/examples/prompts/github_assistant/system.txt.erb +6 -0
  86. data/examples/prompts/github_assistant/user.txt.erb +1 -0
  87. data/examples/prompts/helper/system.txt.erb +1 -0
  88. data/examples/prompts/helper/user.txt.erb +1 -0
  89. data/examples/prompts/keyword_extractor/system.txt.erb +8 -0
  90. data/examples/prompts/keyword_extractor/user.txt.erb +3 -0
  91. data/examples/prompts/order_support/system.txt.erb +27 -0
  92. data/examples/prompts/order_support/user.txt.erb +22 -0
  93. data/examples/prompts/product_support/system.txt.erb +30 -0
  94. data/examples/prompts/product_support/user.txt.erb +32 -0
  95. data/examples/prompts/sentiment_analyzer/system.txt.erb +9 -0
  96. data/examples/prompts/sentiment_analyzer/user.txt.erb +3 -0
  97. data/examples/prompts/synthesizer/system.txt.erb +14 -0
  98. data/examples/prompts/synthesizer/user.txt.erb +15 -0
  99. data/examples/prompts/technical/system.txt.erb +7 -0
  100. data/examples/prompts/technical/user.txt.erb +1 -0
  101. data/examples/prompts/triage/system.txt.erb +16 -0
  102. data/examples/prompts/triage/user.txt.erb +17 -0
  103. data/lib/generators/robot_lab/install_generator.rb +78 -0
  104. data/lib/generators/robot_lab/robot_generator.rb +55 -0
  105. data/lib/generators/robot_lab/templates/initializer.rb.tt +41 -0
  106. data/lib/generators/robot_lab/templates/migration.rb.tt +32 -0
  107. data/lib/generators/robot_lab/templates/result_model.rb.tt +52 -0
  108. data/lib/generators/robot_lab/templates/robot.rb.tt +46 -0
  109. data/lib/generators/robot_lab/templates/robot_test.rb.tt +32 -0
  110. data/lib/generators/robot_lab/templates/routing_robot.rb.tt +53 -0
  111. data/lib/generators/robot_lab/templates/thread_model.rb.tt +40 -0
  112. data/lib/robot_lab/adapters/anthropic.rb +163 -0
  113. data/lib/robot_lab/adapters/base.rb +85 -0
  114. data/lib/robot_lab/adapters/gemini.rb +193 -0
  115. data/lib/robot_lab/adapters/openai.rb +159 -0
  116. data/lib/robot_lab/adapters/registry.rb +81 -0
  117. data/lib/robot_lab/configuration.rb +143 -0
  118. data/lib/robot_lab/error.rb +32 -0
  119. data/lib/robot_lab/errors.rb +70 -0
  120. data/lib/robot_lab/history/active_record_adapter.rb +146 -0
  121. data/lib/robot_lab/history/config.rb +115 -0
  122. data/lib/robot_lab/history/thread_manager.rb +93 -0
  123. data/lib/robot_lab/mcp/client.rb +210 -0
  124. data/lib/robot_lab/mcp/server.rb +84 -0
  125. data/lib/robot_lab/mcp/transports/base.rb +56 -0
  126. data/lib/robot_lab/mcp/transports/sse.rb +117 -0
  127. data/lib/robot_lab/mcp/transports/stdio.rb +133 -0
  128. data/lib/robot_lab/mcp/transports/streamable_http.rb +139 -0
  129. data/lib/robot_lab/mcp/transports/websocket.rb +108 -0
  130. data/lib/robot_lab/memory.rb +882 -0
  131. data/lib/robot_lab/memory_change.rb +123 -0
  132. data/lib/robot_lab/message.rb +357 -0
  133. data/lib/robot_lab/network.rb +350 -0
  134. data/lib/robot_lab/rails/engine.rb +29 -0
  135. data/lib/robot_lab/rails/railtie.rb +42 -0
  136. data/lib/robot_lab/robot.rb +560 -0
  137. data/lib/robot_lab/robot_result.rb +205 -0
  138. data/lib/robot_lab/robotic_model.rb +324 -0
  139. data/lib/robot_lab/state_proxy.rb +188 -0
  140. data/lib/robot_lab/streaming/context.rb +144 -0
  141. data/lib/robot_lab/streaming/events.rb +95 -0
  142. data/lib/robot_lab/streaming/sequence_counter.rb +48 -0
  143. data/lib/robot_lab/task.rb +117 -0
  144. data/lib/robot_lab/tool.rb +223 -0
  145. data/lib/robot_lab/tool_config.rb +112 -0
  146. data/lib/robot_lab/tool_manifest.rb +234 -0
  147. data/lib/robot_lab/user_message.rb +118 -0
  148. data/lib/robot_lab/version.rb +5 -0
  149. data/lib/robot_lab/waiter.rb +73 -0
  150. data/lib/robot_lab.rb +195 -0
  151. data/mkdocs.yml +214 -0
  152. data/sig/robot_lab.rbs +4 -0
  153. metadata +442 -0
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ module Streaming
5
+ # Context for managing streaming events during execution
6
+ #
7
+ # StreamingContext provides methods for publishing events with
8
+ # automatic sequencing, timestamping, and ID generation.
9
+ #
10
+ # @example
11
+ # context = Context.new(
12
+ # run_id: "run_123",
13
+ # message_id: "msg_456",
14
+ # scope: "network",
15
+ # publish: ->(event) { broadcast(event) }
16
+ # )
17
+ #
18
+ # context.publish_event(event: "text.delta", data: { delta: "Hello" })
19
+ #
20
+ class Context
21
+ # @!attribute [r] run_id
22
+ # @return [String] the unique run identifier
23
+ # @!attribute [r] parent_run_id
24
+ # @return [String, nil] the parent run identifier for nested contexts
25
+ # @!attribute [r] message_id
26
+ # @return [String] the current message identifier
27
+ # @!attribute [r] scope
28
+ # @return [String] the context scope (network, robot, etc.)
29
+ attr_reader :run_id, :parent_run_id, :message_id, :scope
30
+
31
+ # Creates a new streaming Context.
32
+ #
33
+ # @param run_id [String] unique run identifier
34
+ # @param message_id [String] current message identifier
35
+ # @param scope [String, Symbol] context scope
36
+ # @param publish [Proc] callback for publishing events
37
+ # @param parent_run_id [String, nil] parent run identifier
38
+ # @param sequence_counter [SequenceCounter, nil] shared sequence counter
39
+ def initialize(run_id:, message_id:, scope:, publish:, parent_run_id: nil, sequence_counter: nil)
40
+ @run_id = run_id
41
+ @parent_run_id = parent_run_id
42
+ @message_id = message_id
43
+ @scope = scope.to_s
44
+ @publish = publish
45
+ @sequence = sequence_counter || SequenceCounter.new
46
+ end
47
+
48
+ # Publish an event
49
+ #
50
+ # @param event [String] Event type
51
+ # @param data [Hash] Event data
52
+ #
53
+ def publish_event(event:, data: {})
54
+ chunk = build_chunk(event, data)
55
+
56
+ begin
57
+ @publish.call(chunk)
58
+ rescue StandardError => e
59
+ RobotLab.configuration.logger.warn("Streaming error: #{e.message}")
60
+ end
61
+
62
+ chunk
63
+ end
64
+
65
+ # Create a child context for nested robot runs
66
+ #
67
+ # @param robot_run_id [String]
68
+ # @return [Context]
69
+ #
70
+ def create_child_context(robot_run_id)
71
+ Context.new(
72
+ run_id: robot_run_id,
73
+ parent_run_id: @run_id,
74
+ message_id: generate_message_id,
75
+ scope: "robot",
76
+ publish: @publish,
77
+ sequence_counter: @sequence # Share sequence counter
78
+ )
79
+ end
80
+
81
+ # Create context with shared sequence counter
82
+ #
83
+ # @param run_id [String]
84
+ # @param message_id [String]
85
+ # @param scope [String]
86
+ # @return [Context]
87
+ #
88
+ def create_context_with_shared_sequence(run_id:, message_id:, scope:)
89
+ Context.new(
90
+ run_id: run_id,
91
+ message_id: message_id,
92
+ scope: scope,
93
+ publish: @publish,
94
+ sequence_counter: @sequence
95
+ )
96
+ end
97
+
98
+ # Generate a part ID (OpenAI-compatible, max 40 chars)
99
+ #
100
+ # @return [String]
101
+ #
102
+ def generate_part_id
103
+ short_msg_id = @message_id[0, 8]
104
+ timestamp = (Time.now.to_f * 1000).to_i.to_s[-6..]
105
+ random = SecureRandom.hex(4)
106
+ "part_#{short_msg_id}_#{timestamp}_#{random}"
107
+ end
108
+
109
+ # Generate a step ID for Inngest compatibility
110
+ #
111
+ # @param base_name [String]
112
+ # @return [String]
113
+ #
114
+ def generate_step_id(base_name)
115
+ "publish-#{@sequence.current}:#{base_name}"
116
+ end
117
+
118
+ # Generate a new message ID
119
+ #
120
+ # @return [String]
121
+ #
122
+ def generate_message_id
123
+ SecureRandom.uuid
124
+ end
125
+
126
+ private
127
+
128
+ def build_chunk(event, data)
129
+ seq = @sequence.next
130
+ {
131
+ event: event,
132
+ data: data.merge(
133
+ run_id: @run_id,
134
+ message_id: @message_id,
135
+ scope: @scope
136
+ ),
137
+ timestamp: (Time.now.to_f * 1000).to_i,
138
+ sequence_number: seq,
139
+ id: "publish-#{seq}:#{event}"
140
+ }
141
+ end
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ module Streaming
5
+ # Event type definitions for streaming
6
+ #
7
+ # Defines the structure and types of events emitted during
8
+ # robot and network execution.
9
+ #
10
+ module Events
11
+ # Run lifecycle events
12
+ RUN_STARTED = "run.started"
13
+ RUN_COMPLETED = "run.completed"
14
+ RUN_FAILED = "run.failed"
15
+ RUN_INTERRUPTED = "run.interrupted"
16
+
17
+ # Step events (for durable execution)
18
+ STEP_STARTED = "step.started"
19
+ STEP_COMPLETED = "step.completed"
20
+ STEP_FAILED = "step.failed"
21
+
22
+ # Part events (message composition)
23
+ PART_CREATED = "part.created"
24
+ PART_COMPLETED = "part.completed"
25
+ PART_FAILED = "part.failed"
26
+
27
+ # Content delta events (token streaming)
28
+ TEXT_DELTA = "text.delta"
29
+ TOOL_CALL_ARGUMENTS_DELTA = "tool_call.arguments.delta"
30
+ TOOL_CALL_OUTPUT_DELTA = "tool_call.output.delta"
31
+ REASONING_DELTA = "reasoning.delta"
32
+ DATA_DELTA = "data.delta"
33
+
34
+ # Human-in-the-loop events
35
+ HITL_REQUESTED = "hitl.requested"
36
+ HITL_RESOLVED = "hitl.resolved"
37
+
38
+ # Metadata events
39
+ USAGE_UPDATED = "usage.updated"
40
+ METADATA_UPDATED = "metadata.updated"
41
+
42
+ # Terminal event
43
+ STREAM_ENDED = "stream.ended"
44
+
45
+ # All event types
46
+ ALL_EVENTS = [
47
+ RUN_STARTED, RUN_COMPLETED, RUN_FAILED, RUN_INTERRUPTED,
48
+ STEP_STARTED, STEP_COMPLETED, STEP_FAILED,
49
+ PART_CREATED, PART_COMPLETED, PART_FAILED,
50
+ TEXT_DELTA, TOOL_CALL_ARGUMENTS_DELTA, TOOL_CALL_OUTPUT_DELTA,
51
+ REASONING_DELTA, DATA_DELTA,
52
+ HITL_REQUESTED, HITL_RESOLVED,
53
+ USAGE_UPDATED, METADATA_UPDATED,
54
+ STREAM_ENDED
55
+ ].freeze
56
+
57
+ # Lifecycle events
58
+ LIFECYCLE_EVENTS = [
59
+ RUN_STARTED, RUN_COMPLETED, RUN_FAILED, RUN_INTERRUPTED
60
+ ].freeze
61
+
62
+ # Delta events (content streaming)
63
+ DELTA_EVENTS = [
64
+ TEXT_DELTA, TOOL_CALL_ARGUMENTS_DELTA, TOOL_CALL_OUTPUT_DELTA,
65
+ REASONING_DELTA, DATA_DELTA
66
+ ].freeze
67
+
68
+ class << self
69
+ # Checks if the event is a lifecycle event.
70
+ #
71
+ # @param event [String] the event type
72
+ # @return [Boolean]
73
+ def lifecycle?(event)
74
+ LIFECYCLE_EVENTS.include?(event)
75
+ end
76
+
77
+ # Checks if the event is a delta (content streaming) event.
78
+ #
79
+ # @param event [String] the event type
80
+ # @return [Boolean]
81
+ def delta?(event)
82
+ DELTA_EVENTS.include?(event)
83
+ end
84
+
85
+ # Checks if the event is a valid event type.
86
+ #
87
+ # @param event [String] the event type
88
+ # @return [Boolean]
89
+ def valid?(event)
90
+ ALL_EVENTS.include?(event)
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ module Streaming
5
+ # Monotonic sequence counter for event ordering
6
+ #
7
+ # Provides globally unique, strictly increasing sequence numbers
8
+ # for event ordering across streaming contexts.
9
+ #
10
+ # Thread-safe via Mutex.
11
+ #
12
+ class SequenceCounter
13
+ # Creates a new SequenceCounter.
14
+ #
15
+ # @param start [Integer] the starting value (default: 0)
16
+ def initialize(start: 0)
17
+ @value = start
18
+ @mutex = Mutex.new
19
+ end
20
+
21
+ # Get the next sequence number
22
+ #
23
+ # @return [Integer]
24
+ #
25
+ def next
26
+ @mutex.synchronize do
27
+ @value += 1
28
+ end
29
+ end
30
+
31
+ # Get the current value without incrementing
32
+ #
33
+ # @return [Integer]
34
+ #
35
+ def current
36
+ @mutex.synchronize { @value }
37
+ end
38
+
39
+ # Reset to a specific value
40
+ #
41
+ # @param value [Integer]
42
+ #
43
+ def reset(value = 0)
44
+ @mutex.synchronize { @value = value }
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ # Wraps a Robot for use as a pipeline step with per-task configuration
5
+ #
6
+ # Task provides a way to pass step-specific context, MCP servers, tools,
7
+ # and memory to individual robots within a network pipeline. The task's
8
+ # context is deep-merged with the network's run parameters.
9
+ #
10
+ # @example Basic task with context
11
+ # task = Task.new(
12
+ # name: :billing,
13
+ # robot: billing_robot,
14
+ # context: { department: "billing", escalation_level: 2 }
15
+ # )
16
+ #
17
+ # @example Task with MCP and tools
18
+ # task = Task.new(
19
+ # name: :developer,
20
+ # robot: dev_robot,
21
+ # context: { project: "api" },
22
+ # mcp: [filesystem_server, github_server],
23
+ # tools: [CodeSearch, FileReader]
24
+ # )
25
+ #
26
+ class Task
27
+ # @!attribute [r] name
28
+ # @return [Symbol] the task/step name
29
+ # @!attribute [r] robot
30
+ # @return [Robot] the wrapped robot instance
31
+ attr_reader :name, :robot
32
+
33
+ # Creates a new Task instance.
34
+ #
35
+ # @param name [Symbol] the task/step name
36
+ # @param robot [Robot] the robot instance to wrap
37
+ # @param context [Hash] task-specific context (deep-merged with run params)
38
+ # @param mcp [Symbol, Array] MCP server config (:none, :inherit, or array)
39
+ # @param tools [Symbol, Array] tools config (:none, :inherit, or array)
40
+ # @param memory [Memory, Hash, nil] task-specific memory
41
+ #
42
+ def initialize(name:, robot:, context: {}, mcp: :none, tools: :none, memory: nil)
43
+ @name = name.to_sym
44
+ @robot = robot
45
+ @context = context
46
+ @mcp = mcp
47
+ @tools = tools
48
+ @memory = memory
49
+ end
50
+
51
+ # SimpleFlow step interface
52
+ #
53
+ # Enhances the result's run_params with task-specific configuration
54
+ # before delegating to the wrapped robot.
55
+ #
56
+ # @param result [SimpleFlow::Result] incoming result from previous step
57
+ # @return [SimpleFlow::Result] result with robot output
58
+ #
59
+ def call(result)
60
+ # Get current run params and deep merge with task context
61
+ run_params = deep_merge(
62
+ result.context[:run_params] || {},
63
+ @context
64
+ )
65
+
66
+ # Add task-specific robot config
67
+ run_params[:mcp] = @mcp unless @mcp == :none
68
+ run_params[:tools] = @tools unless @tools == :none
69
+ run_params[:memory] = @memory if @memory
70
+
71
+ # Create enhanced result with merged params
72
+ enhanced_result = result.with_context(:run_params, run_params)
73
+
74
+ # Delegate to robot
75
+ @robot.call(enhanced_result)
76
+ end
77
+
78
+ # Converts the task to a hash representation.
79
+ #
80
+ # @return [Hash]
81
+ #
82
+ def to_h
83
+ {
84
+ name: @name,
85
+ robot: @robot.name,
86
+ context: @context,
87
+ mcp: @mcp,
88
+ tools: @tools,
89
+ memory: @memory ? true : nil
90
+ }.compact
91
+ end
92
+
93
+ private
94
+
95
+ # Deep merge two hashes
96
+ #
97
+ # Values from `override` take precedence. Nested hashes are merged
98
+ # recursively. Arrays are replaced, not concatenated.
99
+ #
100
+ # @param base [Hash] the base hash
101
+ # @param override [Hash] the overriding hash
102
+ # @return [Hash] the merged result
103
+ #
104
+ def deep_merge(base, override)
105
+ base = base.transform_keys(&:to_sym)
106
+ override = override.transform_keys(&:to_sym)
107
+
108
+ base.merge(override) do |_key, old_val, new_val|
109
+ if old_val.is_a?(Hash) && new_val.is_a?(Hash)
110
+ deep_merge(old_val, new_val)
111
+ else
112
+ new_val
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,223 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ # Defines a tool/function that robots can use
5
+ #
6
+ # Tools are capabilities that robots can invoke during execution.
7
+ # They have a name, description, parameter schema, and handler.
8
+ #
9
+ # @example Simple tool
10
+ # tool = Tool.new(
11
+ # name: "get_time",
12
+ # description: "Get the current time",
13
+ # handler: -> (_input, **_opts) { Time.now.to_s }
14
+ # )
15
+ #
16
+ # @example Tool with parameters (using ruby_llm-schema)
17
+ # class WeatherParams < RubyLLM::Schema
18
+ # string :location, description: "City name"
19
+ # string :unit, enum: %w[celsius fahrenheit], required: false
20
+ # end
21
+ #
22
+ # tool = Tool.new(
23
+ # name: "get_weather",
24
+ # description: "Get weather for a location",
25
+ # parameters: WeatherParams,
26
+ # handler: ->(input, **opts) {
27
+ # # input[:location], input[:unit] are validated
28
+ # fetch_weather(input[:location], input[:unit] || "celsius")
29
+ # }
30
+ # )
31
+ #
32
+ class Tool
33
+ # @!attribute [r] name
34
+ # @return [String] the unique identifier for the tool
35
+ # @!attribute [r] description
36
+ # @return [String, nil] a description of what the tool does
37
+ # @!attribute [r] parameters
38
+ # @return [Class, Hash, nil] the parameter schema (RubyLLM::Schema or JSON Schema hash)
39
+ # @!attribute [r] handler
40
+ # @return [Proc, nil] the callable that executes the tool logic
41
+ # @!attribute [r] mcp
42
+ # @return [String, nil] the MCP server name if this is an MCP-provided tool
43
+ # @!attribute [r] strict
44
+ # @return [Boolean, nil] whether strict mode is enabled
45
+ attr_reader :name, :description, :parameters, :handler, :mcp, :strict
46
+
47
+ # Creates a new Tool instance.
48
+ #
49
+ # @param name [String] the unique identifier for the tool
50
+ # @param description [String, nil] a description of what the tool does
51
+ # @param parameters [Class, Hash, nil] parameter schema (RubyLLM::Schema or JSON Schema)
52
+ # @param handler [Proc, nil] the callable that executes the tool logic
53
+ # @param mcp [String, nil] MCP server name if this is an MCP tool
54
+ # @param strict [Boolean, nil] whether strict mode is enabled
55
+ # @yield [input, **opts] optional block as handler
56
+ #
57
+ # @example Tool with block handler
58
+ # Tool.new(name: "greet", description: "Greet user") do |input, **opts|
59
+ # "Hello, #{input[:name]}!"
60
+ # end
61
+ def initialize(name:, description: nil, parameters: nil, handler: nil, mcp: nil, strict: nil, &block)
62
+ @name = name.to_s
63
+ @description = description
64
+ @parameters = parameters
65
+ @handler = handler || block
66
+ @mcp = mcp
67
+ @strict = strict
68
+ end
69
+
70
+ # Execute the tool with input and context
71
+ #
72
+ # Supports two calling conventions:
73
+ # - Direct: tool.call(input, robot: robot, network: network)
74
+ # - ruby_llm: tool.call(args) - called without keyword args
75
+ #
76
+ # @param input [Hash] The input parameters (validated against schema)
77
+ # @param robot [Robot, nil] The robot invoking this tool
78
+ # @param network [NetworkRun, nil] The network context if running in a network
79
+ # @param step [Object, nil] Durable execution step context
80
+ # @return [Object] The tool's output
81
+ #
82
+ def call(input, robot: nil, network: nil, step: nil)
83
+ raise Error, "Tool '#{name}' has no handler defined" unless handler
84
+
85
+ validated_input = validate_input(input)
86
+ handler.call(validated_input, robot: robot, network: network, step: step)
87
+ rescue Error
88
+ raise
89
+ rescue StandardError => e
90
+ { error: Errors.serialize(e) }
91
+ end
92
+
93
+ # Convert to JSON Schema for LLM function calling
94
+ #
95
+ # @return [Hash] JSON Schema representation
96
+ #
97
+ def to_json_schema
98
+ schema = if parameters.respond_to?(:to_json_schema)
99
+ # ruby_llm-schema class
100
+ parameters.new.to_json_schema[:schema]
101
+ elsif parameters.is_a?(Hash)
102
+ # Raw JSON schema
103
+ parameters
104
+ else
105
+ # No parameters
106
+ { type: "object", properties: {}, required: [] }
107
+ end
108
+
109
+ {
110
+ name: name,
111
+ description: description,
112
+ parameters: schema
113
+ }.compact
114
+ end
115
+
116
+ # Convert to ruby_llm Tool class for integration
117
+ #
118
+ # @return [Class] A RubyLLM::Tool subclass
119
+ #
120
+ def to_ruby_llm_tool
121
+ tool = self
122
+ Class.new(RubyLLM::Tool) do
123
+ description tool.description
124
+
125
+ # Define parameters from schema
126
+ if tool.parameters.respond_to?(:to_json_schema)
127
+ schema = tool.parameters.new.to_json_schema[:schema]
128
+ schema[:properties]&.each do |prop_name, prop_def|
129
+ param prop_name.to_sym,
130
+ type: prop_def[:type],
131
+ desc: prop_def[:description],
132
+ required: schema[:required]&.include?(prop_name.to_s)
133
+ end
134
+ elsif tool.parameters.is_a?(Hash) && tool.parameters[:properties]
135
+ tool.parameters[:properties].each do |prop_name, prop_def|
136
+ param prop_name.to_sym,
137
+ type: prop_def[:type] || "string",
138
+ desc: prop_def[:description],
139
+ required: tool.parameters[:required]&.include?(prop_name.to_s)
140
+ end
141
+ end
142
+
143
+ define_method(:execute) do |**kwargs|
144
+ # This will be overridden at runtime with proper context
145
+ kwargs
146
+ end
147
+ end
148
+ end
149
+
150
+ # Converts the tool to a hash representation.
151
+ #
152
+ # @return [Hash] a hash containing the tool configuration
153
+ def to_h
154
+ {
155
+ name: name,
156
+ description: description,
157
+ parameters: parameters_to_hash,
158
+ mcp: mcp,
159
+ strict: strict
160
+ }.compact
161
+ end
162
+
163
+ # Converts the tool to JSON.
164
+ #
165
+ # @param args [Array] arguments passed to to_json
166
+ # @return [String] JSON representation of the tool
167
+ def to_json(*args)
168
+ to_h.to_json(*args)
169
+ end
170
+
171
+ # Check if this is an MCP-provided tool
172
+ #
173
+ # @return [Boolean]
174
+ #
175
+ def mcp?
176
+ !mcp.nil?
177
+ end
178
+
179
+ # Return parameters schema for ruby_llm compatibility
180
+ #
181
+ # @return [Hash, nil] JSON Schema for tool parameters
182
+ #
183
+ def params_schema
184
+ if parameters.respond_to?(:to_json_schema)
185
+ parameters.new.to_json_schema[:schema]
186
+ elsif parameters.is_a?(Hash)
187
+ parameters
188
+ end
189
+ end
190
+
191
+ # Provider-specific parameters for ruby_llm compatibility
192
+ #
193
+ # @return [Hash] Empty hash (no provider-specific params)
194
+ #
195
+ def provider_params
196
+ {}
197
+ end
198
+
199
+ private
200
+
201
+ def validate_input(input)
202
+ return input unless parameters
203
+
204
+ input = input.transform_keys(&:to_sym) if input.is_a?(Hash)
205
+
206
+ if parameters.respond_to?(:new) && parameters.ancestors.include?(defined?(RubyLLM::Schema) ? RubyLLM::Schema : Object)
207
+ # Validate with ruby_llm-schema (if available)
208
+ # For now, just pass through
209
+ input
210
+ else
211
+ input
212
+ end
213
+ end
214
+
215
+ def parameters_to_hash
216
+ if parameters.respond_to?(:to_json_schema)
217
+ parameters.new.to_json_schema
218
+ elsif parameters.is_a?(Hash)
219
+ parameters
220
+ end
221
+ end
222
+ end
223
+ end