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.



64
65
66
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
# File 'lib/kward/conversation.rb', line 64

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 = {}
  @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



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

def compaction_system_message
  @compaction_system_message
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



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

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



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

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



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

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



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

def messages
  @messages
end

#modelString? (readonly)

Returns model id captured for session/runtime prompts.

Returns:

  • (String, nil)

    model id captured for session/runtime prompts



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

def model
  @model
end

#on_appendProc?

Returns persistence callback invoked after appending a message.

Returns:

  • (Proc, nil)

    persistence callback invoked after appending a message



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

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



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

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



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

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



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

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



48
49
50
# File 'lib/kward/conversation.rb', line 48

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



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

def plugin_registry
  @plugin_registry
end

#providerString? (readonly)

Returns provider captured for session/runtime prompts.

Returns:

  • (String, nil)

    provider captured for session/runtime prompts



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

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



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

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



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

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



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

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



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

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



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

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



32
33
34
# File 'lib/kward/conversation.rb', line 32

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.



133
134
135
136
137
138
# File 'lib/kward/conversation.rb', line 133

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



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

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



112
113
114
115
# File 'lib/kward/conversation.rb', line 112

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



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

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



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

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.



105
106
107
108
109
110
# File 'lib/kward/conversation.rb', line 105

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.



222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
# File 'lib/kward/conversation.rb', line 222

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



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

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

#last_entry_compaction?Boolean

Returns:

  • (Boolean)


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

def last_entry_compaction?
  @last_entry_compaction
end

#last_file_change_resultObject



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

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



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

def mark_last_entry_compaction!
  @last_entry_compaction = true
end

#mark_read(path) ⇒ Object



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

def mark_read(path)
  @read_paths << path
end

#persist_runtime_context!Object



196
197
198
# File 'lib/kward/conversation.rb', line 196

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

#plugin_prompt_contextObject



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

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.



177
178
179
180
181
182
183
184
185
186
187
# File 'lib/kward/conversation.rb', line 177

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



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

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

#store_tool_output_artifact(tool_name:, content:) ⇒ Object



148
149
150
151
152
153
154
155
156
157
158
159
# File 'lib/kward/conversation.rb', line 148

def store_tool_output_artifact(tool_name:, content:)
  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: Time.now.utc
  }
  id
end

#tool_output_artifact_id_for(tool_name:, content:) ⇒ Object



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

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:) ⇒ Object



189
190
191
192
193
194
# File 'lib/kward/conversation.rb', line 189

def update_runtime_context!(provider: nil, model:, reasoning_effort:)
  @provider = provider unless provider.to_s.empty?
  @model = model
  @reasoning_effort = reasoning_effort
  refresh_system_message!
end