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) ⇒ Conversation

Returns a new instance of Conversation.



67
68
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
# File 'lib/kward/conversation.rb', line 67

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)
  @workspace_root = ConfigFiles.canonical_workspace_root(workspace_root)
  @provider = provider
  @model = model
  @reasoning_effort = reasoning_effort
  @plugin_registry = plugin_registry
  @messages = []
  restored_system_message, transcript_messages = split_system_message(messages)
  if system_message.equal?(DEFAULT_SYSTEM_MESSAGE)
    if restored_system_message
      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)
    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) : nil
  end
  @compaction_system_message = compaction_system_message
  @workspace_agents_mtime = workspace_agents_mtime
  @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

#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

#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.



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

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



169
170
171
172
# File 'lib/kward/conversation.rb', line 169

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



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

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



121
122
123
124
125
126
127
128
# File 'lib/kward/conversation.rb', line 121

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



144
145
146
# File 'lib/kward/conversation.rb', line 144

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.



109
110
111
112
113
114
# File 'lib/kward/conversation.rb', line 109

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.



230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
# File 'lib/kward/conversation.rb', line 230

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



175
176
177
# File 'lib/kward/conversation.rb', line 175

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

#last_entry_compaction?Boolean

Returns:

  • (Boolean)


251
252
253
# File 'lib/kward/conversation.rb', line 251

def last_entry_compaction?
  @last_entry_compaction
end

#last_file_change_resultObject



259
260
261
262
263
# File 'lib/kward/conversation.rb', line 259

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



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

def mark_last_entry_compaction!
  @last_entry_compaction = true
end

#mark_read(path) ⇒ Object



212
213
214
# File 'lib/kward/conversation.rb', line 212

def mark_read(path)
  @read_paths << path
end

#persist_runtime_context!Object



204
205
206
# File 'lib/kward/conversation.rb', line 204

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

#plugin_prompt_contextObject



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

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.



185
186
187
188
189
190
191
192
193
194
195
# File 'lib/kward/conversation.rb', line 185

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)
  @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)
  @workspace_agents_mtime = workspace_agents_mtime
  replacement
end

#refresh_system_message_if_workspace_agents_changed!Object



208
209
210
# File 'lib/kward/conversation.rb', line 208

def refresh_system_message_if_workspace_agents_changed!
  refresh_system_message! if @system_message_enabled && workspace_agents_mtime != @workspace_agents_mtime
end

#restore_tool_output_artifact(tool_name:, content:, created_at: nil) ⇒ Object



156
157
158
159
160
161
162
163
164
165
166
167
# File 'lib/kward/conversation.rb', line 156

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



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

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



148
149
150
# File 'lib/kward/conversation.rb', line 148

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_runtime_context!(provider: nil, model:, reasoning_effort:, refresh: true) ⇒ Object



197
198
199
200
201
202
# File 'lib/kward/conversation.rb', line 197

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