kward 0.67.1 → 0.68.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +20 -0
- data/Gemfile.lock +2 -2
- data/README.md +5 -5
- data/doc/authentication.md +24 -1
- data/doc/configuration.md +9 -2
- data/doc/extensibility.md +1 -1
- data/doc/getting-started.md +4 -6
- data/doc/plugins.md +0 -2
- data/doc/releasing.md +7 -8
- data/doc/rpc.md +6 -6
- data/doc/usage.md +5 -2
- data/doc/web-search.md +2 -2
- data/kward.gemspec +4 -0
- data/lib/kward/agent.rb +29 -2
- data/lib/kward/ansi.rb +3 -0
- data/lib/kward/auth/anthropic_oauth.rb +291 -0
- data/lib/kward/auth/file.rb +2 -0
- data/lib/kward/auth/github_oauth.rb +3 -0
- data/lib/kward/auth/openai_oauth.rb +4 -0
- data/lib/kward/auth/openrouter_api_key.rb +2 -0
- data/lib/kward/cancellation.rb +3 -0
- data/lib/kward/cli/auth_commands.rb +82 -0
- data/lib/kward/cli/commands.rb +222 -0
- data/lib/kward/cli/compaction.rb +25 -0
- data/lib/kward/cli/doctor.rb +121 -0
- data/lib/kward/cli/interactive_turn.rb +225 -0
- data/lib/kward/cli/memory_commands.rb +133 -0
- data/lib/kward/cli/plugins.rb +112 -0
- data/lib/kward/cli/prompt_interface.rb +132 -0
- data/lib/kward/cli/rendering.rb +389 -0
- data/lib/kward/cli/runtime_helpers.rb +159 -0
- data/lib/kward/cli/sessions.rb +376 -0
- data/lib/kward/cli/settings.rb +663 -0
- data/lib/kward/cli/slash_commands.rb +112 -0
- data/lib/kward/cli/stats.rb +64 -0
- data/lib/kward/cli/tool_summaries.rb +153 -0
- data/lib/kward/cli.rb +38 -2790
- data/lib/kward/cli_transcript_formatter.rb +4 -7
- data/lib/kward/clipboard.rb +1 -0
- data/lib/kward/compaction/file_operation_tracker.rb +3 -0
- data/lib/kward/compactor.rb +29 -7
- data/lib/kward/config_files.rb +33 -24
- data/lib/kward/conversation.rb +70 -5
- data/lib/kward/events.rb +2 -0
- data/lib/kward/export_path.rb +2 -0
- data/lib/kward/image_attachments.rb +2 -0
- data/lib/kward/markdown_transcript.rb +2 -0
- data/lib/kward/memory/manager.rb +13 -0
- data/lib/kward/message_access.rb +23 -2
- data/lib/kward/message_text.rb +45 -0
- data/lib/kward/model/chat_invocation.rb +2 -0
- data/lib/kward/model/client.rb +295 -77
- data/lib/kward/model/context_overflow.rb +2 -0
- data/lib/kward/model/context_usage.rb +3 -0
- data/lib/kward/model/model_info.rb +143 -4
- data/lib/kward/model/payloads.rb +166 -13
- data/lib/kward/model/retry_message.rb +2 -0
- data/lib/kward/model/stream_parser.rb +129 -0
- data/lib/kward/pan/server.rb +3 -1
- data/lib/kward/plugin_registry.rb +12 -0
- data/lib/kward/private_file.rb +2 -0
- data/lib/kward/prompt_interface/banner.rb +3 -0
- data/lib/kward/prompt_interface/composer_controller.rb +262 -0
- data/lib/kward/prompt_interface/composer_renderer.rb +172 -0
- data/lib/kward/prompt_interface/composer_state.rb +221 -0
- data/lib/kward/prompt_interface/key_handler.rb +365 -0
- data/lib/kward/prompt_interface/layout.rb +31 -0
- data/lib/kward/prompt_interface/overlay_renderer.rb +111 -0
- data/lib/kward/prompt_interface/prompt_renderer.rb +91 -0
- data/lib/kward/prompt_interface/question_prompt.rb +328 -0
- data/lib/kward/prompt_interface/runtime_state.rb +59 -0
- data/lib/kward/prompt_interface/screen.rb +186 -0
- data/lib/kward/prompt_interface/selection_prompt.rb +242 -0
- data/lib/kward/prompt_interface/slash_overlay.rb +102 -0
- data/lib/kward/prompt_interface/stream_state.rb +65 -0
- data/lib/kward/prompt_interface/transcript_buffer.rb +85 -0
- data/lib/kward/prompt_interface/transcript_renderer.rb +142 -0
- data/lib/kward/prompt_interface.rb +69 -1832
- data/lib/kward/prompts/commands.rb +2 -0
- data/lib/kward/prompts/templates.rb +3 -0
- data/lib/kward/prompts.rb +2 -0
- data/lib/kward/question_contract.rb +66 -0
- data/lib/kward/resources/avatar_kward_logo.rb +2 -0
- data/lib/kward/resources/pixel_logo.rb +2 -0
- data/lib/kward/rpc/attachment_normalizer.rb +60 -0
- data/lib/kward/rpc/auth_manager.rb +65 -11
- data/lib/kward/rpc/config_manager.rb +11 -0
- data/lib/kward/rpc/prompt_bridge.rb +5 -26
- data/lib/kward/rpc/redactor.rb +3 -0
- data/lib/kward/rpc/runtime_payloads.rb +4 -1
- data/lib/kward/rpc/server.rb +36 -9
- data/lib/kward/rpc/session_manager.rb +121 -345
- data/lib/kward/rpc/session_metrics.rb +68 -0
- data/lib/kward/rpc/session_tree.rb +48 -0
- data/lib/kward/rpc/session_tree_rows.rb +208 -0
- data/lib/kward/rpc/tool_event_normalizer.rb +3 -0
- data/lib/kward/rpc/tool_metadata.rb +3 -0
- data/lib/kward/rpc/transcript_normalizer.rb +3 -0
- data/lib/kward/rpc/transport.rb +3 -0
- data/lib/kward/session_diff.rb +2 -0
- data/lib/kward/session_store.rb +114 -24
- data/lib/kward/session_trash.rb +1 -0
- data/lib/kward/session_tree_renderer.rb +8 -41
- data/lib/kward/session_tree_tool_display.rb +56 -0
- data/lib/kward/skills/registry.rb +3 -0
- data/lib/kward/starter_pack_installer.rb +1 -0
- data/lib/kward/steering.rb +2 -0
- data/lib/kward/telemetry/logger.rb +3 -0
- data/lib/kward/telemetry/stats.rb +3 -0
- data/lib/kward/tools/ask_user_question.rb +20 -32
- data/lib/kward/tools/base.rb +8 -0
- data/lib/kward/tools/code_search.rb +5 -0
- data/lib/kward/tools/edit_file.rb +5 -0
- data/lib/kward/tools/list_directory.rb +5 -0
- data/lib/kward/tools/read_file.rb +5 -0
- data/lib/kward/tools/read_skill.rb +5 -0
- data/lib/kward/tools/registry.rb +33 -2
- data/lib/kward/tools/run_shell_command.rb +5 -0
- data/lib/kward/tools/search/code.rb +7 -0
- data/lib/kward/tools/search/web.rb +17 -14
- data/lib/kward/tools/tool_call.rb +25 -5
- data/lib/kward/tools/web_search.rb +7 -1
- data/lib/kward/tools/write_file.rb +5 -0
- data/lib/kward/transcript_export.rb +2 -0
- data/lib/kward/version.rb +2 -1
- data/lib/kward/workspace.rb +45 -5
- metadata +43 -1
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
require "base64"
|
|
2
1
|
require "securerandom"
|
|
3
2
|
require "thread"
|
|
4
3
|
require "time"
|
|
@@ -13,6 +12,8 @@ require_relative "../events"
|
|
|
13
12
|
require_relative "../export_path"
|
|
14
13
|
require_relative "../memory/manager"
|
|
15
14
|
require_relative "../message_access"
|
|
15
|
+
require_relative "../message_text"
|
|
16
|
+
require_relative "../session_tree_tool_display"
|
|
16
17
|
require_relative "../model/model_info"
|
|
17
18
|
require_relative "../plugin_registry"
|
|
18
19
|
require_relative "../prompts/commands"
|
|
@@ -23,17 +24,36 @@ require_relative "../tools/tool_call"
|
|
|
23
24
|
require_relative "../tools/registry"
|
|
24
25
|
require_relative "../transcript_export"
|
|
25
26
|
require_relative "../workspace"
|
|
27
|
+
require_relative "attachment_normalizer"
|
|
28
|
+
require_relative "config_manager"
|
|
26
29
|
require_relative "prompt_bridge"
|
|
27
30
|
require_relative "runtime_payloads"
|
|
31
|
+
require_relative "session_metrics"
|
|
32
|
+
require_relative "session_tree"
|
|
33
|
+
require_relative "session_tree_rows"
|
|
28
34
|
require_relative "tool_event_normalizer"
|
|
29
35
|
require_relative "transcript_normalizer"
|
|
30
36
|
|
|
37
|
+
# Namespace for the Kward CLI agent runtime.
|
|
31
38
|
module Kward
|
|
39
|
+
# JSON-RPC backend namespace used by UI clients.
|
|
32
40
|
module RPC
|
|
41
|
+
# Owns RPC-visible session lifecycle, async turn queues, and frontend events.
|
|
42
|
+
#
|
|
43
|
+
# `Server` handles JSON-RPC framing/dispatch; `SessionManager` handles the
|
|
44
|
+
# product state behind those methods. It creates/resumes `SessionStore`
|
|
45
|
+
# sessions, builds agents with RPC prompt bridges, serializes turn events for
|
|
46
|
+
# clients, coordinates cancellation and follow-up queues, and integrates
|
|
47
|
+
# memory/plugin hooks for RPC sessions.
|
|
48
|
+
#
|
|
49
|
+
# Keep JSON-RPC wire shape normalization in the `RPC::*Normalizer` classes,
|
|
50
|
+
# persistence in `SessionStore`, and model/tool behavior in `Agent` and
|
|
51
|
+
# `ToolRegistry`. This class should coordinate those pieces rather than own
|
|
52
|
+
# their low-level mechanics.
|
|
33
53
|
class SessionManager
|
|
34
54
|
RECENT_EVENT_LIMIT = 1_000
|
|
35
|
-
RPC_ATTACHMENT_MAX_BYTES =
|
|
36
|
-
RPC_IMAGE_MIME_TYPES =
|
|
55
|
+
RPC_ATTACHMENT_MAX_BYTES = AttachmentNormalizer::MAX_BYTES
|
|
56
|
+
RPC_IMAGE_MIME_TYPES = AttachmentNormalizer::IMAGE_MIME_TYPES
|
|
37
57
|
STREAMING_BEHAVIORS = ["newTurn", "followUp", "steer"].freeze
|
|
38
58
|
FOOTER_REFRESH_INTERVAL = 1.0
|
|
39
59
|
WORKER_STOP = Object.new.freeze
|
|
@@ -41,17 +61,32 @@ module Kward
|
|
|
41
61
|
RpcSession = Struct.new(:id, :workspace_root, :store, :session, :conversation, :agent, :tool_registry, :prompt, :plugin_output, :queue, :worker, :running_turn_id, :footer_worker, :last_footer_text, keyword_init: true)
|
|
42
62
|
Turn = Struct.new(:id, :session_id, :input, :display_input, :status, :cancel_requested, :cancellation, :created_at, :started_at, :finished_at, :events, :next_sequence, :error, :streaming_behavior, :plugin_command_name, :plugin_arguments, :steering, keyword_init: true)
|
|
43
63
|
|
|
44
|
-
|
|
64
|
+
# Creates an object for RPC session lifecycle and turn coordination.
|
|
65
|
+
def initialize(
|
|
66
|
+
server:,
|
|
67
|
+
client: Client.new,
|
|
68
|
+
config_dir: ConfigFiles.config_dir,
|
|
69
|
+
config_manager: ConfigManager.new(config_path: File.join(config_dir, "config.json")),
|
|
70
|
+
context_usage: ContextUsage.new,
|
|
71
|
+
session_trash: SessionTrash.new
|
|
72
|
+
)
|
|
45
73
|
@server = server
|
|
46
74
|
@client = client
|
|
47
75
|
@config_dir = config_dir
|
|
76
|
+
@config_manager = config_manager
|
|
48
77
|
@context_usage = context_usage
|
|
78
|
+
@session_metrics = SessionMetrics.new(context_usage: context_usage)
|
|
49
79
|
@session_trash = session_trash
|
|
50
80
|
@sessions = {}
|
|
51
81
|
@turns = {}
|
|
52
82
|
@mutex = Mutex.new
|
|
53
83
|
end
|
|
54
84
|
|
|
85
|
+
# Creates a new RPC session or resumes the remembered session when allowed.
|
|
86
|
+
#
|
|
87
|
+
# Returns the normalized session payload expected by RPC clients. The RPC
|
|
88
|
+
# session id is separate from the persisted session id so one persisted file
|
|
89
|
+
# can be closed and reopened by different client connections.
|
|
55
90
|
def create_session(workspace_root: Dir.pwd, name: nil, resume_last: false)
|
|
56
91
|
workspace_root = validate_workspace_root(workspace_root)
|
|
57
92
|
store = SessionStore.new(config_dir: @config_dir, cwd: workspace_root)
|
|
@@ -61,7 +96,7 @@ module Kward
|
|
|
61
96
|
end
|
|
62
97
|
|
|
63
98
|
conversation = new_conversation(workspace_root: workspace_root)
|
|
64
|
-
session = store.create(model: conversation.model, reasoning_effort: conversation.reasoning_effort)
|
|
99
|
+
session = store.create(provider: conversation.provider, model: conversation.model, reasoning_effort: conversation.reasoning_effort)
|
|
65
100
|
session.rename(name) unless name.to_s.strip.empty?
|
|
66
101
|
session.attach(conversation)
|
|
67
102
|
rpc_session = build_rpc_session(store, session, conversation, workspace_root)
|
|
@@ -80,6 +115,7 @@ module Kward
|
|
|
80
115
|
session, conversation = store.load(
|
|
81
116
|
location[:path],
|
|
82
117
|
workspace: configured_workspace(root),
|
|
118
|
+
provider: current_model[:provider],
|
|
83
119
|
model: current_model_id,
|
|
84
120
|
reasoning_effort: current_reasoning_effort
|
|
85
121
|
)
|
|
@@ -102,12 +138,14 @@ module Kward
|
|
|
102
138
|
.map { |info| session_info_payload(info, workspace_root: root) }
|
|
103
139
|
end
|
|
104
140
|
|
|
141
|
+
# Renames the persisted session attached to an RPC session id.
|
|
105
142
|
def rename_session(session_id:, name:)
|
|
106
143
|
rpc_session = fetch_session(session_id)
|
|
107
144
|
rpc_session.session.rename(name)
|
|
108
145
|
session_payload(rpc_session)
|
|
109
146
|
end
|
|
110
147
|
|
|
148
|
+
# Creates an independent copy of the current conversation branch.
|
|
111
149
|
def clone_session(session_id:)
|
|
112
150
|
source = fetch_session(session_id)
|
|
113
151
|
session, conversation = source.store.create_independent_from_conversation(source.conversation, parent_session: source.session)
|
|
@@ -118,6 +156,7 @@ module Kward
|
|
|
118
156
|
session_payload(rpc_session)
|
|
119
157
|
end
|
|
120
158
|
|
|
159
|
+
# Compacts an RPC session and emits start/end events for UI progress.
|
|
121
160
|
def compact_session(session_id:, custom_instructions: "")
|
|
122
161
|
rpc_session = fetch_session(session_id)
|
|
123
162
|
emit_session_event(rpc_session, "compactionStart", {})
|
|
@@ -135,10 +174,11 @@ module Kward
|
|
|
135
174
|
raise e
|
|
136
175
|
end
|
|
137
176
|
|
|
177
|
+
# Lists user-message entries that can be used as fork points.
|
|
138
178
|
def fork_messages(session_id:)
|
|
139
179
|
rpc_session = fetch_session(session_id)
|
|
140
180
|
{
|
|
141
|
-
messages:
|
|
181
|
+
messages: session_tree_helper(rpc_session).entries.filter_map do |record|
|
|
142
182
|
message = record["message"]
|
|
143
183
|
next unless message.is_a?(Hash) && message_role(message) == "user"
|
|
144
184
|
|
|
@@ -147,10 +187,12 @@ module Kward
|
|
|
147
187
|
}
|
|
148
188
|
end
|
|
149
189
|
|
|
190
|
+
# Creates a new session from history before the selected user message.
|
|
150
191
|
def fork_session(session_id:, entry_id:)
|
|
151
192
|
source = fetch_session(session_id)
|
|
152
|
-
|
|
153
|
-
|
|
193
|
+
tree = session_tree_helper(source)
|
|
194
|
+
entries = tree.entries
|
|
195
|
+
resolved_entry_id = tree.resolve_entry_id(entry_id, entries: entries)
|
|
154
196
|
selected_index = entries.index { |record| record["id"].to_s == resolved_entry_id.to_s }
|
|
155
197
|
selected = selected_index && entries[selected_index]
|
|
156
198
|
raise ArgumentError, "Unknown fork entryId: #{entry_id}" unless selected
|
|
@@ -160,6 +202,7 @@ module Kward
|
|
|
160
202
|
|
|
161
203
|
session, conversation = source.store.create_independent_from_messages(
|
|
162
204
|
entries[0...selected_index].filter_map { |record| record["message"] },
|
|
205
|
+
provider: source.conversation.provider,
|
|
163
206
|
model: source.conversation.model,
|
|
164
207
|
reasoning_effort: source.conversation.reasoning_effort,
|
|
165
208
|
parent_session: source.session
|
|
@@ -175,28 +218,32 @@ module Kward
|
|
|
175
218
|
}
|
|
176
219
|
end
|
|
177
220
|
|
|
221
|
+
# Returns the flattened session tree rows consumed by RPC clients.
|
|
178
222
|
def session_tree(session_id:)
|
|
179
223
|
rpc_session = fetch_session(session_id)
|
|
180
224
|
{ items: flatten_session_tree(rpc_session) }
|
|
181
225
|
end
|
|
182
226
|
|
|
227
|
+
# Persists a label override for one tree entry.
|
|
183
228
|
def set_tree_label(session_id:, entry_id:, label: nil)
|
|
184
229
|
rpc_session = fetch_session(session_id)
|
|
185
230
|
rpc_session.session.append_label_change(entry_id, label)
|
|
186
231
|
{ ok: true }
|
|
187
232
|
end
|
|
188
233
|
|
|
234
|
+
# Moves the active branch to a tree entry, optionally summarizing abandoned history.
|
|
189
235
|
def navigate_tree(session_id:, entry_id:, summarize: false, custom_instructions: nil)
|
|
190
236
|
rpc_session = fetch_session(session_id)
|
|
191
|
-
|
|
192
|
-
|
|
237
|
+
tree = session_tree_helper(rpc_session)
|
|
238
|
+
entries = tree.entries
|
|
239
|
+
resolved_entry_id = tree.resolve_entry_id(entry_id, entries: entries)
|
|
193
240
|
entry = rpc_session.store.session_entry(rpc_session.session.path, resolved_entry_id)
|
|
194
241
|
raise ArgumentError, "Unknown tree entryId: #{entry_id}" unless entry
|
|
195
242
|
|
|
196
|
-
raise ArgumentError, "Tree entry is not selectable: #{entry_id}" unless
|
|
243
|
+
raise ArgumentError, "Tree entry is not selectable: #{entry_id}" unless tree.selectable_entry?(entry)
|
|
197
244
|
|
|
198
245
|
message = entry["message"]
|
|
199
|
-
user_entry =
|
|
246
|
+
user_entry = tree.user_entry?(entry)
|
|
200
247
|
target_leaf = user_entry ? entry["parentId"] : entry["id"]
|
|
201
248
|
editor_text = user_entry ? full_message_text(message) : nil
|
|
202
249
|
previous_leaf = rpc_session.session.leaf_id
|
|
@@ -217,6 +264,7 @@ module Kward
|
|
|
217
264
|
}.compact
|
|
218
265
|
end
|
|
219
266
|
|
|
267
|
+
# Exports the current transcript in markdown or JSON format.
|
|
220
268
|
def export_session(session_id:, path: nil, format: nil)
|
|
221
269
|
rpc_session = fetch_session(session_id)
|
|
222
270
|
format = export_format(format)
|
|
@@ -226,6 +274,7 @@ module Kward
|
|
|
226
274
|
{ path: path, format: format }
|
|
227
275
|
end
|
|
228
276
|
|
|
277
|
+
# Deletes the backing session file through the configured trash strategy.
|
|
229
278
|
def delete_session(session_id:)
|
|
230
279
|
rpc_session = fetch_session(session_id)
|
|
231
280
|
path = rpc_session.session.path
|
|
@@ -234,12 +283,14 @@ module Kward
|
|
|
234
283
|
{ deleted: deleted, path: path }
|
|
235
284
|
end
|
|
236
285
|
|
|
286
|
+
# Stops workers and removes an RPC session from the live session map.
|
|
237
287
|
def close_session(session_id:)
|
|
238
288
|
rpc_session = fetch_session(session_id)
|
|
239
289
|
close_rpc_session(rpc_session)
|
|
240
290
|
{ closed: true }
|
|
241
291
|
end
|
|
242
292
|
|
|
293
|
+
# Closes idle empty sessions left behind by UI lifecycle transitions.
|
|
243
294
|
def cleanup_unused_sessions
|
|
244
295
|
rpc_sessions = @mutex.synchronize { @sessions.values.dup }
|
|
245
296
|
rpc_sessions.reverse_each do |rpc_session|
|
|
@@ -250,11 +301,18 @@ module Kward
|
|
|
250
301
|
{ closed: true }
|
|
251
302
|
end
|
|
252
303
|
|
|
304
|
+
# Returns the normalized transcript for the active RPC session.
|
|
253
305
|
def transcript(session_id:)
|
|
254
306
|
rpc_session = fetch_session(session_id)
|
|
255
307
|
{ session: session_payload(rpc_session), messages: TranscriptNormalizer.new(rpc_session.conversation.messages).normalize }
|
|
256
308
|
end
|
|
257
309
|
|
|
310
|
+
# Queues or starts an async model turn for an RPC session.
|
|
311
|
+
#
|
|
312
|
+
# `streaming_behavior` controls busy-session behavior: create a new turn,
|
|
313
|
+
# queue a follow-up, or steer the running turn when the active provider
|
|
314
|
+
# supports native steering. The returned turn id is used for status,
|
|
315
|
+
# cancellation, and event replay.
|
|
258
316
|
def start_turn(session_id:, input:, streaming_behavior: nil, attachments: [])
|
|
259
317
|
rpc_session = fetch_session(session_id)
|
|
260
318
|
normalized_attachments = normalize_attachments(attachments)
|
|
@@ -439,13 +497,30 @@ module Kward
|
|
|
439
497
|
normalize_model(provider: provider, id: model, model: model, contextWindow: context_window, current: true)
|
|
440
498
|
end
|
|
441
499
|
|
|
500
|
+
def session_model(rpc_session)
|
|
501
|
+
current = current_model
|
|
502
|
+
provider = rpc_session.conversation.provider || current[:provider]
|
|
503
|
+
model = rpc_session.conversation.model || current[:id]
|
|
504
|
+
reasoning_effort = rpc_session.conversation.reasoning_effort || current_reasoning_effort
|
|
505
|
+
reasoning_effort = nil unless ModelInfo.reasoning_supported?(provider, model)
|
|
506
|
+
context_window = current[:contextWindow] if provider == current[:provider] && model == current[:id]
|
|
507
|
+
normalize_model(
|
|
508
|
+
provider: provider,
|
|
509
|
+
id: model,
|
|
510
|
+
model: model,
|
|
511
|
+
reasoningEffort: reasoning_effort,
|
|
512
|
+
contextWindow: context_window,
|
|
513
|
+
current: true
|
|
514
|
+
)
|
|
515
|
+
end
|
|
516
|
+
|
|
442
517
|
def in_flight_steer_supported?
|
|
443
518
|
supports_in_flight_steer?
|
|
444
519
|
end
|
|
445
520
|
|
|
446
521
|
def runtime_state(session_id:)
|
|
447
522
|
rpc_session = fetch_session(session_id)
|
|
448
|
-
model =
|
|
523
|
+
model = session_model(rpc_session)
|
|
449
524
|
compaction_settings = self.compaction_settings
|
|
450
525
|
auto_compaction_reserve_tokens = compaction_reserve_tokens(
|
|
451
526
|
context_window: model[:contextWindow],
|
|
@@ -459,7 +534,7 @@ module Kward
|
|
|
459
534
|
steering_supported: supports_in_flight_steer?,
|
|
460
535
|
auto_compaction_reserve_tokens: auto_compaction_reserve_tokens,
|
|
461
536
|
active_persona_label: active_persona_label(rpc_session),
|
|
462
|
-
message_count: message_count(rpc_session.conversation),
|
|
537
|
+
message_count: @session_metrics.message_count(rpc_session.conversation),
|
|
463
538
|
pending_count: pending_turn_count(rpc_session.id),
|
|
464
539
|
compaction_enabled: compaction_settings.enabled,
|
|
465
540
|
workspace_guardrails_enabled: workspace_guardrails_enabled?
|
|
@@ -469,8 +544,8 @@ module Kward
|
|
|
469
544
|
def runtime_stats(session_id:)
|
|
470
545
|
rpc_session = fetch_session(session_id)
|
|
471
546
|
session = session_payload(rpc_session)
|
|
472
|
-
counts = message_stats(rpc_session.conversation)
|
|
473
|
-
model =
|
|
547
|
+
counts = @session_metrics.message_stats(rpc_session.conversation)
|
|
548
|
+
model = session_model(rpc_session)
|
|
474
549
|
compaction_settings = self.compaction_settings
|
|
475
550
|
auto_compaction_reserve_tokens = compaction_reserve_tokens(
|
|
476
551
|
context_window: model[:contextWindow],
|
|
@@ -481,7 +556,7 @@ module Kward
|
|
|
481
556
|
counts: counts,
|
|
482
557
|
model: model,
|
|
483
558
|
auto_compaction_reserve_tokens: auto_compaction_reserve_tokens,
|
|
484
|
-
context_usage: context_usage(rpc_session, model),
|
|
559
|
+
context_usage: @session_metrics.context_usage(rpc_session, model, client: @client),
|
|
485
560
|
compaction_enabled: compaction_settings.enabled
|
|
486
561
|
)
|
|
487
562
|
end
|
|
@@ -529,6 +604,7 @@ module Kward
|
|
|
529
604
|
def new_conversation(workspace_root: Dir.pwd)
|
|
530
605
|
Conversation.new(
|
|
531
606
|
workspace_root: workspace_root,
|
|
607
|
+
provider: (@client.current_provider if @client.respond_to?(:current_provider)),
|
|
532
608
|
model: (@client.current_model if @client.respond_to?(:current_model)),
|
|
533
609
|
reasoning_effort: (@client.current_reasoning_effort if @client.respond_to?(:current_reasoning_effort)),
|
|
534
610
|
plugin_registry: plugin_registry
|
|
@@ -540,8 +616,7 @@ module Kward
|
|
|
540
616
|
reasoning_effort = current_reasoning_effort
|
|
541
617
|
sessions = @mutex.synchronize { @sessions.values }
|
|
542
618
|
sessions.each do |rpc_session|
|
|
543
|
-
rpc_session.conversation.update_runtime_context!(model: model, reasoning_effort: reasoning_effort)
|
|
544
|
-
rpc_session.session.update_runtime(model: model, reasoning_effort: reasoning_effort)
|
|
619
|
+
rpc_session.conversation.update_runtime_context!(provider: current_model[:provider], model: model, reasoning_effort: reasoning_effort)
|
|
545
620
|
end
|
|
546
621
|
end
|
|
547
622
|
|
|
@@ -621,42 +696,6 @@ module Kward
|
|
|
621
696
|
@mutex.synchronize { @sessions.values.count { |rpc_session| rpc_session.workspace_root == workspace_root } }
|
|
622
697
|
end
|
|
623
698
|
|
|
624
|
-
def message_count(conversation)
|
|
625
|
-
conversation.messages.count { |message| message_role(message) != "system" }
|
|
626
|
-
end
|
|
627
|
-
|
|
628
|
-
def context_usage(rpc_session, model)
|
|
629
|
-
context_parts = if @client.respond_to?(:current_context_parts)
|
|
630
|
-
@client.current_context_parts(rpc_session.conversation.messages, rpc_session.tool_registry.schemas)
|
|
631
|
-
else
|
|
632
|
-
{ provider: model[:provider], model: model[:id], messages: rpc_session.conversation.messages, tools: rpc_session.tool_registry.schemas }
|
|
633
|
-
end
|
|
634
|
-
@context_usage.call(
|
|
635
|
-
provider: model[:provider],
|
|
636
|
-
model: model[:id],
|
|
637
|
-
context_window: model[:contextWindow],
|
|
638
|
-
context_parts: context_parts
|
|
639
|
-
)
|
|
640
|
-
end
|
|
641
|
-
|
|
642
|
-
def message_stats(conversation)
|
|
643
|
-
conversation.messages.each_with_object({ userMessages: 0, assistantMessages: 0, toolCalls: 0, toolResults: 0, totalMessages: 0 }) do |message, counts|
|
|
644
|
-
role = message_role(message)
|
|
645
|
-
next if role == "system"
|
|
646
|
-
|
|
647
|
-
counts[:totalMessages] += 1
|
|
648
|
-
case role
|
|
649
|
-
when "user"
|
|
650
|
-
counts[:userMessages] += 1
|
|
651
|
-
when "assistant"
|
|
652
|
-
counts[:assistantMessages] += 1
|
|
653
|
-
counts[:toolCalls] += tool_calls(message).length
|
|
654
|
-
when "tool", "toolResult"
|
|
655
|
-
counts[:toolResults] += 1
|
|
656
|
-
end
|
|
657
|
-
end
|
|
658
|
-
end
|
|
659
|
-
|
|
660
699
|
def tool_calls(message)
|
|
661
700
|
MessageAccess.tool_calls(message)
|
|
662
701
|
end
|
|
@@ -669,24 +708,15 @@ module Kward
|
|
|
669
708
|
MessageAccess.content(message)
|
|
670
709
|
end
|
|
671
710
|
|
|
672
|
-
def
|
|
673
|
-
|
|
674
|
-
end
|
|
675
|
-
|
|
676
|
-
def resolve_tree_entry_id(entries, entry_id)
|
|
677
|
-
id = entry_id.to_s
|
|
678
|
-
return id if entries.any? { |record| record["id"].to_s == id }
|
|
679
|
-
|
|
680
|
-
match = id.match(/\Amessage:(\d+)\z/)
|
|
681
|
-
return entries[match[1].to_i]&.dig("id") if match
|
|
682
|
-
|
|
683
|
-
id
|
|
711
|
+
def session_tree_helper(rpc_session)
|
|
712
|
+
SessionTree.new(rpc_session)
|
|
684
713
|
end
|
|
685
714
|
|
|
686
715
|
def reload_rpc_session(rpc_session)
|
|
687
716
|
session, conversation = rpc_session.store.load(
|
|
688
717
|
rpc_session.session.path,
|
|
689
718
|
workspace: configured_workspace(rpc_session.workspace_root),
|
|
719
|
+
provider: current_model[:provider],
|
|
690
720
|
model: current_model_id,
|
|
691
721
|
reasoning_effort: current_reasoning_effort
|
|
692
722
|
)
|
|
@@ -700,242 +730,19 @@ module Kward
|
|
|
700
730
|
def flatten_session_tree(rpc_session)
|
|
701
731
|
roots = rpc_session.store.session_tree(rpc_session.session.path)
|
|
702
732
|
current_leaf = rpc_session.session.leaf_id || rpc_session.store.current_leaf(rpc_session.session.path)
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
walk = lambda do |node, indent, just_branched, show_connector, is_last, gutters, virtual_root_child|
|
|
710
|
-
entry = node[:source]["entry"] || {}
|
|
711
|
-
entry_id = entry["id"].to_s
|
|
712
|
-
formatted = tree_entry_display(entry, tool_calls_by_id)
|
|
713
|
-
display_indent = multiple_roots ? [indent - 1, 0].max : indent
|
|
714
|
-
result << {
|
|
715
|
-
entryId: entry_id,
|
|
716
|
-
parentId: entry["parentId"],
|
|
717
|
-
role: formatted[:role],
|
|
718
|
-
text: formatted[:text],
|
|
719
|
-
current: !current_leaf.to_s.empty? && entry_id == current_leaf.to_s,
|
|
720
|
-
depth: display_indent,
|
|
721
|
-
isLast: is_last,
|
|
722
|
-
ancestorContinues: gutters.map { |gutter| gutter[:show] },
|
|
723
|
-
activePath: active_path.include?(entry_id),
|
|
724
|
-
selectable: selectable_tree_entry?(entry),
|
|
725
|
-
label: node[:source]["label"] || entry["resolvedLabel"],
|
|
726
|
-
labelTimestamp: node[:source]["labelTimestamp"],
|
|
727
|
-
prefix: tree_prefix(display_indent, gutters, show_connector && !virtual_root_child, is_last, !node[:children].empty?)
|
|
728
|
-
}.compact
|
|
729
|
-
|
|
730
|
-
children = node[:children].sort_by { |child| tree_contains_active_path?(child, active_path) ? 0 : 1 }
|
|
731
|
-
multiple_children = children.length > 1
|
|
732
|
-
child_indent = if multiple_children
|
|
733
|
-
indent + 1
|
|
734
|
-
elsif just_branched && indent.positive?
|
|
735
|
-
indent + 1
|
|
736
|
-
else
|
|
737
|
-
indent
|
|
738
|
-
end
|
|
739
|
-
connector_position = [display_indent - 1, 0].max
|
|
740
|
-
child_gutters = show_connector && !virtual_root_child ? gutters + [{ position: connector_position, show: !is_last }] : gutters
|
|
741
|
-
children.each_with_index do |child, index|
|
|
742
|
-
walk.call(child, child_indent, multiple_children, multiple_children, index == children.length - 1, child_gutters, false)
|
|
743
|
-
end
|
|
744
|
-
end
|
|
745
|
-
|
|
746
|
-
visible_roots.sort_by { |root| tree_contains_active_path?(root, active_path) ? 0 : 1 }.each_with_index do |root, index|
|
|
747
|
-
walk.call(root, multiple_roots ? 1 : 0, multiple_roots, multiple_roots, index == visible_roots.length - 1, [], multiple_roots)
|
|
748
|
-
end
|
|
749
|
-
result
|
|
750
|
-
end
|
|
751
|
-
|
|
752
|
-
def user_tree_entry?(entry)
|
|
753
|
-
message = entry["message"]
|
|
754
|
-
message.is_a?(Hash) && message_role(message) == "user"
|
|
755
|
-
end
|
|
756
|
-
|
|
757
|
-
def selectable_tree_entry?(entry)
|
|
758
|
-
!entry["id"].to_s.empty? && ["message", "compaction", "branch_summary"].include?(entry["type"])
|
|
759
|
-
end
|
|
760
|
-
|
|
761
|
-
def nearest_visible_parent_by_id(user_entries, entries)
|
|
762
|
-
user_ids = user_entries.map { |entry| entry["id"].to_s }.to_h { |id| [id, true] }
|
|
763
|
-
by_id = entries.to_h { |entry| [entry["id"].to_s, entry] }
|
|
764
|
-
user_entries.each_with_object({}) do |entry, parents|
|
|
765
|
-
parent_id = entry["parentId"]
|
|
766
|
-
while parent_id && by_id[parent_id.to_s] && !user_ids[parent_id.to_s]
|
|
767
|
-
parent_id = by_id[parent_id.to_s]["parentId"]
|
|
768
|
-
end
|
|
769
|
-
parents[entry["id"].to_s] = user_ids[parent_id.to_s] ? parent_id.to_s : nil
|
|
770
|
-
end
|
|
771
|
-
end
|
|
772
|
-
|
|
773
|
-
def active_path_ids(entries, leaf_id)
|
|
774
|
-
by_id = entries.to_h { |entry| [entry["id"].to_s, entry] }
|
|
775
|
-
ids = []
|
|
776
|
-
current = by_id[leaf_id.to_s]
|
|
777
|
-
while current
|
|
778
|
-
ids << current["id"].to_s
|
|
779
|
-
current = by_id[current["parentId"].to_s]
|
|
780
|
-
end
|
|
781
|
-
ids
|
|
782
|
-
end
|
|
783
|
-
|
|
784
|
-
def tree_active_path(roots, leaf_id)
|
|
785
|
-
by_id = tree_entries_by_id(roots)
|
|
786
|
-
ids = []
|
|
787
|
-
current = by_id[leaf_id.to_s]
|
|
788
|
-
while current
|
|
789
|
-
ids << current["id"].to_s
|
|
790
|
-
current = by_id[current["parentId"].to_s]
|
|
791
|
-
end
|
|
792
|
-
ids
|
|
793
|
-
end
|
|
794
|
-
|
|
795
|
-
def tree_entries_by_id(roots)
|
|
796
|
-
roots.each_with_object({}) do |root, map|
|
|
797
|
-
stack = [root]
|
|
798
|
-
until stack.empty?
|
|
799
|
-
node = stack.pop
|
|
800
|
-
entry = node["entry"] || {}
|
|
801
|
-
map[entry["id"].to_s] = entry unless entry["id"].to_s.empty?
|
|
802
|
-
stack.concat(Array(node["children"]))
|
|
803
|
-
end
|
|
804
|
-
end
|
|
805
|
-
end
|
|
806
|
-
|
|
807
|
-
def visible_tree_nodes(node, current_leaf)
|
|
808
|
-
children = Array(node["children"]).flat_map { |child| visible_tree_nodes(child, current_leaf) }
|
|
809
|
-
return children if hidden_tree_entry?(node["entry"] || {}, current_leaf)
|
|
810
|
-
|
|
811
|
-
[{ source: node, children: children }]
|
|
812
|
-
end
|
|
813
|
-
|
|
814
|
-
def hidden_tree_entry?(entry, current_leaf)
|
|
815
|
-
return false if current_leaf && entry["id"].to_s == current_leaf.to_s
|
|
816
|
-
return false unless entry["type"] == "message"
|
|
817
|
-
|
|
818
|
-
message = entry["message"]
|
|
819
|
-
return false unless message.is_a?(Hash) && message_role(message) == "assistant"
|
|
820
|
-
|
|
821
|
-
content = message_content(message)
|
|
822
|
-
content_tool_calls = content.is_a?(Array) && content.any? { |part| ToolCall.value(part, :type) == "toolCall" }
|
|
823
|
-
(content_tool_calls && !tree_text_content?(content)) || (!tool_calls(message).empty? && full_message_text(message).empty?)
|
|
824
|
-
end
|
|
825
|
-
|
|
826
|
-
def tree_text_content?(content)
|
|
827
|
-
Array(content).any? { |part| ToolCall.value(part, :type) == "text" && ToolCall.value(part, :text).to_s.strip != "" }
|
|
828
|
-
end
|
|
829
|
-
|
|
830
|
-
def tree_contains_active_path?(node, active_path)
|
|
831
|
-
entry_id = (node[:source]["entry"] || {})["id"].to_s
|
|
832
|
-
active_path.include?(entry_id) || node[:children].any? { |child| tree_contains_active_path?(child, active_path) }
|
|
833
|
-
end
|
|
834
|
-
|
|
835
|
-
def tree_tool_calls(roots)
|
|
836
|
-
roots.each_with_object({}) do |root, tool_calls_by_id|
|
|
837
|
-
stack = [root]
|
|
838
|
-
until stack.empty?
|
|
839
|
-
node = stack.pop
|
|
840
|
-
entry = node["entry"] || {}
|
|
841
|
-
message = entry["message"]
|
|
842
|
-
if entry["type"] == "message" && message.is_a?(Hash) && message_role(message) == "assistant"
|
|
843
|
-
tool_calls(message).each { |tool_call| tool_calls_by_id[ToolCall.id(tool_call).to_s] = tool_call }
|
|
844
|
-
end
|
|
845
|
-
stack.concat(Array(node["children"]))
|
|
846
|
-
end
|
|
847
|
-
end
|
|
848
|
-
end
|
|
849
|
-
|
|
850
|
-
def tree_entry_display(entry, tool_calls_by_id = {})
|
|
851
|
-
case entry["type"]
|
|
852
|
-
when "message"
|
|
853
|
-
message = entry["message"] || {}
|
|
854
|
-
role = message_role(message).to_s
|
|
855
|
-
return { role: "tool", text: format_tool_result(message, tool_calls_by_id) } if ["tool", "toolResult"].include?(role)
|
|
856
|
-
return { role: role.empty? ? "message" : role, text: display_message_text(message) }
|
|
857
|
-
when "compaction"
|
|
858
|
-
return { role: "compaction", text: display_message_text(entry["message"] || {}) }
|
|
859
|
-
when "branch_summary"
|
|
860
|
-
return { role: "summary", text: truncate_tree_text(entry["summary"]) }
|
|
861
|
-
end
|
|
862
|
-
|
|
863
|
-
{ role: entry["type"].to_s.empty? ? "entry" : entry["type"].to_s, text: entry["type"].to_s }
|
|
864
|
-
end
|
|
865
|
-
|
|
866
|
-
def tree_prefix(display_indent, gutters, show_connector, is_last, foldable)
|
|
867
|
-
return "" if display_indent.to_i <= 0
|
|
868
|
-
|
|
869
|
-
connector_position = show_connector ? display_indent - 1 : -1
|
|
870
|
-
(0...(display_indent * 3)).map do |index|
|
|
871
|
-
level = index / 3
|
|
872
|
-
position = index % 3
|
|
873
|
-
gutter = gutters.find { |candidate| candidate[:position] == level }
|
|
874
|
-
|
|
875
|
-
if gutter
|
|
876
|
-
position.zero? && gutter[:show] ? "│" : " "
|
|
877
|
-
elsif show_connector && level == connector_position
|
|
878
|
-
if position.zero?
|
|
879
|
-
is_last ? "└" : "├"
|
|
880
|
-
elsif position == 1
|
|
881
|
-
foldable ? "⊟" : "─"
|
|
882
|
-
else
|
|
883
|
-
" "
|
|
884
|
-
end
|
|
885
|
-
else
|
|
886
|
-
" "
|
|
887
|
-
end
|
|
888
|
-
end.join
|
|
889
|
-
end
|
|
890
|
-
|
|
891
|
-
def format_tool_result(message, tool_calls_by_id)
|
|
892
|
-
tool_call = tool_calls_by_id[tree_message_tool_call_id(message).to_s]
|
|
893
|
-
return format_tool_call(tool_call) if tool_call
|
|
894
|
-
|
|
895
|
-
name = tree_message_tool_name(message).to_s
|
|
896
|
-
name = "tool" if name.empty?
|
|
897
|
-
"[#{name}]"
|
|
898
|
-
end
|
|
899
|
-
|
|
900
|
-
def tree_message_tool_call_id(message)
|
|
901
|
-
MessageAccess.tool_call_id(message) || ToolCall.value(message, :toolCallId)
|
|
902
|
-
end
|
|
903
|
-
|
|
904
|
-
def tree_message_tool_name(message)
|
|
905
|
-
MessageAccess.name(message) || ToolCall.value(message, :toolName)
|
|
906
|
-
end
|
|
907
|
-
|
|
908
|
-
def format_tool_call(tool_call)
|
|
909
|
-
name = ToolCall.display_name(tool_call)
|
|
910
|
-
args = ToolCall.arguments(tool_call)
|
|
911
|
-
case name
|
|
912
|
-
when "read"
|
|
913
|
-
path = args["path"] || args[:path] || args["file_path"] || args[:file_path]
|
|
914
|
-
offset = args["offset"] || args[:offset]
|
|
915
|
-
limit = args["limit"] || args[:limit]
|
|
916
|
-
display = path.to_s
|
|
917
|
-
if offset || limit
|
|
918
|
-
start_line = offset || 1
|
|
919
|
-
end_line = limit ? start_line.to_i + limit.to_i - 1 : nil
|
|
920
|
-
display += ":#{start_line}#{end_line ? "-#{end_line}" : ""}"
|
|
921
|
-
end
|
|
922
|
-
"[read: #{display}]"
|
|
923
|
-
when "write", "edit"
|
|
924
|
-
path = args["path"] || args[:path] || args["file_path"] || args[:file_path]
|
|
925
|
-
"[#{name}: #{path}]"
|
|
926
|
-
when "bash"
|
|
927
|
-
command = (args["command"] || args[:command]).to_s.gsub(/[\n\t]/, " ").strip
|
|
928
|
-
"[bash: #{command.length > 50 ? "#{command.slice(0, 50)}..." : command}]"
|
|
929
|
-
else
|
|
930
|
-
serialized = JSON.dump(args)
|
|
931
|
-
"[#{name}: #{serialized.length > 40 ? "#{serialized.slice(0, 40)}..." : serialized}]"
|
|
932
|
-
end
|
|
733
|
+
tree = session_tree_helper(rpc_session)
|
|
734
|
+
SessionTreeRows.new(
|
|
735
|
+
roots: roots,
|
|
736
|
+
current_leaf: current_leaf,
|
|
737
|
+
selectable: ->(entry) { tree.selectable_entry?(entry) }
|
|
738
|
+
).rows
|
|
933
739
|
end
|
|
934
740
|
|
|
935
741
|
def summarize_branch(rpc_session, from_id:, to_id:, custom_instructions: nil)
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
742
|
+
tree = session_tree_helper(rpc_session)
|
|
743
|
+
entries = tree.entries
|
|
744
|
+
active = tree.active_path_ids(entries, from_id)
|
|
745
|
+
target = tree.active_path_ids(entries, to_id)
|
|
939
746
|
target_lookup = target.to_h { |id| [id, true] }
|
|
940
747
|
abandoned = active.reject { |id| target_lookup[id] }
|
|
941
748
|
messages = entries.select { |entry| abandoned.include?(entry["id"].to_s) }.filter_map { |entry| entry["message"] }
|
|
@@ -959,13 +766,7 @@ module Kward
|
|
|
959
766
|
end
|
|
960
767
|
|
|
961
768
|
def full_message_text(message)
|
|
962
|
-
|
|
963
|
-
text = if content.is_a?(Array)
|
|
964
|
-
content.filter_map { |part| part["text"] || part[:text] }.join("\n")
|
|
965
|
-
else
|
|
966
|
-
content.to_s
|
|
967
|
-
end
|
|
968
|
-
text.strip
|
|
769
|
+
MessageText.full_text(message)
|
|
969
770
|
end
|
|
970
771
|
|
|
971
772
|
def supports_in_flight_steer?
|
|
@@ -1012,42 +813,7 @@ module Kward
|
|
|
1012
813
|
end
|
|
1013
814
|
|
|
1014
815
|
def normalize_attachments(attachments)
|
|
1015
|
-
|
|
1016
|
-
raise ArgumentError, "attachments must be an array" unless attachments.is_a?(Array)
|
|
1017
|
-
|
|
1018
|
-
attachments.map { |attachment| normalize_attachment(attachment) }
|
|
1019
|
-
end
|
|
1020
|
-
|
|
1021
|
-
def normalize_attachment(attachment)
|
|
1022
|
-
raise ArgumentError, "attachment must be an object" unless attachment.is_a?(Hash)
|
|
1023
|
-
|
|
1024
|
-
type = ToolCall.value(attachment, :type).to_s
|
|
1025
|
-
raise ArgumentError, "Unsupported attachment type: #{type.empty? ? "unknown" : type}" unless type == "image"
|
|
1026
|
-
|
|
1027
|
-
mime_type = normalize_attachment_mime_type(ToolCall.value(attachment, :mimeType) || ToolCall.value(attachment, :mime_type) || ToolCall.value(attachment, :media_type))
|
|
1028
|
-
raise ArgumentError, "Unsupported image MIME type: #{mime_type.empty? ? "unknown" : mime_type}" unless RPC_IMAGE_MIME_TYPES.include?(mime_type)
|
|
1029
|
-
|
|
1030
|
-
data = ToolCall.value(attachment, :data).to_s
|
|
1031
|
-
raise ArgumentError, "Image attachment data must be valid base64" if data.empty?
|
|
1032
|
-
raise ArgumentError, "Image attachment data must be raw base64" if data.start_with?("data:")
|
|
1033
|
-
declared_size = ToolCall.value(attachment, :sizeBytes) || ToolCall.value(attachment, :size_bytes)
|
|
1034
|
-
raise ArgumentError, "Image attachment is too large" if declared_size && declared_size.to_i > RPC_ATTACHMENT_MAX_BYTES
|
|
1035
|
-
|
|
1036
|
-
decoded_size = Base64.strict_decode64(data).bytesize
|
|
1037
|
-
raise ArgumentError, "Image attachment is too large" if decoded_size > RPC_ATTACHMENT_MAX_BYTES
|
|
1038
|
-
|
|
1039
|
-
result = { type: "image", data: data, mimeType: mime_type }
|
|
1040
|
-
name = ToolCall.value(attachment, :name)
|
|
1041
|
-
result[:alt] = name.to_s unless name.to_s.empty?
|
|
1042
|
-
result
|
|
1043
|
-
rescue ArgumentError => e
|
|
1044
|
-
raise e if e.message.start_with?("Unsupported", "Image attachment", "attachment")
|
|
1045
|
-
|
|
1046
|
-
raise ArgumentError, "Image attachment data must be valid base64"
|
|
1047
|
-
end
|
|
1048
|
-
|
|
1049
|
-
def normalize_attachment_mime_type(mime_type)
|
|
1050
|
-
mime_type.to_s.downcase
|
|
816
|
+
AttachmentNormalizer.new(max_bytes: RPC_ATTACHMENT_MAX_BYTES, mime_types: RPC_IMAGE_MIME_TYPES).normalize(attachments)
|
|
1051
817
|
end
|
|
1052
818
|
|
|
1053
819
|
def plugin_registry
|
|
@@ -1058,6 +824,11 @@ module Kward
|
|
|
1058
824
|
PromptCommands::BUILTIN_RESERVED_COMMAND_NAMES + ConfigFiles.prompt_templates(reserved_commands: PromptCommands::BUILTIN_RESERVED_COMMAND_NAMES).map(&:command)
|
|
1059
825
|
end
|
|
1060
826
|
|
|
827
|
+
# Wires together the per-RPC-session runtime objects.
|
|
828
|
+
#
|
|
829
|
+
# This is the RPC counterpart to the CLI interactive setup: attach plugin
|
|
830
|
+
# context, create a prompt bridge for UI questions/footer output, advertise
|
|
831
|
+
# tools with the workspace guardrail policy, and build the shared `Agent`.
|
|
1061
832
|
def build_rpc_session(store, session, conversation, workspace_root)
|
|
1062
833
|
conversation.plugin_registry ||= plugin_registry if conversation.respond_to?(:plugin_registry)
|
|
1063
834
|
id = SecureRandom.uuid
|
|
@@ -1093,11 +864,11 @@ module Kward
|
|
|
1093
864
|
end
|
|
1094
865
|
|
|
1095
866
|
def workspace_guardrails_enabled?
|
|
1096
|
-
|
|
867
|
+
@config_manager.workspace_guardrails_enabled?
|
|
1097
868
|
end
|
|
1098
869
|
|
|
1099
870
|
def session_auto_resume_enabled?
|
|
1100
|
-
|
|
871
|
+
@config_manager.session_auto_resume_enabled?
|
|
1101
872
|
end
|
|
1102
873
|
|
|
1103
874
|
def config_path
|
|
@@ -1200,6 +971,11 @@ module Kward
|
|
|
1200
971
|
rpc_session.worker = nil if rpc_session.worker == Thread.current
|
|
1201
972
|
end
|
|
1202
973
|
|
|
974
|
+
# Executes one queued turn and emits normalized RPC events.
|
|
975
|
+
#
|
|
976
|
+
# This method is intentionally the only place that calls `Agent#ask` for RPC
|
|
977
|
+
# turns. Keep event translation near this boundary so CLI rendering and RPC
|
|
978
|
+
# protocol details do not leak into `Agent`.
|
|
1203
979
|
def run_turn(rpc_session, turn)
|
|
1204
980
|
rpc_session.running_turn_id = turn.id
|
|
1205
981
|
turn.steering = build_steering(turn) if supports_in_flight_steer? && !turn.plugin_command_name
|