smart_prompt 0.5.3 → 0.5.4

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: 28b52596217dded1ca7b39d43e098de3b01f9db8e0a6acb06201399ef6c6877d
4
- data.tar.gz: f29cb242e7900d1c1321451f05b5aa1075155653c6b894707ed7b172893d6833
3
+ metadata.gz: a2082e6227f4e29a5e1459abf8e490e557c193dcea6ec58574463f070568684e
4
+ data.tar.gz: f375959c75e4f67564be1d1824d7046886d54b09ea7aeb5818394d2c748f8721
5
5
  SHA512:
6
- metadata.gz: ba8c550d079e37c5b5155393522568bef857821cb1440d810517f37d1067b9b7276ef46e52a5ef9e0605e066e65b53a6dc100c4a42a71585d9d99d0354322160
7
- data.tar.gz: 4a2cb608d7b3aeb757a813e75ef5710ad4d106811e743d91e3dbdbb9c07f44c6d184e334eb1528a8999b551b32bbf2c4ab6037bfaabb405055fd4ab053ca4eec
6
+ metadata.gz: 20348a9065c3ff0ad2f3e8b790adfc88f598f09c996309b222e19a803157b140f952108f2c7e3bd7faab45bf4f719896734c156ba51c5966be435fd587792f94
7
+ data.tar.gz: ccd492a7abe0cccb8565bc8f39fd2ae7b950c43305e78b06a3feb9ebf7643afa6a789f6fbddd511fd499839d405d520f2da1787effdb2a6e69f3b378684908c1
data/CHANGELOG.md CHANGED
@@ -12,6 +12,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
12
12
  ### Changed
13
13
  - **Refactored the Zhipu / SenseNova / SiliconFlow adapters** — extracted byte-identical cross-provider logic into four shared concerns under `lib/smart_prompt/concerns/` (`HTTPClient`, `MultimodalMessages`, `OpenAIChatShaping`, `ImagePersistence`), and split the Zhipu and SiliconFlow adapters into per-modality capability modules under `lib/smart_prompt/adapters/<provider>/` (`Text` / `Embed` / `Image` / `Video` / `Voice` / `Rerank`). Pure internal refactor — no public-API change (`send_request` stays 5-arg, all DSL-delegated method names preserved), behavior unchanged. ~286 lines removed and the previously triplicated HTTP / multimodal / chat-shaping / image-persistence code now has a single source.
14
14
 
15
+ ## [0.5.4] - 2026-09-13
16
+ ### Added
17
+ - `Engine#system_message_transformer` hook — an optional callable applied to every system message in `Conversation#sys_msg`, so embedding applications can inject system-prompt policy (e.g. a native tool-call protocol) without monkey-patching `WorkerContext`
18
+ - `WorkerContext#transient_prompt` — adds a user prompt to the current request only, without persisting it to `HistoryManager`
19
+ - `HistoryManager#upsert_system_message` — keeps exactly one durable system message per session (idempotent no-op when unchanged)
20
+
21
+ ### Fixed
22
+ - `Message` serialization now preserves `tool_calls` / `tool_call_id` / `reasoning_content`, so HistoryManager round-trips no longer produce malformed tool requests (missing tool-call pairing) or drop DeepSeek-style reasoning content
23
+ - `Session` trimming now operates in "pair groups", keeping an `assistant(tool_calls)` message together with the consecutive `tool` results that follow it — preventing orphan `tool` messages that the OpenAI-compatible API rejects with HTTP 400
24
+ - `WorkerContext#sys_msg` now forwards `with_history` to `Conversation#sys_msg` (previously system messages never reached history); the durable copy is upserted rather than appended, so worker loops never accumulate one system message per round
25
+ - `WorkerContext#send_msg` with `with_history: true` now includes `transient_prompt` messages in the request (previously they were silently dropped); the persisted-prompt pattern still sends history directly, so nothing is duplicated
26
+
15
27
  ## [0.5.1] - 2026-06-21
16
28
  ### Added
17
29
  - **SenseNova (商汤日日新) support** — unified `SenseNovaAdapter` covering chat (商量), multimodal vision, Cupido embeddings, and 秒画 text-to-image, with SSE streaming and reasoning-field handling
@@ -131,8 +131,9 @@ module SmartPrompt
131
131
  end
132
132
 
133
133
  def sys_msg(message, params = {})
134
- @sys_msg = thinking_system_message(message)
135
- add_message({ role: "system", content: @sys_msg }, params[:with_history])
134
+ @sys_msg = thinking_system_message(transform_system_message(message))
135
+ upsert_system_message(@sys_msg) if params[:with_history]
136
+ add_message({ role: "system", content: @sys_msg }, false)
136
137
  self
137
138
  end
138
139
 
@@ -171,6 +172,29 @@ module SmartPrompt
171
172
  "default_#{Time.now.to_i}_#{rand(1000)}"
172
173
  end
173
174
 
175
+ # Apply an optional engine-level hook so embedding applications can inject
176
+ # their own system-prompt policy (e.g. a native tool-call protocol) without
177
+ # monkey-patching WorkerContext.
178
+ def transform_system_message(message)
179
+ transformer = @engine.system_message_transformer if @engine.respond_to?(:system_message_transformer)
180
+ transformer ? transformer.call(message) : message
181
+ end
182
+
183
+ # Keep exactly one durable system message per session. Repeated rounds
184
+ # replace the persisted system copy instead of appending snapshots of
185
+ # changing progress/repair state forever.
186
+ def upsert_system_message(content)
187
+ if @engine.history_manager
188
+ @use_history_manager = true
189
+ @session_id ||= generate_default_session_id
190
+ @engine.history_manager.upsert_system_message(@session_id, content)
191
+ else
192
+ messages = Array(@engine.history_messages)
193
+ messages.delete_if { |item| (item[:role] || item["role"]).to_s == "system" }
194
+ messages << { role: "system", content: content }
195
+ end
196
+ end
197
+
174
198
  public
175
199
 
176
200
  def send_msg(params = {})
@@ -2,6 +2,10 @@ module SmartPrompt
2
2
  class Engine
3
3
  attr_reader :config_file, :config, :adapters, :current_adapter, :llms, :models, :templates
4
4
  attr_reader :stream_response, :history_manager
5
+ # Optional callable applied to every system message in Conversation#sys_msg.
6
+ # Lets an embedding application inject its own system-prompt policy (e.g. a
7
+ # native tool-call protocol) without monkey-patching WorkerContext.
8
+ attr_accessor :system_message_transformer
5
9
 
6
10
  def initialize(config_file)
7
11
  @config_file = config_file
@@ -12,6 +16,7 @@ module SmartPrompt
12
16
  @current_workers = {}
13
17
  @history_messages = []
14
18
  @history_manager = nil
19
+ @system_message_transformer = nil
15
20
  load_config(config_file)
16
21
  SmartPrompt.logger.info "Started create the SmartPrompt engine."
17
22
  @stream_proc = Proc.new do |chunk, _bytesize|
@@ -104,6 +104,28 @@ module SmartPrompt
104
104
  end
105
105
  end
106
106
 
107
+ # Keep exactly one durable system message per session. Repeated worker rounds
108
+ # replace the persisted system copy instead of appending snapshots of
109
+ # changing progress/repair state forever. Returns false when the session
110
+ # already held an identical single system message (idempotent no-op).
111
+ def upsert_system_message(session_id, content, options = {})
112
+ begin
113
+ session = get_session(session_id, options)
114
+ systems = session.messages.select(&:system_message?)
115
+ if systems.one? && systems.first.content.to_s == content.to_s
116
+ return false
117
+ end
118
+
119
+ session.messages.delete_if(&:system_message?)
120
+ rescue => e
121
+ log_error "Failed to upsert system message in session #{session_id}", e
122
+ raise HistoryManagerError, "Failed to upsert system message: #{e.message}"
123
+ end
124
+
125
+ add_message(session_id, { role: "system", content: content.to_s }, options)
126
+ true
127
+ end
128
+
107
129
  # Get context (messages) from a session
108
130
  def get_context(session_id, max_tokens = nil, strategy = nil)
109
131
  begin
@@ -4,7 +4,8 @@ module SmartPrompt
4
4
  # Message represents a single message in a conversation history
5
5
  # It contains role, content, timestamp, and metadata
6
6
  class Message
7
- attr_reader :role, :content, :timestamp, :metadata, :token_count
7
+ attr_reader :role, :content, :timestamp, :metadata, :token_count,
8
+ :tool_calls, :tool_call_id, :reasoning_content
8
9
  attr_accessor :importance_score, :is_summary
9
10
 
10
11
  def initialize(data)
@@ -15,6 +16,12 @@ module SmartPrompt
15
16
  @token_count = nil # Lazy calculation
16
17
  @importance_score = data[:importance_score] || data["importance_score"]
17
18
  @is_summary = data[:is_summary] || data["is_summary"] || false
19
+ # Tool-call pairing and reasoning fields must survive serialization so
20
+ # HistoryManager round-trips don't drop tool_calls / tool_call_id /
21
+ # reasoning_content and produce malformed tool requests.
22
+ @tool_calls = data[:tool_calls] || data["tool_calls"]
23
+ @tool_call_id = data[:tool_call_id] || data["tool_call_id"]
24
+ @reasoning_content = data[:reasoning_content] || data["reasoning_content"]
18
25
  end
19
26
 
20
27
  # Calculate token count using provided counter
@@ -29,7 +36,7 @@ module SmartPrompt
29
36
 
30
37
  # Convert message to hash format
31
38
  def to_h
32
- {
39
+ h = {
33
40
  role: @role,
34
41
  content: @content,
35
42
  timestamp: @timestamp.iso8601,
@@ -37,6 +44,10 @@ module SmartPrompt
37
44
  importance_score: @importance_score,
38
45
  is_summary: @is_summary
39
46
  }
47
+ h[:tool_calls] = @tool_calls if @tool_calls
48
+ h[:tool_call_id] = @tool_call_id if @tool_call_id
49
+ h[:reasoning_content] = @reasoning_content if @reasoning_content
50
+ h
40
51
  end
41
52
 
42
53
  private
@@ -90,20 +90,27 @@ module SmartPrompt
90
90
  end
91
91
  end
92
92
 
93
- # Remove oldest non-system messages to meet message count limit
93
+ # Remove oldest non-system messages to meet message count limit, trimming
94
+ # in "pair groups" so an assistant(tool_calls) message is never separated
95
+ # from the tool results that follow it.
94
96
  def remove_oldest_messages_to_limit(max_messages)
95
97
  system_messages = @messages.select(&:system_message?)
96
98
  non_system_messages = @messages.reject(&:system_message?)
97
99
 
98
- # Keep only the most recent non-system messages
99
- messages_to_keep = max_messages - system_messages.length
100
- messages_to_keep = [messages_to_keep, 0].max
100
+ messages_to_keep = [max_messages - system_messages.length, 0].max
101
101
 
102
- kept_non_system = non_system_messages.last(messages_to_keep)
103
- @messages = system_messages + kept_non_system
102
+ kept = []
103
+ count = 0
104
+ pair_groups(non_system_messages).reverse_each do |group|
105
+ break if count + group.length > messages_to_keep
106
+
107
+ kept.unshift(group)
108
+ count += group.length
109
+ end
110
+ @messages = system_messages + kept.flatten
104
111
  end
105
112
 
106
- # Remove oldest non-system messages to meet token limit
113
+ # Remove oldest non-system messages to meet token limit, also in pair groups.
107
114
  def remove_oldest_messages_to_token_limit(max_tokens)
108
115
  system_messages = @messages.select(&:system_message?)
109
116
  non_system_messages = @messages.reject(&:system_message?)
@@ -111,21 +118,45 @@ module SmartPrompt
111
118
  system_tokens = system_messages.sum { |msg| msg.token_count || 0 }
112
119
  available_tokens = max_tokens - system_tokens
113
120
 
114
- # Keep adding messages from the end until we hit the token limit
115
- kept_messages = []
121
+ kept = []
116
122
  current_tokens = 0
123
+ pair_groups(non_system_messages).reverse_each do |group|
124
+ group_tokens = group.sum { |msg| msg.token_count || 0 }
125
+ break if current_tokens + group_tokens > available_tokens
126
+
127
+ kept.unshift(group)
128
+ current_tokens += group_tokens
129
+ end
130
+ @messages = system_messages + kept.flatten
131
+ end
117
132
 
118
- non_system_messages.reverse_each do |msg|
119
- msg_tokens = msg.token_count || 0
120
- if current_tokens + msg_tokens <= available_tokens
121
- kept_messages.unshift(msg)
122
- current_tokens += msg_tokens
133
+ # Split non-system messages into groups where an assistant message that
134
+ # carries tool_calls stays together with the consecutive tool results that
135
+ # follow it. Every other message forms its own single-element group.
136
+ def pair_groups(messages)
137
+ groups = []
138
+ i = 0
139
+ while i < messages.length
140
+ msg = messages[i]
141
+ if assistant_with_tool_calls?(msg)
142
+ group = [msg]
143
+ i += 1
144
+ while i < messages.length && messages[i].role.to_s == "tool"
145
+ group << messages[i]
146
+ i += 1
147
+ end
148
+ groups << group
123
149
  else
124
- break
150
+ groups << [msg]
151
+ i += 1
125
152
  end
126
153
  end
154
+ groups
155
+ end
127
156
 
128
- @messages = system_messages + kept_messages
157
+ def assistant_with_tool_calls?(msg)
158
+ msg.role.to_s == "assistant" &&
159
+ msg.respond_to?(:tool_calls) && msg.tool_calls && !msg.tool_calls.empty?
129
160
  end
130
161
 
131
162
  # Calculate importance score for a message
@@ -1,3 +1,3 @@
1
1
  module SmartPrompt
2
- VERSION = "0.5.3"
2
+ VERSION = "0.5.4"
3
3
  end
@@ -57,18 +57,42 @@ module SmartPrompt
57
57
  @params = params
58
58
  @engine = engine
59
59
  @proc = proc
60
+ @transient_messages = []
61
+ end
62
+
63
+ # Add a user prompt to the current request only, without persisting it to
64
+ # HistoryManager. Progress, retry notes and hard-intervention text are
65
+ # snapshots, not conversation history; persisting one large user message per
66
+ # round would evict the assistant/tool evidence we need to retain.
67
+ def transient_prompt(content)
68
+ @transient_messages << content
69
+ @conversation.prompt(content, with_history: false)
60
70
  end
61
71
 
62
72
  def method_missing(method, *args, &block)
63
73
  if @conversation.respond_to?(method)
64
74
  if method == :send_msg
65
- if @proc == nil
66
- @conversation.send_msg(params)
75
+ send_params = params
76
+ if params[:with_history] && !@transient_messages.empty?
77
+ # The default send path would send *only* history_messages when
78
+ # with_history=true, silently dropping the transient prompt just
79
+ # added to @conversation.messages. Merge both sources for this
80
+ # request and use the ordinary send path. When there is no transient
81
+ # prompt, keep the original with_history send so the persisted
82
+ # prompt pattern is not duplicated.
83
+ prepare_transient_history_request!
84
+ send_params = params.merge(with_history: false)
85
+ end
86
+ if @proc.nil?
87
+ @conversation.send_msg(send_params)
67
88
  else
68
- @conversation.send_msg_by_stream(params, &@proc)
89
+ @conversation.send_msg_by_stream(send_params, &@proc)
69
90
  end
70
91
  elsif method == :sys_msg
71
- @conversation.sys_msg(*args)
92
+ # The system message always belongs to the current request. Its
93
+ # durable session copy is upserted by Conversation#sys_msg so a worker
94
+ # loop never accumulates one preserved system message per round.
95
+ @conversation.sys_msg(*args, with_history: params[:with_history])
72
96
  elsif method == :prompt
73
97
  @conversation.prompt(*args, with_history: params[:with_history])
74
98
  else
@@ -80,7 +104,7 @@ module SmartPrompt
80
104
  end
81
105
 
82
106
  def respond_to_missing?(method, include_private = false)
83
- @conversation.respond_to?(method) || super
107
+ method == :transient_prompt || @conversation.respond_to?(method) || super
84
108
  end
85
109
 
86
110
  def params
@@ -107,5 +131,43 @@ module SmartPrompt
107
131
  worker = Worker.new(worker_name, @engine)
108
132
  worker.execute_by_stream(params, proc)
109
133
  end
134
+
135
+ private
136
+
137
+ # Merge the persisted session history with the transient prompts recorded
138
+ # this round. Persisted prompts stay in history_messages and are therefore
139
+ # already part of the request, so only the transient user messages need to
140
+ # be appended to avoid duplication.
141
+ def prepare_transient_history_request!
142
+ current = Array(@conversation.messages)
143
+ history = session_history
144
+ SmartPrompt.logger&.info(
145
+ "[SmartPrompt history] session=#{@params[:session_id]} " \
146
+ "messages=#{history.size} roles=#{history.map { |message| message_role(message) }.tally}"
147
+ )
148
+ system = current.select { |message| message_role(message) == "system" }
149
+ historical_turns = history.reject { |message| message_role(message) == "system" }
150
+ transient_turn = @transient_messages.map { |content| { role: "user", content: content } }
151
+ @conversation.instance_variable_set(:@messages, system + historical_turns + transient_turn)
152
+ end
153
+
154
+ def session_history
155
+ if @engine.respond_to?(:history_manager) && @engine.history_manager
156
+ sid = @params[:session_id]
157
+ raise ArgumentError, "history requires an explicit session_id" if sid.to_s.strip.empty?
158
+
159
+ @engine.history_manager.get_context(sid).map(&:to_h)
160
+ elsif @engine.respond_to?(:history_messages)
161
+ Array(@engine.history_messages)
162
+ else
163
+ []
164
+ end
165
+ end
166
+
167
+ def message_role(message)
168
+ return message.role.to_s if message.respond_to?(:role)
169
+
170
+ (message[:role] || message["role"]).to_s
171
+ end
110
172
  end
111
173
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: smart_prompt
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.3
4
+ version: 0.5.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - zhuang biaowei
@@ -259,7 +259,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
259
259
  - !ruby/object:Gem::Version
260
260
  version: '0'
261
261
  requirements: []
262
- rubygems_version: 4.0.13
262
+ rubygems_version: 4.0.16
263
263
  specification_version: 4
264
264
  summary: A smart prompt management and LLM interaction gem
265
265
  test_files: []