robot_lab 0.0.11 → 0.1.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.
@@ -736,6 +736,131 @@ bot = RobotLab.build(name: "latecomer", system_prompt: "Hello.")
736
736
  bot.with_bus(existing_bus) # now connected and can send/receive messages
737
737
  ```
738
738
 
739
+ ## Context Window Compression
740
+
741
+ Long-running robots accumulate conversation history that can grow to fill the context window. `compress_history` prunes old turns using TF-IDF cosine similarity against the most recent context, keeping turns that are still relevant and discarding or summarizing those that aren't.
742
+
743
+ ```ruby
744
+ # Default settings: protect 3 most-recent turn pairs, drop anything below 0.2
745
+ robot.compress_history
746
+
747
+ # Tune all thresholds
748
+ robot.compress_history(
749
+ recent_turns: 5, # number of recent user+assistant pairs to always keep
750
+ keep_threshold: 0.6, # turns with score >= this are kept verbatim (default 0.6)
751
+ drop_threshold: 0.2 # turns with score < this are dropped (default 0.2)
752
+ )
753
+ ```
754
+
755
+ Medium-relevance turns (between thresholds) are dropped by default. Pass a `summarizer:` callable to replace them with a one-sentence summary instead:
756
+
757
+ ```ruby
758
+ summarizer = RobotLab.build(name: "summarizer", system_prompt: "Summarize concisely in one sentence.")
759
+
760
+ robot.compress_history(
761
+ summarizer: ->(text) { summarizer.run("Summarize: #{text}").reply }
762
+ )
763
+ ```
764
+
765
+ **What is always preserved regardless of score:**
766
+ - System messages
767
+ - Tool call/result message pairs (dropping half would corrupt the conversation)
768
+ - The most recent `recent_turns` user+assistant pairs
769
+
770
+ Requires the `classifier` gem (`~> 2.3`):
771
+
772
+ ```ruby
773
+ gem "classifier", "~> 2.3"
774
+ ```
775
+
776
+ ## Convergence Detection
777
+
778
+ `RobotLab::Convergence` uses TF-IDF cosine similarity to detect when two independent agents have reached the same conclusion. The primary use case is a network router that skips an expensive reconciler robot when two verifiers already agree.
779
+
780
+ ```ruby
781
+ # Check the similarity score directly (returns Float 0.0..1.0)
782
+ score = RobotLab::Convergence.similarity(result_a.reply, result_b.reply)
783
+
784
+ # Boolean convergence check (default threshold: 0.85)
785
+ RobotLab::Convergence.detected?(result_a.reply, result_b.reply)
786
+
787
+ # Custom threshold
788
+ RobotLab::Convergence.detected?(text_a, text_b, threshold: 0.75)
789
+ ```
790
+
791
+ Wire it into a network router for the reconciler fast-path:
792
+
793
+ ```ruby
794
+ verifier_a = RobotLab.build(name: "verifier_a", system_prompt: "Verify the answer.")
795
+ verifier_b = RobotLab.build(name: "verifier_b", system_prompt: "Independently verify the answer.")
796
+ reconciler = RobotLab.build(name: "reconciler", system_prompt: "Reconcile conflicting answers.")
797
+
798
+ router = lambda do |args|
799
+ a = args.context[:verifier_a]&.reply.to_s
800
+ b = args.context[:verifier_b]&.reply.to_s
801
+
802
+ # Skip reconciler when verifiers agree
803
+ RobotLab::Convergence.detected?(a, b) ? nil : ["reconciler"]
804
+ end
805
+
806
+ network = RobotLab.create_network(name: "verify", router: router) do
807
+ # ...
808
+ end
809
+ ```
810
+
811
+ Requires the `classifier` gem (`~> 2.3`).
812
+
813
+ ## Structured Delegation
814
+
815
+ A robot can delegate a task to another robot using `delegate(to:, task:)`. The result is a `RobotResult` annotated with `delegated_by`, `duration`, and token counts.
816
+
817
+ ### Synchronous Delegation
818
+
819
+ The default: blocks the calling robot until the delegatee finishes.
820
+
821
+ ```ruby
822
+ analyst = RobotLab.build(name: "analyst", system_prompt: "Analyze data.")
823
+ manager = RobotLab.build(name: "manager", system_prompt: "Coordinate work.")
824
+
825
+ result = manager.delegate(to: analyst, task: "Summarize the Q3 report.")
826
+ puts result.reply
827
+ puts "Completed in %.2fs using %d tokens" % [result.duration, result.output_tokens]
828
+ puts result.delegated_by # => "manager"
829
+ ```
830
+
831
+ ### Asynchronous Delegation (Fan-out)
832
+
833
+ Pass `async: true` to get a `DelegationFuture` back immediately. Call `.value` to block for the result when you need it.
834
+
835
+ ```ruby
836
+ writer = RobotLab.build(name: "writer", system_prompt: "Write clearly.")
837
+ analyst = RobotLab.build(name: "analyst", system_prompt: "Analyze data.")
838
+ manager = RobotLab.build(name: "manager", system_prompt: "Coordinate.")
839
+
840
+ # Fan out — both start immediately
841
+ f1 = manager.delegate(to: analyst, task: "Analyze Q3 numbers", async: true)
842
+ f2 = manager.delegate(to: writer, task: "Draft the intro paragraph", async: true)
843
+
844
+ # ... do other work here ...
845
+
846
+ # Collect results (blocks if not yet done)
847
+ analysis = f1.value
848
+ draft = f2.value
849
+
850
+ # With a timeout (raises DelegationFuture::DelegationTimeout)
851
+ result = f1.value(timeout: 30)
852
+ ```
853
+
854
+ `DelegationFuture` API:
855
+
856
+ | Method | Description |
857
+ |--------|-------------|
858
+ | `resolved?` | Non-blocking poll — true if completed or errored |
859
+ | `value(timeout: nil)` | Block until done; re-raises any error from the delegatee |
860
+ | `wait` | Alias for `value` |
861
+ | `robot_name` | Name of the robot that was delegated to |
862
+ | `delegated_by` | Name of the robot that created this future |
863
+
739
864
  ## Configuration
740
865
 
741
866
  RobotLab uses `MywayConfig` for configuration. Access the config object directly -- there is no `RobotLab.configure` block:
@@ -84,6 +84,29 @@ network = RobotLab.create_network(name: "parallel_analysis") do
84
84
  end
85
85
  ```
86
86
 
87
+ ### Concurrency Cap
88
+
89
+ When a network fans out to many parallel robots, each makes a simultaneous LLM API call. With no limit this can exhaust API rate-limit quotas or database connection pools under load. Set `max_concurrent_robots:` on a `RunConfig` to cap how many robot tasks run at once — the rest queue behind an `Async::Semaphore` and start as slots open:
90
+
91
+ ```ruby
92
+ config = RobotLab::RunConfig.new(max_concurrent_robots: 4)
93
+
94
+ network = RobotLab.create_network(name: "launch_assessment", config: config) do
95
+ # All six declared parallel, but at most 4 LLM calls in-flight simultaneously
96
+ task :market, market_robot, depends_on: :none
97
+ task :competitive, comp_robot, depends_on: :none
98
+ task :tech, tech_robot, depends_on: :none
99
+ task :risk, risk_robot, depends_on: :none
100
+ task :financial, financial_robot, depends_on: :none # queues until a slot opens
101
+ task :legal, legal_robot, depends_on: :none # queues until a slot opens
102
+
103
+ task :director, director_robot, depends_on: [:market, :competitive, :tech,
104
+ :risk, :financial, :legal]
105
+ end
106
+ ```
107
+
108
+ `nil` (the default) means unlimited — identical to pre-existing behavior. For Rails deployments, size the cap to match your database connection pool and API rate tier. See [Example 31](../../examples/31_launch_assessment.rb) for a working demo.
109
+
87
110
  ### Optional Tasks
88
111
 
89
112
  Optional tasks only run when explicitly activated by a preceding robot:
@@ -362,34 +362,73 @@ channel.send({ message: "Hello!", session_id: sessionId });
362
362
 
363
363
  ## Background Jobs
364
364
 
365
+ ### RobotLab::Job Base Class
366
+
367
+ All RobotLab background jobs inherit from `RobotLab::Job` (`RobotLab::RailsIntegration::Job`), which handles the full robot-run lifecycle automatically:
368
+
369
+ 1. Resolves the robot class (from the `robot_class` DSL or a `robot_class:` kwarg at enqueue time)
370
+ 2. Finds or creates a `RobotLabThread` record and stamps the incoming message
371
+ 3. Wires Turbo Stream callbacks when `turbo-rails` is available (graceful no-op otherwise)
372
+ 4. Runs the robot and persists the `RobotResult` to `RobotLabResult`
373
+ 5. Broadcasts a completion or error event via Turbo Streams
374
+
375
+ `retry_on StandardError` (3 attempts, 5 s wait) and `discard_on ActiveJob::DeserializationError` are configured by default.
376
+
365
377
  ### RobotRunJob (Generated)
366
378
 
367
- The install generator creates `app/jobs/robot_run_job.rb` an ActiveJob class that wraps robot execution with result persistence and optional Turbo Stream broadcasting.
379
+ The install generator creates a thin subclass you can enqueue with any robot class at runtime:
380
+
381
+ ```ruby title="app/jobs/robot_run_job.rb"
382
+ class RobotRunJob < RobotLab::Job
383
+ queue_as :default
384
+ end
385
+ ```
368
386
 
369
387
  ```ruby
370
- # Enqueue from a controller
388
+ # Enqueue from a controller — pass robot_class: as a string
371
389
  RobotRunJob.perform_later(
372
390
  robot_class: "SupportRobot",
373
- message: params[:message],
374
- thread_id: session_id
391
+ message: params[:message],
392
+ thread_id: session_id
375
393
  )
376
394
 
377
395
  render json: { status: "processing" }
378
396
  ```
379
397
 
380
- The job:
398
+ ### Dedicated Job (robot_class DSL)
381
399
 
382
- 1. Finds or creates a `RobotLabThread` by `thread_id`
383
- 2. Resolves the robot class via `constantize.build`
384
- 3. Wires Turbo Stream callbacks when `turbo-rails` is available (graceful no-op otherwise)
385
- 4. Runs the robot and persists the result to `RobotLabResult`
386
- 5. Broadcasts completion or error events via Turbo Streams
400
+ Generate a job pre-bound to a specific robot class so callers never need to pass `robot_class:`:
401
+
402
+ ```bash
403
+ rails generate robot_lab:job Support # binds to SupportRobot, queue: default
404
+ rails generate robot_lab:job Support --queue ai # custom queue name
405
+ ```
387
406
 
388
- Customize the generated job to change queue name, retry policy, or error handling.
407
+ ```ruby title="app/jobs/support_job.rb"
408
+ class SupportJob < RobotLab::Job
409
+ queue_as :default
410
+ robot_class SupportRobot
411
+ end
412
+ ```
413
+
414
+ ```ruby
415
+ # No robot_class: needed at enqueue time
416
+ SupportJob.perform_later(message: params[:message], thread_id: session_id)
417
+ ```
418
+
419
+ The `robot_class` DSL is per-subclass and does not affect sibling job classes.
420
+
421
+ ### Omitting thread_id (fire-and-forget)
422
+
423
+ When `thread_id` is omitted the job runs the robot and returns the result without any persistence or broadcasting:
424
+
425
+ ```ruby
426
+ RobotRunJob.perform_later(robot_class: "ChatRobot", message: "ping")
427
+ ```
389
428
 
390
429
  ### Turbo Stream Token Streaming
391
430
 
392
- When `turbo-rails` is installed, `RobotRunJob` automatically streams content tokens and tool call badges to the browser in real time.
431
+ When `turbo-rails` is installed, `RobotLab::Job` automatically streams content tokens and tool call badges to the browser in real time.
393
432
 
394
433
  #### View Setup
395
434
 
@@ -435,7 +474,7 @@ The stream name convention is `"robot_lab_thread_#{thread_id}"`, matching the `R
435
474
 
436
475
  ### Custom Background Job
437
476
 
438
- For full control, write your own job instead of using the generated one:
477
+ For full control outside of the `RobotLab::Job` lifecycle (e.g. custom persistence or a different broadcasting strategy), inherit from `ApplicationJob` directly:
439
478
 
440
479
  ```ruby title="app/jobs/process_message_job.rb"
441
480
  class ProcessMessageJob < ApplicationJob
@@ -1,79 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class RobotRunJob < ApplicationJob
3
+ # Generic background job for executing any robot asynchronously.
4
+ #
5
+ # Inherits from RobotLab::Job — Turbo Stream wiring, thread persistence,
6
+ # and completion/error broadcasting are all handled by the base class.
7
+ #
8
+ # Pass robot_class: at enqueue time to select which robot to run.
9
+ #
10
+ # @example Enqueue from a controller
11
+ # RobotRunJob.perform_later(
12
+ # robot_class: "ChatRobot",
13
+ # message: params[:message],
14
+ # thread_id: session_id
15
+ # )
16
+ #
17
+ class RobotRunJob < RobotLab::Job
4
18
  queue_as :default
5
-
6
- retry_on StandardError, wait: 5.seconds, attempts: 3
7
- discard_on ActiveJob::DeserializationError
8
-
9
- def perform(robot_class:, message:, thread_id:, **context)
10
- thread = RobotLabThread.find_or_create_by_session_id(thread_id)
11
- thread.update!(last_user_message: message, last_user_message_at: Time.current)
12
-
13
- robot = resolve_robot(robot_class, thread_id)
14
- result = robot.run(message, **context)
15
-
16
- persist_result(thread, result)
17
- broadcast_completion(thread_id)
18
- rescue StandardError => e
19
- broadcast_error(thread_id, e)
20
- raise
21
- end
22
-
23
- private
24
-
25
- def resolve_robot(robot_class, thread_id)
26
- klass = robot_class.to_s.constantize
27
- stream_name = "robot_lab_thread_#{thread_id}"
28
-
29
- if turbo_available?
30
- on_content = RobotLab::RailsIntegration::TurboStreamCallbacks.build_content_callback(
31
- stream_name: stream_name
32
- )
33
- on_tool_call = RobotLab::RailsIntegration::TurboStreamCallbacks.build_tool_call_callback(
34
- stream_name: stream_name
35
- )
36
- klass.build(on_content: on_content, on_tool_call: on_tool_call)
37
- else
38
- klass.build
39
- end
40
- end
41
-
42
- def persist_result(thread, result)
43
- sequence = thread.results.maximum(:sequence_number).to_i + 1
44
- exported = result.export
45
-
46
- thread.results.create!(
47
- robot_name: result.robot_name,
48
- sequence_number: sequence,
49
- output_data: exported[:output],
50
- tool_calls_data: exported[:tool_calls],
51
- stop_reason: result.stop_reason,
52
- checksum: result.checksum
53
- )
54
- end
55
-
56
- def broadcast_completion(thread_id)
57
- return unless turbo_available?
58
-
59
- Turbo::StreamsChannel.broadcast_replace_to(
60
- "robot_lab_thread_#{thread_id}",
61
- target: "robot_status",
62
- html: "<div id=\"robot_status\"><span class=\"complete\">Complete</span></div>"
63
- )
64
- end
65
-
66
- def broadcast_error(thread_id, error)
67
- return unless turbo_available?
68
-
69
- Turbo::StreamsChannel.broadcast_append_to(
70
- "robot_lab_thread_#{thread_id}",
71
- target: "robot_errors",
72
- html: "<div class=\"error\">#{ERB::Util.html_escape(error.message)}</div>"
73
- )
74
- end
75
-
76
- def turbo_available?
77
- defined?(Turbo::StreamsChannel)
78
- end
79
19
  end
@@ -0,0 +1,248 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Example 31: Product Launch Assessment — 6 Parallel Analysts, Cap of 4
5
+ #
6
+ # Six specialist robots evaluate a product launch simultaneously.
7
+ # max_concurrent_robots: 4 ensures at most 4 LLM API calls are in-flight
8
+ # at once. Robots 5 and 6 queue behind the Async::Semaphore and start as
9
+ # soon as any of the first 4 finishes — providing natural back-pressure
10
+ # without slowing the pipeline more than necessary.
11
+ #
12
+ # Architecture:
13
+ #
14
+ # ┌──────────────────────────────────────────────────────────────────────┐
15
+ # │ PARALLEL ANALYSIS PHASE (max_concurrent_robots: 4) │
16
+ # │ │
17
+ # │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
18
+ # │ │ Market │ │ Compet. │ │ Tech │ │ Risk │ slots 1-4 │
19
+ # │ │ Analyst │ │ Analyst │ │ Reviewer │ │ Assessor │ │
20
+ # │ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
21
+ # │ │ │ │ │ │
22
+ # │ start start start start │
23
+ # │ │
24
+ # │ ┌──────────┐ ┌──────────┐ │
25
+ # │ │Financial │ │ Legal │ queued — start when a slot opens │
26
+ # │ │ Reviewer │ │ Reviewer │ │
27
+ # │ └────┬─────┘ └────┬─────┘ │
28
+ # │ │ │ │
29
+ # │ (deferred) (deferred) │
30
+ # │ │
31
+ # │ ┌─────────────────────────────────────────────────────────────┐ │
32
+ # │ │ SHARED MEMORY │ │
33
+ # │ │ :market :competitive :tech :risk :financial :legal │ │
34
+ # │ └─────────────────────────────────────────────────────────────┘ │
35
+ # │ │ │
36
+ # │ ▼ │
37
+ # │ ┌─────────────────────────────────────────────────────────────┐ │
38
+ # │ │ Launch Director │ │
39
+ # │ │ Blocks on reactive memory until all 6 findings arrive, │ │
40
+ # │ │ then issues a GO / NO-GO recommendation. │ │
41
+ # │ └─────────────────────────────────────────────────────────────┘ │
42
+ # └──────────────────────────────────────────────────────────────────────┘
43
+ #
44
+ # Key config:
45
+ # RunConfig.new(max_concurrent_robots: 4)
46
+ #
47
+ # Usage:
48
+ # ANTHROPIC_API_KEY=your_key ruby examples/31_launch_assessment.rb
49
+
50
+ ENV["ROBOT_LAB_TEMPLATE_PATH"] ||= File.join(__dir__, "prompts")
51
+
52
+ require_relative "../lib/robot_lab"
53
+
54
+ RubyLLM.configure { |c| c.logger = Logger.new(File::NULL) }
55
+
56
+ # ── AnalystRobot ────────────────────────────────────────────────────────────────
57
+ #
58
+ # Runs its LLM call, writes the verdict to shared memory, and logs a timing line
59
+ # so you can see when the semaphore releases robots 5 and 6.
60
+
61
+ class AnalystRobot < RobotLab::Robot
62
+ attr_reader :memory_key
63
+ attr_writer :shared_memory
64
+
65
+ def initialize(name:, memory_key:, role:)
66
+ super(
67
+ name: name,
68
+ system_prompt: "You are a #{role}. " \
69
+ "Review the product brief in 2-3 crisp sentences from your area of expertise. " \
70
+ "Close with a one-word verdict: READY or NOT-READY."
71
+ )
72
+ @memory_key = memory_key
73
+ end
74
+
75
+ def call(result)
76
+ brief = extract_brief(result)
77
+ started = Time.now
78
+ puts " [#{name}] started at +#{"%.1f" % (started - $run_start)}s"
79
+
80
+ verdict = run(brief).reply.strip
81
+
82
+ elapsed = "%.1f" % (Time.now - started)
83
+ puts " [#{name}] finished in #{elapsed}s — #{verdict.split.last(2).join(" ")}"
84
+
85
+ if @shared_memory
86
+ @shared_memory.current_writer = name
87
+ @shared_memory.set(@memory_key, verdict)
88
+ end
89
+
90
+ result.with_context(name.to_sym, verdict).continue(verdict)
91
+ end
92
+
93
+ private
94
+
95
+ def extract_brief(result)
96
+ case result.value
97
+ when Hash then result.value[:message].to_s
98
+ when RobotLab::RobotResult then result.value.reply.to_s
99
+ else result.value.to_s
100
+ end
101
+ end
102
+ end
103
+
104
+ # ── LaunchDirector ──────────────────────────────────────────────────────────────
105
+ #
106
+ # Waits for all 6 findings via reactive memory, then issues the final call.
107
+ # SimpleFlow guarantees the six analyst tasks are done before this task runs,
108
+ # so the memory.get is effectively a non-blocking read by the time we arrive here.
109
+
110
+ class LaunchDirector < RobotLab::Robot
111
+ attr_writer :shared_memory
112
+
113
+ FINDING_KEYS = %i[market competitive tech risk financial legal].freeze
114
+
115
+ def call(result)
116
+ puts " [#{name}] reading all findings from shared memory..."
117
+ findings = @shared_memory.get(*FINDING_KEYS, wait: 120)
118
+
119
+ if findings.values.any? { |v| v == :timeout }
120
+ timed_out = findings.select { |_, v| v == :timeout }.keys
121
+ puts " [#{name}] WARNING: timed out waiting for: #{timed_out.join(", ")}"
122
+ end
123
+
124
+ prompt = <<~PROMPT
125
+ Six specialist analysts have reviewed our product launch readiness.
126
+ Based on their findings, issue a final GO or NO-GO recommendation
127
+ in 3-5 sentences. Be direct and specific about the key deciding factors.
128
+
129
+ Market analysis: #{findings[:market] || "(not received)"}
130
+ Competitive analysis: #{findings[:competitive] || "(not received)"}
131
+ Technical review: #{findings[:tech] || "(not received)"}
132
+ Risk assessment: #{findings[:risk] || "(not received)"}
133
+ Financial review: #{findings[:financial] || "(not received)"}
134
+ Legal review: #{findings[:legal] || "(not received)"}
135
+
136
+ Begin your response with "GO -" or "NO-GO -".
137
+ PROMPT
138
+
139
+ recommendation = run(prompt).reply.strip
140
+ @shared_memory.set(:recommendation, recommendation)
141
+ puts " [#{name}] recommendation ready"
142
+
143
+ result.with_context(:recommendation, recommendation).continue(recommendation)
144
+ end
145
+ end
146
+
147
+ # ── Product Brief ───────────────────────────────────────────────────────────────
148
+
149
+ PRODUCT_BRIEF = <<~BRIEF
150
+ Product: "Orion" — an AI-powered project management tool that auto-generates
151
+ sprint plans from Jira backlogs, detects scope creep in real-time, and integrates
152
+ with GitHub and Slack via webhooks. SaaS pricing: $25/seat/month, 14-day free trial.
153
+ Target: mid-size engineering teams (20-200 developers). Launch date: 6 weeks out.
154
+ Beta: 12 paying customers, 94% satisfaction, 0 critical bugs open. SOC 2 Type I
155
+ certification in progress, expected within 30 days.
156
+ BRIEF
157
+
158
+ # ── Build the six analysts ───────────────────────────────────────────────────────
159
+
160
+ ANALYSTS = [
161
+ { name: "market_analyst", key: :market, role: "market opportunity analyst" },
162
+ { name: "competitive_analyst", key: :competitive, role: "competitive intelligence analyst" },
163
+ { name: "tech_reviewer", key: :tech, role: "technical readiness and quality reviewer" },
164
+ { name: "risk_assessor", key: :risk, role: "product risk assessment specialist" },
165
+ { name: "financial_reviewer", key: :financial, role: "financial viability and pricing analyst" },
166
+ { name: "legal_reviewer", key: :legal, role: "legal, compliance, and IP reviewer" },
167
+ ].freeze
168
+
169
+ analyst_robots = ANALYSTS.map do |spec|
170
+ AnalystRobot.new(name: spec[:name], memory_key: spec[:key], role: spec[:role])
171
+ end
172
+
173
+ director = LaunchDirector.new(
174
+ name: "launch_director",
175
+ system_prompt: "You are the VP of Product making the final launch call."
176
+ )
177
+
178
+ # ── Network — note the concurrency cap ──────────────────────────────────────────
179
+
180
+ config = RobotLab::RunConfig.new(max_concurrent_robots: 4)
181
+
182
+ analyst_names = analyst_robots.map { |r| r.name.to_sym }
183
+
184
+ network = RobotLab.create_network(name: "launch_assessment", config: config) do
185
+ analyst_robots.each do |robot|
186
+ task robot.name.to_sym, robot, depends_on: :none
187
+ end
188
+
189
+ task :launch_director, director, depends_on: analyst_names
190
+ end
191
+
192
+ # Assign shared memory so each robot can write to it directly
193
+ shared_memory = network.memory
194
+ (analyst_robots + [director]).each { |r| r.shared_memory = shared_memory }
195
+
196
+ # Subscribe for a memory-level audit trail
197
+ analyst_robots.each do |robot|
198
+ network.memory.subscribe(robot.memory_key) do |change|
199
+ puts " [memory] :#{change.key} written by #{change.writer}"
200
+ end
201
+ end
202
+
203
+ # ── Run ─────────────────────────────────────────────────────────────────────────
204
+
205
+ puts "=" * 68
206
+ puts "Example 31: Product Launch Assessment"
207
+ puts " 6 specialist analysts in parallel, max_concurrent_robots: 4"
208
+ puts "=" * 68
209
+ puts
210
+ puts "Pipeline:"
211
+ puts network.visualize
212
+ puts
213
+ puts "Concurrency config: #{config.inspect}"
214
+ puts
215
+ puts "Product brief:"
216
+ puts PRODUCT_BRIEF.strip.gsub(/^/, " ")
217
+ puts
218
+ puts "-" * 68
219
+ puts "Running — analysts 5 and 6 queue until a semaphore slot opens..."
220
+ puts "-" * 68
221
+ puts
222
+
223
+ $run_start = Time.now
224
+ result = network.run(message: PRODUCT_BRIEF)
225
+ elapsed = "%.1f" % (Time.now - $run_start)
226
+
227
+ puts
228
+ puts "-" * 68
229
+ puts "All analysts complete. Total wall time: #{elapsed}s"
230
+ puts "-" * 68
231
+ puts
232
+ puts "=" * 68
233
+ puts "LAUNCH DIRECTOR RECOMMENDATION"
234
+ puts "=" * 68
235
+ puts
236
+ puts network.memory[:recommendation]
237
+ puts
238
+ puts "=" * 68
239
+ puts "INDIVIDUAL ANALYST VERDICTS"
240
+ puts "=" * 68
241
+ puts
242
+ analyst_robots.each do |robot|
243
+ label = robot.name.gsub("_", " ").upcase
244
+ finding = network.memory.get(robot.memory_key).to_s
245
+ puts "#{label}"
246
+ puts finding
247
+ puts
248
+ end
data/examples/README.md CHANGED
@@ -57,6 +57,7 @@ examples/
57
57
  26_document_store.rb # Embedding-based document store (RAG) via fastembed
58
58
  29_ractor_tools.rb # Ractor-safe tools: worker pool, freeze_deep, parallel batch
59
59
  30_ractor_network.rb # Ractor network scheduler: dependency waves, parallel_mode
60
+ 31_launch_assessment.rb # 6 parallel analysts, max_concurrent_robots: 4 semaphore cap
60
61
  18_rails/ # Minimal Rails 8 demo app (full integration)
61
62
  app/robots/chat_robot.rb # Robot factory with system prompt + TimeTool
62
63
  app/tools/time_tool.rb # Custom RobotLab::Tool subclass
@@ -297,6 +298,14 @@ and the `pipeline.step_dependencies` dependency graph inspection.
297
298
 
298
299
  **Requires:** None for Parts 1 & 2. LLM API key for Part 3.
299
300
 
301
+ ### 31 — Product Launch Assessment (Concurrency Cap)
302
+
303
+ Six specialist robots evaluate a product launch simultaneously: market, competitive, technical, risk, financial, and legal analysts. `RunConfig.new(max_concurrent_robots: 4)` caps the `Async::Semaphore` at 4 in-flight LLM calls — robots 5 and 6 queue until a slot opens. A `LaunchDirector` reads all six findings from shared reactive memory and issues a GO / NO-GO recommendation. Start timestamps in the output make the semaphore behavior visible.
304
+
305
+ Demonstrates: `max_concurrent_robots:` on `RunConfig`, `Async::Semaphore` back-pressure via `simple_flow`, six parallel `depends_on: :none` tasks, shared memory writes and blocking reads.
306
+
307
+ **Requires:** LLM API key
308
+
300
309
  ### 18 — Rails Integration Demo
301
310
 
302
311
  A minimal, hand-built Rails 8 app that exercises every piece of RobotLab's Rails integration end-to-end. No `rails new` — every file is hand-crafted for minimum size.
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+
5
+ module RobotLab
6
+ module Generators
7
+ # Generates a RobotLab job subclass pre-wired to a specific robot class.
8
+ #
9
+ # Usage:
10
+ # rails generate robot_lab:job NAME [options]
11
+ #
12
+ # Examples:
13
+ # rails generate robot_lab:job Support
14
+ # # => app/jobs/support_job.rb (robot_class SupportRobot)
15
+ #
16
+ class JobGenerator < ::Rails::Generators::NamedBase
17
+ source_root File.expand_path("templates", __dir__)
18
+
19
+ class_option :queue, type: :string, default: "default",
20
+ desc: "ActiveJob queue name"
21
+
22
+ # Creates the job file.
23
+ #
24
+ # @return [void]
25
+ def create_job_file
26
+ template "robot_job.rb.tt", "app/jobs/#{file_name}_job.rb"
27
+ end
28
+
29
+ private
30
+
31
+ def queue_name
32
+ options[:queue]
33
+ end
34
+
35
+ def robot_class_name
36
+ "#{class_name}Robot"
37
+ end
38
+ end
39
+ end
40
+ end