smart_prompt 0.5.2 → 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.
@@ -0,0 +1,108 @@
1
+ require "base64"
2
+ require "net/http"
3
+ require "uri"
4
+
5
+ module SmartPrompt
6
+ # Shared multimodal-message normalization for Net::HTTP adapters (ZhipuAI, SenseNova,
7
+ # SiliconFlow). Turns an OpenAI-style content array into the shape the provider expects,
8
+ # inlining local image/audio/video files as base64 data URLs and passing http(s)/data
9
+ # URLs through. Each adapter previously carried a near-identical copy of this logic.
10
+ #
11
+ # SiliconFlow's variant is the superset (image_url + video_url + audio_url, preserving
12
+ # detail/max_frames/fps); Zhipu/SenseNova only ever send image_url, which is a subset.
13
+ module MultimodalMessages
14
+ SUPPORTED_IMAGE_FORMATS = %w[jpg jpeg png gif bmp webp].freeze
15
+
16
+ def process_multimodal_messages(messages)
17
+ messages.map do |msg|
18
+ role = msg[:role] || msg["role"]
19
+ content = msg[:content] || msg["content"]
20
+ content = content.map { |item| normalize_content_item(item) } if content.is_a?(Array)
21
+ { "role" => role, "content" => content }
22
+ end
23
+ end
24
+
25
+ def normalize_content_item(item)
26
+ return { "type" => "text", "text" => item.to_s } unless item.is_a?(Hash)
27
+
28
+ case item[:type] || item["type"]
29
+ when "image_url"
30
+ normalize_media_part(item, "image_url", :image)
31
+ when "video_url"
32
+ normalize_media_part(item, "video_url", :video)
33
+ when "audio_url"
34
+ normalize_media_part(item, "audio_url", :audio)
35
+ else
36
+ stringify_hash(item)
37
+ end
38
+ end
39
+
40
+ # Build an image_url/video_url/audio_url part, inlining local files as data URLs and
41
+ # preserving any extra keys (detail, max_frames, fps) on the media hash.
42
+ def normalize_media_part(item, type, media_kind)
43
+ iu = item[type.to_sym] || item[type]
44
+ if iu.is_a?(Hash)
45
+ url = iu[:url] || iu["url"]
46
+ part = { "type" => type, type => { "url" => normalize_media_url(url, media_kind) } }
47
+ iu.each { |k, v| part[type][k.to_s] = stringify_hash(v) unless k.to_s == "url" }
48
+ part
49
+ else
50
+ { "type" => type, type => { "url" => normalize_media_url(iu, media_kind) } }
51
+ end
52
+ end
53
+
54
+ # Resolve a media URL embedded in a message: http(s)/data pass through; a local path
55
+ # is base64-encoded as a data URL.
56
+ def normalize_media_url(url, kind = :image)
57
+ return url if url.nil?
58
+ return url if url.start_with?("http://", "https://", "data:")
59
+
60
+ label = kind == :image ? "Image" : kind.to_s.capitalize
61
+ raise Error, "#{label} file not found: #{url}" unless File.exist?(url)
62
+ ext = File.extname(url).downcase.delete(".")
63
+ case kind
64
+ when :image
65
+ raise Error, "Unsupported image format: #{ext}" unless SUPPORTED_IMAGE_FORMATS.include?(ext)
66
+ mime = ext == "jpg" ? "jpeg" : ext
67
+ "data:image/#{mime};base64,#{Base64.strict_encode64(File.binread(url))}"
68
+ when :audio
69
+ "data:audio/#{ext.empty? ? 'wav' : ext};base64,#{Base64.strict_encode64(File.binread(url))}"
70
+ when :video
71
+ "data:video/#{ext.empty? ? 'mp4' : ext};base64,#{Base64.strict_encode64(File.binread(url))}"
72
+ end
73
+ end
74
+
75
+ # Single-arg image-only shim (call sites like generate_video pass a plain image URL).
76
+ def normalize_image_url(url)
77
+ normalize_media_url(url, :image)
78
+ end
79
+
80
+ # Accept a local path, a base64 data URL, or an http(s) URL for image-edit /
81
+ # image-to-video `image` fields.
82
+ def normalize_input_image(image)
83
+ return image if image.nil?
84
+
85
+ if image.is_a?(String)
86
+ return image if image.start_with?("data:")
87
+ return image if image.start_with?("http://", "https://")
88
+ end
89
+
90
+ raise Error, "Image file not found: #{image}" unless File.exist?(image)
91
+ ext = File.extname(image).downcase.delete(".")
92
+ raise Error, "Unsupported image format: #{ext}" unless SUPPORTED_IMAGE_FORMATS.include?(ext)
93
+ mime = ext == "jpg" ? "jpeg" : ext
94
+ "data:image/#{mime};base64,#{Base64.strict_encode64(File.binread(image))}"
95
+ end
96
+
97
+ def stringify_hash(hash)
98
+ case hash
99
+ when Hash
100
+ hash.each_with_object({}) { |(k, v), memo| memo[k.to_s] = stringify_hash(v) }
101
+ when Array
102
+ hash.map { |v| stringify_hash(v) }
103
+ else
104
+ hash
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,87 @@
1
+ module SmartPrompt
2
+ # Shared shaping of Net::HTTP chat responses into the OpenAI completion / stream
3
+ # shape that the rest of SmartPrompt (Engine#@stream_proc, Conversation) expects.
4
+ #
5
+ # Reasoning models expose a thinking trace under a provider-specific field —
6
+ # surfaced here uniformly as `reasoning_content`. Adapters override one hook:
7
+ #
8
+ # reasoning_field_name — the source field on message/delta (default
9
+ # "reasoning_content"; SenseNova uses "reasoning"). Its value is remapped to
10
+ # reasoning_content so Engine#@stream_proc needs no per-provider logic.
11
+ #
12
+ # extra_top_level_fields(raw) — extra top-level keys to copy onto the shaped
13
+ # response/chunk (default {}; SenseNova adds system_fingerprint).
14
+ module OpenAIChatShaping
15
+ def build_completion_response(raw)
16
+ msg = raw.dig("choices", 0, "message") || {}
17
+ message = { "role" => msg["role"] || "assistant" }
18
+ message["content"] = msg["content"]
19
+ reasoning = msg[reasoning_field_name]
20
+ message["reasoning_content"] = reasoning if reasoning
21
+ message["tool_calls"] = msg["tool_calls"] if msg["tool_calls"]
22
+
23
+ response = {
24
+ "id" => raw["id"],
25
+ "object" => raw["object"] || "chat.completion",
26
+ "created" => raw["created"],
27
+ "model" => raw["model"],
28
+ "choices" => [{
29
+ "index" => 0,
30
+ "message" => message,
31
+ "finish_reason" => raw.dig("choices", 0, "finish_reason"),
32
+ }],
33
+ }
34
+ response["usage"] = raw["usage"] if raw["usage"]
35
+ merge_extra_top_level(response, raw)
36
+ response
37
+ end
38
+
39
+ def build_stream_chunk(data)
40
+ chunk = {
41
+ "id" => data["id"],
42
+ "object" => data["object"],
43
+ "created" => data["created"],
44
+ "model" => data["model"],
45
+ }
46
+ chunk["usage"] = data["usage"] if data["usage"]
47
+ merge_extra_top_level(chunk, data)
48
+
49
+ choices = data["choices"] || []
50
+ if choices.any?
51
+ delta = choices[0]["delta"] || {}
52
+ new_delta = {}
53
+ new_delta["role"] = delta["role"] if delta["role"]
54
+ new_delta["content"] = delta["content"] if delta["content"]
55
+ reasoning = delta[reasoning_field_name]
56
+ new_delta["reasoning_content"] = reasoning if reasoning
57
+ new_delta["tool_calls"] = delta["tool_calls"] if delta["tool_calls"]
58
+ chunk["choices"] = [{
59
+ "index" => choices[0]["index"] || 0,
60
+ "delta" => new_delta,
61
+ "finish_reason" => choices[0]["finish_reason"],
62
+ }]
63
+ else
64
+ chunk["choices"] = []
65
+ end
66
+ chunk
67
+ end
68
+
69
+ # ---- hooks (override in adapter) -----------------------------------------
70
+
71
+ def reasoning_field_name
72
+ "reasoning_content"
73
+ end
74
+
75
+ def extra_top_level_fields(_raw)
76
+ {}
77
+ end
78
+
79
+ private
80
+
81
+ def merge_extra_top_level(target, raw)
82
+ extra_top_level_fields(raw).each do |k, v|
83
+ target[k] = v unless v.nil? || target.key?(k)
84
+ end
85
+ end
86
+ end
87
+ end
@@ -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
@@ -0,0 +1,91 @@
1
+ require "base64"
2
+ require "json"
3
+ require "net/http"
4
+ require "uri"
5
+ require "fileutils"
6
+ require_relative "concerns/image_persistence"
7
+ require_relative "concerns/openai_chat_shaping"
8
+ require_relative "concerns/multimodal_messages"
9
+ require_relative "concerns/http_client"
10
+ require_relative "adapters/siliconflow/text"
11
+ require_relative "adapters/siliconflow/embed"
12
+ require_relative "adapters/siliconflow/image"
13
+ require_relative "adapters/siliconflow/video"
14
+ require_relative "adapters/siliconflow/voice"
15
+ require_relative "adapters/siliconflow/rerank"
16
+
17
+ module SmartPrompt
18
+ # Adapter for 硅基流动 (SiliconFlow / SiliconCloud) — one adapter owns the whole
19
+ # provider: every category shares the base URL https://api.siliconflow.cn/v1 and
20
+ # Bearer auth.
21
+ #
22
+ # Per-modality behavior lives in capability modules under adapters/siliconflow/
23
+ # (Text / Embed / Image / Video / Voice / Rerank); cross-provider plumbing (HTTP,
24
+ # multimodal normalization, chat shaping, image saving) comes from the shared
25
+ # concerns. This class wires them together + holds config/credentials.
26
+ #
27
+ # Provider-specific quirks (all vs https://docs.siliconflow.cn/cn/api-reference):
28
+ # chat/vision — POST {base}/chat/completions (reasoning_content, no remap)
29
+ # embeddings — POST {base}/embeddings (dimensions only for Qwen3-Embedding)
30
+ # rerank — POST {base}/rerank (results[].relevance_score)
31
+ # image/edit — POST {base}/images/generations (images[].url; image_size/batch_size/guidance_scale)
32
+ # video — POST {base}/video/submit -> POST {base}/video/status (async; results.videos[].url)
33
+ # tts — POST {base}/audio/speech (binary audio response)
34
+ # asr — POST {base}/audio/transcriptions (multipart, field "file")
35
+ # voice — /uploads/audio/voice, /audio/voice/list, /audio/voice/deletions
36
+ class SiliconFlowAdapter < LLMAdapter
37
+ DEFAULT_BASE_URL = "https://api.siliconflow.cn/v1".freeze
38
+
39
+ # Cross-provider shared concerns
40
+ include ImagePersistence
41
+ include OpenAIChatShaping
42
+ include MultimodalMessages
43
+ include HTTPClient
44
+
45
+ # Per-capability modules
46
+ include SiliconFlow::Text
47
+ include SiliconFlow::Embed
48
+ include SiliconFlow::Image
49
+ include SiliconFlow::Video
50
+ include SiliconFlow::Voice
51
+ include SiliconFlow::Rerank
52
+
53
+ # ---- hooks for shared concerns -------------------------------------------
54
+ def provider_label
55
+ "SiliconFlow"
56
+ end
57
+
58
+ def default_image_prefix
59
+ "siliconflow_image"
60
+ end
61
+
62
+ def initialize(config)
63
+ super
64
+ SmartPrompt.logger.info "Start create the SmartPrompt SiliconFlowAdapter."
65
+
66
+ api_key = @config["api_key"]
67
+ if api_key.is_a?(String) && api_key.start_with?("ENV[") && api_key.end_with?("]")
68
+ api_key = eval(api_key)
69
+ end
70
+ # Tolerate a missing key at construction (e.g. when the ENV var isn't set yet)
71
+ # and let the first request fail with a clear auth error.
72
+ SmartPrompt.logger.warn "SiliconFlow api_key is empty — API calls will fail until it is set." if api_key.nil? || api_key.to_s.strip.empty?
73
+
74
+ @api_key = api_key
75
+ @base_url = (@config["url"] || DEFAULT_BASE_URL).to_s.chomp("/")
76
+ # Optional per-method URL overrides (default to the standard paths off @base_url).
77
+ @image_url = (@config["image_url"] || "#{@base_url}/images/generations").to_s
78
+ @video_submit_url = (@config["video_submit_url"] || "#{@base_url}/video/submit").to_s
79
+ @video_status_url = (@config["video_status_url"] || "#{@base_url}/video/status").to_s
80
+ @speech_url = (@config["speech_url"] || "#{@base_url}/audio/speech").to_s
81
+ @transcription_url = (@config["transcription_url"] || "#{@base_url}/audio/transcriptions").to_s
82
+ @voice_upload_url = (@config["voice_upload_url"] || "#{@base_url}/uploads/audio/voice").to_s
83
+ @voice_list_url = (@config["voice_list_url"] || "#{@base_url}/audio/voice/list").to_s
84
+ @voice_delete_url = (@config["voice_delete_url"] || "#{@base_url}/audio/voice/deletions").to_s
85
+ SmartPrompt.logger.info "SiliconFlow base_url=#{@base_url}"
86
+ rescue => e
87
+ SmartPrompt.logger.error "Failed to initialize SiliconFlow client: #{e.message}"
88
+ raise e.is_a?(SmartPrompt::Error) ? e : LLMAPIError, "Invalid SiliconFlow configuration: #{e.message}"
89
+ end
90
+ end
91
+ end
@@ -1,3 +1,3 @@
1
1
  module SmartPrompt
2
- VERSION = "0.5.2"
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