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 +4 -4
- data/CHANGELOG.md +12 -0
- data/lib/smart_prompt/conversation.rb +26 -2
- data/lib/smart_prompt/engine.rb +5 -0
- data/lib/smart_prompt/history_manager.rb +22 -0
- data/lib/smart_prompt/message.rb +13 -2
- data/lib/smart_prompt/session.rb +47 -16
- data/lib/smart_prompt/version.rb +1 -1
- data/lib/smart_prompt/worker.rb +67 -5
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a2082e6227f4e29a5e1459abf8e490e557c193dcea6ec58574463f070568684e
|
|
4
|
+
data.tar.gz: f375959c75e4f67564be1d1824d7046886d54b09ea7aeb5818394d2c748f8721
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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 = {})
|
data/lib/smart_prompt/engine.rb
CHANGED
|
@@ -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
|
data/lib/smart_prompt/message.rb
CHANGED
|
@@ -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
|
data/lib/smart_prompt/session.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
150
|
+
groups << [msg]
|
|
151
|
+
i += 1
|
|
125
152
|
end
|
|
126
153
|
end
|
|
154
|
+
groups
|
|
155
|
+
end
|
|
127
156
|
|
|
128
|
-
|
|
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
|
data/lib/smart_prompt/version.rb
CHANGED
data/lib/smart_prompt/worker.rb
CHANGED
|
@@ -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
|
-
|
|
66
|
-
|
|
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(
|
|
89
|
+
@conversation.send_msg_by_stream(send_params, &@proc)
|
|
69
90
|
end
|
|
70
91
|
elsif method == :sys_msg
|
|
71
|
-
|
|
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.
|
|
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.
|
|
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: []
|