truffle 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 +45 -0
- data/LICENSE +21 -0
- data/README.md +214 -0
- data/lib/truffle/agent.rb +132 -0
- data/lib/truffle/message.rb +74 -0
- data/lib/truffle/providers/base.rb +31 -0
- data/lib/truffle/providers/openai.rb +135 -0
- data/lib/truffle/response.rb +32 -0
- data/lib/truffle/tool.rb +90 -0
- data/lib/truffle/toolbox.rb +40 -0
- data/lib/truffle/version.rb +5 -0
- data/lib/truffle.rb +73 -0
- metadata +64 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 4c83bac38456b5457ce9a27d9ae82d0c3e13f88d94c01a3e205bb4854cec9c14
|
|
4
|
+
data.tar.gz: 12967cb3deeeefe37534703064f7bc1e7041888a308e4337cc60e08a4d587b8e
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: bc709412da01c71f9130bb9c24889b74af6a5438c222912b09b07f399ac2111440d10e161a606102f47bac8985b9f976aba3e75cacf35967b8ef538b5b52dd06
|
|
7
|
+
data.tar.gz: bc39da2fd510350510f0fb5f171183cf241284f9d7647e6b277ade821dbc8f8f72bf3b421f82eac3eb276ae00544c0434213e2a14757e933f78e2e07ecd4f33f
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to Truffle are documented here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/), and the project aims to follow
|
|
5
|
+
[Semantic Versioning](https://semver.org/).
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
- Renamed the project from "Pith" to **Truffle** (gem `truffle`, module
|
|
11
|
+
`Truffle`, repo `truffle-dev/truffle-rb`).
|
|
12
|
+
- Reframed as a from-scratch, byte-for-byte-faithful port of
|
|
13
|
+
[pi](https://github.com/earendil-works/pi) with no runtime gem dependencies.
|
|
14
|
+
Dropped the planned `ruby_llm` adapter; every provider is hand-written.
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
- `NORTH_STAR.md`: the project's fixed destination.
|
|
18
|
+
- `docs/BRAIN.md`: the self-updating continuity file (locked invariants plus a
|
|
19
|
+
compacted mutable state) read and updated on every build run.
|
|
20
|
+
- Rewritten `ROADMAP.md` mapping Phases 1–5 to pi's package structure.
|
|
21
|
+
|
|
22
|
+
## [0.1.0] - 2026-06-28
|
|
23
|
+
|
|
24
|
+
First release. The agent-core runtime, ported from
|
|
25
|
+
[pi](https://github.com/earendil-works/pi) to plain Ruby.
|
|
26
|
+
|
|
27
|
+
### Added
|
|
28
|
+
- `Truffle::Agent`: the agent loop (prompt -> tool calls -> tool results -> answer)
|
|
29
|
+
with a `max_turns` guard and an ordered event stream.
|
|
30
|
+
- Tool DSL via `Truffle.tool` / `Truffle::Tool.define`: typed params, JSON Schema
|
|
31
|
+
generation, string-key to keyword-arg symbolization, and error capture that
|
|
32
|
+
feeds tool failures back to the model instead of crashing the loop.
|
|
33
|
+
- `Truffle::Toolbox`: a named, enumerable collection of tools.
|
|
34
|
+
- Provider seam (`Truffle::Providers::Base`) and a dependency-free OpenAI Chat
|
|
35
|
+
Completions provider built on `Net::HTTP`.
|
|
36
|
+
- Event API (`Agent#on`) for `agent_start`, `turn_start`, `message`,
|
|
37
|
+
`tool_call`, `tool_result`, `turn_end`, `agent_end`.
|
|
38
|
+
- `examples/calculator.rb`: a runnable multi-tool demo.
|
|
39
|
+
- Test suite: hermetic minitest tests plus one live OpenAI round-trip test,
|
|
40
|
+
skipped unless `OPENAI_API_KEY` is set.
|
|
41
|
+
- `script/rb`: run any command in a `ruby:3.3-slim` container for hosts without
|
|
42
|
+
a local Ruby.
|
|
43
|
+
|
|
44
|
+
[Unreleased]: https://github.com/truffle-dev/truffle-rb/compare/v0.1.0...HEAD
|
|
45
|
+
[0.1.0]: https://github.com/truffle-dev/truffle-rb/releases/tag/v0.1.0
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Truffle
|
|
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 all
|
|
13
|
+
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 THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# Truffle
|
|
2
|
+
|
|
3
|
+
A complete **agent harness for Ruby**, built from scratch. Truffle gives you the
|
|
4
|
+
loop that turns a language model into an agent: it sends a prompt, lets the model
|
|
5
|
+
ask for tools, runs those tools, feeds the results back, and repeats until the
|
|
6
|
+
model answers. It is a faithful port of
|
|
7
|
+
[pi](https://github.com/earendil-works/pi) to idiomatic Ruby. No framework, no
|
|
8
|
+
service, no runtime gem dependencies. Plain Ruby and the standard library.
|
|
9
|
+
|
|
10
|
+
```ruby
|
|
11
|
+
require "truffle"
|
|
12
|
+
|
|
13
|
+
weather = Truffle.tool("get_weather", "Look up the weather for a city") do
|
|
14
|
+
param :city, :string, "city name", required: true
|
|
15
|
+
run { |city:| "It is 22C and sunny in #{city}." }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
agent = Truffle.agent(
|
|
19
|
+
provider: :openai,
|
|
20
|
+
model: "gpt-4o-mini",
|
|
21
|
+
system_prompt: "You are a concise assistant. Use tools when they help.",
|
|
22
|
+
tools: [weather]
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
puts agent.run("What's the weather in Lisbon?")
|
|
26
|
+
# => "It's 22C and sunny in Lisbon right now."
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The model decided to call `get_weather(city: "Lisbon")`, Truffle ran your Ruby
|
|
30
|
+
block, handed the result back, and the model wrote the final answer. That whole
|
|
31
|
+
round trip is the agent loop, and it is the thing Truffle exists to give you.
|
|
32
|
+
|
|
33
|
+

|
|
34
|
+
|
|
35
|
+
*The full suite (unit tests plus one live OpenAI round-trip) passing, and the
|
|
36
|
+
calculator example chaining three real tool calls to reach 240.*
|
|
37
|
+
|
|
38
|
+
## Why Truffle
|
|
39
|
+
|
|
40
|
+
Ruby has been missing a tiny, readable **agent runtime**: the part that owns the
|
|
41
|
+
turn loop, the tool dispatch, the message history, and the events a UI hangs off.
|
|
42
|
+
Truffle is that runtime, written from scratch.
|
|
43
|
+
|
|
44
|
+
It is a faithful port of [pi](https://github.com/earendil-works/pi), the
|
|
45
|
+
self-extensible coding agent harness. The aim is a byte-for-byte-faithful Ruby
|
|
46
|
+
port of pi's agent core, growing into a full harness with skills, commands,
|
|
47
|
+
sessions, and memory. You can read the whole loop in one sitting
|
|
48
|
+
(`lib/truffle/agent.rb`) and understand exactly what your agent does.
|
|
49
|
+
|
|
50
|
+
- **Provider-agnostic, built from scratch.** The agent talks to a single `chat`
|
|
51
|
+
seam. A provider is any object that answers `chat(messages:, tools:, model:)`.
|
|
52
|
+
An OpenAI provider ships in the box, written against the wire API directly with
|
|
53
|
+
no client gem. Anthropic and other providers follow the same hand-written path.
|
|
54
|
+
- **Tools are plain blocks.** Define a tool with a name, a description, typed
|
|
55
|
+
params, and a Ruby block. Truffle generates the JSON Schema the model needs and
|
|
56
|
+
symbolizes the model's arguments back into keyword args for you.
|
|
57
|
+
- **Observable.** Subscribe to `agent_start`, `tool_call`, `tool_result`,
|
|
58
|
+
`agent_end`, and more. Build a TUI, a log stream, or a web view without the
|
|
59
|
+
harness knowing how it is rendered.
|
|
60
|
+
- **Dependency-free core.** The OpenAI provider uses `Net::HTTP` and the JSON
|
|
61
|
+
in the standard library. Nothing to vendor, nothing to audit but the code you
|
|
62
|
+
see.
|
|
63
|
+
|
|
64
|
+
## Install
|
|
65
|
+
|
|
66
|
+
```ruby
|
|
67
|
+
# Gemfile
|
|
68
|
+
gem "truffle"
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
```sh
|
|
72
|
+
bundle install
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Or from a checkout:
|
|
76
|
+
|
|
77
|
+
```sh
|
|
78
|
+
gem build truffle.gemspec
|
|
79
|
+
gem install ./truffle-0.1.0.gem
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Truffle targets Ruby >= 3.1.
|
|
83
|
+
|
|
84
|
+
## Quick start
|
|
85
|
+
|
|
86
|
+
Set your key and run the bundled calculator example, which shows the model
|
|
87
|
+
chaining several tool calls:
|
|
88
|
+
|
|
89
|
+
```sh
|
|
90
|
+
export OPENAI_API_KEY=sk-...
|
|
91
|
+
ruby examples/calculator.rb "What is (12 + 8) multiplied by 7, then add 100?"
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
```
|
|
95
|
+
Q: What is (12 + 8) multiplied by 7, then add 100?
|
|
96
|
+
------------------------------------------------------------
|
|
97
|
+
-> calling add(a=12, b=8)
|
|
98
|
+
<- add returned 20
|
|
99
|
+
-> calling multiply(a=20, b=7)
|
|
100
|
+
<- multiply returned 140
|
|
101
|
+
-> calling add(a=140, b=100)
|
|
102
|
+
<- add returned 240
|
|
103
|
+
------------------------------------------------------------
|
|
104
|
+
A: The final result is 240.
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Core concepts
|
|
108
|
+
|
|
109
|
+
### Tools
|
|
110
|
+
|
|
111
|
+
```ruby
|
|
112
|
+
add = Truffle.tool("add", "Add two integers") do
|
|
113
|
+
param :a, :integer, "first addend", required: true
|
|
114
|
+
param :b, :integer, "second addend", required: true
|
|
115
|
+
run { |a:, b:| a + b }
|
|
116
|
+
end
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
- `param name, type, description, required:` declares an input. Types map to
|
|
120
|
+
JSON Schema (`:string`, `:integer`, `:number`, `:boolean`, ...).
|
|
121
|
+
- `run { |a:, b:| ... }` is your handler. The model emits string keys; Truffle
|
|
122
|
+
symbolizes them into keyword args. Return any value; it is stringified before
|
|
123
|
+
it goes back to the model.
|
|
124
|
+
- Raising inside a handler does not crash the loop. The error is caught and fed
|
|
125
|
+
back to the model as the tool result, so it can recover or apologize.
|
|
126
|
+
|
|
127
|
+
### Agents
|
|
128
|
+
|
|
129
|
+
```ruby
|
|
130
|
+
agent = Truffle.agent(
|
|
131
|
+
provider: :openai,
|
|
132
|
+
model: "gpt-4o-mini",
|
|
133
|
+
system_prompt: "You are a precise calculator.",
|
|
134
|
+
tools: [add],
|
|
135
|
+
max_turns: 12
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
answer = agent.run("What is 2 + 3?")
|
|
139
|
+
agent.reset # clears history, keeps the system prompt and tools
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
`run` drives the loop to completion and returns the final assistant text.
|
|
143
|
+
`max_turns` guards against a model that never settles; exceeding it raises
|
|
144
|
+
`Truffle::Error`.
|
|
145
|
+
|
|
146
|
+
### Events
|
|
147
|
+
|
|
148
|
+
```ruby
|
|
149
|
+
agent.on(:tool_call) { |e| puts "-> #{e[:call].name}(#{e[:call].arguments})" }
|
|
150
|
+
agent.on(:tool_result) { |e| puts "<- #{e[:result]}" }
|
|
151
|
+
agent.on { |type, payload| logger.debug(type => payload) } # every event
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Events fire in order: `agent_start`, then per turn `turn_start`, `message`,
|
|
155
|
+
`tool_call`/`tool_result` (one pair per tool the model invokes), `turn_end`,
|
|
156
|
+
and finally `agent_end`.
|
|
157
|
+
|
|
158
|
+
### Providers
|
|
159
|
+
|
|
160
|
+
A provider is anything that implements:
|
|
161
|
+
|
|
162
|
+
```ruby
|
|
163
|
+
def chat(messages:, tools:, model: nil, **options)
|
|
164
|
+
# -> Truffle::Response
|
|
165
|
+
end
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
The bundled `Truffle::Providers::OpenAI` talks to the Chat Completions API over
|
|
169
|
+
`Net::HTTP`. To target another backend, subclass `Truffle::Providers::Base` and
|
|
170
|
+
implement `chat`. The roadmap adds first-class Anthropic and other providers,
|
|
171
|
+
each hand-written against the seam.
|
|
172
|
+
|
|
173
|
+
## Testing
|
|
174
|
+
|
|
175
|
+
```sh
|
|
176
|
+
rake test
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
The default suite is hermetic and offline: it drives the agent loop with a stub
|
|
180
|
+
provider, so you can run it anywhere without a key. One additional test
|
|
181
|
+
(`test/test_openai_integration.rb`) performs a real OpenAI round trip and is
|
|
182
|
+
**skipped unless `OPENAI_API_KEY` is set**. With a key present it verifies the
|
|
183
|
+
full path: prompt -> model requests a tool -> Truffle runs it -> model answers
|
|
184
|
+
with the tool's result.
|
|
185
|
+
|
|
186
|
+
No local Ruby? The repo ships `script/rb`, a thin wrapper that runs any command
|
|
187
|
+
inside a `ruby:3.3-slim` container, so `script/rb rake test` works on a host
|
|
188
|
+
with only Docker.
|
|
189
|
+
|
|
190
|
+
## Project layout
|
|
191
|
+
|
|
192
|
+
```
|
|
193
|
+
lib/truffle.rb # top-level API: Truffle.agent, Truffle.tool, Truffle.provider
|
|
194
|
+
lib/truffle/agent.rb # the agent loop (the heart of the port)
|
|
195
|
+
lib/truffle/tool.rb # tool DSL + JSON Schema generation
|
|
196
|
+
lib/truffle/toolbox.rb # a named collection of tools
|
|
197
|
+
lib/truffle/message.rb # message + tool-call value objects
|
|
198
|
+
lib/truffle/response.rb # a provider's reply
|
|
199
|
+
lib/truffle/providers/base.rb # the provider seam
|
|
200
|
+
lib/truffle/providers/openai.rb # OpenAI Chat Completions provider
|
|
201
|
+
examples/calculator.rb # runnable multi-tool demo
|
|
202
|
+
test/ # minitest suite (offline + one live test)
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
## Credits
|
|
206
|
+
|
|
207
|
+
Truffle is a from-scratch Ruby port of
|
|
208
|
+
[pi](https://github.com/earendil-works/pi) by Mario Zechner (MIT). pi is the
|
|
209
|
+
blueprint; the Ruby implementation is written from the ground up. Thanks to the
|
|
210
|
+
pi project for the design.
|
|
211
|
+
|
|
212
|
+
## License
|
|
213
|
+
|
|
214
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Truffle
|
|
4
|
+
# A stateful agent: a provider, a system prompt, a running message history,
|
|
5
|
+
# and a toolbox. Calling #run drives the agent loop to completion.
|
|
6
|
+
#
|
|
7
|
+
# The loop is the port of pi's agent-core runtime:
|
|
8
|
+
#
|
|
9
|
+
# run(text)
|
|
10
|
+
# emit :agent_start
|
|
11
|
+
# append user message
|
|
12
|
+
# loop:
|
|
13
|
+
# emit :turn_start
|
|
14
|
+
# response = provider.chat(messages, tools)
|
|
15
|
+
# append assistant message; emit :message
|
|
16
|
+
# if response has tool calls:
|
|
17
|
+
# for each call: emit :tool_call, run tool, append tool result,
|
|
18
|
+
# emit :tool_result
|
|
19
|
+
# emit :turn_end ; continue # feed results back to the model
|
|
20
|
+
# else:
|
|
21
|
+
# emit :turn_end ; emit :agent_end ; return assistant text
|
|
22
|
+
#
|
|
23
|
+
# Events let a UI (TUI, web, logs) observe the run without the harness
|
|
24
|
+
# knowing anything about how it is rendered. Subscribe with #on.
|
|
25
|
+
class Agent
|
|
26
|
+
DEFAULT_MAX_TURNS = 12
|
|
27
|
+
|
|
28
|
+
EVENTS = %i[agent_start turn_start message tool_call tool_result turn_end agent_end].freeze
|
|
29
|
+
|
|
30
|
+
attr_reader :provider, :messages, :toolbox, :system_prompt, :max_turns
|
|
31
|
+
|
|
32
|
+
def initialize(provider:, system_prompt: nil, tools: [], model: nil,
|
|
33
|
+
max_turns: DEFAULT_MAX_TURNS)
|
|
34
|
+
@provider = provider
|
|
35
|
+
@system_prompt = system_prompt
|
|
36
|
+
@model = model
|
|
37
|
+
@max_turns = max_turns
|
|
38
|
+
@toolbox = tools.is_a?(Toolbox) ? tools : Toolbox.new(tools)
|
|
39
|
+
@listeners = Hash.new { |h, k| h[k] = [] }
|
|
40
|
+
|
|
41
|
+
@messages = []
|
|
42
|
+
@messages << Message.system(system_prompt) if system_prompt
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Register a listener. `on(:tool_call) { |payload| ... }` for one event, or
|
|
46
|
+
# `on { |type, payload| ... }` (no event arg) for every event.
|
|
47
|
+
def on(event = nil, &block)
|
|
48
|
+
raise ArgumentError, "on requires a block" unless block
|
|
49
|
+
|
|
50
|
+
if event.nil?
|
|
51
|
+
@listeners[:_all] << block
|
|
52
|
+
else
|
|
53
|
+
event = event.to_sym
|
|
54
|
+
unless EVENTS.include?(event)
|
|
55
|
+
raise ArgumentError, "unknown event #{event.inspect}, expected one of #{EVENTS.inspect}"
|
|
56
|
+
end
|
|
57
|
+
@listeners[event] << block
|
|
58
|
+
end
|
|
59
|
+
self
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Send a user message and run the loop until the model answers without
|
|
63
|
+
# requesting a tool. Returns the final assistant text.
|
|
64
|
+
def run(user_input)
|
|
65
|
+
emit(:agent_start, input: user_input)
|
|
66
|
+
@messages << Message.user(user_input)
|
|
67
|
+
|
|
68
|
+
final_text = nil
|
|
69
|
+
turns = 0
|
|
70
|
+
|
|
71
|
+
loop do
|
|
72
|
+
turns += 1
|
|
73
|
+
if turns > max_turns
|
|
74
|
+
raise Error, "exceeded max_turns (#{max_turns}) without a final answer"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
emit(:turn_start, turn: turns)
|
|
78
|
+
response = @provider.chat(messages: @messages, tools: @toolbox.to_schema, model: @model)
|
|
79
|
+
@messages << response.message
|
|
80
|
+
emit(:message, message: response.message, usage: response.usage)
|
|
81
|
+
|
|
82
|
+
unless response.tool_calls?
|
|
83
|
+
final_text = response.text
|
|
84
|
+
emit(:turn_end, turn: turns, tool_results: [])
|
|
85
|
+
break
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
tool_results = run_tool_calls(response.tool_calls)
|
|
89
|
+
emit(:turn_end, turn: turns, tool_results: tool_results)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
emit(:agent_end, output: final_text, messages: @messages)
|
|
93
|
+
final_text
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Reset history back to just the system prompt (keeps tools + listeners).
|
|
97
|
+
def reset
|
|
98
|
+
@messages = []
|
|
99
|
+
@messages << Message.system(@system_prompt) if @system_prompt
|
|
100
|
+
self
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
def run_tool_calls(tool_calls)
|
|
106
|
+
tool_calls.map do |call|
|
|
107
|
+
emit(:tool_call, call: call)
|
|
108
|
+
result = execute(call)
|
|
109
|
+
message = Message.tool(content: result, tool_call_id: call.id, name: call.name)
|
|
110
|
+
@messages << message
|
|
111
|
+
emit(:tool_result, call: call, result: result, message: message)
|
|
112
|
+
result
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def execute(call)
|
|
117
|
+
tool = @toolbox[call.name]
|
|
118
|
+
return "Error: unknown tool '#{call.name}'" if tool.nil?
|
|
119
|
+
|
|
120
|
+
tool.call(call.arguments)
|
|
121
|
+
rescue StandardError => e
|
|
122
|
+
# A tool raising should not kill the loop; report it back to the model so
|
|
123
|
+
# it can recover or apologize. This mirrors how pi treats tool failures.
|
|
124
|
+
"Error running tool '#{call.name}': #{e.class}: #{e.message}"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def emit(event, **payload)
|
|
128
|
+
@listeners[event].each { |l| l.call(payload) }
|
|
129
|
+
@listeners[:_all].each { |l| l.call(event, payload) }
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Truffle
|
|
4
|
+
# A single message in an agent conversation.
|
|
5
|
+
#
|
|
6
|
+
# Truffle works with one flat message type across every provider. The provider
|
|
7
|
+
# layer is responsible for translating these into whatever wire shape a given
|
|
8
|
+
# API expects (see Truffle::Providers::Base#serialize_messages). Keeping a single
|
|
9
|
+
# in-memory representation is what lets the agent loop stay provider-agnostic.
|
|
10
|
+
#
|
|
11
|
+
# Roles:
|
|
12
|
+
# :system - instructions that steer the assistant
|
|
13
|
+
# :user - input from the human (or upstream caller)
|
|
14
|
+
# :assistant - a model turn; may carry tool_calls instead of (or with) text
|
|
15
|
+
# :tool - the result of running a tool, linked by tool_call_id
|
|
16
|
+
class Message
|
|
17
|
+
ROLES = %i[system user assistant tool].freeze
|
|
18
|
+
|
|
19
|
+
attr_reader :role, :content, :tool_calls, :tool_call_id, :name
|
|
20
|
+
|
|
21
|
+
def initialize(role:, content: nil, tool_calls: [], tool_call_id: nil, name: nil)
|
|
22
|
+
role = role.to_sym
|
|
23
|
+
unless ROLES.include?(role)
|
|
24
|
+
raise ArgumentError, "unknown role #{role.inspect}, expected one of #{ROLES.inspect}"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
@role = role
|
|
28
|
+
@content = content
|
|
29
|
+
@tool_calls = tool_calls || []
|
|
30
|
+
@tool_call_id = tool_call_id
|
|
31
|
+
@name = name
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.system(content)
|
|
35
|
+
new(role: :system, content: content)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.user(content)
|
|
39
|
+
new(role: :user, content: content)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.assistant(content: nil, tool_calls: [])
|
|
43
|
+
new(role: :assistant, content: content, tool_calls: tool_calls)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# A tool result message, linked back to the assistant tool call by id.
|
|
47
|
+
def self.tool(content:, tool_call_id:, name: nil)
|
|
48
|
+
new(role: :tool, content: content, tool_call_id: tool_call_id, name: name)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def tool_calls?
|
|
52
|
+
!@tool_calls.empty?
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def to_h
|
|
56
|
+
{
|
|
57
|
+
role: role,
|
|
58
|
+
content: content,
|
|
59
|
+
tool_calls: tool_calls.map(&:to_h),
|
|
60
|
+
tool_call_id: tool_call_id,
|
|
61
|
+
name: name
|
|
62
|
+
}.compact
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# A single tool invocation requested by the model.
|
|
67
|
+
ToolCall = Struct.new(:id, :name, :arguments, keyword_init: true) do
|
|
68
|
+
# arguments is always a parsed Hash with string keys, mirroring the JSON the
|
|
69
|
+
# model emitted. The agent symbolizes keys before handing them to the tool.
|
|
70
|
+
def to_h
|
|
71
|
+
{ id: id, name: name, arguments: arguments }
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Truffle
|
|
4
|
+
module Providers
|
|
5
|
+
# The contract every provider implements. This single seam is what makes
|
|
6
|
+
# Truffle provider-agnostic: the agent loop only ever calls #chat and reads a
|
|
7
|
+
# Truffle::Response back. Swapping OpenAI for Anthropic or a local model is a
|
|
8
|
+
# one-line change at construction time. Every provider is written from
|
|
9
|
+
# scratch against this seam; there are no runtime gem dependencies.
|
|
10
|
+
#
|
|
11
|
+
# Subclasses must implement #chat. They are free to translate Truffle::Message
|
|
12
|
+
# objects and tool schemas into their native wire format however they like.
|
|
13
|
+
class Base
|
|
14
|
+
# @param messages [Array<Truffle::Message>] the conversation so far
|
|
15
|
+
# @param tools [Array<Hash>] provider-neutral tool schemas (Toolbox#to_schema)
|
|
16
|
+
# @param model [String, nil] override the default model for this call
|
|
17
|
+
# @return [Truffle::Response]
|
|
18
|
+
def chat(messages:, tools: [], model: nil, **options)
|
|
19
|
+
raise NotImplementedError, "#{self.class} must implement #chat"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Human-readable provider id, used in events and errors.
|
|
23
|
+
def name
|
|
24
|
+
self.class.name.split("::").last.downcase
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Raised when a provider's HTTP call fails or returns an error payload.
|
|
29
|
+
class Error < StandardError; end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module Truffle
|
|
8
|
+
module Providers
|
|
9
|
+
# OpenAI Chat Completions provider with tool calling.
|
|
10
|
+
#
|
|
11
|
+
# Deliberately dependency-free: it speaks the HTTP API directly with
|
|
12
|
+
# Net::HTTP and the stdlib JSON, so a fresh Ruby can run Truffle with nothing
|
|
13
|
+
# but `gem install truffle`. It also works against any OpenAI-compatible
|
|
14
|
+
# endpoint (Ollama, vLLM, Together, OpenRouter, ...) by passing :base_url.
|
|
15
|
+
class OpenAI < Base
|
|
16
|
+
DEFAULT_MODEL = "gpt-4o-mini"
|
|
17
|
+
DEFAULT_BASE_URL = "https://api.openai.com/v1"
|
|
18
|
+
|
|
19
|
+
attr_reader :model
|
|
20
|
+
|
|
21
|
+
def initialize(api_key: ENV["OPENAI_API_KEY"], model: DEFAULT_MODEL,
|
|
22
|
+
base_url: DEFAULT_BASE_URL, open_timeout: 15, read_timeout: 120)
|
|
23
|
+
super()
|
|
24
|
+
raise ArgumentError, "missing OpenAI API key (set OPENAI_API_KEY or pass :api_key)" if api_key.nil? || api_key.empty?
|
|
25
|
+
|
|
26
|
+
@api_key = api_key
|
|
27
|
+
@model = model
|
|
28
|
+
@base_url = base_url.chomp("/")
|
|
29
|
+
@open_timeout = open_timeout
|
|
30
|
+
@read_timeout = read_timeout
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def name
|
|
34
|
+
"openai"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def chat(messages:, tools: [], model: nil, **options)
|
|
38
|
+
body = {
|
|
39
|
+
model: model || @model,
|
|
40
|
+
messages: serialize_messages(messages)
|
|
41
|
+
}
|
|
42
|
+
unless tools.empty?
|
|
43
|
+
body[:tools] = tools.map { |t| { type: "function", function: t } }
|
|
44
|
+
body[:tool_choice] = options.fetch(:tool_choice, "auto")
|
|
45
|
+
end
|
|
46
|
+
body[:temperature] = options[:temperature] if options.key?(:temperature)
|
|
47
|
+
body[:max_tokens] = options[:max_tokens] if options.key?(:max_tokens)
|
|
48
|
+
|
|
49
|
+
payload = post("/chat/completions", body)
|
|
50
|
+
choice = payload.fetch("choices").first
|
|
51
|
+
Response.new(
|
|
52
|
+
message: deserialize_message(choice.fetch("message")),
|
|
53
|
+
usage: payload["usage"] || {},
|
|
54
|
+
raw: payload,
|
|
55
|
+
model: payload["model"],
|
|
56
|
+
finish_reason: choice["finish_reason"]
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def serialize_messages(messages)
|
|
63
|
+
messages.map do |m|
|
|
64
|
+
case m.role
|
|
65
|
+
when :assistant
|
|
66
|
+
h = { role: "assistant", content: m.content }
|
|
67
|
+
unless m.tool_calls.empty?
|
|
68
|
+
h[:tool_calls] = m.tool_calls.map do |tc|
|
|
69
|
+
{
|
|
70
|
+
id: tc.id,
|
|
71
|
+
type: "function",
|
|
72
|
+
function: { name: tc.name, arguments: JSON.generate(tc.arguments) }
|
|
73
|
+
}
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
h
|
|
77
|
+
when :tool
|
|
78
|
+
{ role: "tool", tool_call_id: m.tool_call_id, content: m.content.to_s }
|
|
79
|
+
else
|
|
80
|
+
{ role: m.role.to_s, content: m.content.to_s }
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def deserialize_message(raw)
|
|
86
|
+
tool_calls = Array(raw["tool_calls"]).map do |tc|
|
|
87
|
+
fn = tc["function"] || {}
|
|
88
|
+
ToolCall.new(
|
|
89
|
+
id: tc["id"],
|
|
90
|
+
name: fn["name"],
|
|
91
|
+
arguments: parse_arguments(fn["arguments"])
|
|
92
|
+
)
|
|
93
|
+
end
|
|
94
|
+
Message.assistant(content: raw["content"], tool_calls: tool_calls)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def parse_arguments(raw)
|
|
98
|
+
return {} if raw.nil? || raw == ""
|
|
99
|
+
|
|
100
|
+
JSON.parse(raw)
|
|
101
|
+
rescue JSON::ParserError
|
|
102
|
+
# A model very occasionally emits malformed JSON for arguments. Surface
|
|
103
|
+
# the raw string under a sentinel key rather than crashing the loop.
|
|
104
|
+
{ "_raw" => raw }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def post(path, body)
|
|
108
|
+
uri = URI("#{@base_url}#{path}")
|
|
109
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
110
|
+
http.use_ssl = uri.scheme == "https"
|
|
111
|
+
http.open_timeout = @open_timeout
|
|
112
|
+
http.read_timeout = @read_timeout
|
|
113
|
+
|
|
114
|
+
request = Net::HTTP::Post.new(uri)
|
|
115
|
+
request["Authorization"] = "Bearer #{@api_key}"
|
|
116
|
+
request["Content-Type"] = "application/json"
|
|
117
|
+
request.body = JSON.generate(body)
|
|
118
|
+
|
|
119
|
+
response = http.request(request)
|
|
120
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
121
|
+
raise Error, "OpenAI #{response.code}: #{truncate(response.body)}"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
JSON.parse(response.body)
|
|
125
|
+
rescue JSON::ParserError => e
|
|
126
|
+
raise Error, "could not parse OpenAI response: #{e.message}"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def truncate(str, limit = 500)
|
|
130
|
+
s = str.to_s
|
|
131
|
+
s.length > limit ? "#{s[0, limit]}..." : s
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Truffle
|
|
4
|
+
# A normalized response from a provider's chat call.
|
|
5
|
+
#
|
|
6
|
+
# Every provider returns one of these regardless of its native wire format, so
|
|
7
|
+
# the agent loop never has to branch on which model it is talking to.
|
|
8
|
+
class Response
|
|
9
|
+
attr_reader :message, :usage, :raw, :model, :finish_reason
|
|
10
|
+
|
|
11
|
+
def initialize(message:, usage: {}, raw: nil, model: nil, finish_reason: nil)
|
|
12
|
+
@message = message
|
|
13
|
+
@usage = usage || {}
|
|
14
|
+
@raw = raw
|
|
15
|
+
@model = model
|
|
16
|
+
@finish_reason = finish_reason
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# The text content of the assistant turn (may be nil on a pure tool call).
|
|
20
|
+
def text
|
|
21
|
+
message.content
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def tool_calls
|
|
25
|
+
message.tool_calls
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def tool_calls?
|
|
29
|
+
message.tool_calls?
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
data/lib/truffle/tool.rb
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Truffle
|
|
4
|
+
# A tool the agent can call.
|
|
5
|
+
#
|
|
6
|
+
# A tool is a name, a human-readable description (the model reads this to
|
|
7
|
+
# decide when to call it), a JSON-Schema parameter spec, and a callable that
|
|
8
|
+
# actually runs. Build one with the block DSL:
|
|
9
|
+
#
|
|
10
|
+
# weather = Truffle::Tool.define("get_weather", "Look up the weather for a city") do
|
|
11
|
+
# param :city, :string, "City name, e.g. 'Berlin'", required: true
|
|
12
|
+
# param :units, :string, "celsius or fahrenheit"
|
|
13
|
+
# run { |city:, units: "celsius"| WeatherApi.fetch(city, units) }
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# The block runs in a small builder context so `param` and `run` read cleanly.
|
|
17
|
+
class Tool
|
|
18
|
+
attr_reader :name, :description, :parameters, :handler
|
|
19
|
+
|
|
20
|
+
def initialize(name:, description:, parameters:, handler:)
|
|
21
|
+
@name = name.to_s
|
|
22
|
+
@description = description.to_s
|
|
23
|
+
@parameters = parameters
|
|
24
|
+
@handler = handler
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.define(name, description, &block)
|
|
28
|
+
builder = Builder.new
|
|
29
|
+
builder.instance_eval(&block) if block
|
|
30
|
+
new(
|
|
31
|
+
name: name,
|
|
32
|
+
description: description,
|
|
33
|
+
parameters: builder.schema,
|
|
34
|
+
handler: builder.handler
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Run the tool. `arguments` is a Hash with string keys (as the model emits);
|
|
39
|
+
# they are symbolized so the handler can use keyword arguments.
|
|
40
|
+
def call(arguments)
|
|
41
|
+
kwargs = (arguments || {}).each_with_object({}) { |(k, v), h| h[k.to_sym] = v }
|
|
42
|
+
result = handler.call(**kwargs)
|
|
43
|
+
result.is_a?(String) ? result : result.inspect
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# JSON-Schema-shaped function spec, provider-neutral. Providers wrap this in
|
|
47
|
+
# whatever envelope they need (OpenAI: {type:"function", function:{...}}).
|
|
48
|
+
def to_schema
|
|
49
|
+
{
|
|
50
|
+
name: name,
|
|
51
|
+
description: description,
|
|
52
|
+
parameters: parameters
|
|
53
|
+
}
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Collects param declarations and the run block into a JSON Schema + handler.
|
|
57
|
+
class Builder
|
|
58
|
+
attr_reader :handler
|
|
59
|
+
|
|
60
|
+
def initialize
|
|
61
|
+
@properties = {}
|
|
62
|
+
@required = []
|
|
63
|
+
@handler = ->(**) { "" }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def param(name, type, description = nil, required: false, **extra)
|
|
67
|
+
spec = { type: type.to_s }
|
|
68
|
+
spec[:description] = description if description
|
|
69
|
+
spec.merge!(extra)
|
|
70
|
+
@properties[name.to_s] = spec
|
|
71
|
+
@required << name.to_s if required
|
|
72
|
+
self
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def run(&block)
|
|
76
|
+
raise ArgumentError, "run requires a block" unless block
|
|
77
|
+
|
|
78
|
+
@handler = block
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def schema
|
|
82
|
+
{
|
|
83
|
+
type: "object",
|
|
84
|
+
properties: @properties,
|
|
85
|
+
required: @required
|
|
86
|
+
}
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Truffle
|
|
4
|
+
# An ordered collection of tools the agent can reach for, keyed by name.
|
|
5
|
+
class Toolbox
|
|
6
|
+
include Enumerable
|
|
7
|
+
|
|
8
|
+
def initialize(tools = [])
|
|
9
|
+
@tools = {}
|
|
10
|
+
Array(tools).each { |t| add(t) }
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def add(tool)
|
|
14
|
+
@tools[tool.name] = tool
|
|
15
|
+
self
|
|
16
|
+
end
|
|
17
|
+
alias << add
|
|
18
|
+
|
|
19
|
+
def [](name)
|
|
20
|
+
@tools[name.to_s]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def each(&block)
|
|
24
|
+
@tools.values.each(&block)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def empty?
|
|
28
|
+
@tools.empty?
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def names
|
|
32
|
+
@tools.keys
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Provider-neutral schemas for every tool, in declared order.
|
|
36
|
+
def to_schema
|
|
37
|
+
@tools.values.map(&:to_schema)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
data/lib/truffle.rb
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "truffle/version"
|
|
4
|
+
require_relative "truffle/message"
|
|
5
|
+
require_relative "truffle/response"
|
|
6
|
+
require_relative "truffle/tool"
|
|
7
|
+
require_relative "truffle/toolbox"
|
|
8
|
+
require_relative "truffle/providers/base"
|
|
9
|
+
require_relative "truffle/providers/openai"
|
|
10
|
+
require_relative "truffle/agent"
|
|
11
|
+
|
|
12
|
+
# Truffle is a complete agent harness for Ruby, built from scratch.
|
|
13
|
+
#
|
|
14
|
+
# It is a faithful port of earendil-works/pi to idiomatic Ruby: the agent-core
|
|
15
|
+
# runtime (tool calling, state, and an event-streaming protocol), with a
|
|
16
|
+
# provider-agnostic LLM seam written from the ground up and no runtime gem
|
|
17
|
+
# dependencies.
|
|
18
|
+
#
|
|
19
|
+
# Quick start:
|
|
20
|
+
#
|
|
21
|
+
# require "truffle"
|
|
22
|
+
#
|
|
23
|
+
# add = Truffle::Tool.define("add", "Add two integers") do
|
|
24
|
+
# param :a, :integer, required: true
|
|
25
|
+
# param :b, :integer, required: true
|
|
26
|
+
# run { |a:, b:| a + b }
|
|
27
|
+
# end
|
|
28
|
+
#
|
|
29
|
+
# agent = Truffle.agent(
|
|
30
|
+
# provider: :openai,
|
|
31
|
+
# system_prompt: "You are a precise calculator. Use tools for arithmetic.",
|
|
32
|
+
# tools: [add]
|
|
33
|
+
# )
|
|
34
|
+
# puts agent.run("What is 21 plus 21?")
|
|
35
|
+
module Truffle
|
|
36
|
+
# Generic Truffle error type; provider HTTP errors are Truffle::Providers::Error.
|
|
37
|
+
class Error < StandardError; end
|
|
38
|
+
|
|
39
|
+
PROVIDERS = {
|
|
40
|
+
openai: Providers::OpenAI
|
|
41
|
+
}.freeze
|
|
42
|
+
|
|
43
|
+
module_function
|
|
44
|
+
|
|
45
|
+
# Build a provider by symbol (:openai) or pass a ready-made instance through.
|
|
46
|
+
def provider(name, **options)
|
|
47
|
+
return name if name.is_a?(Providers::Base)
|
|
48
|
+
|
|
49
|
+
klass = PROVIDERS[name.to_sym]
|
|
50
|
+
raise Error, "unknown provider #{name.inspect}, known: #{PROVIDERS.keys.inspect}" if klass.nil?
|
|
51
|
+
|
|
52
|
+
klass.new(**options)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Convenience constructor: Truffle.agent(provider: :openai, tools: [...], ...).
|
|
56
|
+
# `provider:` may be a symbol, an options-less default, or a provider instance.
|
|
57
|
+
def agent(provider:, system_prompt: nil, tools: [], model: nil,
|
|
58
|
+
max_turns: Agent::DEFAULT_MAX_TURNS, **provider_options)
|
|
59
|
+
prov = provider(provider, **provider_options)
|
|
60
|
+
Agent.new(
|
|
61
|
+
provider: prov,
|
|
62
|
+
system_prompt: system_prompt,
|
|
63
|
+
tools: tools,
|
|
64
|
+
model: model,
|
|
65
|
+
max_turns: max_turns
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Define a tool: Truffle.tool("name", "desc") { param ...; run { ... } }.
|
|
70
|
+
def tool(name, description, &block)
|
|
71
|
+
Tool.define(name, description, &block)
|
|
72
|
+
end
|
|
73
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: truffle
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Truffle
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-06-28 00:00:00.000000000 Z
|
|
12
|
+
dependencies: []
|
|
13
|
+
description: |
|
|
14
|
+
Truffle is a dependency-free agent harness for Ruby, built from scratch as a
|
|
15
|
+
faithful port of earendil-works/pi: a provider-agnostic LLM seam, an agent
|
|
16
|
+
loop with tool calling and state, an event-streaming protocol for UIs, and a
|
|
17
|
+
foundation for skills, commands, sessions, and memory. No runtime gem
|
|
18
|
+
dependencies; the LLM client, tool layer, and event model are written from
|
|
19
|
+
the ground up.
|
|
20
|
+
email:
|
|
21
|
+
- truffleagent@gmail.com
|
|
22
|
+
executables: []
|
|
23
|
+
extensions: []
|
|
24
|
+
extra_rdoc_files: []
|
|
25
|
+
files:
|
|
26
|
+
- CHANGELOG.md
|
|
27
|
+
- LICENSE
|
|
28
|
+
- README.md
|
|
29
|
+
- lib/truffle.rb
|
|
30
|
+
- lib/truffle/agent.rb
|
|
31
|
+
- lib/truffle/message.rb
|
|
32
|
+
- lib/truffle/providers/base.rb
|
|
33
|
+
- lib/truffle/providers/openai.rb
|
|
34
|
+
- lib/truffle/response.rb
|
|
35
|
+
- lib/truffle/tool.rb
|
|
36
|
+
- lib/truffle/toolbox.rb
|
|
37
|
+
- lib/truffle/version.rb
|
|
38
|
+
homepage: https://github.com/truffle-dev/truffle-rb
|
|
39
|
+
licenses:
|
|
40
|
+
- MIT
|
|
41
|
+
metadata:
|
|
42
|
+
homepage_uri: https://github.com/truffle-dev/truffle-rb
|
|
43
|
+
source_code_uri: https://github.com/truffle-dev/truffle-rb
|
|
44
|
+
changelog_uri: https://github.com/truffle-dev/truffle-rb/blob/main/CHANGELOG.md
|
|
45
|
+
post_install_message:
|
|
46
|
+
rdoc_options: []
|
|
47
|
+
require_paths:
|
|
48
|
+
- lib
|
|
49
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '3.1'
|
|
54
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
55
|
+
requirements:
|
|
56
|
+
- - ">="
|
|
57
|
+
- !ruby/object:Gem::Version
|
|
58
|
+
version: '0'
|
|
59
|
+
requirements: []
|
|
60
|
+
rubygems_version: 3.5.22
|
|
61
|
+
signing_key:
|
|
62
|
+
specification_version: 4
|
|
63
|
+
summary: A complete agent harness for Ruby.
|
|
64
|
+
test_files: []
|