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 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
+ ![Truffle test suite and a live three-tool agent run](docs/screenshot-tests.png)
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
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Truffle
4
+ VERSION = "0.1.0"
5
+ 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: []