terret-core 0.1.0 → 0.1.1
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 +4 -4
- data/lib/terret/approvals.rb +267 -0
- data/lib/terret/compactor.rb +116 -0
- data/lib/terret/credentials.rb +172 -0
- data/lib/terret/llm.rb +90 -2
- data/lib/terret/loop.rb +538 -26
- data/lib/terret/redactor.rb +101 -0
- data/lib/terret/sessions.rb +325 -22
- data/lib/terret/store.rb +79 -0
- data/lib/terret/subagents.rb +99 -0
- data/lib/terret/titler.rb +58 -0
- data/lib/terret/tools.rb +280 -11
- data/lib/terret.rb +18 -1
- metadata +8 -1
data/lib/terret/llm.rb
CHANGED
|
@@ -16,16 +16,89 @@ module Terret
|
|
|
16
16
|
# Stream events yielded by adapters.
|
|
17
17
|
TextDelta = Data.define(:text)
|
|
18
18
|
ToolCallEnd = Data.define(:tool_call)
|
|
19
|
+
Usage = Data.define(:prompt_tokens, :completion_tokens, :cost)
|
|
20
|
+
StreamError = Data.define(:message, :code) # surfaced mid-stream, then raised
|
|
19
21
|
MessageStop = Data.define(:stop_reason) # :end_turn | :tool_use
|
|
20
22
|
|
|
23
|
+
# Storage codec for message parts. Durable logs hold only these primitive,
|
|
24
|
+
# storage-named hashes ("text", never Terret::LLM::Text), so a class
|
|
25
|
+
# rename can never invalidate stored sessions. decode(encode(p)) == p.
|
|
26
|
+
module_function
|
|
27
|
+
|
|
28
|
+
def encode_part(part)
|
|
29
|
+
case part
|
|
30
|
+
when Text then { type: "text", text: part.text }
|
|
31
|
+
when ToolCall then { type: "tool_call", id: part.id, name: part.name, args: part.args }
|
|
32
|
+
when ToolResult then { type: "tool_result", id: part.id, content: part.content, error: part.error }
|
|
33
|
+
else raise ArgumentError, "cannot encode #{part.class} as a message part"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def decode_part(hash)
|
|
38
|
+
case hash[:type]
|
|
39
|
+
when "text" then Text.new(text: hash[:text])
|
|
40
|
+
when "tool_call" then ToolCall.new(id: hash[:id], name: hash[:name], args: hash[:args])
|
|
41
|
+
when "tool_result" then ToolResult.new(id: hash[:id], content: hash[:content], error: hash[:error])
|
|
42
|
+
else raise ArgumentError, "unknown part tag #{hash[:type].inspect}"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Adapter failures. Retryable covers 429/overload/5xx and transport drops
|
|
47
|
+
# before any bytes streamed; everything else raises through immediately.
|
|
48
|
+
class AdapterError < StandardError
|
|
49
|
+
attr_reader :status, :body
|
|
50
|
+
|
|
51
|
+
def initialize(message, status: nil, body: nil)
|
|
52
|
+
super(message)
|
|
53
|
+
@status = status
|
|
54
|
+
@body = body
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
class RetryableError < AdapterError; end
|
|
59
|
+
|
|
60
|
+
# Shared behaviour for real adapters: jittered exponential backoff around
|
|
61
|
+
# anything raising RetryableError. Sleeper is injectable for tests.
|
|
62
|
+
class AdapterBase
|
|
63
|
+
def initialize(max_attempts: 4, base_delay: 0.5, sleeper: ->(s) { sleep(s) })
|
|
64
|
+
@max_attempts = max_attempts
|
|
65
|
+
@base_delay = base_delay
|
|
66
|
+
@sleeper = sleeper
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def with_retries
|
|
70
|
+
attempt = 0
|
|
71
|
+
begin
|
|
72
|
+
yield
|
|
73
|
+
rescue RetryableError
|
|
74
|
+
attempt += 1
|
|
75
|
+
raise if attempt >= @max_attempts
|
|
76
|
+
|
|
77
|
+
# equal jitter: uniform over (half, full] of base * 2^n
|
|
78
|
+
cap = @base_delay * (2**(attempt - 1))
|
|
79
|
+
@sleeper.call(cap * (0.5 + rand * 0.5))
|
|
80
|
+
retry
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
21
85
|
# ctx.llm — the adapter seam. `llm/stream` is a waterfall wrapping every
|
|
22
86
|
# request: middleware may rewrite the request or replace the stream.
|
|
23
87
|
class Service < Hames::Service
|
|
24
88
|
service_key :llm
|
|
89
|
+
config_schema roles: { type: Hash, default: {},
|
|
90
|
+
doc: "role => \"provider/model\" map; an open set of roles" }
|
|
25
91
|
|
|
26
92
|
def start(_ctx)
|
|
27
93
|
@adapters = {}
|
|
28
|
-
@roles = config[:roles] || {} # :main => "fake/scripted"
|
|
94
|
+
@roles = (config[:roles] || {}).dup # :main => "fake/scripted"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# The role map is the whole of this service's config, and layering
|
|
98
|
+
# replaces a row's config wholesale — so does this. Registered adapters
|
|
99
|
+
# are runtime state, not config, and survive untouched.
|
|
100
|
+
def reconfigure(config)
|
|
101
|
+
@roles = (config[:roles] || {}).dup
|
|
29
102
|
end
|
|
30
103
|
|
|
31
104
|
def register_adapter(name, adapter)
|
|
@@ -39,6 +112,20 @@ module Terret
|
|
|
39
112
|
[@adapters.fetch(provider), model]
|
|
40
113
|
end
|
|
41
114
|
|
|
115
|
+
# §9.2 set_model lands here: repoint a role at a "provider/model" spec
|
|
116
|
+
# on the live service. Takes effect at the next step's resolve.
|
|
117
|
+
def set_role(role, spec)
|
|
118
|
+
raise ArgumentError, "role must be a name, got #{role.inspect}" unless role.respond_to?(:to_sym)
|
|
119
|
+
|
|
120
|
+
provider, model = spec.to_s.split("/", 2)
|
|
121
|
+
if provider.nil? || provider.empty? || model.nil? || model.empty?
|
|
122
|
+
raise ArgumentError, "model spec must be provider/model, got #{spec.inspect}"
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
@adapters.fetch(provider) { raise ArgumentError, "unknown provider #{provider.inspect}" }
|
|
126
|
+
@roles[role.to_sym] = spec.to_s
|
|
127
|
+
end
|
|
128
|
+
|
|
42
129
|
# Streams the request through the `llm/stream` waterfall; the base of
|
|
43
130
|
# the waterfall invokes the resolved adapter. Yields StreamEvents,
|
|
44
131
|
# returns the final assistant Message.
|
|
@@ -52,7 +139,7 @@ module Terret
|
|
|
52
139
|
end
|
|
53
140
|
|
|
54
141
|
# Deterministic scripted adapter for tests, demos, and record/replay.
|
|
55
|
-
# Script: array of hashes { text:, tool_calls: [ToolCall, ...] }.
|
|
142
|
+
# Script: array of hashes { text:, tool_calls: [ToolCall, ...], usage: }.
|
|
56
143
|
class FakeAdapter
|
|
57
144
|
def initialize(script)
|
|
58
145
|
@script = script.dup
|
|
@@ -70,6 +157,7 @@ module Terret
|
|
|
70
157
|
yield ToolCallEnd.new(tool_call: tc)
|
|
71
158
|
parts << tc
|
|
72
159
|
end
|
|
160
|
+
yield Usage.new(**step[:usage]) if step[:usage]
|
|
73
161
|
yield MessageStop.new(stop_reason: calls.empty? ? :end_turn : :tool_use)
|
|
74
162
|
Message.new(role: :assistant, parts: parts)
|
|
75
163
|
end
|