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.



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
109
110
# File 'lib/kward/conversation.rb', line 71

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_message ⇒ Hash? (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_meter ⇒ ContextBudgetMeter (readonly)

Returns runtime context savings for this conversation.

Returns:



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

def context_budget_meter
  @context_budget_meter
end

#execution_profile_context ⇒ String? (readonly)

Returns plugin prompt context used in the current system prompt.

Returns:

  • (String, nil) —

    plugin prompt context used in the current system prompt



63
64
65
# File 'lib/kward/conversation.rb', line 63

def execution_profile_context
  @execution_profile_context
end

#last_memory_retrieval ⇒ Hash?

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

Returns:

  • (Hash, nil) —

    metadata for the last memory retrieval attached to the session



59
60
61
# File 'lib/kward/conversation.rb', line 59

def last_memory_retrieval
  @last_memory_retrieval
end

#last_plugin_prompt_context ⇒ String? (readonly)

Returns plugin prompt context used in the current system prompt.

Returns:

  • (String, nil) —

    plugin prompt context used in the current system prompt



63
64
65
# File 'lib/kward/conversation.rb', line 63

def last_plugin_prompt_context
  @last_plugin_prompt_context
end

#memory_context ⇒ String?

Returns memory prompt context injected into refreshed system messages.

Returns:

  • (String, nil) —

    memory prompt context injected into refreshed system messages



57
58
59
# File 'lib/kward/conversation.rb', line 57

def memory_context
  @memory_context
end

#messages ⇒ Array<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

#model ⇒ String? (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_append ⇒ Proc?

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_compact ⇒ Proc?

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_update ⇒ Proc?

Returns callback invoked when runtime metadata should be persisted.

Returns:

  • (Proc, nil) —

    callback invoked when runtime metadata should be persisted



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

def on_runtime_update
  @on_runtime_update
end

#on_system_message_change ⇒ Proc?

Returns callback invoked when the system prompt runtime state changes.

Returns:

  • (Proc, nil) —

    callback invoked when the system prompt runtime state changes



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

def on_system_message_change
  @on_system_message_change
end

#on_tool_execution ⇒ Proc?

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

#on_turn_summary ⇒ Proc?

Returns callback for branch-anchored turn summary metadata.

Returns:

  • (Proc, nil) —

    callback for branch-anchored turn summary metadata



51
52
53
# File 'lib/kward/conversation.rb', line 51

def on_turn_summary
  @on_turn_summary
end

#plugin_registry ⇒ PluginRegistry?

Returns registry used to collect plugin prompt context.

Returns:

  • (PluginRegistry, nil) —

    registry used to collect plugin prompt context



61
62
63
# File 'lib/kward/conversation.rb', line 61

def plugin_registry
  @plugin_registry
end

#project_skill_paths ⇒ Array<String>? (readonly)

Returns explicitly permitted project skill paths.

Returns:

  • (Array<String>, nil) —

    explicitly permitted project skill paths



69
70
71
# File 'lib/kward/conversation.rb', line 69

def project_skill_paths
  @project_skill_paths
end

#provider ⇒ String? (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_paths ⇒ Set<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_effort ⇒ String? (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_memories ⇒ Array<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_message ⇒ Hash? (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_artifacts ⇒ Hash (readonly)

Returns original large tool outputs retained outside model context.

Returns:

  • (Hash) —

    original large tool outputs retained outside model context



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

def tool_output_artifacts
  @tool_output_artifacts
end

#workspace_root ⇒ String (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.



152
153
154
155
156
157
# File 'lib/kward/conversation.rb', line 152

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



184
185
186
187
# File 'lib/kward/conversation.rb', line 184

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



123
124
125
126
# File 'lib/kward/conversation.rb', line 123

def append_assistant(message)
  message = { role: "assistant", content: message } if message.is_a?(String)
  append_message(message)
end

#append_system_turn(request) ⇒ Object



201
202
203
# File 'lib/kward/conversation.rb', line 201

def append_system_turn(request)
  append_message(request.history_message)
end

#append_tool(tool_call_id:, name:, content:) ⇒ Object



136
137
138
139
140
141
142
143
# File 'lib/kward/conversation.rb', line 136

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



159
160
161
# File 'lib/kward/conversation.rb', line 159

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.



116
117
118
119
120
121
# File 'lib/kward/conversation.rb', line 116

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.



291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
# File 'lib/kward/conversation.rb', line 291

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_messages ⇒ Array<Hash>

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

Returns:

  • (Array<Hash>) —

    provider request context: current system prompt plus durable transcript



206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
# File 'lib/kward/conversation.rb', line 206

def context_messages
  instruction = { role: "system", content: @system_turn.system, system_turn: true } if @system_turn
  inserted = false
  messages = @messages.filter_map do |message|
    record = message[:plugin_system_turn] || message["plugin_system_turn"]
    next message.reject { |key, _| key.to_s == "turn_summary" } unless record
    next unless @system_turn && (record[:id] || record["id"]) == @system_turn.id

    inserted = true
    instruction
  end
  # Compaction can remove the history marker; reissue the current directive
  # after the compacted context rather than demoting it to the base prompt.
  messages << instruction if instruction && !inserted
  @system_message ? [@system_message] + messages : messages
end

#finish_turn(summary) ⇒ Object



128
129
130
131
132
133
134
# File 'lib/kward/conversation.rb', line 128

def finish_turn(summary)
  message = @messages.last
  return unless message

  message["turn_summary"] = summary
  @on_turn_summary&.call(message, summary)
end

#last_entry_compaction? ⇒ Boolean

Returns:

  • (Boolean)


312
313
314
# File 'lib/kward/conversation.rb', line 312

def last_entry_compaction?
  @last_entry_compaction
end

#last_file_change_result ⇒ Object



320
321
322
323
324
# File 'lib/kward/conversation.rb', line 320

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



316
317
318
# File 'lib/kward/conversation.rb', line 316

def mark_last_entry_compaction!
  @last_entry_compaction = true
end

#mark_read(path) ⇒ Object



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

def mark_read(path)
  @read_paths << path
end

#persist_runtime_context! ⇒ Object



254
255
256
# File 'lib/kward/conversation.rb', line 254

def persist_runtime_context!
  @on_runtime_update&.call(provider: @provider, model: @model, reasoning_effort: @reasoning_effort)
end

#plugin_prompt_context ⇒ Object



277
278
279
280
281
282
# File 'lib/kward/conversation.rb', line 277

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.



229
230
231
232
233
234
235
236
237
238
239
240
# File 'lib/kward/conversation.rb', line 229

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.



265
266
267
268
269
270
271
# File 'lib/kward/conversation.rb', line 265

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



258
259
260
# File 'lib/kward/conversation.rb', line 258

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



171
172
173
174
175
176
177
178
179
180
181
182
# File 'lib/kward/conversation.rb', line 171

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



167
168
169
# File 'lib/kward/conversation.rb', line 167

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



163
164
165
# File 'lib/kward/conversation.rb', line 163

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



242
243
244
245
# File 'lib/kward/conversation.rb', line 242

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



247
248
249
250
251
252
# File 'lib/kward/conversation.rb', line 247

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

#with_system_turn(request) ⇒ Object

Scopes plugin instructions to one host-owned turn, including retries. The overlay is deliberately not persisted or folded into the base prompt.



191
192
193
194
195
196
197
198
199
# File 'lib/kward/conversation.rb', line 191

def with_system_turn(request)
  previous = @system_turn
  raise ArgumentError, "A system-instruction turn is already active" if previous

  @system_turn = request
  yield
ensure
  @system_turn = previous
end