Skip to content
Kward Search API index

Class: Kward::Conversation

Inherits:
Object
  • Object
show all
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

Class Method Summary collapse

Instance Method Summary collapse

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 = []
  restored_system_message, transcript_messages = split_system_message(messages)
  if system_message.equal?(DEFAULT_SYSTEM_MESSAGE)
    if restored_system_message && @execution_profile_context.to_s.empty?
      system_message = restored_system_message
    else
      @last_plugin_prompt_context = plugin_prompt_context
      system_message = Prompts.system_message(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
  @system_message_enabled = !@system_message.nil?
  if compaction_system_message.equal?(DEFAULT_SYSTEM_MESSAGE)
    compaction_system_message = @system_message_enabled ? Prompts.system_message(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 = 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(transcript_messages)
  @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_messageHash? (readonly)

Returns system prompt used when summarizing old context.

Returns:

  • (Hash, nil)

    system prompt used when summarizing old context



35
36
37
# File 'lib/kward/conversation.rb', line 35

def compaction_system_message
  @compaction_system_message
end

#context_budget_meterContextBudgetMeter (readonly)

Returns runtime context savings for this conversation.

Returns:



65
66
67
# File 'lib/kward/conversation.rb', line 65

def context_budget_meter
  @context_budget_meter
end

#execution_profile_contextString? (readonly)

Returns plugin prompt context used in the current system prompt.

Returns:

  • (String, nil)

    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_retrievalHash?

Returns metadata for the last memory retrieval attached to the session.

Returns:

  • (Hash, nil)

    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_contextString? (readonly)

Returns plugin prompt context used in the current system prompt.

Returns:

  • (String, nil)

    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_contextString?

Returns memory prompt context injected into refreshed system messages.

Returns:

  • (String, nil)

    memory prompt context injected into refreshed system messages



55
56
57
# File 'lib/kward/conversation.rb', line 55

def memory_context
  @memory_context
end

#messagesArray<Hash> (readonly)

Returns ordered durable transcript entries, excluding runtime system prompt state.

Returns:

  • (Array<Hash>)

    ordered durable transcript entries, excluding runtime system prompt state



27
28
29
# File 'lib/kward/conversation.rb', line 27

def messages
  @messages
end

#modelString? (readonly)

Returns model id captured for session/runtime prompts.

Returns:

  • (String, nil)

    model id captured for session/runtime prompts



39
40
41
# File 'lib/kward/conversation.rb', line 39

def model
  @model
end

#on_appendProc?

Returns persistence callback invoked after appending a message.

Returns:

  • (Proc, nil)

    persistence callback invoked after appending a message



45
46
47
# File 'lib/kward/conversation.rb', line 45

def on_append
  @on_append
end

#on_compactProc?

Returns persistence callback invoked after compaction replaces history.

Returns:

  • (Proc, nil)

    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_updateProc?

Returns callback invoked when runtime metadata should be persisted.

Returns:

  • (Proc, nil)

    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_changeProc?

Returns callback invoked when the system prompt runtime state changes.

Returns:

  • (Proc, nil)

    callback invoked when the system prompt runtime state changes



53
54
55
# File 'lib/kward/conversation.rb', line 53

def on_system_message_change
  @on_system_message_change
end

#on_tool_executionProc?

Returns callback invoked when a tool execution record should be persisted.

Returns:

  • (Proc, nil)

    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_registryPluginRegistry?

Returns registry used to collect plugin prompt context.

Returns:

  • (PluginRegistry, nil)

    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_pathsArray<String>? (readonly)

Returns explicitly permitted project skill paths.

Returns:

  • (Array<String>, nil)

    explicitly permitted project skill paths



67
68
69
# File 'lib/kward/conversation.rb', line 67

def project_skill_paths
  @project_skill_paths
end

#providerString? (readonly)

Returns provider captured for session/runtime prompts.

Returns:

  • (String, nil)

    provider captured for session/runtime prompts



37
38
39
# File 'lib/kward/conversation.rb', line 37

def provider
  @provider
end

#read_pathsSet<String> (readonly)

Returns resolved paths read by file tools during the active context.

Returns:

  • (Set<String>)

    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_effortString? (readonly)

Returns reasoning effort captured for session/runtime prompts.

Returns:

  • (String, nil)

    reasoning effort captured for session/runtime prompts



41
42
43
# File 'lib/kward/conversation.rb', line 41

def reasoning_effort
  @reasoning_effort
end

#session_memoriesArray<Hash> (readonly)

Returns memories scoped to this conversation session.

Returns:

  • (Array<Hash>)

    memories scoped to this conversation session



43
44
45
# File 'lib/kward/conversation.rb', line 43

def session_memories
  @session_memories
end

#system_messageHash? (readonly)

Returns current system prompt included when building provider request context.

Returns:

  • (Hash, nil)

    current system prompt included when building provider request context



29
30
31
# File 'lib/kward/conversation.rb', line 29

def system_message
  @system_message
end

#tool_output_artifactsHash (readonly)

Returns original large tool outputs retained outside model context.

Returns:

  • (Hash)

    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_rootString (readonly)

Returns canonical workspace root used for prompts and file guardrails.

Returns:

  • (String)

    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(message)
  message = { role: "assistant", content: message } if message.is_a?(String)
  append_message(message)
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:)
  append_message({
    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)
  message = { role: "user", content: content }
  message[:display_content] = display_content.to_s unless display_content.nil?
  append_message(message)
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: [])
  message = if compaction_summary
              { role: "compactionSummary", summary: summary.to_s }
            else
              { role: "assistant", content: summary.to_s }
            end
  if compaction_summary
    message[:first_kept_entry_id] = first_kept_entry_id if first_kept_entry_id
    message[:tokens_before] = tokens_before if tokens_before
    message[:from_hook] = from_hook
    message[:details] = details || {}
  end
  @messages = []
  @messages << message
  @messages.concat(Array(keep_messages))
  @read_paths.clear
  @last_entry_compaction = true
  @on_compact&.call(message)
  message
end

#context_messagesArray<Hash>

Returns provider request context: current system prompt plus durable transcript.

Returns:

  • (Array<Hash>)

    provider request context: current system prompt plus durable transcript



180
181
182
# File 'lib/kward/conversation.rb', line 180

def context_messages
  @system_message ? [@system_message] + @messages : @messages.dup
end

#last_entry_compaction?Boolean

Returns:

  • (Boolean)


273
274
275
# File 'lib/kward/conversation.rb', line 273

def last_entry_compaction?
  @last_entry_compaction
end

#last_file_change_resultObject



281
282
283
284
285
# File 'lib/kward/conversation.rb', line 281

def last_file_change_result
  @messages.select do |message|
    MessageAccess.role(message) == "tool" && ["write_file", "edit_file"].include?(MessageAccess.name(message))
  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_contextObject



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 refresh_system_message!
  return nil unless @system_message_enabled

  @last_plugin_prompt_context = plugin_prompt_context
  replacement = Prompts.system_message(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.system_message(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 refresh_system_message_if_sources_changed!
  return unless @system_message_enabled

  changed = workspace_agents_mtime != @workspace_agents_mtime ||
    ConfigFiles.system_prompt_sources_fingerprint != @system_prompt_sources_fingerprint
  refresh_system_message! if changed
end

#refresh_system_message_if_workspace_agents_changed!Object



219
220
221
# File 'lib/kward/conversation.rb', line 219

def refresh_system_message_if_workspace_agents_changed!
  refresh_system_message_if_sources_changed!
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
  refresh_system_message!
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
  refresh_system_message! if refresh
end