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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +57 -0
- data/README.md +180 -0
- data/docs/api/core/result.md +123 -0
- data/docs/api/errors.md +185 -0
- data/docs/api/messages/index.md +21 -0
- data/docs/getting-started/configuration.md +1 -1
- data/docs/guides/building-robots.md +125 -0
- data/docs/guides/creating-networks.md +23 -0
- data/docs/guides/rails-integration.md +52 -13
- data/examples/18_rails/app/jobs/robot_run_job.rb +15 -75
- data/examples/31_launch_assessment.rb +248 -0
- data/examples/README.md +9 -0
- data/lib/generators/robot_lab/job_generator.rb +40 -0
- data/lib/generators/robot_lab/templates/job.rb.tt +10 -81
- data/lib/generators/robot_lab/templates/robot_job.rb.tt +18 -0
- data/lib/robot_lab/message.rb +1 -1
- data/lib/robot_lab/network.rb +1 -1
- data/lib/robot_lab/rails_integration/job.rb +158 -0
- data/lib/robot_lab/rails_integration/railtie.rb +9 -0
- data/lib/robot_lab/run_config.rb +1 -1
- data/lib/robot_lab/version.rb +1 -1
- data/lib/robot_lab.rb +4 -0
- metadata +9 -4
- data/.github/workflows/deploy-yard-docs.yml +0 -52
|
@@ -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
|
|
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:
|
|
374
|
-
thread_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
|
-
|
|
398
|
+
### Dedicated Job (robot_class DSL)
|
|
381
399
|
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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
|
-
|
|
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, `
|
|
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
|
|
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
|
-
|
|
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
|