lyman 0.2.1 → 0.3.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.
@@ -0,0 +1,127 @@
1
+ require_relative "style"
2
+
3
+ # Thinking models prefix replies with <think>...</think> (the worker
4
+ # normalizes separate-reasoning-field servers to the same convention). When
5
+ # the reply arrives one fragment at a time we can't regex the whole thing, so
6
+ # this streams a short preview of the thinking — the first few lines,
7
+ # rendered as a dim "✻ …" aside rather than the literal tags — then elides
8
+ # the rest once the model stops thinking.
9
+ #
10
+ # Emits [think_text, reply_text] pairs. Both stream straight to the terminal
11
+ # today, but the split is the seam for reply-side processing (a markdown
12
+ # renderer, say — mind that anything buffering the reply trades away its
13
+ # token-by-token streaming, which is why this harness doesn't ship one).
14
+ class ThinkFilter
15
+ OPEN = "<think>"
16
+ CLOSE = "</think>"
17
+ MARK = "✻ "
18
+ SNIPPET_LINES = 3
19
+ SNIPPET_CHARS = 240 # think blocks are often one long unwrapped paragraph
20
+
21
+ def initialize
22
+ @state = :start
23
+ @buffer = +""
24
+ @lines = 0
25
+ @chars = 0
26
+ @truncated = false
27
+ end
28
+
29
+ # Returns the printable portion of this fragment as a
30
+ # [think_text, reply_text] pair (either may be empty).
31
+ def filter(fragment)
32
+ @buffer << fragment
33
+ case @state
34
+ when :start then filter_start
35
+ when :thinking then filter_thinking
36
+ when :after_think then ["", filter_after_think]
37
+ when :passing then ["", take_buffer]
38
+ end
39
+ end
40
+
41
+ # Call when the message is complete: releases anything still held back
42
+ # (e.g. a reply that was nothing but "<thin"), and closes out the styling
43
+ # of a think block the model never closed itself.
44
+ def flush
45
+ case @state
46
+ when :start then ["", take_buffer]
47
+ when :thinking then [(@truncated ? "…" : "") + RESET, ""]
48
+ else ["", ""]
49
+ end
50
+ end
51
+
52
+ private
53
+
54
+ def take_buffer
55
+ out = @buffer
56
+ @buffer = +""
57
+ out
58
+ end
59
+
60
+ def filter_start
61
+ if @buffer.start_with?(OPEN)
62
+ @state = :thinking
63
+ @buffer = @buffer[OPEN.length..]
64
+ think, reply = filter_thinking
65
+ [DIM + MARK + think, reply]
66
+ elsif OPEN.start_with?(@buffer)
67
+ ["", ""] # could still become <think>; wait for more
68
+ else
69
+ @state = :passing
70
+ ["", take_buffer]
71
+ end
72
+ end
73
+
74
+ def filter_thinking
75
+ if (idx = @buffer.index(CLOSE))
76
+ thought = @buffer[0...idx]
77
+ @buffer = @buffer[(idx + CLOSE.length)..]
78
+ @state = :after_think
79
+ [snippet(thought) + (@truncated ? "…" : "") + RESET + "\n\n", filter_after_think]
80
+ else
81
+ # Keep any tail that could be the start of CLOSE split across
82
+ # fragments; preview the rest.
83
+ keep = partial_close_suffix
84
+ thought = @buffer[0, @buffer.length - keep.length]
85
+ @buffer = keep
86
+ [snippet(thought), ""]
87
+ end
88
+ end
89
+
90
+ # The leading portion of the thought that fits the preview budget.
91
+ def snippet(text)
92
+ return "" if @truncated
93
+ out = +""
94
+ text.each_char do |ch|
95
+ if @chars >= SNIPPET_CHARS || (ch == "\n" && @lines >= SNIPPET_LINES - 1)
96
+ @truncated = true
97
+ break
98
+ end
99
+ out << ch
100
+ @chars += 1
101
+ @lines += 1 if ch == "\n"
102
+ end
103
+ out
104
+ end
105
+
106
+ # We end the think preview with "\n\n" ourselves, so swallow the model's
107
+ # own whitespace between the close tag and the reply.
108
+ def filter_after_think
109
+ stripped = @buffer.lstrip
110
+ if stripped.empty?
111
+ @buffer = +""
112
+ ""
113
+ else
114
+ @state = :passing
115
+ @buffer = stripped
116
+ take_buffer
117
+ end
118
+ end
119
+
120
+ def partial_close_suffix
121
+ (1...CLOSE.length).reverse_each do |len|
122
+ tail = @buffer[-len, len]
123
+ return tail if tail && CLOSE.start_with?(tail)
124
+ end
125
+ +""
126
+ end
127
+ end
@@ -0,0 +1,36 @@
1
+ require "cli/ui"
2
+ require_relative "style"
3
+
4
+ # Prints tool activity around the execution stage: the calls the model
5
+ # requested on the way in, a ✓-and-result line on the way out. Results are
6
+ # summarized to one line here — the full text is on the conversation, where
7
+ # the model (and any logging side-worker) sees it.
8
+ class ToolPrinter
9
+ RESULT_WIDTH = 60
10
+
11
+ def calls(conversation)
12
+ conversation.pending_tool_calls.each do |tool_call|
13
+ puts gray(" ⚙ #{tool_call.dig("function", "name")} #{tool_call.dig("function", "arguments")}")
14
+ end
15
+ end
16
+
17
+ def results(conversation)
18
+ messages = conversation.messages
19
+ request = messages.rindex { |m| m["role"] == "assistant" }
20
+ return unless request
21
+ names = (messages[request]["tool_calls"] || [])
22
+ .to_h { |tc| [tc["id"], tc.dig("function", "name")] }
23
+ messages[(request + 1)..].each do |message|
24
+ next unless message["role"] == "tool"
25
+ summary = gray("#{names[message["tool_call_id"]]} → #{summarize(message["content"])}")
26
+ puts " #{CLI::UI.fmt("{{v}}")} #{summary}"
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def summarize(text)
33
+ line = text.to_s.gsub(/\s+/, " ").strip
34
+ (line.length > RESULT_WIDTH) ? "#{line[0, RESULT_WIDTH - 1]}…" : line
35
+ end
36
+ end
@@ -0,0 +1,28 @@
1
+ require_relative "style"
2
+
3
+ # The silence between sending a prompt and the first streamed token can be
4
+ # long on a local model (prefill). cli-ui's spinner owns the calling thread
5
+ # for the duration of a block, which can't express "spin until the first
6
+ # delta arrives" — so this is the one hand-rolled widget: a background
7
+ # spinner with a stop method. Quiet when output is piped.
8
+ class WaitSpinner
9
+ FRAMES = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏]
10
+
11
+ def start(label)
12
+ return unless TTY
13
+ stop
14
+ @thread = Thread.new do
15
+ FRAMES.cycle do |frame|
16
+ print "\r#{GRAY}#{frame} #{label}#{RESET}"
17
+ sleep 0.08
18
+ end
19
+ end
20
+ end
21
+
22
+ def stop
23
+ return unless @thread
24
+ @thread.kill.join
25
+ @thread = nil
26
+ print "\r\e[K" # erase the spinner line
27
+ end
28
+ end
data/harness/repl.rb ADDED
@@ -0,0 +1,116 @@
1
+ #!/usr/bin/env ruby
2
+ #
3
+ # The REPL archetype: a human drives the loop and decides when it ends.
4
+ # One of three archetype shells — repl, daemon, script — around the same
5
+ # circuit; see docs/design/harness-archetypes.md.
6
+ #
7
+ # The shell: state + a process. Deliberately boring.
8
+ #
9
+ # This file is the one legible wiring script — the circuit pattern from
10
+ # docs/design/circuit-pattern.md, wired filter-in: one `pipeline.shift`
11
+ # per turn, with all model⇄tool rounds happening inside the call.
12
+ #
13
+ # Everything the model does streams to the terminal as it happens —
14
+ # pre-tool narration, tool calls, the final answer. Fitting the display
15
+ # to the model (like hiding a <think> block) is this shell's job, not
16
+ # the library's — the harness/repl/ files are that display layer. Their
17
+ # dependencies (cli-ui for color and glyphs, reline for prompt
18
+ # line-editing and history) stay out of the circuit; restyle or delete
19
+ # them freely.
20
+
21
+ # require_relative, not the load path: these are files planted beside this
22
+ # script, not the lyman gem.
23
+ require_relative "../lib/lyman"
24
+ require_relative "repl/style"
25
+ require_relative "repl/round_printer"
26
+ require_relative "repl/tool_printer"
27
+ require "cli/ui"
28
+ require "reline"
29
+
30
+ # This is a top-level wiring script by design; mixing the DSL into main
31
+ # is the point, not an accident.
32
+ include Shifty::DSL # standard:disable Style/MixinUsage
33
+
34
+ $stdout.sync = true
35
+
36
+ BASE_URL = ENV.fetch("LYMAN_BASE_URL", "http://localhost:11434/v1")
37
+ MODEL = ENV.fetch("LYMAN_MODEL", "gemma4:latest")
38
+
39
+ # ── Tools: schema and handler side by side, guts on the outside ────────────
40
+ TOOLS = {
41
+ "current_time" => {
42
+ schema: {
43
+ "type" => "function",
44
+ "function" => {
45
+ "name" => "current_time",
46
+ "description" => "Returns the current local date and time",
47
+ "parameters" => {"type" => "object", "properties" => {}, "required" => []}
48
+ }
49
+ },
50
+ handler: ->(_args) { Time.now.strftime("%Y-%m-%d %H:%M:%S %Z") }
51
+ }
52
+ }
53
+
54
+ schemas = TOOLS.values.map { |tool| tool[:schema] }
55
+ handlers = TOOLS.transform_values { |tool| tool[:handler] }
56
+
57
+ # ── Shell state ─────────────────────────────────────────────────────────────
58
+ conversation = Lyman::Conversation.new(
59
+ system_prompt: "You are a helpful assistant. Keep replies brief. " \
60
+ "Your replies are printed verbatim in a plain-text terminal: write prose, " \
61
+ "and avoid markdown and LaTeX markup."
62
+ )
63
+ rounds = [] # the circuit's queue — visible right here, not smuggled
64
+ printer = RoundPrinter.new(MODEL)
65
+ tool_printer = ToolPrinter.new
66
+
67
+ # ── The circuit ─────────────────────────────────────────────────────────────
68
+ pipeline =
69
+ source_worker { rounds.shift } |
70
+ side_worker { |_c| printer.start_round } |
71
+ Lyman::Workers.chat_completion(
72
+ base_url: BASE_URL, model: MODEL, tools: schemas,
73
+ on_delta: printer.method(:delta)
74
+ ) |
75
+ side_worker { |_c| printer.finish_round } |
76
+ relay_worker { |c|
77
+ (c.pending_tool_calls.empty? || c.runaway?) ? c.finish : c
78
+ } |
79
+ side_worker { |c| tool_printer.calls(c) unless c.finished? } |
80
+ Lyman::Workers.tool_execution(handlers) |
81
+ side_worker { |c| tool_printer.results(c) unless c.finished? } |
82
+ side_worker { |c| rounds << c unless c.finished? } |
83
+ filter_worker { |c| c.finished? }
84
+
85
+ # ── Shell process ───────────────────────────────────────────────────────────
86
+ puts CLI::UI.fmt("{{bold:lyman}} ⇢ {{magenta:#{MODEL}}} @ {{blue:#{BASE_URL}}}")
87
+ puts
88
+ puts gray(<<~HINTS.gsub(/^/, " "))
89
+ exit: blank line, ctrl-c, or ctrl-d
90
+ change the model: LYMAN_MODEL=qwen3.5:2b #{$PROGRAM_NAME}
91
+ change the endpoint: LYMAN_BASE_URL=http://localhost:1234/v1 #{$PROGRAM_NAME}
92
+ available tools: #{TOOLS.keys.join(", ")} — a ⚙ line appears when the model calls one
93
+ HINTS
94
+
95
+ loop do
96
+ puts
97
+ # Reline gives the prompt line editing and history, but emits screen-
98
+ # redraw escapes even when input is piped — so scripted runs get gets.
99
+ input = begin
100
+ if $stdin.tty?
101
+ Reline.readline(CLI::UI.fmt("{{cyan:you}}> "), true)&.strip
102
+ else
103
+ print "you> "
104
+ $stdin.gets&.strip
105
+ end
106
+ rescue Interrupt
107
+ nil # ctrl-c leaves like ctrl-d, not with a stack trace
108
+ end
109
+ break if input.nil? || input.empty?
110
+
111
+ # Conversation is an immutable value (shifty 0.6 freezes every handoff),
112
+ # so state flows in the open: enqueue a new conversation carrying the
113
+ # user's message, and rebind to the finished turn the circuit hands back.
114
+ rounds << conversation.with_user_message(input)
115
+ conversation = pipeline.shift
116
+ end
data/harness/script.rb ADDED
@@ -0,0 +1,81 @@
1
+ #!/usr/bin/env ruby
2
+ #
3
+ # The script archetype: launched by something out of scope — cron, a
4
+ # Makefile, a hand on a keyboard — with its work item in hand. It
5
+ # processes that one item (however many model⇄tool rounds it takes) and
6
+ # halts. One of three archetype shells — repl, daemon, script — around
7
+ # the same circuit; see docs/design/harness-archetypes.md.
8
+ #
9
+ # The shell: state + a process. Here the process needs no loop at all —
10
+ # the circuit still runs as many rounds as the model asks for, because
11
+ # repetition lives in the pipeline (docs/design/circuit-pattern.md), not
12
+ # in the shell. One enqueue, one shift, done.
13
+ #
14
+ # No display layer: output is the final answer on stdout, which is what
15
+ # a cron job or a calling script wants to capture. Everything between —
16
+ # the tool calls, the intermediate rounds — stays on the conversation,
17
+ # where a logging side_worker can be spliced in if you want to see it.
18
+
19
+ # require_relative, not the load path: these are files planted beside this
20
+ # script, not the lyman gem.
21
+ require_relative "../lib/lyman"
22
+
23
+ # This is a top-level wiring script by design; mixing the DSL into main
24
+ # is the point, not an accident.
25
+ include Shifty::DSL # standard:disable Style/MixinUsage
26
+
27
+ BASE_URL = ENV.fetch("LYMAN_BASE_URL", "http://localhost:11434/v1")
28
+ MODEL = ENV.fetch("LYMAN_MODEL", "gemma4:latest")
29
+
30
+ # ── Tools: schema and handler side by side, guts on the outside ────────────
31
+ TOOLS = {
32
+ "current_time" => {
33
+ schema: {
34
+ "type" => "function",
35
+ "function" => {
36
+ "name" => "current_time",
37
+ "description" => "Returns the current local date and time",
38
+ "parameters" => {"type" => "object", "properties" => {}, "required" => []}
39
+ }
40
+ },
41
+ handler: ->(_args) { Time.now.strftime("%Y-%m-%d %H:%M:%S %Z") }
42
+ }
43
+ }
44
+
45
+ schemas = TOOLS.values.map { |tool| tool[:schema] }
46
+ handlers = TOOLS.transform_values { |tool| tool[:handler] }
47
+
48
+ # ── The work item ───────────────────────────────────────────────────────────
49
+ # It arrives at launch: arguments if given, stdin otherwise — the two
50
+ # channels a cron line or a calling script naturally speaks.
51
+ task = ARGV.empty? ? $stdin.read.to_s.strip : ARGV.join(" ")
52
+ abort "usage: #{$PROGRAM_NAME} TASK... (or pipe the task on stdin)" if task.empty?
53
+
54
+ # ── Shell state ─────────────────────────────────────────────────────────────
55
+ conversation = Lyman::Conversation.new(
56
+ system_prompt: "You are a task runner. Complete the given task and reply " \
57
+ "with the result only — plain text, no markdown, no commentary."
58
+ )
59
+ rounds = [] # the circuit's queue — visible right here, not smuggled
60
+
61
+ # ── The circuit ─────────────────────────────────────────────────────────────
62
+ pipeline =
63
+ source_worker { rounds.shift } |
64
+ Lyman::Workers.chat_completion(base_url: BASE_URL, model: MODEL, tools: schemas) |
65
+ relay_worker { |c|
66
+ (c.pending_tool_calls.empty? || c.runaway?) ? c.finish : c
67
+ } |
68
+ Lyman::Workers.tool_execution(handlers) |
69
+ side_worker { |c| rounds << c unless c.finished? } |
70
+ filter_worker { |c| c.finished? }
71
+
72
+ # ── Shell process ───────────────────────────────────────────────────────────
73
+ # Enqueue before shifting — never pull the source while the queue is empty
74
+ # (the nil footgun: a nil from a source ends the stream permanently).
75
+ # Conversation is an immutable value (shifty 0.6 freezes every handoff):
76
+ # with_user_message returns a new conversation, and the finished turn
77
+ # comes back from the pipeline.
78
+ rounds << conversation.with_user_message(task)
79
+ result = pipeline.shift
80
+
81
+ puts result.last_assistant_content
@@ -62,8 +62,11 @@ module Lyman
62
62
  Next steps:
63
63
  cd #{name}
64
64
  bundle install
65
- ruby harness/chat.rb # defaults to Ollama at http://localhost:11434/v1
65
+ ruby harness/repl.rb # defaults to Ollama at http://localhost:11434/v1
66
66
  lyman doctor # smoke-test the pipeline
67
+
68
+ The repl is one of three harness archetypes; the other two are a
69
+ `lyman add` away (daemon_harness, script_harness).
67
70
  EPILOGUE
68
71
  end
69
72
  end
@@ -6,7 +6,7 @@
6
6
  # process's own, and it's the client's bundle — not the CLI's — that needs
7
7
  # to resolve shifty).
8
8
  #
9
- # Wires the same circuit shape as harness/chat.rb (docs/design/
9
+ # Wires the same circuit shape as harness/repl.rb (docs/design/
10
10
  # circuit-pattern.md): a queue-backed source, the model⇄tool round, the
11
11
  # finish/requeue plumbing. The only substitution is the transport: a stub
12
12
  # stands in at the chat_completion seam. That's legitimate, not a mock
@@ -20,13 +20,14 @@ require "./lib/lyman"
20
20
  include Shifty::DSL # standard:disable Style/MixinUsage
21
21
 
22
22
  conversation = Lyman::Conversation.new(system_prompt: "doctor")
23
- conversation.add_user_message("ping the tool once, then answer")
24
- rounds = [] # the circuit's queue, shell scope — mirrors harness/chat.rb
23
+ .with_user_message("ping the tool once, then answer")
24
+ rounds = [] # the circuit's queue, shell scope — mirrors harness/repl.rb
25
25
 
26
26
  # Stands in for Lyman::Workers.chat_completion: round 1 calls the "ping"
27
27
  # tool, round 2 answers plainly. A real transport wouldn't know the round
28
28
  # count in advance, but the seam only cares that something plays by the
29
- # wire's rules — append an assistant message, bump the round counter.
29
+ # wire's rules — hand off a new conversation with the assistant message
30
+ # appended (with_assistant_message also counts the round).
30
31
  call_count = 0
31
32
  stub_transport = relay_worker do |c|
32
33
  call_count += 1
@@ -46,17 +47,14 @@ stub_transport = relay_worker do |c|
46
47
  else
47
48
  {"role" => "assistant", "content" => "done"}
48
49
  end
49
- c.add_assistant_message(message)
50
- c.rounds += 1
51
- c
50
+ c.with_assistant_message(message)
52
51
  end
53
52
 
54
53
  pipeline =
55
54
  source_worker { rounds.shift } |
56
55
  stub_transport |
57
56
  relay_worker { |c|
58
- c.finish! if c.pending_tool_calls.empty? || c.runaway?
59
- c
57
+ (c.pending_tool_calls.empty? || c.runaway?) ? c.finish : c
60
58
  } |
61
59
  Lyman::Workers.tool_execution({"ping" => ->(_) { "pong" }}) |
62
60
  side_worker { |c| rounds << c unless c.finished? } |
@@ -32,11 +32,62 @@ module Lyman
32
32
  role: :managed,
33
33
  description: "Relay worker: executes pending tool calls"
34
34
  },
35
- "harness" => {
36
- source: "harness/chat.rb",
37
- dest: "harness/chat.rb",
35
+ # The three harness archetypes (docs/design/harness-archetypes.md):
36
+ # same circuit, different shells. `new` plants the repl — the
37
+ # archetype you can talk to on day one; the other two are opt-in
38
+ # (`lyman add daemon_harness` / `lyman add script_harness`) because a
39
+ # narrow, purpose-built agent wants one shell shape, not three.
40
+ "repl_harness" => {
41
+ source: "harness/repl.rb",
42
+ dest: "harness/repl.rb",
38
43
  role: :owned,
39
- description: "The wiring script — yours from day one; lyman never updates it"
44
+ description: "The REPL archetype: a human drives the loop — yours from day one; lyman never updates it"
45
+ },
46
+ "daemon_harness" => {
47
+ source: "harness/daemon.rb",
48
+ dest: "harness/daemon.rb",
49
+ role: :owned,
50
+ optional: true,
51
+ description: "The daemon archetype: launch once, loop on an inbound event stream indefinitely"
52
+ },
53
+ "script_harness" => {
54
+ source: "harness/script.rb",
55
+ dest: "harness/script.rb",
56
+ role: :owned,
57
+ optional: true,
58
+ description: "The script archetype: take one work item at launch, process it, halt"
59
+ },
60
+ # The repl's display layer, one artifact per widget so ownership —
61
+ # and any drift `lyman diff` reports — stays per-file, not per-blob.
62
+ "repl_style" => {
63
+ source: "harness/repl/style.rb",
64
+ dest: "harness/repl/style.rb",
65
+ role: :owned,
66
+ description: "Terminal styling codes and the gray() helper shared by the repl display"
67
+ },
68
+ "think_filter" => {
69
+ source: "harness/repl/think_filter.rb",
70
+ dest: "harness/repl/think_filter.rb",
71
+ role: :owned,
72
+ description: "Streams a dim preview of <think> blocks, then elides the rest"
73
+ },
74
+ "wait_spinner" => {
75
+ source: "harness/repl/wait_spinner.rb",
76
+ dest: "harness/repl/wait_spinner.rb",
77
+ role: :owned,
78
+ description: "Background spinner for the silence before the first streamed token"
79
+ },
80
+ "round_printer" => {
81
+ source: "harness/repl/round_printer.rb",
82
+ dest: "harness/repl/round_printer.rb",
83
+ role: :owned,
84
+ description: "Streams one round to the terminal: spinner, model label, think preview, reply"
85
+ },
86
+ "tool_printer" => {
87
+ source: "harness/repl/tool_printer.rb",
88
+ dest: "harness/repl/tool_printer.rb",
89
+ role: :owned,
90
+ description: "Prints tool calls on the way in, summarized results on the way out"
40
91
  },
41
92
  "claude_md" => {
42
93
  source: "templates/CLAUDE.md",
@@ -103,9 +154,9 @@ module Lyman
103
154
  ARTIFACTS.select { |_, spec| spec[:role] == :managed }
104
155
  end
105
156
 
106
- # What `new` plants: everything except opt-in artifacts (those exist
107
- # for situations a fresh scaffold can't be in, like a pre-existing
108
- # CLAUDE.md reach them with `lyman add`).
157
+ # What `new` plants: everything except opt-in artifacts alternates a
158
+ # fresh scaffold shouldn't presume (the daemon and script archetypes,
159
+ # the skill variant of CLAUDE.md). Reach them with `lyman add`.
109
160
  def self.default
110
161
  ARTIFACTS.reject { |_, spec| spec[:optional] }
111
162
  end
@@ -2,6 +2,6 @@ module Lyman
2
2
  module CLI
3
3
  # The gem's own version — distinct from any artifact's `planted_at`.
4
4
  # This is what the manifest's top-level `lyman:` key records.
5
- VERSION = "0.2.1"
5
+ VERSION = "0.3.0"
6
6
  end
7
7
  end
@@ -3,63 +3,64 @@ module Lyman
3
3
  # conversation so far, plus the control data workers consult to decide
4
4
  # *whether* to act (never *which of several things* to do).
5
5
  #
6
+ # An immutable value. Shifty 0.6 deep-freezes every value at a worker
7
+ # boundary (the :frozen handoff policy), so change is expressed as new
8
+ # values: each with_* method returns a new Conversation and leaves the
9
+ # receiver untouched. Data#with structurally shares the unchanged
10
+ # members — safe precisely because handed-off values are frozen.
11
+ #
6
12
  # Messages use string keys throughout, for clean round-tripping with
7
13
  # OpenAI-compatible wire formats.
8
- class Conversation
9
- attr_reader :messages
10
- attr_accessor :rounds, :max_rounds
11
-
12
- def initialize(system_prompt: nil, max_rounds: 10)
13
- @messages = []
14
- @messages << {"role" => "system", "content" => system_prompt} if system_prompt
15
- @rounds = 0
16
- @max_rounds = max_rounds
17
- @finished = false
14
+ class Conversation < Data.define(:messages, :rounds, :max_rounds, :finished)
15
+ def initialize(system_prompt: nil, messages: nil, rounds: 0, max_rounds: 10, finished: false)
16
+ messages ||= system_prompt ? [{"role" => "system", "content" => system_prompt}] : []
17
+ super(messages: messages, rounds: rounds, max_rounds: max_rounds, finished: finished)
18
18
  end
19
19
 
20
- def add_user_message(text)
21
- @messages << {"role" => "user", "content" => text}
22
- @finished = false
23
- @rounds = 0
24
- self
20
+ def with_user_message(text)
21
+ with(
22
+ messages: messages + [{"role" => "user", "content" => text}],
23
+ rounds: 0,
24
+ finished: false
25
+ )
25
26
  end
26
27
 
27
- def add_assistant_message(message)
28
- @messages << message
29
- self
28
+ # A round *is* one model reply, so the counter lives here rather than
29
+ # in the transport worker — swap the transport and the runaway guard
30
+ # still can't be forgotten.
31
+ def with_assistant_message(message)
32
+ with(messages: messages + [message], rounds: rounds + 1)
30
33
  end
31
34
 
32
- def add_tool_result(tool_call_id, content)
33
- @messages << {
35
+ def with_tool_result(tool_call_id, content)
36
+ with(messages: messages + [{
34
37
  "role" => "tool",
35
38
  "tool_call_id" => tool_call_id,
36
39
  "content" => content
37
- }
38
- self
40
+ }])
39
41
  end
40
42
 
41
43
  def pending_tool_calls
42
- last = @messages.last
44
+ last = messages.last
43
45
  return [] unless last && last["role"] == "assistant"
44
46
  last["tool_calls"] || []
45
47
  end
46
48
 
47
49
  def last_assistant_content
48
- message = @messages.rfind { |m| m["role"] == "assistant" }
50
+ message = messages.rfind { |m| m["role"] == "assistant" }
49
51
  message && message["content"]
50
52
  end
51
53
 
52
- def finish!
53
- @finished = true
54
- self
54
+ def finish
55
+ with(finished: true)
55
56
  end
56
57
 
57
58
  def finished?
58
- @finished
59
+ finished
59
60
  end
60
61
 
61
62
  def runaway?
62
- @rounds >= @max_rounds
63
+ rounds >= max_rounds
63
64
  end
64
65
  end
65
66
  end
@@ -8,8 +8,8 @@ module Lyman
8
8
  extend Shifty::DSL
9
9
 
10
10
  # Relay worker: sends the conversation to an OpenAI-compatible chat
11
- # completions endpoint and appends the assistant's reply (which may
12
- # include tool calls). Increments the conversation's round counter.
11
+ # completions endpoint and hands off a new conversation with the
12
+ # assistant's reply (which may include tool calls) appended.
13
13
  #
14
14
  # Pass an on_delta callable to stream: it receives each content
15
15
  # fragment as it arrives. The worker still returns the conversation
@@ -42,9 +42,7 @@ module Lyman
42
42
  fetch_message(http, uri, payload)
43
43
  end
44
44
 
45
- conversation.add_assistant_message(message)
46
- conversation.rounds += 1
47
- conversation
45
+ conversation.with_assistant_message(message)
48
46
  end
49
47
  end
50
48