Skip to content

Commit 1eb55fe

Browse files
authored
Merge pull request #13 from MadBomber/develop
feat(phase-3): structured delegation and memory notification coalescing
2 parents d8da931 + 810dced commit 1eb55fe

10 files changed

Lines changed: 699 additions & 8 deletions

File tree

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@
3131
- <strong>Tool Loop Circuit Breaker</strong> - <code>max_tool_rounds:</code> guards against runaway tool call loops<br>
3232
- <strong>Learning Accumulation</strong> - <code>robot.learn()</code> builds up cross-run observations with deduplication<br>
3333
- <strong>Context Window Compression</strong> - <code>robot.compress_history()</code> prunes irrelevant old turns via TF cosine scoring<br>
34-
- <strong>Convergence Detection</strong> - <code>RobotLab::Convergence</code> detects when independent agents agree, enabling reconciler fast-path
34+
- <strong>Convergence Detection</strong> - <code>RobotLab::Convergence</code> detects when independent agents agree, enabling reconciler fast-path<br>
35+
- <strong>Structured Delegation</strong> - <code>robot.delegate(to:, task:)</code> sync or async inter-robot calls with duration and token metadata; async fan-out via <code>DelegationFuture</code>
3536
</td>
3637
</tr>
3738
</table>

docs/guides/observability.md

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ Facilities that help you monitor, control, improve, and scale robot behaviour:
77
- **Learning Accumulation** — build up cross-run observations that guide future runs
88
- **Context Window Compression** — prune irrelevant history to stay within token budgets
99
- **Convergence Detection** — detect when independent agents reach the same conclusion
10+
- **Structured Delegation** — synchronous inter-robot calls with duration and token metadata
1011

1112
---
1213

@@ -392,6 +393,88 @@ gem "classifier", "~> 2.3"
392393

393394
---
394395

396+
---
397+
398+
## Structured Delegation
399+
400+
### The Problem
401+
402+
RobotLab has two existing patterns for one robot to involve another:
403+
404+
- **Pipelines** — predefined sequences where robots share memory and run in order
405+
- **Bus messaging** — fire-and-forget pub/sub with no return value
406+
407+
Neither gives you a synchronous call that returns a result with provenance and cost metadata. `delegate` fills that gap.
408+
409+
### Synchronous delegation
410+
411+
Blocks until the delegatee finishes and returns a `RobotResult` annotated with provenance and timing:
412+
413+
```ruby
414+
result = manager.delegate(to: specialist, task: "Analyze this data: ...")
415+
416+
puts result.reply # specialist's answer
417+
puts result.robot_name # => "specialist" (who did the work)
418+
puts result.delegated_by # => "manager" (who asked)
419+
puts result.duration # => 1.43 (wall-clock seconds)
420+
puts result.input_tokens # => 812
421+
puts result.output_tokens # => 94
422+
```
423+
424+
All keyword arguments are forwarded to the delegatee's `run()`:
425+
426+
```ruby
427+
result = manager.delegate(to: worker, task: "hello", company_name: "Acme")
428+
```
429+
430+
### Asynchronous delegation — parallel fan-out
431+
432+
Pass `async: true` to get a `DelegationFuture` back immediately. The delegatee runs in a background thread. Call `future.value` to block for the result, or `future.resolved?` to poll without blocking.
433+
434+
```ruby
435+
# Fire both delegations simultaneously
436+
f1 = manager.delegate(to: summarizer, task: "Summarize: #{doc}", async: true)
437+
f2 = manager.delegate(to: analyst, task: "Key metric: #{doc}", async: true)
438+
439+
# Both are running in parallel here
440+
puts f1.resolved? # false (probably)
441+
442+
# Collect when ready (optional timeout in seconds)
443+
summary = f1.value(timeout: 30)
444+
analysis = f2.value(timeout: 30)
445+
```
446+
447+
If the delegatee raises an error, `future.value` re-raises it. If `timeout:` expires before the result arrives, `DelegationFuture::DelegationTimeout` is raised.
448+
449+
### When to Use Each Pattern
450+
451+
| Pattern | Return value | Concurrent | Use when |
452+
|---|---|---|---|
453+
| `pipeline` | shared memory | yes (parallel groups) | fixed workflow graph |
454+
| `bus` messaging | none (fire-and-forget) | yes | notify without waiting for a reply |
455+
| `delegate` | `RobotResult` with metadata | no | need the result back, one at a time |
456+
| `delegate(async: true)` | `DelegationFuture` | yes | parallel fan-out, collect results later |
457+
458+
### Full Example
459+
460+
```ruby
461+
manager = RobotLab.build(name: "manager", system_prompt: "You are a project manager.")
462+
summarizer = RobotLab.build(name: "summarizer", system_prompt: "Summarize in 1-2 sentences.")
463+
analyst = RobotLab.build(name: "analyst", system_prompt: "Identify the key metric.")
464+
465+
# Parallel fan-out
466+
f1 = manager.delegate(to: summarizer, task: "Summarize: #{document}", async: true)
467+
f2 = manager.delegate(to: analyst, task: "Key metric: #{document}", async: true)
468+
469+
summary = f1.value(timeout: 60)
470+
analysis = f2.value(timeout: 60)
471+
472+
puts "#{summary.robot_name} (#{summary.duration.round(2)}s): #{summary.reply}"
473+
puts "#{analysis.robot_name} (#{analysis.duration.round(2)}s): #{analysis.reply}"
474+
```
475+
476+
---
477+
395478
## See Also
396479

397480
- [Robot API](../api/core/robot.md#token--cost-tracking)
@@ -400,3 +483,4 @@ gem "classifier", "~> 2.3"
400483
- [Example 21 — Learning Accumulation Loop](../../examples/21_learning_loop.rb)
401484
- [Example 22 — Context Window Compression](../../examples/22_context_compression.rb)
402485
- [Example 23 — Convergence Detection](../../examples/23_convergence.rb)
486+
- [Example 24 — Structured Delegation](../../examples/24_structured_delegation.rb)
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
#!/usr/bin/env ruby
2+
# frozen_string_literal: true
3+
4+
# Example 24: Structured Delegation
5+
#
6+
# Demonstrates robot.delegate(to:, task:) for structured inter-robot calls,
7+
# in both synchronous (blocking) and asynchronous (parallel fan-out) modes.
8+
#
9+
# Demonstrates:
10+
# - robot.delegate(to:, task:) — sync: blocks, returns RobotResult
11+
# - robot.delegate(to:, task:, async: true) — async: returns DelegationFuture
12+
# - future.value / future.value(timeout: N) — block until result ready
13+
# - future.resolved? — non-blocking poll
14+
# - result.delegated_by — which robot delegated
15+
# - result.robot_name — which robot did the work
16+
# - result.duration — wall-clock seconds for the delegated call
17+
# - result.input_tokens / result.output_tokens — delegatee's token usage
18+
# - Contrast with bus messaging (fire-and-forget) and pipelines (predefined)
19+
#
20+
# Usage:
21+
# ANTHROPIC_API_KEY=your_key ruby examples/24_structured_delegation.rb
22+
23+
ENV["ROBOT_LAB_TEMPLATE_PATH"] ||= File.join(__dir__, "prompts")
24+
25+
require_relative "../lib/robot_lab"
26+
27+
puts "=" * 60
28+
puts "Example 24: Structured Delegation"
29+
puts "=" * 60
30+
puts
31+
32+
# ---------------------------------------------------------------------------
33+
# Build a manager and two specialist robots
34+
# ---------------------------------------------------------------------------
35+
manager = RobotLab.build(
36+
name: "manager",
37+
system_prompt: "You are a project manager. Delegate tasks concisely."
38+
)
39+
40+
summarizer = RobotLab.build(
41+
name: "summarizer",
42+
system_prompt: "You are a concise summarizer. Produce a 1-2 sentence summary."
43+
)
44+
45+
analyst = RobotLab.build(
46+
name: "analyst",
47+
system_prompt: "You are a data analyst. Identify the key metric in one sentence."
48+
)
49+
50+
# ---------------------------------------------------------------------------
51+
# Manager delegates to each specialist in turn
52+
# ---------------------------------------------------------------------------
53+
document = <<~TEXT
54+
Q4 revenue came in at $4.2M, up 18% year-over-year. Customer acquisition
55+
cost dropped to $120, the lowest in three years. Churn held steady at 2.1%.
56+
Net promoter score improved from 42 to 58. The mobile app drove 34% of new
57+
sign-ups, compared to 19% in Q3.
58+
TEXT
59+
60+
puts "Document:"
61+
puts document
62+
puts "-" * 60
63+
64+
# ---------------------------------------------------------------------------
65+
# Synchronous delegation — sequential, blocks until each result arrives
66+
# ---------------------------------------------------------------------------
67+
puts "── Synchronous (sequential) ──────────────────────────────"
68+
puts
69+
70+
puts "Delegating to summarizer (blocking)..."
71+
summary_result = manager.delegate(to: summarizer, task: "Summarize this report:\n\n#{document}")
72+
73+
puts "Summary (from #{summary_result.robot_name}, delegated by #{summary_result.delegated_by}):"
74+
puts " #{summary_result.reply}"
75+
puts " Duration: #{"%.2f" % summary_result.duration}s | " \
76+
"Tokens: #{summary_result.input_tokens} in / #{summary_result.output_tokens} out"
77+
puts
78+
79+
puts "Delegating to analyst (blocking)..."
80+
analysis_result = manager.delegate(to: analyst, task: "What is the single most important metric here?\n\n#{document}")
81+
82+
puts "Analysis (from #{analysis_result.robot_name}, delegated by #{analysis_result.delegated_by}):"
83+
puts " #{analysis_result.reply}"
84+
puts " Duration: #{"%.2f" % analysis_result.duration}s | " \
85+
"Tokens: #{analysis_result.input_tokens} in / #{analysis_result.output_tokens} out"
86+
puts
87+
88+
# ---------------------------------------------------------------------------
89+
# Asynchronous delegation — parallel fan-out, results collected later
90+
# ---------------------------------------------------------------------------
91+
puts "── Asynchronous (parallel fan-out) ───────────────────────"
92+
puts
93+
94+
# Fresh robots — each delegate call should start from a clean slate
95+
async_summarizer = RobotLab.build(
96+
name: "summarizer",
97+
system_prompt: "You are a concise summarizer. Produce a 1-2 sentence summary."
98+
)
99+
async_analyst = RobotLab.build(
100+
name: "analyst",
101+
system_prompt: "You are a data analyst. Identify the key metric in one sentence."
102+
)
103+
104+
puts "Firing both delegations in parallel..."
105+
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
106+
107+
f_summary = manager.delegate(to: async_summarizer, task: "Summarize this report:\n\n#{document}", async: true)
108+
f_analysis = manager.delegate(to: async_analyst, task: "What is the single most important metric?\n\n#{document}", async: true)
109+
110+
puts "Both futures launched. Futures resolved? " \
111+
"summary=#{f_summary.resolved?} analysis=#{f_analysis.resolved?}"
112+
puts "Collecting results..."
113+
114+
summary = f_summary.value(timeout: 60)
115+
analysis = f_analysis.value(timeout: 60)
116+
117+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0
118+
119+
puts
120+
puts "Summary (#{summary.robot_name}): #{summary.reply}"
121+
puts "Analysis (#{analysis.robot_name}): #{analysis.reply}"
122+
puts
123+
puts "Total wall time with parallelism: #{"%.2f" % elapsed}s " \
124+
"(vs ~#{"%.2f" % (summary.duration + analysis.duration)}s sequential)"
125+
puts
126+
127+
# ---------------------------------------------------------------------------
128+
# Contrast with the alternatives
129+
# ---------------------------------------------------------------------------
130+
puts "=" * 60
131+
puts "When to use delegate vs. the alternatives"
132+
puts "=" * 60
133+
puts <<~TEXT
134+
135+
bus messaging — fire-and-forget; no return value; async
136+
use when: you want to notify without waiting
137+
138+
pipeline — predefined sequence; robots share memory
139+
use when: you have a fixed workflow graph
140+
141+
delegate() — synchronous; blocks; returns RobotResult with metadata
142+
use when: one robot needs the result of another's work
143+
144+
delegate(async:true) — returns DelegationFuture immediately
145+
use when: you want to run multiple delegates in
146+
parallel and collect results when ready
147+
148+
TEXT
149+
150+
puts "Done."

examples/README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ examples/
5050
21_learning_loop.rb # Learning accumulation across runs with robot.learn
5151
22_context_compression.rb # Context window compression with HistoryCompressor
5252
23_convergence.rb # Debate convergence detection and reconciler fast-path
53+
24_structured_delegation.rb # Structured delegation with duration and token tracking
5354
18_rails/ # Minimal Rails 8 demo app (full integration)
5455
app/robots/chat_robot.rb # Robot factory with system prompt + TimeTool
5556
app/tools/time_tool.rb # Custom RobotLab::Tool subclass
@@ -190,6 +191,12 @@ Demonstrates `robot.compress_history()` for reducing token usage in long convers
190191

191192
**Requires:** `gem 'classifier', '~> 2.3'` in your Gemfile (no LLM calls in the demo itself)
192193

194+
### 24 — Structured Delegation
195+
196+
A manager robot delegates sub-tasks to a summarizer and an analyst. Each `delegate()` call returns a `RobotResult` annotated with `delegated_by`, `duration`, and token counts. Includes a comparison table of when to use delegation vs. bus messaging vs. pipelines.
197+
198+
**Requires:** LLM API key
199+
193200
### 23 — Debate Convergence Detection
194201

195202
Demonstrates `RobotLab::Convergence` for detecting when two independent agents have reached the same conclusion. Scores pairs of texts from identical → semantically similar → partially related → unrelated, showing how the similarity metric varies. Includes the router fast-path pattern: when two verifier robots agree above a threshold, the expensive reconciler LLM call is skipped entirely.

lib/robot_lab/delegation_future.rb

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
# frozen_string_literal: true
2+
3+
module RobotLab
4+
# A promise-like object returned by robot.delegate(async: true).
5+
#
6+
# The delegated task runs in a background thread. The caller can check
7+
# whether the result is ready with +resolved?+ and block until it arrives
8+
# with +value+ (or its alias +wait+).
9+
#
10+
# @example Fan-out to two specialists in parallel
11+
# f1 = manager.delegate(to: summarizer, task: "summarize ...", async: true)
12+
# f2 = manager.delegate(to: analyst, task: "analyze ...", async: true)
13+
#
14+
# # Other work here while both run in parallel
15+
#
16+
# summary = f1.value # blocks if not yet done
17+
# analysis = f2.value
18+
#
19+
# @example With a timeout
20+
# result = future.value(timeout: 10) # raises DelegationTimeout if too slow
21+
#
22+
class DelegationFuture
23+
# Raised when +value(timeout: N)+ expires before the result arrives.
24+
class DelegationTimeout < Error; end
25+
26+
# @return [String] name of the robot that was delegated to
27+
attr_reader :robot_name
28+
29+
# @return [String] name of the robot that created this future
30+
attr_reader :delegated_by
31+
32+
# @param robot_name [String]
33+
# @param delegated_by [String]
34+
def initialize(robot_name:, delegated_by:)
35+
@robot_name = robot_name
36+
@delegated_by = delegated_by
37+
@mutex = Mutex.new
38+
@cv = ConditionVariable.new
39+
@result = nil
40+
@error = nil
41+
@resolved = false
42+
end
43+
44+
# True once the delegated task has completed (successfully or with an error).
45+
#
46+
# @return [Boolean]
47+
def resolved?
48+
@mutex.synchronize { @resolved }
49+
end
50+
51+
# Block until the result is available and return it.
52+
#
53+
# @param timeout [Numeric, nil] maximum seconds to wait; nil means wait forever
54+
# @return [RobotResult]
55+
# @raise [DelegationTimeout] if +timeout+ is given and expires
56+
# @raise [StandardError] re-raises any error thrown by the delegated task
57+
def value(timeout: nil)
58+
@mutex.synchronize do
59+
unless @resolved
60+
@cv.wait(@mutex, timeout)
61+
end
62+
63+
unless @resolved
64+
raise DelegationTimeout,
65+
"Delegation to '#{@robot_name}' timed out after #{timeout}s"
66+
end
67+
68+
raise @error if @error
69+
70+
@result
71+
end
72+
end
73+
alias_method :wait, :value
74+
75+
# @api private — called by Robot#delegate from the worker thread
76+
def resolve!(result)
77+
@mutex.synchronize do
78+
@result = result
79+
@resolved = true
80+
@cv.broadcast
81+
end
82+
end
83+
84+
# @api private — called by Robot#delegate from the worker thread on error
85+
def reject!(error)
86+
@mutex.synchronize do
87+
@error = error
88+
@resolved = true
89+
@cv.broadcast
90+
end
91+
end
92+
end
93+
end

0 commit comments

Comments
 (0)