llm.rb 16.0.0.pre.4 → 16.0.0.pre.5

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a83847d3c33a48980bd9740d7aff397db757ec91224440a97c10240c0ae3a290
4
- data.tar.gz: 402d6ea4a68fc987e3597d1b6dd84089f2804c7774ef35e38fb8fc424b0e62b0
3
+ metadata.gz: f5558a0514d6bd6b9648f743a308ce8e6dbb3802b8709edc6caf45d7c1ab3de8
4
+ data.tar.gz: a5d3178f0c79e152b8032cce974be69edd1e52cc629658034a750183ccb41ea8
5
5
  SHA512:
6
- metadata.gz: f75dda6e725c6aa48748a407e744c94c006d3db4973cf45064f7f0252fc2bc818ee7a99b564e66c4bad6ad4066bf5214b912a637b3b25b8b9f335d45d226c7e2
7
- data.tar.gz: ea3e7fe4a32687c7a34f4b85a0bd4aeee5b7b68387452dd1ac98a3572f1b1f74b4f19ac3ee32b10cf6f53563b9eceb5a8e9292e6001b90728e4997c7b9a301a2
6
+ metadata.gz: 3a0288112d140d3e0a3e34dc35e570ceb25ad62fd7f3c33a2942cb111432992b7194e18fb406bf55a25a945aff0e89ce055c292a1de50c00b66c801027b95c52
7
+ data.tar.gz: 454a1c74a59401689c88947714f741ada1d39f6ce95013bf8bdd6fab76690adafd3a4be8af02e9eace2a71ae26c3f4daf3a1751fdf326549f722da888cbcb309
data/CHANGELOG.md CHANGED
@@ -56,6 +56,29 @@
56
56
  has not been saved, where `#messages` reads the column. The Sequel plugin
57
57
  answers to `#messages!` too, where it is the same call as `#messages`.
58
58
 
59
+ ### Agent
60
+
61
+ * **agent: keep the instructions it injected in step** <br>
62
+ [`LLM::Agent`](https://r.uby.dev/api-docs/llm.rb/LLM/Agent.html) now brings
63
+ the instructions it injected up to date before a request goes out, so a
64
+ conversation restored from saved state runs on the agent's current
65
+ instructions rather than on the ones it was saved with. The message is
66
+ found by a mark the agent puts on it, so a message the caller composed is
67
+ never replaced, and the mark is written by
68
+ [`LLM::Message#to_h`](https://r.uby.dev/api-docs/llm.rb/LLM/Message.html#to_h-instance_method)
69
+ and read back on a restore, which is the conversation the refresh exists
70
+ for. Before this, the instructions were injected once and the message
71
+ stayed as it was for the life of the conversation.
72
+
73
+ * **agent: identify its own instructions by the mark, not by the role** <br>
74
+ Fix a bug where the agent decided whether it had already injected its
75
+ instructions by looking for a message with the `system` role. Google gives
76
+ instructions the `user` role, so the agent did not recognise the message it
77
+ had already injected and added another copy of the instructions on every
78
+ turn. It now asks the provider which role instructions are given, and
79
+ identifies the message it wrote by its mark, because on Google the message
80
+ a caller seeds a conversation with is a user message too.
81
+
59
82
  ### Console
60
83
 
61
84
  * **console: erase with the backspace key on OpenBSD** <br>
@@ -142,6 +165,18 @@
142
165
  once, when the whole turn had finished, so an interrupted turn persisted
143
166
  nothing.
144
167
 
168
+ ### Prompt
169
+
170
+ * **prompt: let a message carry fields a caller attaches to it** <br>
171
+ [`LLM::Prompt#system`](https://r.uby.dev/api-docs/llm.rb/LLM/Prompt.html#system-instance_method),
172
+ [`#user`](https://r.uby.dev/api-docs/llm.rb/LLM/Prompt.html#user-instance_method),
173
+ [`#developer`](https://r.uby.dev/api-docs/llm.rb/LLM/Prompt.html#developer-instance_method),
174
+ and [`#talk`](https://r.uby.dev/api-docs/llm.rb/LLM/Prompt.html#talk-instance_method),
175
+ along with its `#chat` alias, now take an `extra:` keyword, so a message a
176
+ caller composes can carry fields of its own. The runtime reads those fields
177
+ back rather than inferring provenance from a role, which is how an agent
178
+ tells the instructions it injected from a message the caller wrote.
179
+
145
180
  ### Provider
146
181
 
147
182
  * **deepseek: support image attachments in chat completions** <br>
data/README.md CHANGED
@@ -78,7 +78,8 @@ agent.talk "hello world"
78
78
  Streams can be simple IO objects or subclasses of
79
79
  [`LLM::Stream`](https://r.uby.dev/api-docs/llm.rb/LLM/Stream.html)
80
80
  with structured callbacks for content,
81
- reasoning, tool calls, tool returns, and compaction.
81
+ reasoning, tool calls, tool returns, the boundary a completed
82
+ request marks, and compaction.
82
83
  Streams can also observe message transformers, which rewrite
83
84
  outgoing messages before they reach the provider.
84
85
 
@@ -102,6 +103,11 @@ class MyStream < LLM::Stream
102
103
  def on_tool_return(tool, result)
103
104
  end
104
105
 
106
+ # A request has completed: the response is in the conversation, and
107
+ # any tools it asked for run after this.
108
+ def on_step(ctx, res)
109
+ end
110
+
105
111
  # Before a transformer rewrites an outgoing message.
106
112
  def on_transform(transformer)
107
113
  end
@@ -424,7 +424,9 @@ The block receives the prompt object when it takes an argument, and
424
424
  otherwise runs in the prompt's context:
425
425
  [`LLM::Prompt#to_a`](https://r.uby.dev/api-docs/llm.rb/LLM/Prompt.html#to_a)
426
426
  returns the messages in order, and two prompts are equal when their
427
- messages match.
427
+ messages match. Each of `system`, `user`, `developer`, and `talk`
428
+ accepts an `extra:` hash, which is attached to the message it builds,
429
+ so a caller can carry a field of its own alongside the content.
428
430
 
429
431
  #### Why would I use it?
430
432
 
@@ -66,9 +66,9 @@ should use the `console` names.
66
66
  When `bin/llm.rb` starts, it looks for an `AGENTS.md` file in the
67
67
  current working directory. If one exists, its contents become the
68
68
  agent's instructions for the session, so a project can describe how it
69
- wants the agent to work without passing flags. The instructions are
70
- injected once, so a resumed session that already has a system message
71
- keeps the one it has.
69
+ wants the agent to work without passing flags. The agent brings its
70
+ instructions up to date before each request, so a resumed session runs
71
+ on the current file rather than the one it was saved with.
72
72
 
73
73
  The user-message label is exposed through
74
74
  [`LLM::Console#sender`](https://r.uby.dev/api-docs/llm.rb/LLM/Console.html#sender-instance_method),
@@ -34,13 +34,23 @@ and
34
34
  for serialization and
35
35
  [`LLM::Context#restore`](https://r.uby.dev/api-docs/llm.rb/LLM/Context.html#restore)
36
36
  for deserialization. Save writes the current state, restore loads
37
- it back and picks up where the conversation left off. The ORM
38
- wrappers automate this; each
39
- [`LLM::Agent#talk`](https://r.uby.dev/api-docs/llm.rb/LLM/Agent.html#talk)
40
- or
41
- [`LLM::Agent#ask`](https://r.uby.dev/api-docs/llm.rb/LLM/Agent.html#ask)
42
- call persists the
43
- updated state back to the column automatically.
37
+ it back and picks up where the conversation left off.
38
+
39
+ The ORM wrappers write on a different boundary than the file does, and
40
+ the two are worth telling apart.
41
+ [`LLM::Step`](https://r.uby.dev/api-docs/llm.rb/LLM/Step.html) is
42
+ prepended onto the stream, and it saves the conversation through the
43
+ record the context is bound to each time
44
+ [`LLM::Stream#on_step`](https://r.uby.dev/api-docs/llm.rb/LLM/Stream.html#on_step-instance_method)
45
+ fires - after a response is in the conversation and before the tools it
46
+ asked for run. A turn that runs tools before it answers is therefore
47
+ saved more than once, and an application that writes it down can
48
+ continue an interrupted turn instead of redoing it.
49
+
50
+ A `path:` has no record behind it to write to, so that one saves once
51
+ per turn. The difference is what each is for: the column is a
52
+ checkpoint after every completed request, and the file is a session at
53
+ the end of one.
44
54
 
45
55
  #### Why would I use it?
46
56
 
@@ -242,12 +252,10 @@ first call, a fresh agent is created and the conversation starts
242
252
  from scratch.
243
253
 
244
254
  On subsequent calls, the stored state is restored and the
245
- conversation continues. Every
246
- [`LLM::Agent#talk`](https://r.uby.dev/api-docs/llm.rb/LLM/Agent.html#talk)
247
- or
248
- [`LLM::Agent#ask`](https://r.uby.dev/api-docs/llm.rb/LLM/Agent.html#ask)
249
- call persists the
250
- updated state back to the column automatically.
255
+ conversation continues. The column is written after each completed
256
+ request rather than at the end of a turn, so a turn that runs tools
257
+ before it answers is saved more than once - and a turn that is
258
+ interrupted can be resumed from its last completed request.
251
259
 
252
260
  The `data_column:` option lets you use a different column name.
253
261
  The `format:` option controls the storage type. Use `:string` for
@@ -327,7 +335,7 @@ agent.talk "perform research"
327
335
  #### Why would I use it?
328
336
 
329
337
  The ActiveRecord wrapper integrates with your existing models.
330
- Agent state is automatically persisted after each conversation turn
338
+ Agent state is automatically persisted after each completed request,
331
339
  using the same `create!`, `save!`, and query methods you already
332
340
  use.
333
341
 
@@ -447,11 +455,8 @@ the plugin in the model class. The `data` column stores
447
455
  the full agent state as JSON, same structure as ActiveRecord.
448
456
  On first call, a fresh agent is created. On subsequent calls,
449
457
  the stored state is restored and the conversation continues.
450
- Every
451
- [`LLM::Agent#talk`](https://r.uby.dev/api-docs/llm.rb/LLM/Agent.html#talk)
452
- or
453
- [`LLM::Agent#ask`](https://r.uby.dev/api-docs/llm.rb/LLM/Agent.html#ask)
454
- call persists the updated state.
458
+ The state is written after each completed request, as it is under
459
+ ActiveRecord.
455
460
 
456
461
  The `data_column:` and `format:` options work identically to
457
462
  ActiveRecord. For `:jsonb`, Sequel loads the `pg_json` extension
@@ -21,8 +21,9 @@ the model responds, and if it requests tools the agent
21
21
  executes them automatically and feeds the results back. The tool
22
22
  loop can be bounded with
23
23
  [`LLM::Agent.tool_budget`](https://r.uby.dev/api-docs/llm.rb/LLM/Agent.html#tool_budget-class_method)
24
- (see the Tool budget section). Instructions are injected once
25
- unless a system message is already present.
24
+ (see the Tool budget section). Instructions are injected once and
25
+ brought up to date from the agent before each request, unless the
26
+ prompt already carries a system message of the caller's own.
26
27
 
27
28
  #### Why would I use it?
28
29
 
@@ -67,6 +67,17 @@ receives tokens as they arrive.
67
67
  fires when the model requests a tool.
68
68
  [`LLM::Stream#on_tool_return`](https://r.uby.dev/api-docs/llm.rb/LLM/Stream.html#on_tool_return)
69
69
  fires when the tool completes.
70
+ [`LLM::Stream#on_step`](https://r.uby.dev/api-docs/llm.rb/LLM/Stream.html#on_step-instance_method)
71
+ fires when a request completes, which is the boundary between one
72
+ request and the next: the response is in the conversation, and a
73
+ turn that asked for tools runs them after this while a turn that is
74
+ finished ends here. That boundary is where a conversation is whole,
75
+ and the runtime's
76
+ [`LLM::Step`](https://r.uby.dev/api-docs/llm.rb/LLM/Step.html) is
77
+ written against it - it is prepended onto a stream, and saves the
78
+ conversation through the record the context is bound to, so a turn
79
+ that is interrupted can be continued from its last completed request
80
+ rather than started over.
70
81
  [`LLM::Stream#on_retry`](https://r.uby.dev/api-docs/llm.rb/LLM/Stream.html#on_retry)
71
82
  fires each time a failed request is retried. Compaction hooks
72
83
  let you show progress or log what was trimmed. Skill hooks bracket a
@@ -96,6 +107,11 @@ class MyStream < LLM::Stream
96
107
  def on_tool_return(tool, result)
97
108
  end
98
109
 
110
+ # A request has completed: the response is in the conversation, and
111
+ # any tools it asked for run after this.
112
+ def on_step(ctx, res)
113
+ end
114
+
99
115
  # Before a transformer rewrites an outgoing message.
100
116
  def on_transform(transformer)
101
117
  end
@@ -135,9 +151,3 @@ agent.talk "Explain Ruby fibers."
135
151
  A stream subclass gives you visibility into more than just content chunks. React to tool
136
152
  calls as they happen, show compaction progress, or integrate with
137
153
  an existing observability stack.
138
-
139
- #### Notes
140
-
141
- The IO-like form is equivalent to
142
- [`LLM::Stream#on_content`](https://r.uby.dev/api-docs/llm.rb/LLM/Stream.html#on_content)
143
- and does not include the other hooks.
data/lib/llm/agent.rb CHANGED
@@ -15,7 +15,10 @@ module LLM
15
15
  # instead of leaving tool loops to the caller.
16
16
  #
17
17
  # **Notes:**
18
- # * Instructions are injected once unless a system message is already present.
18
+ # * Instructions are injected once, and kept in step after that. A
19
+ # restored conversation carries the instructions that were current
20
+ # when it started, and the message is brought up to date before the
21
+ # next request goes out.
19
22
  # * An agent automatically executes tool loops (unlike {LLM::Context LLM::Context}).
20
23
  # * The tool loop can be bounded with `tool_budget`. Once the budget is
21
24
  # spent, no further tool calls are run for that turn: the agent sends an
@@ -53,6 +56,14 @@ module LLM
53
56
  UNDEFINED = Object.new
54
57
  private_constant :UNDEFINED
55
58
 
59
+ ##
60
+ # Marks the message an agent injects as its own, so that a later
61
+ # turn can tell it from a message the caller composed. The role
62
+ # cannot do that: it is true of whatever the caller put there.
63
+ # @api private
64
+ INSTRUCTIONS = {instructions: true}.freeze
65
+ private_constant :INSTRUCTIONS
66
+
56
67
  ##
57
68
  # Ugh :)
58
69
  # @api private
@@ -802,27 +813,92 @@ module LLM
802
813
  # @return [LLM::Prompt]
803
814
  def apply_instructions(new_prompt)
804
815
  return new_prompt unless @instructions
816
+ ##
817
+ # A prompt that carries a system message of its own is the
818
+ # caller's, and the instructions already in the conversation are
819
+ # left as they are. The context is deliberately not consulted: a
820
+ # restored context always holds the message this exists to bring
821
+ # up to date, which is what `inject_instructions?` tests for.
822
+ refresh_instructions! unless carries_system_message?(new_prompt)
805
823
  if LLM::Prompt === new_prompt
806
- new_prompt.system(@instructions) if inject_instructions?(new_prompt)
824
+ new_prompt.system(@instructions, extra: INSTRUCTIONS) if inject_instructions?(new_prompt)
807
825
  new_prompt
808
826
  else
809
827
  prompt do
810
- _1.system(@instructions) if inject_instructions?
828
+ _1.system(@instructions, extra: INSTRUCTIONS) if inject_instructions?
811
829
  _1.user(new_prompt)
812
830
  end
813
831
  end
814
832
  end
815
833
 
834
+ ##
835
+ # Brings the instructions stored in the conversation up to date.
836
+ #
837
+ # The message is identified by the mark the agent put on it when it
838
+ # injected it, and not by its role or its position. A role is true
839
+ # of whatever the caller put there - on a provider whose system role
840
+ # is `:user`, the first message of a conversation the caller seeded
841
+ # is a user message too - so a rule written on either of those
842
+ # replaces a message that was never the agent's to replace.
843
+ #
844
+ # A message with no mark is left alone, which is the safe direction:
845
+ # a conversation written before the mark existed keeps its stale
846
+ # instructions rather than losing a message. Stale is recoverable.
847
+ #
848
+ # An agent with no instructions returns first. The caller reaches
849
+ # this through `apply_instructions`, which already returns early for
850
+ # the same reason, and the guard is repeated here so that the method
851
+ # cannot replace a message with nil however it is reached.
852
+ #
853
+ # Comparing content rather than the message is deliberate.
854
+ # {LLM::Message#==} compares everything a message carries apart from
855
+ # its id, which drags fields that have nothing to do with
856
+ # instructions into the comparison. The mark is carried onto the
857
+ # replacement so that the turn after this one can still find it, and
858
+ # so is the role the message already had: it is the provider's, and
859
+ # rebuilding it here would undo the reason the replacement is safe.
860
+ # @api private
861
+ # @return [void]
862
+ def refresh_instructions!
863
+ return unless @instructions
864
+ message = @ctx.messages.first
865
+ return unless message&.extra&.key?(:instructions)
866
+ return if message.content == @instructions
867
+ @ctx.messages.replace(
868
+ [LLM::Message.new(message.role, @instructions, message.extra), *@ctx.messages.drop(1)]
869
+ )
870
+ end
871
+
872
+ ##
873
+ # Returns true when a prompt carries a system message of its own.
874
+ #
875
+ # This is the half of `inject_instructions?` that asks about the
876
+ # prompt rather than about the context, and it is the one a refresh
877
+ # needs. The context half says "do not inject again", which is not
878
+ # the same question as "whose instructions are these".
879
+ #
880
+ # The role asked about is the provider's: a prompt's `system` is
881
+ # built with the role the provider gives instructions, which is not
882
+ # the string "system" everywhere.
883
+ # @param [LLM::Prompt, Object] prompt
884
+ # @return [Boolean]
885
+ def carries_system_message?(prompt)
886
+ role = @llm.system_role.to_s
887
+ LLM::Prompt === prompt && prompt.to_a.any? { _1.role.to_s == role }
888
+ end
889
+
816
890
  ##
817
891
  # Returns true when agent instructions should be injected for the turn.
818
- # Instructions are injected once unless a system message is already
819
- # present in the existing context or the prompt being sent.
892
+ # Instructions are injected once unless a message with the provider's
893
+ # system role is already present in the existing context or the prompt
894
+ # being sent.
820
895
  # @param [LLM::Prompt, nil] prompt
821
896
  # @return [Boolean]
822
897
  def inject_instructions?(prompt = nil)
823
- return false if @ctx.messages.any?(&:system?)
898
+ role = @llm.system_role.to_s
899
+ return false if @ctx.messages.any? { _1.role.to_s == role }
824
900
  return true if prompt.nil?
825
- !prompt.to_a.any?(&:system?)
901
+ !prompt.to_a.any? { _1.role.to_s == role }
826
902
  end
827
903
 
828
904
  ##
@@ -40,6 +40,19 @@ class LLM::Context
40
40
 
41
41
  private
42
42
 
43
+ ##
44
+ # Rebuilds one message from a payload.
45
+ #
46
+ # The fields that describe a message are named here rather than
47
+ # taken from everything the payload holds, so a field that
48
+ # {LLM::Message#to_h} writes has to be read here as well - and one
49
+ # that is not named is dropped on the way in, whatever the payload
50
+ # carries. `instructions` marks the message an agent injected as its
51
+ # own, and the agent looks for that mark rather than for a role or a
52
+ # position when it refreshes the instructions in a restored
53
+ # conversation.
54
+ # @param [Hash] payload
55
+ # @return [LLM::Message]
43
56
  def deserialize_message(payload)
44
57
  tool_calls = deserialize_tool_calls(payload["tools"])
45
58
  returns = deserialize_returns(payload["content"]) if returns.nil?
@@ -47,8 +60,18 @@ class LLM::Context
47
60
  usage = payload["usage"]
48
61
  reasoning_content = payload["reasoning_content"]
49
62
  compaction = payload["compaction"]
63
+ instructions = payload["instructions"]
50
64
  id = payload["id"]
51
- extra = {tool_calls:, original_tool_calls:, tools: @params[:tools], usage:, reasoning_content:, compaction:, id:}.compact
65
+ extra = {
66
+ tool_calls:,
67
+ original_tool_calls:,
68
+ tools: @params[:tools],
69
+ usage:,
70
+ reasoning_content:,
71
+ compaction:,
72
+ instructions:,
73
+ id:
74
+ }.compact
52
75
  content = returns.nil? ? deserialize_content(payload["content"]) : returns
53
76
  LLM::Message.new(payload["role"], content, extra)
54
77
  end
data/lib/llm/message.rb CHANGED
@@ -46,6 +46,12 @@ module LLM
46
46
 
47
47
  ##
48
48
  # Returns a Hash representation of the message.
49
+ #
50
+ # This is the whole of a message as far as storage is concerned:
51
+ # {LLM::Context#to_h} maps its messages through here, and restore
52
+ # reads them back from the same list. A field that is not named
53
+ # here does not survive a save, so a field the runtime relies on
54
+ # after a restore has to be named.
49
55
  # @return [Hash]
50
56
  def to_h
51
57
  {
@@ -54,6 +60,7 @@ module LLM
54
60
  content:,
55
61
  reasoning_content:,
56
62
  compaction: extra.compaction,
63
+ instructions: extra.instructions,
57
64
  tools: extra.tool_calls&.map { LLM::Object === _1 ? _1.to_h : _1 },
58
65
  usage:,
59
66
  original_tool_calls: extra.original_tool_calls
data/lib/llm/prompt.rb CHANGED
@@ -40,40 +40,51 @@ class LLM::Prompt
40
40
  # The message
41
41
  # @param [Symbol] role
42
42
  # The role (eg user, system)
43
+ # @param [Hash, nil] extra
44
+ # Fields carried alongside the message. A caller that composes a
45
+ # message of its own can mark it here, and the runtime reads the
46
+ # mark back rather than inferring it from the role.
43
47
  # @return [void]
44
- def talk(content, role: @provider.user_role)
48
+ def talk(content, role: @provider.user_role, extra: nil)
45
49
  role = case role.to_sym
46
50
  when :system then @provider.system_role
47
51
  when :user then @provider.user_role
48
52
  when :developer then @provider.developer_role
49
53
  else role
50
54
  end
51
- @buffer << LLM::Message.new(role, content)
55
+ message = extra ? LLM::Message.new(role, content, extra) : LLM::Message.new(role, content)
56
+ @buffer << message
52
57
  end
53
58
  alias_method :chat, :talk
54
59
 
55
60
  ##
56
61
  # @param [String] content
57
62
  # The message content
63
+ # @param [Hash, nil] extra
64
+ # Fields carried alongside the message
58
65
  # @return [void]
59
- def user(content)
60
- talk(content, role: @provider.user_role)
66
+ def user(content, extra: nil)
67
+ talk(content, role: @provider.user_role, extra:)
61
68
  end
62
69
 
63
70
  ##
64
71
  # @param [String] content
65
72
  # The message content
73
+ # @param [Hash, nil] extra
74
+ # Fields carried alongside the message
66
75
  # @return [void]
67
- def system(content)
68
- talk(content, role: @provider.system_role)
76
+ def system(content, extra: nil)
77
+ talk(content, role: @provider.system_role, extra:)
69
78
  end
70
79
 
71
80
  ##
72
81
  # @param [String] content
73
82
  # The message content
83
+ # @param [Hash, nil] extra
84
+ # Fields carried alongside the message
74
85
  # @return [void]
75
- def developer(content)
76
- talk(content, role: @provider.developer_role)
86
+ def developer(content, extra: nil)
87
+ talk(content, role: @provider.developer_role, extra:)
77
88
  end
78
89
 
79
90
  ##
data/lib/llm/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LLM
4
- VERSION = "16.0.0.pre.4"
4
+ VERSION = "16.0.0.pre.5"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: llm.rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 16.0.0.pre.4
4
+ version: 16.0.0.pre.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Robert Gleeson