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 +7 -0
- data/CHANGELOG.md +40 -0
- data/LICENSE.txt +21 -0
- data/README.md +215 -0
- data/lib/ruby_llm/team/artifact.rb +8 -0
- data/lib/ruby_llm/team/run.rb +53 -0
- data/lib/ruby_llm/team/version.rb +7 -0
- data/lib/ruby_llm/team.rb +584 -0
- metadata +67 -0
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
|
+
[](https://github.com/jetthoughts/ruby_llm-team/actions/workflows/ci.yml)
|
|
8
|
+
[](https://www.ruby-lang.org)
|
|
9
|
+
[](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,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,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: []
|