terret-core 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: fb6dbfe91797674f464068e08da10eb0d19289dc30d3a791c5c46d9450c9ff13
4
+ data.tar.gz: fa2cabe0f12d5fe1a94d9d14cb22686f9e6208dfd9b5a69316124ee1bf162ee5
5
+ SHA512:
6
+ metadata.gz: c4cfee58e20514fdeb1e8f3d60e3336916c6c54e169377f1ff64ec1ba1d6373b6dc2a2a01c7f579ede66d0f49a92fc90151947910b9a4bfe5e895dca3ab4b426
7
+ data.tar.gz: c250f6994e87dc668bd6cc60d9e967ff8f1232b972cb0be4853c4c0ba9a257012450424701d93fc13b39106e2dbcb63d3cddf55abccfa5a4ec2ed9bd31a8a2ba
data/lib/terret/llm.rb ADDED
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Terret
4
+ module LLM
5
+ # Provider-neutral vocabulary. Adapters translate these to wire formats.
6
+ Text = Data.define(:text)
7
+ ToolCall = Data.define(:id, :name, :args)
8
+ ToolResult = Data.define(:id, :content, :error)
9
+ Message = Data.define(:role, :parts) do
10
+ def tool_calls = parts.grep(ToolCall)
11
+ def text = parts.grep(Text).map(&:text).join
12
+ end
13
+
14
+ Request = Data.define(:model, :system, :messages, :tools)
15
+
16
+ # Stream events yielded by adapters.
17
+ TextDelta = Data.define(:text)
18
+ ToolCallEnd = Data.define(:tool_call)
19
+ MessageStop = Data.define(:stop_reason) # :end_turn | :tool_use
20
+
21
+ # ctx.llm — the adapter seam. `llm/stream` is a waterfall wrapping every
22
+ # request: middleware may rewrite the request or replace the stream.
23
+ class Service < Hames::Service
24
+ service_key :llm
25
+
26
+ def start(_ctx)
27
+ @adapters = {}
28
+ @roles = config[:roles] || {} # :main => "fake/scripted"
29
+ end
30
+
31
+ def register_adapter(name, adapter)
32
+ @adapters[name.to_s] = adapter
33
+ -> { @adapters.delete(name.to_s) }
34
+ end
35
+
36
+ def resolve(role)
37
+ spec = @roles.fetch(role) { raise KeyError, "no model role #{role.inspect}" }
38
+ provider, model = spec.split("/", 2)
39
+ [@adapters.fetch(provider), model]
40
+ end
41
+
42
+ # Streams the request through the `llm/stream` waterfall; the base of
43
+ # the waterfall invokes the resolved adapter. Yields StreamEvents,
44
+ # returns the final assistant Message.
45
+ def stream(ctx, role:, request:, &on_event)
46
+ adapter, model = resolve(role)
47
+ req = request.with(model: model)
48
+ ctx.waterfall("llm/stream", req) do |final_req|
49
+ adapter.stream(final_req, &on_event)
50
+ end
51
+ end
52
+ end
53
+
54
+ # Deterministic scripted adapter for tests, demos, and record/replay.
55
+ # Script: array of hashes { text:, tool_calls: [ToolCall, ...] }.
56
+ class FakeAdapter
57
+ def initialize(script)
58
+ @script = script.dup
59
+ end
60
+
61
+ def stream(_request)
62
+ step = @script.shift or raise "FakeAdapter script exhausted"
63
+ parts = []
64
+ if (t = step[:text])
65
+ t.chars.each_slice(8) { |chunk| yield TextDelta.new(text: chunk.join) }
66
+ parts << Text.new(text: t)
67
+ end
68
+ calls = Array(step[:tool_calls])
69
+ calls.each do |tc|
70
+ yield ToolCallEnd.new(tool_call: tc)
71
+ parts << tc
72
+ end
73
+ yield MessageStop.new(stop_reason: calls.empty? ? :end_turn : :tool_use)
74
+ Message.new(role: :assistant, parts: parts)
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Terret
4
+ Claim = Data.define(:messages, :rejected, :reason) do
5
+ def self.of(messages) = new(messages:, rejected: false, reason: nil)
6
+ def self.reject(reason:) = new(messages: [], rejected: true, reason:)
7
+ end
8
+
9
+ class Agent
10
+ attr_reader :id, :session_id, :ctx
11
+ attr_accessor :status
12
+
13
+ def initialize(id:, session_id:, ctx:)
14
+ @id = id
15
+ @session_id = session_id
16
+ @ctx = ctx # a forked, agent-scoped context
17
+ @inbox = [] # injected context waits here until a waking message
18
+ @status = :idle
19
+ end
20
+
21
+ def inject(text)
22
+ @inbox << text
23
+ end
24
+
25
+ def drain_inbox = @inbox.slice!(0..)
26
+ end
27
+
28
+ # ctx.loop — the default driver. A step is one model request plus the tool
29
+ # calls it makes; a turn is zero or more steps and closes once nothing is
30
+ # owed. The driver is itself a plugin: replace this row in config and every
31
+ # tool, adapter, and UI keeps working.
32
+ class Loop < Hames::Service
33
+ service_key :loop
34
+ inject :sessions, :tools, :llm, :prompt
35
+
36
+ MAX_STEPS = 25
37
+
38
+ def start(ctx)
39
+ @ctx = ctx
40
+ end
41
+
42
+ def spawn_agent(session_id:, id: "agent-#{session_id}")
43
+ Agent.new(id:, session_id:, ctx: @ctx.fork)
44
+ end
45
+
46
+ # Runs one turn for `input`. Returns the turn status symbol.
47
+ def run_turn(agent, input)
48
+ ctx = agent.ctx
49
+ sessions = ctx[:sessions]
50
+ sid = agent.session_id
51
+ agent.status = :running
52
+
53
+ sessions.append(sid, "turn/start", { agent: agent.id })
54
+ status = :completed
55
+ steps = 0
56
+
57
+ # claim next-step input plus anything waiting in the inbox
58
+ pending = [input, *agent.drain_inbox].compact
59
+
60
+ loop do
61
+ claim = ctx.waterfall("agent/pre_step", Claim.of(pending)) { |c| c }
62
+ if claim.rejected || (steps.zero? && claim.messages.empty? && pending.empty?)
63
+ # a rejected or empty first claim still closes a durable turn that
64
+ # spent no step, so the log records the attempt
65
+ status = claim.rejected ? :rejected : :empty
66
+ break
67
+ end
68
+
69
+ steps += 1
70
+ raise "runaway turn" if steps > MAX_STEPS
71
+
72
+ sessions.append(sid, "step/start", { n: steps })
73
+ claim.messages.each { |t| sessions.append(sid, "user/message", { text: t }) }
74
+ pending = []
75
+
76
+ history = sessions.derive_messages(sid)
77
+ request = LLM::Request.new(model: nil, system: ctx[:prompt].render(agent:),
78
+ messages: history, tools: ctx[:tools].schemas)
79
+ request = ctx.waterfall("agent/request", request)
80
+ sessions.assert_log_invariant!(sid, request.messages)
81
+
82
+ message = ctx[:llm].stream(ctx, role: :main, request: request) do |ev|
83
+ case ev
84
+ when LLM::TextDelta
85
+ sessions.append(sid, "assistant/chunk", { text: ev.text })
86
+ end
87
+ end
88
+ sessions.append(sid, "assistant/message", { parts: message.parts })
89
+
90
+ calls = message.tool_calls
91
+ if calls.empty?
92
+ sessions.append(sid, "step/end", { n: steps })
93
+ break # nothing owed
94
+ end
95
+
96
+ calls.each do |tc|
97
+ sessions.append(sid, "tool/call", { id: tc.id, name: tc.name, args: tc.args })
98
+ result = ctx[:tools].execute(
99
+ Tools::Call.new(id: tc.id, name: tc.name, args: tc.args, session_id: sid)
100
+ )
101
+ sessions.append(sid, "tool/result",
102
+ { id: result.id, content: result.content, error: result.error })
103
+ end
104
+ sessions.append(sid, "step/end", { n: steps })
105
+ # tools owe another request -> next step
106
+ end
107
+
108
+ ctx.serial("agent/turn_stopping", agent)
109
+ sessions.append(sid, "turn/end", { status: status })
110
+ agent.status = :idle
111
+ status
112
+ end
113
+ end
114
+
115
+ # ctx.prompt — minimal prompt assembly: priority-ordered sections rendered
116
+ # per step; registration is an effect.
117
+ class Prompt < Hames::Service
118
+ service_key :prompt
119
+
120
+ def start(ctx)
121
+ @ctx = ctx
122
+ @sections = []
123
+ end
124
+
125
+ def register_section(name, priority: 100, &block)
126
+ entry = [priority, name.to_s, block]
127
+ @ctx.effect do
128
+ @sections << entry
129
+ -> { @sections.delete(entry) }
130
+ end
131
+ end
132
+
133
+ def render(env = {})
134
+ @sections.sort_by { |(p, n, _)| [p, n] }
135
+ .filter_map { |(_, _, b)| b.call(env) }
136
+ .join("\n\n")
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "digest"
5
+ require "json"
6
+
7
+ module Terret
8
+ SessionEvent = Data.define(:id, :session_id, :seq, :at, :type, :payload)
9
+
10
+ class LogInvariantViolation < StandardError; end
11
+
12
+ # ctx.sessions — the append-only session log. The single source of the
13
+ # context the model sees: derive_messages projects model history from it,
14
+ # and a digest invariant asserts that what goes out to an adapter is
15
+ # exactly what replaying the log yields ("model-visible means logged").
16
+ class Sessions < Hames::Service
17
+ service_key :sessions
18
+
19
+ Session = Struct.new(:id, :events, :parent_id, keyword_init: true)
20
+
21
+ def start(ctx)
22
+ @ctx = ctx
23
+ @store = {}
24
+ @dir = config[:jsonl_dir] # optional persistence
25
+ end
26
+
27
+ def create(id: SecureRandom.hex(6), parent_id: nil)
28
+ s = Session.new(id:, events: [], parent_id:)
29
+ @store[id] = s
30
+ append(id, "session/created", { parent_id: })
31
+ s
32
+ end
33
+
34
+ def fetch(id) = @store.fetch(id)
35
+
36
+ def append(session_id, type, payload = {})
37
+ decl = Hames.event(type)
38
+ raise Hames::ContractError, "#{type} is not a durable event" unless decl.durable
39
+
40
+ s = fetch(session_id)
41
+ ev = SessionEvent.new(
42
+ id: SecureRandom.hex(8), session_id:, seq: s.events.length,
43
+ at: Time.now.utc, type: type.to_s, payload: payload
44
+ )
45
+ s.events << ev
46
+ persist(ev)
47
+ @ctx.emit("session/event", ev)
48
+ ev
49
+ end
50
+
51
+ # Project provider-neutral model history from the durable log.
52
+ def derive_messages(session_id, upto: nil)
53
+ events = fetch(session_id).events
54
+ events = events.take(upto) if upto
55
+ events.filter_map do |ev|
56
+ case ev.type
57
+ when "user/message", "context/injected"
58
+ LLM::Message.new(role: :user, parts: [LLM::Text.new(text: ev.payload[:text])])
59
+ when "assistant/message"
60
+ LLM::Message.new(role: :assistant, parts: ev.payload[:parts])
61
+ when "tool/result"
62
+ LLM::Message.new(role: :tool, parts: [
63
+ LLM::ToolResult.new(id: ev.payload[:id], content: ev.payload[:content],
64
+ error: ev.payload[:error])
65
+ ])
66
+ end
67
+ end
68
+ end
69
+
70
+ # The enforcement point for "model-visible means logged": the loop calls
71
+ # this with the message list it is about to send; a mismatch against the
72
+ # log projection raises in dev/test.
73
+ def assert_log_invariant!(session_id, outbound_messages)
74
+ derived = derive_messages(session_id)
75
+ return if digest(derived) == digest(outbound_messages)
76
+
77
+ raise LogInvariantViolation,
78
+ "outbound request diverges from session log projection for #{session_id}"
79
+ end
80
+
81
+ def fork(source_id, boundary: nil, child_id: SecureRandom.hex(6))
82
+ src = fetch(source_id)
83
+ child = Session.new(id: child_id, events: [], parent_id: source_id)
84
+ @store[child_id] = child
85
+ events = boundary ? src.events.take(boundary) : src.events.dup
86
+ events.each { |ev| child.events << ev.with(session_id: child_id) }
87
+ append(child_id, "session/forked", { from: source_id, boundary: boundary })
88
+ child
89
+ end
90
+
91
+ private
92
+
93
+ def digest(messages)
94
+ Digest::SHA256.hexdigest(messages.map(&:inspect).join("\x1e"))
95
+ end
96
+
97
+ def persist(ev)
98
+ return unless @dir
99
+
100
+ File.open(File.join(@dir, "#{ev.session_id}.jsonl"), "a") do |f|
101
+ f.puts JSON.generate(type: ev.type, seq: ev.seq, at: ev.at.iso8601,
102
+ payload: ev.payload.inspect)
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Terret
4
+ module Tools
5
+ Definition = Data.define(:name, :description, :params, :handler,
6
+ :mutating, :approval) do
7
+ def schema = { name:, description:, parameters: params }
8
+ end
9
+
10
+ Call = Data.define(:id, :name, :args, :session_id)
11
+ Result = Data.define(:id, :content, :error)
12
+
13
+ # ctx.tools — scoped registry + guarded execution pipeline. Registration
14
+ # is an effect (unloading a plugin removes its tools). Execution runs the
15
+ # three-waterfall pipeline: pre_execute (validate / veto / rewrite) ->
16
+ # execute (a provider may replace execution wholesale) -> post_execute
17
+ # (truncate / redact).
18
+ class Registry < Hames::Service
19
+ service_key :tools
20
+
21
+ def start(ctx)
22
+ @ctx = ctx
23
+ @defs = {}
24
+ end
25
+
26
+ def register(name:, description:, params: {}, mutating: false,
27
+ approval: :never, &handler)
28
+ d = Definition.new(name: name.to_s, description:, params:, handler:,
29
+ mutating:, approval:)
30
+ @ctx.effect do
31
+ @defs[d.name] = d
32
+ -> { @defs.delete(d.name) }
33
+ end
34
+ d
35
+ end
36
+
37
+ def schemas = @defs.values.map(&:schema)
38
+ def fetch(name) = @defs.fetch(name.to_s)
39
+
40
+ def execute(call)
41
+ admitted = @ctx.waterfall("tools/pre_execute", call)
42
+ return Result.new(id: call.id, content: nil, error: admitted.reason) if admitted.is_a?(Veto)
43
+
44
+ result = @ctx.waterfall("tools/execute", admitted) do |c|
45
+ d = fetch(c.name)
46
+ begin
47
+ Result.new(id: c.id, content: d.handler.call(**c.args), error: nil)
48
+ rescue => e
49
+ Result.new(id: c.id, content: nil, error: "#{e.class}: #{e.message}")
50
+ end
51
+ end
52
+ @ctx.waterfall("tools/post_execute", result)
53
+ end
54
+ end
55
+
56
+ Veto = Data.define(:reason)
57
+ end
58
+ end
data/lib/terret.rb ADDED
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require "hames"
5
+ rescue LoadError
6
+ require_relative "../../hames/lib/hames" # monorepo path source
7
+ end
8
+
9
+ module Terret
10
+ VERSION = "0.1.0"
11
+
12
+ # Event vocabulary. Durable events land in the session log; the rest are
13
+ # live extension points. Modes are the public contract (see docs/events.md).
14
+ def self.declare_events!
15
+ e = ->(name, mode, durable: false, doc: nil) {
16
+ Hames.event(name, mode:, durable:, doc:)
17
+ }
18
+ # durable session events
19
+ e.("session/created", :emit, durable: true, doc: "session opened")
20
+ e.("session/forked", :emit, durable: true, doc: "lineage record")
21
+ e.("turn/start", :emit, durable: true, doc: "turn opened")
22
+ e.("turn/end", :emit, durable: true, doc: "turn closed with status")
23
+ e.("step/start", :emit, durable: true, doc: "model step opened")
24
+ e.("step/end", :emit, durable: true, doc: "model step closed")
25
+ e.("user/message", :emit, durable: true, doc: "entered user input")
26
+ e.("assistant/chunk", :emit, durable: true, doc: "raw stream delta (replay/UI fidelity)")
27
+ e.("assistant/message", :emit, durable: true, doc: "completed assistant message")
28
+ e.("tool/call", :emit, durable: true, doc: "tool invocation requested by model")
29
+ e.("tool/result", :emit, durable: true, doc: "tool outcome")
30
+ e.("context/injected", :emit, durable: true, doc: "agent.inject landed in a request")
31
+ # live extension points
32
+ e.("session/event", :emit, doc: "fan-out of every durable append")
33
+ e.("agent/pre_step", :waterfall, doc: "rewrite or reject the claimed messages")
34
+ e.("agent/request", :waterfall, doc: "rewrite the outbound model request")
35
+ e.("llm/stream", :waterfall, doc: "wrap/replace the adapter stream")
36
+ e.("tools/pre_execute", :waterfall, doc: "validate, veto, or rewrite a call")
37
+ e.("tools/execute", :waterfall, doc: "a provider may replace execution")
38
+ e.("tools/post_execute", :waterfall, doc: "truncate/redact the result")
39
+ e.("agent/turn_stopping", :serial, doc: "ordered veto point before turn/end")
40
+ end
41
+ end
42
+
43
+ require_relative "terret/llm"
44
+ require_relative "terret/sessions"
45
+ require_relative "terret/tools"
46
+ require_relative "terret/loop"
47
+
48
+ Terret.declare_events!
metadata ADDED
@@ -0,0 +1,66 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: terret-core
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Obie Fernandez
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: hames
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.1'
26
+ description: 'The core of the Terret agent harness: an append-only session log that
27
+ is the single source of model-visible truth, a scoped tool registry with a vetoable
28
+ execution pipeline, the agent turn/step loop as a replaceable plugin, and a provider-neutral
29
+ LLM seam.'
30
+ email:
31
+ - obiefernandez@gmail.com
32
+ executables: []
33
+ extensions: []
34
+ extra_rdoc_files: []
35
+ files:
36
+ - lib/terret.rb
37
+ - lib/terret/llm.rb
38
+ - lib/terret/loop.rb
39
+ - lib/terret/sessions.rb
40
+ - lib/terret/tools.rb
41
+ homepage: https://terret.org
42
+ licenses:
43
+ - MIT
44
+ metadata:
45
+ homepage_uri: https://terret.org
46
+ source_code_uri: https://github.com/terret-org/terret
47
+ bug_tracker_uri: https://github.com/terret-org/terret/issues
48
+ rubygems_mfa_required: 'true'
49
+ rdoc_options: []
50
+ require_paths:
51
+ - lib
52
+ required_ruby_version: !ruby/object:Gem::Requirement
53
+ requirements:
54
+ - - ">="
55
+ - !ruby/object:Gem::Version
56
+ version: '4.0'
57
+ required_rubygems_version: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ requirements: []
63
+ rubygems_version: 4.0.16
64
+ specification_version: 4
65
+ summary: 'Terret agent harness core: session log, tools pipeline, agent loop'
66
+ test_files: []