ruby_llm-team 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 276088260e61f9aeed5607c1e613f408ff4c58cf777cae74e6fe01d8890da170
4
+ data.tar.gz: 110604d8bb1288218f78bd5ef2285f2d380a0ebad64f29185d23f45c1fd6ce5e
5
+ SHA512:
6
+ metadata.gz: 12067c8dc5622ddfb3355a007b5282f65b231ed0d22e719b311aa07d7fd91f91a3c5df1e21a42e670f6d1a9989f8fd510ccf58381e8cb9df8c1bb51c8f24fc9a
7
+ data.tar.gz: bd7c200b51eb79f6fb5553cfc1b4634c6ae392c35d3c82bf0fcdd077ce2425d54e5e3522d1af96cc0087eba1f8756fa4ee4f43c998e6dbad8ed35fd9a6deecf0
data/CHANGELOG.md ADDED
@@ -0,0 +1,40 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 — experimental
4
+
5
+ First public release. The API is deliberately small but not yet stable: artifact ordering,
6
+ error types, and trace serialization all changed shortly before this release. Pin an exact
7
+ version, and read [docs/DECISIONS.md](docs/DECISIONS.md) for what the gem refuses to do and
8
+ why — those refusals are the stable part.
9
+
10
+ ### Added
11
+
12
+ - Named immutable artifact versions with `as:`/`from:` handoffs and the thin `Team#run` API.
13
+ - Machine-readable traces: `Session#to_h`/`#to_json` with per-call and run-total best-known
14
+ token usage; prompts and results export only with `include_content: true`.
15
+ - Typed `BudgetExceededError < CollaborationError` for budget exhaustion.
16
+ - `examples/code_review/` — parallel fan-out/fan-in with a VCR-replayed spec and a
17
+ line comparison against the upstream plain-Ruby pattern.
18
+
19
+ ### Fixed
20
+
21
+ - Artifact versions are reserved in submission order, so `artifact(name)` is deterministic
22
+ when parallel work completes out of order.
23
+ - Non-`StandardError` crashes finalize their call as `:failed` and re-raise instead of
24
+ leaving it `:running` with a burned budget slot.
25
+ - Fiber siblings settle before a crash propagates; thread joins no longer mask the first crash.
26
+ - A coworker instance delegating back into its own call fails with a clear error instead of
27
+ `deadlock; recursive locking`.
28
+ - Duplicate coworkers in one `parallel` batch are rejected before reserving budget instead of
29
+ silently dropping results.
30
+ - `share_context: false` sessions no longer record handoff inputs the coworker never received.
31
+
32
+ ### Changed
33
+
34
+ - `Run#step` omitted `from:` now hands over every completed artifact, matching `Session#ask`.
35
+ - The published gem contains only `lib/`, README, CHANGELOG, and LICENSE.
36
+
37
+ ### Foundation
38
+
39
+ - Coworker registry, `delegate_work`/`ask_question` tools, session call budgets,
40
+ thread/fiber `parallel`, selected handoffs, and the Markdown trace.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 JetThoughts
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,215 @@
1
+ # RubyLLM::Team
2
+
3
+ **Several RubyLLM agents, one run you can audit.** Team gives a multi-agent workflow the
4
+ things you would otherwise hand-roll: named handoffs between agents, one call budget for the
5
+ whole run, safe fan-out, and a trace showing the exact prompt every agent received.
6
+
7
+ [![CI](https://github.com/jetthoughts/ruby_llm-team/actions/workflows/ci.yml/badge.svg)](https://github.com/jetthoughts/ruby_llm-team/actions/workflows/ci.yml)
8
+ [![Ruby](https://img.shields.io/badge/ruby-3.1%2B-CC342D)](https://www.ruby-lang.org)
9
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE.txt)
10
+
11
+ It is a small library, not a framework. Your workflow stays ordinary Ruby.
12
+
13
+ ## Do I need this?
14
+
15
+ You do **not** need Team for one agent, or two agents in a straight line. `Agent#ask` is enough.
16
+
17
+ You start needing it at the point below — when several agents share work and you have to answer
18
+ "which version did the editor actually review?" and "why did this run cost 40 calls?"
19
+
20
+ | You are writing this by hand | Team gives you |
21
+ | --- | --- |
22
+ | A hash of results, plus rules for which version is "current" | Named artifact versions with explicit `as:` / `from:` handoffs |
23
+ | A counter so one runaway loop cannot bill you forever | One atomic call budget for the run, across threads and fibers, bounded by default |
24
+ | `Thread.new` per agent and a join that swallows one failure | Fan-out that settles every sibling before raising |
25
+ | `rescue => e` in six places, each shaped differently | One `CollaborationError`, plus typed `BudgetExceededError` |
26
+ | A logger you grep after something goes wrong | A trace with the exact prompt each agent received |
27
+
28
+ ## Install
29
+
30
+ ```ruby
31
+ gem 'ruby_llm-team', require: 'ruby_llm/team'
32
+ ```
33
+
34
+ ## Quickstart
35
+
36
+ ```ruby
37
+ require 'ruby_llm/team'
38
+
39
+ team = RubyLLM::Team.new
40
+ .add(:planner, PlannerAgent)
41
+ .add(:reviewer, ReviewerAgent)
42
+
43
+ execution = team.run(max_calls: 4, context: 'Fix one failing test.') do |run|
44
+ run.step :plan, with: :planner, prompt: 'Write a short implementation plan.'
45
+ run.step :review, with: :reviewer, from: [:plan], prompt: 'Is this plan safe?'
46
+ run.output :review
47
+ end
48
+
49
+ execution.output # => the reviewer's answer
50
+ execution.value(:plan) # => the plan the reviewer actually saw
51
+ puts execution.to_markdown # => the full trace
52
+ ```
53
+
54
+ A coworker is anything that responds to `#ask` — a `RubyLLM::Agent` class, an instance, or a
55
+ plain object. That is what makes offline tests trivial (see [Testing](#testing)).
56
+
57
+ Or hand the tools to a model and let it decide who to consult, with your budget as the limit:
58
+
59
+ ```ruby
60
+ session = team.session(max_calls: 6)
61
+ chat = RubyLLM.chat.with_tools(*session.tools)
62
+
63
+ chat.ask('Solid Queue or Sidekiq for a three-person team?')
64
+ session.calls.map(&:coworker) # => whom the model actually chose to ask
65
+ ```
66
+
67
+ ## How it fits together
68
+
69
+ ```mermaid
70
+ flowchart LR
71
+ App["Your Ruby<br/><i>order, branching, policy</i>"] -->|steps| Session
72
+ subgraph Team["RubyLLM::Team"]
73
+ Session["Session<br/><i>budget · failures · trace</i>"]
74
+ Artifacts[("Artifacts<br/><i>named versions</i>")]
75
+ Session <--> Artifacts
76
+ end
77
+ Session -->|ask| A1["Agent A"]
78
+ Session -->|ask| A2["Agent B"]
79
+ Session -->|ask| A3["Agent C"]
80
+ A1 & A2 & A3 -->|models, tools, retries| RubyLLM["RubyLLM"]
81
+ ```
82
+
83
+ Team sits between your code and your agents. It never owns models, prompts, schemas, retries,
84
+ or your business rules.
85
+
86
+ ## Handoffs are explicit
87
+
88
+ `as:` names an output. `from:` selects which named outputs the next coworker receives, verbatim.
89
+ Reusing a name publishes a new version, so a revision never silently overwrites its source.
90
+
91
+ ```mermaid
92
+ sequenceDiagram
93
+ participant W as writer
94
+ participant E as editor
95
+ W->>W: ask(as: :draft) → draft@v1
96
+ W->>E: from: [:draft]
97
+ E-->>W: review@v1 ("revise")
98
+ W->>W: ask(as: :draft, from: [:draft, :review]) → draft@v2
99
+ Note over W,E: artifact(:draft) is v2 — deterministic,<br/>even when work ran in parallel
100
+ ```
101
+
102
+ ```ruby
103
+ session.ask(:writer, 'Write the draft', as: :draft, from: [])
104
+ session.ask(:editor, 'Review it', as: :review, from: [:draft])
105
+ session.ask(:writer, 'Revise it', as: :draft, from: %i[draft review])
106
+
107
+ session.artifact(:draft).version # => 2
108
+ session.artifact(:draft).sources # => ["draft@v1 (writer)", "review@v1 (editor)"]
109
+ ```
110
+
111
+ ## Running work in parallel
112
+
113
+ ```ruby
114
+ reviews = session.parallel(
115
+ { security: prompt, performance: prompt, style: prompt },
116
+ concurrency: :threads # or :fibers, using the optional async gem
117
+ )
118
+ ```
119
+
120
+ Every task reserves budget up front and appears in the trace. There is deliberately no `limit:`
121
+ — bound concurrency where you own the task list:
122
+
123
+ ```ruby
124
+ tasks.each_slice(3).flat_map { |batch| session.parallel(batch.to_h) }
125
+ ```
126
+
127
+ ## Quality loops stay in your Ruby
128
+
129
+ There is no `refine` or `repair` API. Compose the loop through `session.ask` and every round is
130
+ budget-accounted, traced, and failure-normalized because it is an ordinary call:
131
+
132
+ ```ruby
133
+ session.ask(:writer, task, as: :draft, from: [])
134
+
135
+ 3.times do
136
+ review = session.ask(:critic, 'Review the draft.', as: :review, from: [:draft])
137
+ break if review.fetch('verdict') == 'pass'
138
+
139
+ session.ask(:writer, 'Revise using every finding.', as: :draft, from: %i[draft review])
140
+ end
141
+ ```
142
+
143
+ Your code owns the predicate and the bound. Swap the critic for a validator and the same shape
144
+ becomes a repair loop.
145
+
146
+ ## Traces
147
+
148
+ `to_markdown` renders the run for reading. `to_h` / `to_json` export it for tooling, including
149
+ per-call and run-total token usage:
150
+
151
+ ```ruby
152
+ execution.to_h[:usage] # => { input_tokens: 8_412, output_tokens: 3_120 }
153
+ execution.to_h[:calls] # => structure, statuses, artifact lineage
154
+ execution.to_json(include_content: true) # prompts and results, opt-in
155
+ ```
156
+
157
+ Prompts and results are excluded unless you ask for them, so an exported trace is safe to ship.
158
+ The prompt recorded is the exact text the coworker received — context and handoffs included.
159
+
160
+ ## Testing
161
+
162
+ Coworkers are plain objects, so most tests need no stubbing library and no API key:
163
+
164
+ ```ruby
165
+ writer = Class.new { def ask(_prompt) = 'DRAFT' }
166
+ team = RubyLLM::Team.new.add(:writer, writer)
167
+
168
+ execution = team.run { |run| run.step :draft, with: :writer }
169
+ expect(execution.value(:draft)).to eq('DRAFT')
170
+ ```
171
+
172
+ For live workflows, record one VCR cassette and replay it in CI with no key —
173
+ [`spec/ruby_llm/code_review_workflow_spec.rb`](spec/ruby_llm/code_review_workflow_spec.rb)
174
+ shows both patterns side by side.
175
+
176
+ ## Examples
177
+
178
+ **Copy from [`code_review/`](examples/code_review/) first** — 110 lines, and it shows the whole
179
+ library: parallel fan-out, named handoffs, budget, trace, and a verdict computed in Ruby rather
180
+ than trusted from a model.
181
+
182
+ | Example | Shape | Run it |
183
+ | --- | --- | --- |
184
+ | [`simple_team.rb`](examples/simple_team.rb) | Two coworkers, one handoff | `ruby examples/simple_team.rb` (no API key) |
185
+ | **[`code_review/`](examples/code_review/)** | **Fan-out / fan-in — start here** | `ruby examples/code_review/workflow.rb [diff]` |
186
+ | [`topic_analyst/`](examples/topic_analyst/) | Parallel research, ranked output | `ruby examples/topic_analyst/workflow.rb "Rails jobs"` |
187
+ | [`decision_panel/`](examples/decision_panel/) | **No Ruby orchestration** — a lead model picks whom to consult | `ruby examples/decision_panel/workflow.rb "your question"` |
188
+ | [`editorial_pipeline.rb`](examples/editorial_pipeline.rb) | Two teams composed in plain Ruby | `ruby examples/editorial_pipeline.rb "your domain"` |
189
+ | [`blog/`](examples/blog/) | _Advanced._ Seven editorial passes, bounded gates, escalation | `ruby examples/blog/workflow.rb` |
190
+
191
+ The blog example is production-scale on purpose and is the largest thing here; read it for
192
+ patterns, not as a starting point. Live examples need `OPENROUTER_API_KEY`; the research ones
193
+ also use `YDC_API_KEY`.
194
+
195
+ ## What Team is not
196
+
197
+ No graph DSL, YAML workflows, or role/backstory metaphors. No memory, RAG, MCP, or search. No
198
+ dashboards, persistence, or scheduling — [`ruby_llm-agents`](https://github.com/adham90/ruby_llm-agents)
199
+ owns that Rails layer and Team composes inside it. No hidden retries or model selection: RubyLLM
200
+ owns those.
201
+
202
+ Reasoning and the evidence behind each refusal: [`docs/DECISIONS.md`](docs/DECISIONS.md).
203
+
204
+ ## Development
205
+
206
+ ```bash
207
+ bundle install
208
+ bundle exec rake spec # the suite
209
+ bundle exec rake spec:replay # recorded workflows, no API key needed
210
+ bundle exec rubocop
211
+ ```
212
+
213
+ ## License
214
+
215
+ MIT. See [LICENSE.txt](LICENSE.txt).
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ class Team
5
+ # One immutable, successful output published by a coworker call.
6
+ Artifact = Struct.new(:name, :version, :producer, :sources, :value, :call_index, keyword_init: true)
7
+ end
8
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ class Team
5
+ # Imperative convenience wrapper around one Team::Session.
6
+ class Run
7
+ OUTPUT_UNSET = Object.new.freeze
8
+ private_constant :OUTPUT_UNSET
9
+
10
+ attr_reader :session
11
+
12
+ def initialize(session)
13
+ @session = session
14
+ end
15
+
16
+ # Omitting +from:+ hands over the latest version of every completed
17
+ # artifact, matching Session#ask; pass +from: []+ to start clean.
18
+ def step(name, with:, prompt: nil, from: nil)
19
+ session.ask(with, prompt || "Complete '#{name}'.", as: name, from: from)
20
+ end
21
+
22
+ def output(name = OUTPUT_UNSET)
23
+ return selected_output if name.equal?(OUTPUT_UNSET)
24
+
25
+ name = name.to_s
26
+ raise ArgumentError, "No completed artifact named '#{name}'" unless artifact(name)
27
+
28
+ @output_name = name
29
+ self
30
+ end
31
+
32
+ def value(name) = session.value(name)
33
+ def artifact(name) = session.artifact(name)
34
+ def artifacts(name) = session.artifacts(name)
35
+ def calls = session.calls
36
+ def calls_remaining = session.calls_remaining
37
+ def to_markdown = session.to_markdown
38
+ def to_h(include_content: false) = session.to_h(include_content: include_content)
39
+
40
+ # Mirrors Session#to_json, positional generator state included, so JSON.generate(run)
41
+ # works the same way.
42
+ def to_json(*args, include_content: false) = session.to_json(*args, include_content: include_content)
43
+
44
+ private
45
+
46
+ def selected_output
47
+ return unless @output_name
48
+
49
+ value(@output_name)
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ class Team
5
+ VERSION = '0.1.0'
6
+ end
7
+ end
@@ -0,0 +1,584 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ruby_llm'
4
+ require 'ruby_llm/tool'
5
+ require 'ruby_llm/team/version'
6
+ require 'ruby_llm/team/artifact'
7
+ require 'json'
8
+ require 'securerandom'
9
+
10
+ module RubyLLM
11
+ # Groups named coworkers and creates tools for delegating work.
12
+ #
13
+ # team = RubyLLM::Team.new
14
+ # team.add(:researcher, ResearcherAgent)
15
+ #
16
+ # session = team.session(max_calls: 8) # bound what the model may spend
17
+ # chat.with_tools(*session.tools)
18
+ #
19
+ # The session keeps the artifacts, budget, and trace reachable after the model is done.
20
+ #
21
+ # Registered classes are instantiated per call; registered instances are reused.
22
+ class Team
23
+ class CollaborationError < StandardError; end
24
+ # Raised when the session's +max_calls+ budget rejects a call.
25
+ class BudgetExceededError < CollaborationError; end
26
+ require 'ruby_llm/team/run'
27
+
28
+ # Every call costs money, so a run is bounded unless you say otherwise. This is a smoke
29
+ # alarm rather than a budget: it stops a runaway, and a workflow that legitimately needs
30
+ # more says so in one keyword — examples/blog/workflow.rb passes 40. Pass
31
+ # +max_calls: nil+ for an unbounded run.
32
+ DEFAULT_MAX_CALLS = 25
33
+
34
+ def initialize
35
+ @agents = {}
36
+ end
37
+
38
+ # Registers +agent+ under +role+ and returns +self+.
39
+ def add(role, agent)
40
+ role = role.to_s
41
+ raise ArgumentError, 'provide a role' if role.empty?
42
+ raise ArgumentError, 'provide an agent' unless agent
43
+
44
+ @agents[role] = agent
45
+ self
46
+ end
47
+
48
+ # Creates isolated collaboration state for one lead-agent run.
49
+ def session(max_calls: DEFAULT_MAX_CALLS, share_context: true, context: nil)
50
+ Session.new(@agents.dup.freeze, max_calls: max_calls, share_context: share_context, context: context)
51
+ end
52
+
53
+ # Executes an ordinary Ruby workflow over one isolated session.
54
+ def run(max_calls: DEFAULT_MAX_CALLS, share_context: true, context: nil)
55
+ execution = Run.new(session(max_calls: max_calls, share_context: share_context, context: context))
56
+ yield execution if block_given?
57
+ execution
58
+ end
59
+
60
+ # Preserves coworker results, limits calls, and exposes a trace for one run.
61
+ class Session # rubocop:disable Metrics/ClassLength
62
+ Call = Struct.new(:action, :coworker, :prompt, :result, :status, :inputs, :artifact, :usage,
63
+ keyword_init: true) do
64
+ def error? = result.is_a?(Hash) && (result.key?(:error) || result.key?('error'))
65
+ def complete? = status != :running
66
+ def successful? = complete? && !error?
67
+ end
68
+
69
+ BUDGET_MESSAGE = 'Collaboration call limit reached'
70
+ private_constant :BUDGET_MESSAGE
71
+
72
+ def initialize(agents, max_calls:, share_context:, context:)
73
+ validate_max_calls(max_calls)
74
+
75
+ @agents = agents
76
+ @max_calls = max_calls
77
+ @share_context = share_context
78
+ @context = context&.to_s&.dup&.freeze
79
+ @calls = []
80
+ @accepted_calls = 0
81
+ @mutex = Mutex.new
82
+ initialize_run_state
83
+ initialize_agent_mutexes(agents)
84
+ end
85
+
86
+ def collaboration_tools
87
+ [DelegateWork.new(self), AskQuestion.new(self)]
88
+ end
89
+ alias tools collaboration_tools
90
+
91
+ def ask(coworker, prompt, as: nil, from: nil)
92
+ result = consult(
93
+ action: 'delegate_work', prompt: prompt, coworker: coworker, as: as, from: from
94
+ )
95
+ raise_on_error(result)
96
+ end
97
+
98
+ def parallel(tasks, concurrency: :threads, from: nil)
99
+ runner = parallel_runner(concurrency)
100
+ reject_duplicate_roles(tasks)
101
+ work = reserve_batch(tasks, from)
102
+ send(runner, work).transform_values { |result| raise_on_error(result) }
103
+ end
104
+
105
+ def calls = @mutex.synchronize { @calls.dup.freeze }
106
+
107
+ # Calls still allowed by the budget, or +nil+ when the session is unbounded.
108
+ # Lets an application decide whether an optional pass still fits.
109
+ def calls_remaining
110
+ @mutex.synchronize { @max_calls && [@max_calls - @accepted_calls, 0].max }
111
+ end
112
+
113
+ def artifact(name)
114
+ @mutex.synchronize { @artifacts.fetch(name.to_s, []).last }
115
+ end
116
+
117
+ def artifacts(name)
118
+ @mutex.synchronize { @artifacts.fetch(name.to_s, []).dup.freeze }
119
+ end
120
+
121
+ def value(name) = artifact(name)&.value
122
+
123
+ def to_markdown
124
+ trace = calls.map.with_index(1) do |call, index|
125
+ result = call.complete? ? format_result(call.result) : '_In progress_'
126
+ inputs = call.inputs.empty? ? '_None_' : call.inputs.join(', ')
127
+ # The whole prompt, handoffs included: "what was actually sent" is the point of
128
+ # the trace, so the readable format must not be the one that hides it.
129
+ "## #{index}. #{call.coworker} via #{call.action}\n\n" \
130
+ "### Inputs\n\n#{inputs}\n\n### Request\n\n#{call.prompt}\n\n" \
131
+ "### Result\n\n#{result}"
132
+ end.join("\n\n")
133
+ @context ? "## Shared team context\n\n#{@context}\n\n#{trace}" : trace
134
+ end
135
+
136
+ # Machine-readable trace: structure and best-known usage by default;
137
+ # pass +include_content: true+ to also export prompts and results.
138
+ def to_h(include_content: false)
139
+ # One snapshot under one lock: calls and artifacts must not disagree.
140
+ @mutex.synchronize do
141
+ {
142
+ calls: @calls.each_with_index.map { |call, index| call_to_h(call, index, include_content) },
143
+ artifacts: artifacts_to_h,
144
+ usage: usage_totals(@calls)
145
+ }
146
+ end
147
+ end
148
+
149
+ # Accepts JSON's positional generator state so JSON.generate(session) works.
150
+ def to_json(*_args, include_content: false)
151
+ JSON.generate(to_h(include_content: include_content))
152
+ end
153
+
154
+ def coworkers = @agents.keys.join(', ')
155
+
156
+ def consult(action:, prompt:, coworker:, as: nil, from: nil)
157
+ role = coworker.to_s
158
+ perform(reserve(action, role, prompt, as: as || role, from: from), coworker)
159
+ end
160
+
161
+ private
162
+
163
+ def initialize_run_state
164
+ @fence = SecureRandom.hex(4)
165
+ @artifacts = {}
166
+ @artifact_serials = Hash.new(0)
167
+ @reserved_versions = {}
168
+ end
169
+
170
+ def initialize_agent_mutexes(agents)
171
+ mutexes = {}
172
+ @agent_mutexes = agents.transform_values { |agent| mutexes[agent.__id__] ||= Mutex.new }
173
+ end
174
+
175
+ def raise_on_error(result)
176
+ return result unless result.is_a?(Hash) && (result.key?(:error) || result.key?('error'))
177
+
178
+ message = result[:error] || result['error']
179
+ # Flagged by the session, never inferred from the text: a coworker may return an
180
+ # error that quotes the budget message.
181
+ raise BudgetExceededError, message if result[:budget_exceeded]
182
+
183
+ raise CollaborationError, message
184
+ end
185
+
186
+ def reject_duplicate_roles(tasks)
187
+ roles = tasks.map { |coworker, _prompt| coworker.to_s }
188
+ duplicate = roles.tally.find { |_role, count| count > 1 }&.first
189
+ raise ArgumentError, "duplicate coworker '#{duplicate}' in one parallel batch" if duplicate
190
+ end
191
+
192
+ def validate_max_calls(max_calls)
193
+ return if max_calls.nil? || (max_calls.is_a?(Integer) && max_calls.positive?)
194
+
195
+ raise ArgumentError, 'max_calls must be a positive integer'
196
+ end
197
+
198
+ def perform(reservation, coworker)
199
+ return reservation if reservation.is_a?(Hash)
200
+
201
+ index, full_prompt = reservation
202
+ execute_call(index, full_prompt, coworker)
203
+ rescue StandardError => e
204
+ complete(index, error: "Coworker '#{coworker}' failed: #{e.message}")
205
+ rescue Exception => e # rubocop:disable Lint/RescueException -- finalize the call, then propagate
206
+ complete(index, error: "Coworker '#{coworker}' crashed: #{e.class}: #{e.message}")
207
+ raise
208
+ end
209
+
210
+ def execute_call(index, full_prompt, coworker)
211
+ role = coworker.to_s
212
+ return complete(index, error: unknown_coworker(coworker)) unless @agents.key?(role)
213
+
214
+ result = ask_agent(@agents.fetch(role), role, full_prompt)
215
+ complete(index, result: extract_result(result), usage: usage_from(result))
216
+ end
217
+
218
+ # Best-known token accounting; absent metering is reported as nil, never guessed.
219
+ def usage_from(raw)
220
+ return unless raw.respond_to?(:input_tokens)
221
+
222
+ usage = { input_tokens: raw.input_tokens, output_tokens: raw.output_tokens }
223
+ usage[:model_id] = raw.model_id if raw.respond_to?(:model_id)
224
+ usage = usage.compact
225
+ usage.empty? ? nil : usage
226
+ end
227
+
228
+ def unknown_coworker(coworker)
229
+ "Unknown coworker '#{coworker}'. Available: #{coworkers}"
230
+ end
231
+
232
+ def reserve(action, coworker, prompt, as:, from:)
233
+ @mutex.synchronize { reserve_call(action, coworker, prompt, artifact_name: as, artifacts: from) }
234
+ end
235
+
236
+ def reserve_batch(tasks, from)
237
+ @mutex.synchronize do
238
+ tasks.map do |coworker, prompt|
239
+ role = coworker.to_s
240
+ [coworker, reserve_call('delegate_work', role, prompt, artifact_name: role, artifacts: from)]
241
+ end
242
+ end
243
+ end
244
+
245
+ def reserve_call(action, coworker, prompt, artifact_name:, artifacts:)
246
+ artifact_name = normalize_artifact_name(artifact_name)
247
+ return reject_call(action, coworker, prompt, artifact_name) if call_limit_reached?
248
+
249
+ prior_calls, inputs = handoff_context(artifacts)
250
+ @accepted_calls += 1
251
+ full_prompt = with_history(prompt, prior_calls.map(&:last))
252
+ [append_running_call(action, coworker, full_prompt, inputs, artifact_name), full_prompt]
253
+ end
254
+
255
+ def append_running_call(action, coworker, full_prompt, inputs, artifact_name)
256
+ index = @calls.length
257
+ @reserved_versions[index] = (@artifact_serials[artifact_name] += 1) if artifact_name
258
+ @calls << build_call(
259
+ action, coworker, full_prompt, nil, status: :running, inputs: inputs, artifact: artifact_name
260
+ )
261
+ index
262
+ end
263
+
264
+ # Versions are reserved here in submission order, so +artifact(name)+ stays
265
+ # deterministic when parallel work completes out of order. A failed call
266
+ # leaves a visible gap instead of renumbering published versions.
267
+ def handoff_context(artifact_names)
268
+ return context_calls(artifact_names) if @share_context
269
+
270
+ if artifact_names && !Array(artifact_names).empty?
271
+ raise ArgumentError, 'from: requires a session that shares context'
272
+ end
273
+
274
+ [[], []]
275
+ end
276
+
277
+ def call_limit_reached? = @max_calls && @accepted_calls >= @max_calls
278
+
279
+ # Names the budget and the blocked coworker so a hand-counted max_calls
280
+ # is diagnosable from the error alone.
281
+ def reject_call(action, coworker, prompt, artifact_name)
282
+ message = "#{BUDGET_MESSAGE}: #{@accepted_calls} of #{@max_calls} calls used, " \
283
+ "'#{coworker}' was not run"
284
+ append_call(action, coworker, prompt, error: message, artifact: artifact_name)
285
+ .merge(budget_exceeded: true)
286
+ end
287
+
288
+ def context_calls(artifact_names)
289
+ return artifact_context(artifact_names) unless artifact_names.nil?
290
+
291
+ selected_artifact_context(@artifacts.values.filter_map(&:last).sort_by(&:call_index))
292
+ end
293
+
294
+ def artifact_context(names)
295
+ selected = Array(names).map do |name|
296
+ artifact = @artifacts.fetch(name.to_s, []).last
297
+ raise ArgumentError, "No completed artifact named '#{name}'" unless artifact
298
+
299
+ artifact
300
+ end
301
+ selected_artifact_context(selected)
302
+ end
303
+
304
+ def selected_artifact_context(selected)
305
+ calls = selected.map { |item| [item.call_index, @calls.fetch(item.call_index)] }
306
+ inputs = selected.map { |item| "#{item.name}@v#{item.version} (#{item.producer})" }
307
+ [calls, inputs]
308
+ end
309
+
310
+ def normalize_artifact_name(name)
311
+ return if name.nil?
312
+
313
+ value = name.to_s
314
+ raise ArgumentError, 'provide an artifact name' if value.empty?
315
+
316
+ value.freeze
317
+ end
318
+
319
+ def with_history(prompt, calls)
320
+ full_prompt = @context ? "#{prompt}\n\nShared team context:\n#{@context}" : prompt
321
+ return full_prompt unless @share_context
322
+
323
+ history = calls.map { |call| fenced_result(call) }.join("\n\n")
324
+ return full_prompt if history.empty?
325
+
326
+ "#{full_prompt}\n\nPrevious coworker results (verbatim):\n#{history}"
327
+ end
328
+
329
+ # Each result is wrapped in a per-session random fence. A coworker cannot guess the
330
+ # nonce, so relayed output cannot impersonate a handoff from a coworker that never ran.
331
+ def fenced_result(call)
332
+ "--- result #{@fence} #{call.coworker} via #{call.action} ---\n" \
333
+ "#{format_result(call.result)}\n" \
334
+ "--- end #{@fence} ---"
335
+ end
336
+
337
+ # Re-entrancy is a property of the role, not of how it was registered: a class-backed
338
+ # coworker gets a fresh instance per call and so never touches the mutex below.
339
+ def ask_agent(agent, role, prompt)
340
+ if active_roles.include?(role)
341
+ raise CollaborationError, "Coworker '#{role}' cannot be consulted from inside its own call"
342
+ end
343
+
344
+ active_roles << role
345
+ begin
346
+ call_agent(agent, role, prompt)
347
+ ensure
348
+ active_roles.delete(role)
349
+ end
350
+ end
351
+
352
+ def call_agent(agent, role, prompt)
353
+ return agent.new.ask(prompt) if agent.is_a?(Class)
354
+
355
+ @agent_mutexes.fetch(role).synchronize { agent.ask(prompt) }
356
+ end
357
+
358
+ # Fiber-local, so concurrent work on the same role stays legal while a nested call
359
+ # inside one fiber or thread is refused.
360
+ def active_roles
361
+ Thread.current[:"ruby_llm_team_active_#{object_id}"] ||= []
362
+ end
363
+
364
+ def parallel_runner(concurrency)
365
+ return :parallel_with_threads if concurrency.to_sym == :threads
366
+
367
+ if concurrency.to_sym == :fibers
368
+ require 'async'
369
+ return :parallel_with_fibers
370
+ end
371
+
372
+ raise ArgumentError, 'concurrency must be :threads or :fibers'
373
+ rescue LoadError
374
+ raise LoadError, "The 'async' gem is required for fiber concurrency"
375
+ end
376
+
377
+ def parallel_with_threads(work)
378
+ workers = work.map do |coworker, reservation|
379
+ [coworker, Thread.new { perform(reservation, coworker) }]
380
+ end
381
+ workers.to_h { |coworker, worker| [coworker, worker.value] }
382
+ ensure
383
+ workers&.each { |pair| join_quietly(pair.last) }
384
+ end
385
+
386
+ # The first crash already propagates through Thread#value; joining the rest
387
+ # must not mask it with a sibling's exception.
388
+ def join_quietly(worker)
389
+ worker.join
390
+ rescue Exception # rubocop:disable Lint/RescueException
391
+ nil
392
+ end
393
+
394
+ def parallel_with_fibers(work)
395
+ Async do |parent|
396
+ workers = work.to_h do |coworker, reservation|
397
+ [coworker, parent.async { crash_as_value(reservation, coworker) }]
398
+ end
399
+ settle_fibers(workers)
400
+ end.wait
401
+ end
402
+
403
+ # Async starts tasks eagerly, so a crash escaping the block would abort
404
+ # sibling task creation; carry it as a value and raise after settling.
405
+ def crash_as_value(reservation, coworker)
406
+ perform(reservation, coworker)
407
+ rescue Exception => e # rubocop:disable Lint/RescueException
408
+ e
409
+ end
410
+
411
+ # Waits for every task before propagating the first crash, so a sibling
412
+ # is never cancelled with its call still recorded as :running.
413
+ def settle_fibers(workers)
414
+ outcomes = workers.transform_values(&:wait)
415
+ crash = outcomes.each_value.find { |value| value.is_a?(Exception) }
416
+ raise crash if crash
417
+
418
+ outcomes
419
+ end
420
+
421
+ def complete(index, result: nil, error: nil, usage: nil)
422
+ @mutex.synchronize do
423
+ call = @calls.fetch(index)
424
+ completed, result = completed_call(call, result, error, usage)
425
+ @calls[index] = completed
426
+ publish_artifact(completed, index) if completed.successful? && completed.artifact
427
+ result
428
+ end
429
+ end
430
+
431
+ def completed_call(call, result, error, usage)
432
+ result = { error: error } if error
433
+ status = error ? :failed : :completed
434
+ completed = build_call(
435
+ call.action, call.coworker, call.prompt, result,
436
+ status: status, inputs: call.inputs, artifact: call.artifact, usage: usage
437
+ )
438
+ [completed, result]
439
+ end
440
+
441
+ def publish_artifact(call, index)
442
+ versions = @artifacts.fetch(call.artifact, [])
443
+ artifact = Artifact.new(
444
+ name: call.artifact,
445
+ version: @reserved_versions.fetch(index),
446
+ producer: call.coworker,
447
+ sources: call.inputs,
448
+ value: call.result,
449
+ call_index: index
450
+ ).freeze
451
+ @artifacts[call.artifact] = [*versions, artifact].sort_by(&:version).freeze
452
+ end
453
+
454
+ def append_call(action, coworker, prompt, error:, artifact: nil)
455
+ result = { error: error }
456
+ @calls << build_call(action, coworker, prompt, result, status: :failed, inputs: [], artifact: artifact)
457
+ result
458
+ end
459
+
460
+ def build_call(action, coworker, prompt, result, details)
461
+ Call.new(
462
+ action: action,
463
+ coworker: coworker,
464
+ prompt: prompt.to_s.dup.freeze,
465
+ result: immutable(result),
466
+ status: details.fetch(:status),
467
+ inputs: immutable(details.fetch(:inputs)),
468
+ artifact: details.fetch(:artifact),
469
+ usage: immutable(details[:usage])
470
+ ).freeze
471
+ end
472
+
473
+ def immutable(value)
474
+ case value
475
+ when Hash then value.to_h { |key, item| [immutable(key), immutable(item)] }.freeze
476
+ when Array then value.map { |item| immutable(item) }.freeze
477
+ when String then value.dup.freeze
478
+ else value
479
+ end
480
+ end
481
+
482
+ def call_to_h(call, index, include_content)
483
+ serialized = {
484
+ index: index, action: call.action, coworker: call.coworker,
485
+ status: call.status, artifact: call.artifact, inputs: call.inputs, usage: call.usage
486
+ }
487
+ # The exported prompt is the exact text the coworker received, context and
488
+ # handoffs included — the readable Markdown trace is where it is trimmed.
489
+ serialized.merge!(prompt: call.prompt, result: call.result) if include_content
490
+ serialized
491
+ end
492
+
493
+ def usage_totals(snapshot)
494
+ metered = snapshot.filter_map(&:usage)
495
+ return if metered.empty?
496
+
497
+ {
498
+ input_tokens: metered.sum { |usage| usage[:input_tokens].to_i },
499
+ output_tokens: metered.sum { |usage| usage[:output_tokens].to_i }
500
+ }
501
+ end
502
+
503
+ # Callers hold @mutex.
504
+ def artifacts_to_h
505
+ @artifacts.transform_values do |versions|
506
+ versions.map do |artifact|
507
+ { version: artifact.version, producer: artifact.producer,
508
+ call_index: artifact.call_index, sources: artifact.sources }
509
+ end
510
+ end
511
+ end
512
+
513
+ def extract_result(result)
514
+ return result unless result.respond_to?(:content)
515
+
516
+ content = result.content
517
+ if content.respond_to?(:text) && content.respond_to?(:attachments)
518
+ attachments = Array(content.attachments)
519
+ content = content.text
520
+ else
521
+ attachments = result.respond_to?(:attachments) ? Array(result.attachments) : []
522
+ end
523
+ attachments.empty? ? content : [content, *attachments]
524
+ end
525
+
526
+ def format_result(result)
527
+ result.is_a?(Hash) ? JSON.pretty_generate(result) : result.to_s
528
+ end
529
+ end # rubocop:enable Metrics/ClassLength
530
+
531
+ class CoworkerTool < Tool # :nodoc:
532
+ def self.declare_shared_params
533
+ param :coworker, type: 'string', description: 'Name of the coworker to consult'
534
+ param :context, type: 'string', description: 'Shared context for the coworker',
535
+ required: false
536
+ end
537
+
538
+ def initialize(session)
539
+ super()
540
+ @session = session
541
+ end
542
+
543
+ def description
544
+ "#{self.class.description}\n\nCoworkers: #{@session.coworkers}"
545
+ end
546
+
547
+ private
548
+
549
+ def with_context(main, context)
550
+ context ? "#{main}\n\nContext: #{context}" : main
551
+ end
552
+
553
+ def consult(action:, prompt:, coworker:)
554
+ @session.consult(action: action, prompt: prompt, coworker: coworker)
555
+ end
556
+ end
557
+
558
+ class DelegateWork < CoworkerTool # :nodoc:
559
+ description 'Delegate a task to a coworker and get their result'
560
+ declare_shared_params
561
+ param :task, type: 'string', description: 'The task to delegate'
562
+
563
+ def name = 'delegate_work'
564
+
565
+ def execute(task:, coworker:, context: nil)
566
+ consult(action: name, prompt: with_context(task, context), coworker: coworker)
567
+ end
568
+ end
569
+
570
+ class AskQuestion < CoworkerTool # :nodoc:
571
+ description 'Ask a coworker a question about their expertise'
572
+ declare_shared_params
573
+ param :question, type: 'string', description: 'The question to ask'
574
+
575
+ def name = 'ask_question'
576
+
577
+ def execute(question:, coworker:, context: nil)
578
+ consult(action: name, prompt: with_context(question, context), coworker: coworker)
579
+ end
580
+ end
581
+
582
+ private_constant :CoworkerTool, :DelegateWork, :AskQuestion
583
+ end
584
+ end
metadata ADDED
@@ -0,0 +1,67 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ruby_llm-team
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - JetThoughts
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: ruby_llm
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: 1.16.0
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: 1.16.0
26
+ description: 'A RubyLLM extension that groups named coworkers and exposes delegation
27
+ tools so a model can hand tasks and questions to teammates. Built on the RubyLLM
28
+ public API: Tool, Agent, Message, and Attachment.'
29
+ email:
30
+ - team@jetthoughts.com
31
+ executables: []
32
+ extensions: []
33
+ extra_rdoc_files: []
34
+ files:
35
+ - CHANGELOG.md
36
+ - LICENSE.txt
37
+ - README.md
38
+ - lib/ruby_llm/team.rb
39
+ - lib/ruby_llm/team/artifact.rb
40
+ - lib/ruby_llm/team/run.rb
41
+ - lib/ruby_llm/team/version.rb
42
+ homepage: https://github.com/jetthoughts/ruby_llm-team
43
+ licenses:
44
+ - MIT
45
+ metadata:
46
+ homepage_uri: https://github.com/jetthoughts/ruby_llm-team
47
+ source_code_uri: https://github.com/jetthoughts/ruby_llm-team
48
+ changelog_uri: https://github.com/jetthoughts/ruby_llm-team/blob/master/CHANGELOG.md
49
+ rubygems_mfa_required: 'true'
50
+ rdoc_options: []
51
+ require_paths:
52
+ - lib
53
+ required_ruby_version: !ruby/object:Gem::Requirement
54
+ requirements:
55
+ - - ">="
56
+ - !ruby/object:Gem::Version
57
+ version: 3.1.3
58
+ required_rubygems_version: !ruby/object:Gem::Requirement
59
+ requirements:
60
+ - - ">="
61
+ - !ruby/object:Gem::Version
62
+ version: '0'
63
+ requirements: []
64
+ rubygems_version: 4.0.19
65
+ specification_version: 4
66
+ summary: 'Team collaboration for RubyLLM: delegate work to named coworkers.'
67
+ test_files: []