Class: Kward::Conversation
- Inherits:
-
Object
- Object
- Kward::Conversation
- Defined in:
- lib/kward/conversation.rb
Overview
Mutable transcript and runtime context for one agent session.
Conversation owns message ordering, system prompt refresh, read-before-write
state, memory prompt context, and persistence hooks. It intentionally stores
plain hashes because provider payload builders, session JSONL files, and RPC
normalizers all share the same transcript shape. Use MessageAccess when
reading messages so symbol/string key and legacy field compatibility stays in
one place.
Frontends should not mutate messages directly after attaching a
SessionStore::Session; use append/compact helpers so persistence callbacks
run and session trees stay consistent.
Constant Summary collapse
- DEFAULT_SYSTEM_MESSAGE =
Object.new.freeze
Instance Attribute Summary collapse
-
#compaction_system_message ⇒ Hash?
readonly
System prompt used when summarizing old context.
-
#context_budget_meter ⇒ ContextBudgetMeter
readonly
Runtime context savings for this conversation.
-
#execution_profile_context ⇒ String?
readonly
Plugin prompt context used in the current system prompt.
-
#last_memory_retrieval ⇒ Hash?
Metadata for the last memory retrieval attached to the session.
-
#last_plugin_prompt_context ⇒ String?
readonly
Plugin prompt context used in the current system prompt.
-
#memory_context ⇒ String?
Memory prompt context injected into refreshed system messages.
-
#messages ⇒ Array<Hash>
readonly
Ordered durable transcript entries, excluding runtime system prompt state.
-
#model ⇒ String?
readonly
Model id captured for session/runtime prompts.
-
#on_append ⇒ Proc?
Persistence callback invoked after appending a message.
-
#on_compact ⇒ Proc?
Persistence callback invoked after compaction replaces history.
-
#on_runtime_update ⇒ Proc?
Callback invoked when runtime metadata should be persisted.
-
#on_system_message_change ⇒ Proc?
Callback invoked when the system prompt runtime state changes.
-
#on_tool_execution ⇒ Proc?
Callback invoked when a tool execution record should be persisted.
-
#plugin_registry ⇒ PluginRegistry?
Registry used to collect plugin prompt context.
-
#project_skill_paths ⇒ Array<String>?
readonly
Explicitly permitted project skill paths.
-
#provider ⇒ String?
readonly
Provider captured for session/runtime prompts.
-
#read_paths ⇒ Set<String>
readonly
Resolved paths read by file tools during the active context.
-
#reasoning_effort ⇒ String?
readonly
Reasoning effort captured for session/runtime prompts.
-
#session_memories ⇒ Array<Hash>
readonly
Memories scoped to this conversation session.
-
#system_message ⇒ Hash?
readonly
Current system prompt included when building provider request context.
-
#tool_output_artifacts ⇒ Hash
readonly
Original large tool outputs retained outside model context.
-
#workspace_root ⇒ String
readonly
Canonical workspace root used for prompts and file guardrails.
Class Method Summary collapse
-
.normalize_tool_content(content) ⇒ Object
Tool results may arrive as ASCII-8BIT (BINARY) strings, e.g.
- .tool_output_artifact_id(tool_name:, content:) ⇒ Object
Instance Method Summary collapse
- #append_assistant(message) ⇒ Object
- #append_tool(tool_call_id:, name:, content:) ⇒ Object
- #append_tool_execution(tool_call:, content:) ⇒ Object
-
#append_user(content, display_content: nil) ⇒ Object
Appends a user message and normalizes image attachment syntax.
-
#compact!(summary, compaction_summary: false, first_kept_entry_id: nil, tokens_before: nil, from_hook: false, details: {}, keep_messages: []) ⇒ Object
Replaces most transcript entries with a compaction summary and optional recent messages to keep.
-
#context_messages ⇒ Array<Hash>
Provider request context: current system prompt plus durable transcript.
-
#initialize(system_message: DEFAULT_SYSTEM_MESSAGE, messages: [], read_paths: [], on_append: nil, on_compact: nil, on_tool_execution: nil, on_runtime_update: nil, workspace_root: Dir.pwd, compaction_system_message: DEFAULT_SYSTEM_MESSAGE, provider: nil, model: nil, reasoning_effort: nil, memory_context: nil, session_memories: [], last_memory_retrieval: nil, plugin_registry: nil, execution_profile_context: nil, project_skill_paths: nil) ⇒ Conversation
constructor
A new instance of Conversation.
- #last_entry_compaction? ⇒ Boolean
- #last_file_change_result ⇒ Object
- #mark_last_entry_compaction! ⇒ Object
- #mark_read(path) ⇒ Object
- #persist_runtime_context! ⇒ Object
- #plugin_prompt_context ⇒ Object
-
#refresh_system_message! ⇒ Object
Rebuilds the system message from current config, memory, plugins, and workspace AGENTS.md state.
-
#refresh_system_message_if_sources_changed! ⇒ Object
Refreshes runtime prompt state after workspace or config-owned prompt sources change.
- #refresh_system_message_if_workspace_agents_changed! ⇒ Object
- #restore_tool_output_artifact(tool_name:, content:, created_at: nil) ⇒ Object
- #store_tool_output_artifact(tool_name:, content:) ⇒ Object
- #tool_output_artifact_id_for(tool_name:, content:) ⇒ Object
- #update_execution_profile_context!(context) ⇒ Object
- #update_runtime_context!(provider: nil, model:, reasoning_effort:, refresh: true) ⇒ Object
Constructor Details
#initialize(system_message: DEFAULT_SYSTEM_MESSAGE, messages: [], read_paths: [], on_append: nil, on_compact: nil, on_tool_execution: nil, on_runtime_update: nil, workspace_root: Dir.pwd, compaction_system_message: DEFAULT_SYSTEM_MESSAGE, provider: nil, model: nil, reasoning_effort: nil, memory_context: nil, session_memories: [], last_memory_retrieval: nil, plugin_registry: nil, execution_profile_context: nil, project_skill_paths: nil) ⇒ Conversation
Returns a new instance of Conversation.
69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 |
# File 'lib/kward/conversation.rb', line 69 def initialize(system_message: DEFAULT_SYSTEM_MESSAGE, messages: [], read_paths: [], on_append: nil, on_compact: nil, on_tool_execution: nil, on_runtime_update: nil, workspace_root: Dir.pwd, compaction_system_message: DEFAULT_SYSTEM_MESSAGE, provider: nil, model: nil, reasoning_effort: nil, memory_context: nil, session_memories: [], last_memory_retrieval: nil, plugin_registry: nil, execution_profile_context: nil, project_skill_paths: nil) @workspace_root = ConfigFiles.canonical_workspace_root(workspace_root) @provider = provider @model = model @reasoning_effort = reasoning_effort @plugin_registry = plugin_registry @project_skill_paths = project_skill_paths @execution_profile_context = execution_profile_context.to_s.empty? ? nil : execution_profile_context.to_s.freeze @messages = [] , = () if .equal?(DEFAULT_SYSTEM_MESSAGE) if && @execution_profile_context.to_s.empty? = else @last_plugin_prompt_context = plugin_prompt_context = Prompts.(workspace_root: @workspace_root, model: @model, reasoning_effort: @reasoning_effort, now: prompt_time, memory_context: memory_context, plugin_context: @last_plugin_prompt_context, execution_profile_context: @execution_profile_context, project_skill_paths: @project_skill_paths) end end @system_message = @system_message_enabled = !@system_message.nil? if .equal?(DEFAULT_SYSTEM_MESSAGE) = @system_message_enabled ? Prompts.(workspace_root: @workspace_root, include_workspace_personality: false, model: @model, reasoning_effort: @reasoning_effort, now: prompt_time, execution_profile_context: @execution_profile_context, project_skill_paths: @project_skill_paths) : nil end @compaction_system_message = @workspace_agents_mtime = workspace_agents_mtime @system_prompt_sources_fingerprint = ConfigFiles.system_prompt_sources_fingerprint @last_entry_compaction = false @memory_context = memory_context @session_memories = Array(session_memories) @last_memory_retrieval = last_memory_retrieval @tool_output_artifacts = {} @context_budget_meter = ContextBudgetMeter.new @messages.concat() @read_paths = Set.new(read_paths) @on_append = on_append @on_compact = on_compact @on_tool_execution = on_tool_execution @on_runtime_update = on_runtime_update @on_system_message_change = nil end |
Instance Attribute Details
#compaction_system_message ⇒ Hash? (readonly)
Returns system prompt used when summarizing old context.
35 36 37 |
# File 'lib/kward/conversation.rb', line 35 def @compaction_system_message end |
#context_budget_meter ⇒ ContextBudgetMeter (readonly)
Returns runtime context savings for this conversation.
65 66 67 |
# File 'lib/kward/conversation.rb', line 65 def context_budget_meter @context_budget_meter end |
#execution_profile_context ⇒ String? (readonly)
Returns plugin prompt context used in the current system prompt.
61 62 63 |
# File 'lib/kward/conversation.rb', line 61 def execution_profile_context @execution_profile_context end |
#last_memory_retrieval ⇒ Hash?
Returns metadata for the last memory retrieval attached to the session.
57 58 59 |
# File 'lib/kward/conversation.rb', line 57 def last_memory_retrieval @last_memory_retrieval end |
#last_plugin_prompt_context ⇒ String? (readonly)
Returns plugin prompt context used in the current system prompt.
61 62 63 |
# File 'lib/kward/conversation.rb', line 61 def last_plugin_prompt_context @last_plugin_prompt_context end |
#memory_context ⇒ String?
Returns memory prompt context injected into refreshed system messages.
55 56 57 |
# File 'lib/kward/conversation.rb', line 55 def memory_context @memory_context end |
#messages ⇒ Array<Hash> (readonly)
Returns ordered durable transcript entries, excluding runtime system prompt state.
27 28 29 |
# File 'lib/kward/conversation.rb', line 27 def @messages end |
#model ⇒ String? (readonly)
Returns model id captured for session/runtime prompts.
39 40 41 |
# File 'lib/kward/conversation.rb', line 39 def model @model end |
#on_append ⇒ Proc?
Returns persistence callback invoked after appending a message.
45 46 47 |
# File 'lib/kward/conversation.rb', line 45 def on_append @on_append end |
#on_compact ⇒ Proc?
Returns persistence callback invoked after compaction replaces history.
47 48 49 |
# File 'lib/kward/conversation.rb', line 47 def on_compact @on_compact end |
#on_runtime_update ⇒ Proc?
Returns callback invoked when runtime metadata should be persisted.
51 52 53 |
# File 'lib/kward/conversation.rb', line 51 def on_runtime_update @on_runtime_update end |
#on_system_message_change ⇒ Proc?
Returns callback invoked when the system prompt runtime state changes.
53 54 55 |
# File 'lib/kward/conversation.rb', line 53 def @on_system_message_change end |
#on_tool_execution ⇒ Proc?
Returns callback invoked when a tool execution record should be persisted.
49 50 51 |
# File 'lib/kward/conversation.rb', line 49 def on_tool_execution @on_tool_execution end |
#plugin_registry ⇒ PluginRegistry?
Returns registry used to collect plugin prompt context.
59 60 61 |
# File 'lib/kward/conversation.rb', line 59 def plugin_registry @plugin_registry end |
#project_skill_paths ⇒ Array<String>? (readonly)
Returns explicitly permitted project skill paths.
67 68 69 |
# File 'lib/kward/conversation.rb', line 67 def project_skill_paths @project_skill_paths end |
#provider ⇒ String? (readonly)
Returns provider captured for session/runtime prompts.
37 38 39 |
# File 'lib/kward/conversation.rb', line 37 def provider @provider end |
#read_paths ⇒ Set<String> (readonly)
Returns resolved paths read by file tools during the active context.
31 32 33 |
# File 'lib/kward/conversation.rb', line 31 def read_paths @read_paths end |
#reasoning_effort ⇒ String? (readonly)
Returns reasoning effort captured for session/runtime prompts.
41 42 43 |
# File 'lib/kward/conversation.rb', line 41 def reasoning_effort @reasoning_effort end |
#session_memories ⇒ Array<Hash> (readonly)
Returns memories scoped to this conversation session.
43 44 45 |
# File 'lib/kward/conversation.rb', line 43 def session_memories @session_memories end |
#system_message ⇒ Hash? (readonly)
Returns current system prompt included when building provider request context.
29 30 31 |
# File 'lib/kward/conversation.rb', line 29 def @system_message end |
#tool_output_artifacts ⇒ Hash (readonly)
Returns original large tool outputs retained outside model context.
63 64 65 |
# File 'lib/kward/conversation.rb', line 63 def tool_output_artifacts @tool_output_artifacts end |
#workspace_root ⇒ String (readonly)
Returns canonical workspace root used for prompts and file guardrails.
33 34 35 |
# File 'lib/kward/conversation.rb', line 33 def workspace_root @workspace_root end |
Class Method Details
.normalize_tool_content(content) ⇒ Object
Tool results may arrive as ASCII-8BIT (BINARY) strings, e.g. from Net::HTTP response bodies or shell command output. When such a string is later concatenated with a UTF-8 string containing non-ASCII bytes (during compaction or JSON serialization), Ruby raises Encoding::CompatibilityError. Re-tag BINARY strings as UTF-8 when the bytes are valid UTF-8; otherwise scrub so the content is always serializable and concatenable.
142 143 144 145 146 147 |
# File 'lib/kward/conversation.rb', line 142 def self.normalize_tool_content(content) return content unless content.is_a?(String) && content.encoding == Encoding::ASCII_8BIT probe = content.dup.force_encoding(Encoding::UTF_8) probe.valid_encoding? ? probe : probe.scrub end |
.tool_output_artifact_id(tool_name:, content:) ⇒ Object
174 175 176 177 |
# File 'lib/kward/conversation.rb', line 174 def self.tool_output_artifact_id(tool_name:, content:) digest = Digest::SHA256.hexdigest("#{tool_name}\0#{content}")[0, 16] "toolout_#{digest}" end |
Instance Method Details
#append_assistant(message) ⇒ Object
121 122 123 124 |
# File 'lib/kward/conversation.rb', line 121 def append_assistant() = { role: "assistant", content: } if .is_a?(String) () end |
#append_tool(tool_call_id:, name:, content:) ⇒ Object
126 127 128 129 130 131 132 133 |
# File 'lib/kward/conversation.rb', line 126 def append_tool(tool_call_id:, name:, content:) ({ role: "tool", tool_call_id: tool_call_id, name: name, content: self.class.normalize_tool_content(content) }) end |
#append_tool_execution(tool_call:, content:) ⇒ Object
149 150 151 |
# File 'lib/kward/conversation.rb', line 149 def append_tool_execution(tool_call:, content:) @on_tool_execution&.call(tool_call, content) end |
#append_user(content, display_content: nil) ⇒ Object
Appends a user message and normalizes image attachment syntax.
display_content is transcript/UI text for cases where the model input is
expanded, decorated, or contains encoded attachment content.
114 115 116 117 118 119 |
# File 'lib/kward/conversation.rb', line 114 def append_user(content, display_content: nil) content = ImageAttachments.content_from_text(content) unless content.is_a?(Array) = { role: "user", content: content } [:display_content] = display_content.to_s unless display_content.nil? () end |
#compact!(summary, compaction_summary: false, first_kept_entry_id: nil, tokens_before: nil, from_hook: false, details: {}, keep_messages: []) ⇒ Object
Replaces most transcript entries with a compaction summary and optional recent messages to keep.
Compaction clears read-before-write state because file contents observed before the summary may no longer be represented exactly in the active context. Callers that need file mutation after compaction should read files again through the normal tools.
252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 |
# File 'lib/kward/conversation.rb', line 252 def compact!(summary, compaction_summary: false, first_kept_entry_id: nil, tokens_before: nil, from_hook: false, details: {}, keep_messages: []) = if compaction_summary { role: "compactionSummary", summary: summary.to_s } else { role: "assistant", content: summary.to_s } end if compaction_summary [:first_kept_entry_id] = first_kept_entry_id if first_kept_entry_id [:tokens_before] = tokens_before if tokens_before [:from_hook] = from_hook [:details] = details || {} end @messages = [] @messages << @messages.concat(Array()) @read_paths.clear @last_entry_compaction = true @on_compact&.call() end |
#context_messages ⇒ Array<Hash>
Returns provider request context: current system prompt plus durable transcript.
180 181 182 |
# File 'lib/kward/conversation.rb', line 180 def @system_message ? [@system_message] + @messages : @messages.dup end |
#last_entry_compaction? ⇒ Boolean
273 274 275 |
# File 'lib/kward/conversation.rb', line 273 def last_entry_compaction? @last_entry_compaction end |
#last_file_change_result ⇒ Object
281 282 283 284 285 |
# File 'lib/kward/conversation.rb', line 281 def last_file_change_result @messages.select do || MessageAccess.role() == "tool" && ["write_file", "edit_file"].include?(MessageAccess.name()) end.last end |
#mark_last_entry_compaction! ⇒ Object
277 278 279 |
# File 'lib/kward/conversation.rb', line 277 def mark_last_entry_compaction! @last_entry_compaction = true end |
#mark_read(path) ⇒ Object
234 235 236 |
# File 'lib/kward/conversation.rb', line 234 def mark_read(path) @read_paths << path end |
#persist_runtime_context! ⇒ Object
215 216 217 |
# File 'lib/kward/conversation.rb', line 215 def persist_runtime_context! @on_runtime_update&.call(provider: @provider, model: @model, reasoning_effort: @reasoning_effort) end |
#plugin_prompt_context ⇒ Object
238 239 240 241 242 243 |
# File 'lib/kward/conversation.rb', line 238 def plugin_prompt_context return nil unless plugin_registry context = PluginRegistry::Context.new(conversation: self, workspace_root: @workspace_root) plugin_registry.prompt_context(context) end |
#refresh_system_message! ⇒ Object
Rebuilds the system message from current config, memory, plugins, and workspace AGENTS.md state.
Conversations created with system_message: nil keep system prompts
disabled; this preserves tests, compaction summaries, and imported
transcripts that intentionally do not include runtime instructions.
190 191 192 193 194 195 196 197 198 199 200 201 |
# File 'lib/kward/conversation.rb', line 190 def return nil unless @system_message_enabled @last_plugin_prompt_context = plugin_prompt_context replacement = Prompts.(workspace_root: @workspace_root, model: @model, reasoning_effort: @reasoning_effort, now: prompt_time, memory_context: @memory_context, plugin_context: @last_plugin_prompt_context, execution_profile_context: @execution_profile_context, project_skill_paths: @project_skill_paths) @system_message = replacement @on_system_message_change&.call(replacement) @compaction_system_message = Prompts.(workspace_root: @workspace_root, include_workspace_personality: false, model: @model, reasoning_effort: @reasoning_effort, now: prompt_time, execution_profile_context: @execution_profile_context, project_skill_paths: @project_skill_paths) @workspace_agents_mtime = workspace_agents_mtime @system_prompt_sources_fingerprint = ConfigFiles.system_prompt_sources_fingerprint replacement end |
#refresh_system_message_if_sources_changed! ⇒ Object
Refreshes runtime prompt state after workspace or config-owned prompt sources change. Keep the older public method above as a compatibility alias for existing callers.
226 227 228 229 230 231 232 |
# File 'lib/kward/conversation.rb', line 226 def return unless @system_message_enabled changed = workspace_agents_mtime != @workspace_agents_mtime || ConfigFiles.system_prompt_sources_fingerprint != @system_prompt_sources_fingerprint if changed end |
#refresh_system_message_if_workspace_agents_changed! ⇒ Object
219 220 221 |
# File 'lib/kward/conversation.rb', line 219 def end |
#restore_tool_output_artifact(tool_name:, content:, created_at: nil) ⇒ Object
161 162 163 164 165 166 167 168 169 170 171 172 |
# File 'lib/kward/conversation.rb', line 161 def restore_tool_output_artifact(tool_name:, content:, created_at: nil) text = self.class.normalize_tool_content(content) id = tool_output_artifact_id_for(tool_name: tool_name, content: text) @tool_output_artifacts[id] = { id: id, tool_name: tool_name, content: text, bytes: text.bytesize, created_at: created_at || Time.now.utc } id end |
#store_tool_output_artifact(tool_name:, content:) ⇒ Object
157 158 159 |
# File 'lib/kward/conversation.rb', line 157 def store_tool_output_artifact(tool_name:, content:) restore_tool_output_artifact(tool_name: tool_name, content: content, created_at: Time.now.utc) end |
#tool_output_artifact_id_for(tool_name:, content:) ⇒ Object
153 154 155 |
# File 'lib/kward/conversation.rb', line 153 def tool_output_artifact_id_for(tool_name:, content:) self.class.tool_output_artifact_id(tool_name: tool_name, content: self.class.normalize_tool_content(content)) end |
#update_execution_profile_context!(context) ⇒ Object
203 204 205 206 |
# File 'lib/kward/conversation.rb', line 203 def update_execution_profile_context!(context) @execution_profile_context = context.to_s.empty? ? nil : context.to_s.freeze end |
#update_runtime_context!(provider: nil, model:, reasoning_effort:, refresh: true) ⇒ Object
208 209 210 211 212 213 |
# File 'lib/kward/conversation.rb', line 208 def update_runtime_context!(provider: nil, model:, reasoning_effort:, refresh: true) @provider = provider unless provider.to_s.empty? @model = model @reasoning_effort = reasoning_effort if refresh end |