Cookbook: Adding a Provider
A provider is an implementation of a sole-provider seam: a service key
that exactly one row may claim, where nothing sits behind the key dispatching
between implementations (docs/subagents.md §1). Adding a tool
(docs/cookbook/adding-a-tool.md) grows an agent’s roster. A provider swap
replaces a whole capability the harness resolves by key. This page walks it
using ctx[:summarizer] as the model. Terret already ships two
implementations of that one seam, so this page follows an existing pattern.
The two are Terret::RoleSummarizer (the no-signup default, a model call
through a configured role) and Terret::Morph::Summarizer (Morph’s extractive
Compact API over the wire). Both live in the tree
(gems/terret-core/lib/terret/compactor.rb and gems/terret-morph) and both
claim service_key :summarizer. Only one is ever mounted at a time, and §3
explains the mechanism that guarantees it.
Read docs/hames-primer.md §1 first: a provider is a Hames service, and
“sole-provider seam” is just the kernel’s one-key-one-registration rule (there
enforced by register_service raising ContractError) put to work.
1. The seam contract
A seam is a method signature and a discipline, agreed between the provider and
whatever injects the key. For ctx[:summarizer] the whole contract is one
method:
ctx[:summarizer].summarize(history) # => a String, or nil to decline
history is the projected message history; the return is a compacted string,
or nil/empty to decline (§4). The consumer is the compactor
(gems/terret-core/lib/terret/compactor.rb), which injects the key and treats
a decline as “skip this boundary”:
class Compactor < Hames::Service
service_key :compactor
inject :sessions, :summarizer
# ...
def compact!(session_id)
# ...
summary = @ctx[:summarizer].summarize(history)
unless summary.is_a?(String) && !summary.strip.empty?
warn "terret: compaction skipped for #{session_id}: summarizer declined"
return nil
end
# ... append the boundary event
end
end
That inject :summarizer line is the entire coupling. The compactor names the
key, and boot resolves whichever provider a profile mounted; it never names
Morph or the role summarizer directly. A new provider is a new class that
answers summarize the same way. Nothing about the consumer changes.
Before you write one, find the seam’s contract the same way: the injecting
service and its call site tell you the method, the argument, and what a
decline means. Get the return discipline wrong (raise where the consumer
expects nil) and an optimization that should degrade gracefully turns into a
crash.
2. service_key claims the seam
A provider declares the seam it implements with service_key, and injects
whatever it needs to do the job. RoleSummarizer needs the model seam;
Morph::Summarizer needs no seam at all, only config and a network call:
# gems/terret-core/lib/terret/compactor.rb
class RoleSummarizer < Hames::Service
service_key :summarizer
inject :llm
config_schema role: { type: [String, Symbol], default: :compactor,
doc: "llm role a summary is produced under" }
def summarize(history)
request = LLM::Request.new(model: nil, system: PROMPT, messages: history, tools: [])
@ctx[:llm].stream(@ctx, role: config[:role] || :compactor, request: request) { |_ev| }.text
end
end
# gems/terret-morph/lib/terret/morph/summarizer.rb
class Summarizer < Hames::Service
service_key :summarizer
config_schema compression_ratio: { type: Numeric, default: 0.4,
doc: "target fraction of the original token count" },
api_key: { type: String,
doc: "Morph key; falls back to ENV MORPH_API_KEY when unset" },
api_base: { type: String, default: "https://api.morphllm.com/v1",
doc: "Morph Compact API base URL" },
timeout: { type: Numeric, default: 30.0,
doc: "seconds a compaction request may run" }
# ...
end
Both declare config_schema (docs/hames-primer.md §6) so trt doctor and
the config catalog can read them. A provider that reads config without a
schema is reported unschema'd, the exact signal an unaudited plugin gives
off (docs/composition.md §9). Note what is not in Morph’s schema:
transport: (§5). It is a test seam, not YAML config, so it is read from the
config hash but deliberately left out of the schema. An operator never sets
it, and a doctor should not invite them to.
3. The sole-provider refusal
Two rows both claiming :summarizer collide at boot. They do not resolve by
“last one wins”. Context#register_service refuses a second claim on a key
that is taken, and Service#apply registers through it, so the second row to
mount raises Hames::ContractError naming the key (docs/hames-primer.md
§1). That refusal is the whole meaning of “sole-provider”: the answer to “what
summarizes in this deployment” is decided once, by which single row mounts,
and cannot be quietly doubled.
Which means choosing a provider is choosing which row is enabled, not
mounting both and hoping. A profile mounts exactly one summarizer row.
Swapping RoleSummarizer for Morph is a plugin: swap on that one row, the
same single-row move the sandbox seam uses (docs/composition.md §4):
# in a profile's patch.yml — one row, plugin swapped, config restated whole
rows:
- id: summarizer
plugin: Terret::Morph::Summarizer
config:
api_key: !env MORPH_API_KEY
compression_ratio: 0.4
Because a patch replaces config wholesale (docs/composition.md §4), the swap
carries the new provider’s config with it; a swap that should inherit nothing
from the row it replaces says config: {} out loud. The base bundle ships
no summarizer row at all. Compaction is opt-in, so a profile that wants it
adds both a summarizer provider row and a compactor row
(gems/terret/config/bundle.yml has neither). That is the ordinary shape for
an optional seam: the consumer and its provider are mounted together by the
profile that wants the capability.
A provider gem does not have to be a bundle. terret-morph ships no
terret metadata key in its gemspec: it is a library of one provider class,
mounted by a profile that lists terret/morph in its requires: (or a
bundle’s) and names Terret::Morph::Summarizer in a row.
docs/cookbook/adding-a-bundle.md covers when a gem should ship a bundle of
its own or be mounted by a row someone else writes.
4. Nil-on-failure, where it applies
ctx[:summarizer] is an optimization seam: compaction makes a long
history cheaper, and a compaction that cannot happen should leave the session
exactly as it was, not end the turn. So both providers decline instead of
raising, and every failure mode in Morph funnels through one helper:
def summarize(history)
key = api_key
return decline("MORPH_API_KEY not configured") if key.nil? || key.empty?
# ... POST ...
return decline("HTTP #{status}") unless (200..299).cover?(status)
# ... parse ...
output.empty? ? decline("empty output") : output
rescue JSON::ParserError => e
decline("invalid JSON: #{e.message}")
rescue StandardError => e
decline("#{e.class}: #{e.message}")
end
def decline(message)
warn "terret-morph: compact declined: #{message}"
nil
end
A missing key, a non-2xx status, unparseable JSON, an unexpected response
shape, a non-string output, an empty output, and a transport exception all
decline to nil with a warn, and the compactor skips the boundary and retries
on the next overweight turn. A seam that silently returns nil is a
capability that quietly stopped working. The warn is not decoration; it is how
an operator finds out.
This discipline is specific to seams whose absence is survivable. It is
not a house style to copy blindly onto every provider. A sole-provider seam
the harness cannot proceed without (the session store, say) must raise on
failure, because a nil there is data loss dressed as success. The rule is
the consumer’s: read what the injecting service does with your return value.
The compactor treats nil as “skip”; a store’s caller treats a lost write as
a bug. Match the discipline to what a failure means, and say which
one you chose in a comment, the way Morph’s class doc says “every failure
declines to nil with a warn — compaction is an optimization”.
5. An injectable transport, so tests need no network
Morph talks to a real HTTP API, and its unit tests touch no network. The trick is one seam: the network call is read from the config hash, defaulting to the real implementation, so a test injects a callable and asserts against what the provider sent:
def transport
config[:transport] || method(:http_post)
end
def http_post(url, headers, body)
uri = URI(url)
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = uri.scheme == "https"
http.open_timeout = http.read_timeout = http.write_timeout = timeout
response = http.post(uri.request_uri, body, headers)
[response.code.to_i, response.body]
end
The transport’s contract is call(url, headers, body) => [status, body], a
plain callable, so method(:http_post) satisfies it and so does a lambda.
This is the same injectable-seam move the OpenRouter adapter uses for its HTTP
transport (gems/terret-openrouter): keep the one line that touches the
network behind a callable the config can replace, and the whole provider
becomes testable without mocking a library. Keeping it out of the schema
(§2) is deliberate: an injectable test seam is not an operator knob, and a
doctor listing it would invite someone to set it in YAML.
6. The test
With the transport injectable, a provider test is a fake transport, a boot,
and assertions on both what went over the wire and what came back. The example
below is adapted from terret-morph’s own test and abridged for the page.
The real file (gems/terret-morph/test/summarizer_test.rb) walks six decline
cases and richer wire assertions than the two shown here, but the shape is the
template:
# frozen_string_literal: true
require "minitest/autorun"
require_relative "../lib/terret/morph"
class MorphSummarizerTest < Minitest::Test
HISTORY = [
Terret::LLM::Message.new(role: :user, parts: [Terret::LLM::Text.new(text: "deploy the thing")]),
Terret::LLM::Message.new(role: :assistant, parts: [Terret::LLM::Text.new(text: "Deployed.")])
].freeze
def boot(transport:, config: {})
Hames.reset_events!
Terret.declare_events!
loader = Hames::Loader.new
loader.layer([
{ id: "summarizer", plugin: Terret::Morph::Summarizer,
config: { api_key: "test-key", transport: transport }.merge(config) }
])
loader.boot!
end
def test_happy_path_posts_the_wire_shape_and_returns_output
seen = nil
transport = lambda do |url, headers, body|
seen = [url, headers, JSON.parse(body, symbolize_names: true)]
[200, JSON.generate({ output: "compressed transcript" })]
end
ctx = boot(transport: transport)
assert_equal "compressed transcript", ctx[:summarizer].summarize(HISTORY)
url, headers, body = seen
assert_equal "https://api.morphllm.com/v1/compact", url
assert_equal "Bearer test-key", headers["Authorization"]
assert_in_delta 0.4, body[:compression_ratio]
end
def test_every_failure_mode_declines_to_nil_with_a_warn
cases = {
"HTTP 500" => ->(*) { [500, "boom"] },
"invalid JSON" => ->(*) { [200, "not json"] },
"non-string output" => ->(*) { [200, JSON.generate({ output: 42 })] },
"transport error" => ->(*) { raise IOError, "connection reset" }
}
cases.each do |label, transport|
ctx = boot(transport: transport)
result = nil
warned = capture_warn { result = ctx[:summarizer].summarize(HISTORY) }
assert_nil result, "#{label} must decline to nil"
assert_match(/terret-morph/, warned, "#{label} must warn")
end
end
private
def capture_warn
old = $stderr
$stderr = StringIO.new
yield
$stderr.string
ensure
$stderr = old
end
end
The shape generalizes to any provider: boot layers just the provider row and
injects the fake transport; the happy-path test asserts on what the provider
sent (the wire shape is a contract too, and this is where you pin it); and a
table-driven test walks every decline path, asserting nil and a warn. A
MORPH_LIVE=1-gated test in the same file exercises the real API when a key
is present and skips otherwise. That keeps a real integration honest without
making the default test run depend on a network or a secret.
7. What you have when this is done
A provider is a class that claims a seam key, injects what it needs, declares a schema, and answers the seam’s one method with the seam’s return discipline. Mounting one means choosing which single row claims the key, and the kernel’s sole-provider refusal (§3) guarantees nobody else quietly does. A test needs only a fake transport and a boot: no network, no secret, no mocks.
For the other two build-on-Terret recipes: docs/cookbook/adding-a-tool.md
grows the roster instead of swapping a seam, and
docs/cookbook/adding-a-bundle.md is how any of this reaches a running Terret
through a profile.