kward 0.66.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 +7 -0
- data/.yardopts +9 -0
- data/CHANGELOG.md +12 -0
- data/Gemfile +8 -0
- data/Gemfile.lock +90 -0
- data/LICENSE +21 -0
- data/README.md +101 -0
- data/Rakefile +20 -0
- data/doc/authentication.md +105 -0
- data/doc/code-search.md +56 -0
- data/doc/configuration.md +310 -0
- data/doc/extensibility.md +186 -0
- data/doc/getting-started.md +127 -0
- data/doc/memory.md +192 -0
- data/doc/plugins.md +223 -0
- data/doc/releasing.md +36 -0
- data/doc/rpc.md +635 -0
- data/doc/usage.md +179 -0
- data/doc/web-search.md +28 -0
- data/exe/kward +5 -0
- data/kward.gemspec +33 -0
- data/lib/kward/agent.rb +234 -0
- data/lib/kward/ansi.rb +276 -0
- data/lib/kward/auth/file.rb +11 -0
- data/lib/kward/auth/github_oauth.rb +222 -0
- data/lib/kward/auth/openai_oauth.rb +323 -0
- data/lib/kward/auth/openrouter_api_key.rb +40 -0
- data/lib/kward/cancellation.rb +54 -0
- data/lib/kward/cli.rb +2122 -0
- data/lib/kward/clipboard.rb +84 -0
- data/lib/kward/compactor.rb +998 -0
- data/lib/kward/config_files.rb +564 -0
- data/lib/kward/conversation.rb +148 -0
- data/lib/kward/events.rb +13 -0
- data/lib/kward/export_path.rb +28 -0
- data/lib/kward/image_attachments.rb +331 -0
- data/lib/kward/markdown_transcript.rb +72 -0
- data/lib/kward/memory/manager.rb +652 -0
- data/lib/kward/message_access.rb +42 -0
- data/lib/kward/model/chat_invocation.rb +23 -0
- data/lib/kward/model/client.rb +875 -0
- data/lib/kward/model/context_overflow.rb +55 -0
- data/lib/kward/model/context_usage.rb +104 -0
- data/lib/kward/model/model_info.rb +188 -0
- data/lib/kward/model/retry_message.rb +11 -0
- data/lib/kward/model/stream_parser.rb +205 -0
- data/lib/kward/pan/index.html.erb +143 -0
- data/lib/kward/pan/server.rb +397 -0
- data/lib/kward/plugin_registry.rb +327 -0
- data/lib/kward/private_file.rb +18 -0
- data/lib/kward/prompt_interface.rb +2437 -0
- data/lib/kward/prompts/commands.rb +50 -0
- data/lib/kward/prompts/templates.rb +60 -0
- data/lib/kward/prompts.rb +58 -0
- data/lib/kward/resources/avatar_kward_logo.rb +48 -0
- data/lib/kward/resources/pixel_logo.rb +230 -0
- data/lib/kward/rpc/auth_manager.rb +265 -0
- data/lib/kward/rpc/config_manager.rb +58 -0
- data/lib/kward/rpc/prompt_bridge.rb +104 -0
- data/lib/kward/rpc/redactor.rb +47 -0
- data/lib/kward/rpc/server.rb +639 -0
- data/lib/kward/rpc/session_manager.rb +1122 -0
- data/lib/kward/rpc/tool_event_normalizer.rb +68 -0
- data/lib/kward/rpc/tool_metadata.rb +80 -0
- data/lib/kward/rpc/transcript_normalizer.rb +307 -0
- data/lib/kward/rpc/transport.rb +58 -0
- data/lib/kward/session_diff.rb +125 -0
- data/lib/kward/session_store.rb +493 -0
- data/lib/kward/skills/registry.rb +76 -0
- data/lib/kward/starter_pack_installer.rb +110 -0
- data/lib/kward/steering.rb +56 -0
- data/lib/kward/telemetry/logger.rb +195 -0
- data/lib/kward/telemetry/stats.rb +466 -0
- data/lib/kward/tools/ask_user_question.rb +107 -0
- data/lib/kward/tools/base.rb +45 -0
- data/lib/kward/tools/code_search.rb +65 -0
- data/lib/kward/tools/edit_file.rb +41 -0
- data/lib/kward/tools/list_directory.rb +21 -0
- data/lib/kward/tools/read_file.rb +30 -0
- data/lib/kward/tools/read_skill.rb +27 -0
- data/lib/kward/tools/registry.rb +117 -0
- data/lib/kward/tools/run_shell_command.rb +28 -0
- data/lib/kward/tools/search/code.rb +445 -0
- data/lib/kward/tools/search/web.rb +747 -0
- data/lib/kward/tools/tool_call.rb +87 -0
- data/lib/kward/tools/web_search.rb +48 -0
- data/lib/kward/tools/write_file.rb +29 -0
- data/lib/kward/transcript_export.rb +40 -0
- data/lib/kward/version.rb +4 -0
- data/lib/kward/workspace.rb +377 -0
- data/lib/kward.rb +6 -0
- data/lib/main.rb +3 -0
- metadata +232 -0
|
@@ -0,0 +1,1122 @@
|
|
|
1
|
+
require "base64"
|
|
2
|
+
require "securerandom"
|
|
3
|
+
require "thread"
|
|
4
|
+
require "time"
|
|
5
|
+
require_relative "../agent"
|
|
6
|
+
require_relative "../cancellation"
|
|
7
|
+
require_relative "../model/client"
|
|
8
|
+
require_relative "../compactor"
|
|
9
|
+
require_relative "../config_files"
|
|
10
|
+
require_relative "../model/context_usage"
|
|
11
|
+
require_relative "../conversation"
|
|
12
|
+
require_relative "../events"
|
|
13
|
+
require_relative "../export_path"
|
|
14
|
+
require_relative "../memory/manager"
|
|
15
|
+
require_relative "../message_access"
|
|
16
|
+
require_relative "../model/model_info"
|
|
17
|
+
require_relative "../plugin_registry"
|
|
18
|
+
require_relative "../prompts/commands"
|
|
19
|
+
require_relative "../session_store"
|
|
20
|
+
require_relative "../steering"
|
|
21
|
+
require_relative "../tools/tool_call"
|
|
22
|
+
require_relative "../tools/registry"
|
|
23
|
+
require_relative "../transcript_export"
|
|
24
|
+
require_relative "../workspace"
|
|
25
|
+
require_relative "prompt_bridge"
|
|
26
|
+
require_relative "tool_event_normalizer"
|
|
27
|
+
require_relative "transcript_normalizer"
|
|
28
|
+
|
|
29
|
+
module Kward
|
|
30
|
+
module RPC
|
|
31
|
+
class SessionManager
|
|
32
|
+
RECENT_EVENT_LIMIT = 1_000
|
|
33
|
+
RPC_ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024
|
|
34
|
+
RPC_IMAGE_MIME_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"].freeze
|
|
35
|
+
STREAMING_BEHAVIORS = ["newTurn", "followUp", "steer"].freeze
|
|
36
|
+
WORKER_STOP = Object.new.freeze
|
|
37
|
+
|
|
38
|
+
RpcSession = Struct.new(:id, :workspace_root, :store, :session, :conversation, :agent, :tool_registry, :prompt, :plugin_output, :queue, :worker, :running_turn_id, keyword_init: true)
|
|
39
|
+
Turn = Struct.new(:id, :session_id, :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)
|
|
40
|
+
|
|
41
|
+
def initialize(server:, client: Client.new, config_dir: ConfigFiles.config_dir, context_usage: ContextUsage.new)
|
|
42
|
+
@server = server
|
|
43
|
+
@client = client
|
|
44
|
+
@config_dir = config_dir
|
|
45
|
+
@context_usage = context_usage
|
|
46
|
+
@sessions = {}
|
|
47
|
+
@turns = {}
|
|
48
|
+
@mutex = Mutex.new
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def create_session(workspace_root: Dir.pwd, name: nil)
|
|
52
|
+
workspace_root = validate_workspace_root(workspace_root)
|
|
53
|
+
store = SessionStore.new(config_dir: @config_dir, cwd: workspace_root)
|
|
54
|
+
conversation = new_conversation(workspace_root: workspace_root)
|
|
55
|
+
session = store.create(model: conversation.model, reasoning_effort: conversation.reasoning_effort)
|
|
56
|
+
session.rename(name) unless name.to_s.strip.empty?
|
|
57
|
+
session.attach(conversation)
|
|
58
|
+
rpc_session = build_rpc_session(store, session, conversation, workspace_root)
|
|
59
|
+
remember_session(rpc_session)
|
|
60
|
+
cleanup_other_unused_sessions(rpc_session)
|
|
61
|
+
session_payload(rpc_session)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def resume_session(path:, workspace_root: nil)
|
|
65
|
+
root = validate_workspace_root(workspace_root || Dir.pwd)
|
|
66
|
+
store = SessionStore.new(config_dir: @config_dir, cwd: root)
|
|
67
|
+
location = store.session_location(path)
|
|
68
|
+
root = validate_workspace_root(location[:cwd])
|
|
69
|
+
store = SessionStore.new(config_dir: @config_dir, cwd: root)
|
|
70
|
+
session, conversation = store.load(
|
|
71
|
+
location[:path],
|
|
72
|
+
workspace: Workspace.new(root: root),
|
|
73
|
+
model: current_model_id,
|
|
74
|
+
reasoning_effort: current_reasoning_effort
|
|
75
|
+
)
|
|
76
|
+
rpc_session = build_rpc_session(store, session, conversation, root)
|
|
77
|
+
remember_session(rpc_session)
|
|
78
|
+
cleanup_other_unused_sessions(rpc_session)
|
|
79
|
+
session_payload(rpc_session)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def list_sessions(workspace_root: Dir.pwd, limit: 20)
|
|
83
|
+
root = validate_workspace_root(workspace_root)
|
|
84
|
+
store = SessionStore.new(config_dir: @config_dir, cwd: root)
|
|
85
|
+
limit = limit.to_i <= 0 ? 20 : limit.to_i
|
|
86
|
+
store.recent_tree(limit: limit + active_session_count(root))
|
|
87
|
+
.reject { |info| active_empty_unnamed_session_info?(info, root) }
|
|
88
|
+
.first(limit)
|
|
89
|
+
.map { |info| session_info_payload(info, workspace_root: root) }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def rename_session(session_id:, name:)
|
|
93
|
+
rpc_session = fetch_session(session_id)
|
|
94
|
+
rpc_session.session.rename(name)
|
|
95
|
+
session_payload(rpc_session)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def clone_session(session_id:)
|
|
99
|
+
source = fetch_session(session_id)
|
|
100
|
+
session, conversation = source.store.create_independent_from_conversation(source.conversation, parent_session: source.session)
|
|
101
|
+
rpc_session = build_rpc_session(source.store, session, conversation, source.workspace_root)
|
|
102
|
+
remember_session(rpc_session)
|
|
103
|
+
cleanup_other_unused_sessions(rpc_session)
|
|
104
|
+
session_payload(rpc_session)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def compact_session(session_id:, custom_instructions: "")
|
|
108
|
+
rpc_session = fetch_session(session_id)
|
|
109
|
+
emit_session_event(rpc_session, "compactionStart", {})
|
|
110
|
+
result = Compactor.new(conversation: rpc_session.conversation, client: @client, settings: compaction_settings).compact(custom_instructions: custom_instructions)
|
|
111
|
+
payload = {
|
|
112
|
+
summary: result.summary,
|
|
113
|
+
firstKeptEntryId: result.first_kept_entry_id,
|
|
114
|
+
tokensBefore: result.tokens_before,
|
|
115
|
+
details: result.details
|
|
116
|
+
}.compact
|
|
117
|
+
emit_session_event(rpc_session, "compactionEnd", { result: payload, aborted: false, willRetry: false, errorMessage: nil })
|
|
118
|
+
payload
|
|
119
|
+
rescue StandardError => e
|
|
120
|
+
emit_session_event(rpc_session, "compactionEnd", { result: nil, aborted: true, willRetry: false, errorMessage: e.message }) if rpc_session
|
|
121
|
+
raise e
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def fork_messages(session_id:)
|
|
125
|
+
rpc_session = fetch_session(session_id)
|
|
126
|
+
{
|
|
127
|
+
messages: entry_messages(rpc_session.conversation).each_with_index.filter_map do |message, index|
|
|
128
|
+
next unless message_role(message) == "user"
|
|
129
|
+
|
|
130
|
+
{ entryId: entry_id(index), text: display_message_text(message) }
|
|
131
|
+
end
|
|
132
|
+
}
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def fork_session(session_id:, entry_id:)
|
|
136
|
+
source = fetch_session(session_id)
|
|
137
|
+
index = entry_index(entry_id)
|
|
138
|
+
messages = entry_messages(source.conversation)
|
|
139
|
+
selected = messages[index] || raise(ArgumentError, "Unknown fork entryId: #{entry_id}")
|
|
140
|
+
raise ArgumentError, "Entry is not forkable: #{entry_id}" unless message_role(selected) == "user"
|
|
141
|
+
|
|
142
|
+
session, conversation = source.store.create_independent_from_messages(
|
|
143
|
+
messages[0...index],
|
|
144
|
+
model: source.conversation.model,
|
|
145
|
+
reasoning_effort: source.conversation.reasoning_effort,
|
|
146
|
+
parent_session: source.session
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
# ensure forked sessions retain the original persona context
|
|
150
|
+
rpc_session = build_rpc_session(source.store, session, conversation, source.workspace_root)
|
|
151
|
+
remember_session(rpc_session)
|
|
152
|
+
cleanup_other_unused_sessions(rpc_session)
|
|
153
|
+
{
|
|
154
|
+
session: session_payload(rpc_session),
|
|
155
|
+
text: full_message_text(selected),
|
|
156
|
+
cancelled: false
|
|
157
|
+
}
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def export_session(session_id:, path: nil, format: nil)
|
|
161
|
+
rpc_session = fetch_session(session_id)
|
|
162
|
+
format = export_format(format)
|
|
163
|
+
path = export_path(rpc_session, path, format)
|
|
164
|
+
content = export_content(rpc_session.conversation, format)
|
|
165
|
+
File.write(path, content)
|
|
166
|
+
{ path: path, format: format }
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def delete_session(session_id:)
|
|
170
|
+
rpc_session = fetch_session(session_id)
|
|
171
|
+
path = rpc_session.session.path
|
|
172
|
+
close_session(session_id: session_id)
|
|
173
|
+
File.delete(path) if File.exist?(path)
|
|
174
|
+
{ deleted: true, path: path }
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def close_session(session_id:)
|
|
178
|
+
rpc_session = fetch_session(session_id)
|
|
179
|
+
close_rpc_session(rpc_session)
|
|
180
|
+
{ closed: true }
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def cleanup_unused_sessions
|
|
184
|
+
rpc_sessions = @mutex.synchronize { @sessions.values.dup }
|
|
185
|
+
rpc_sessions.reverse_each do |rpc_session|
|
|
186
|
+
next unless session_idle?(rpc_session)
|
|
187
|
+
|
|
188
|
+
close_rpc_session(rpc_session)
|
|
189
|
+
end
|
|
190
|
+
{ closed: true }
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def transcript(session_id:)
|
|
194
|
+
rpc_session = fetch_session(session_id)
|
|
195
|
+
{ session: session_payload(rpc_session), messages: TranscriptNormalizer.new(rpc_session.conversation.messages).normalize }
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def start_turn(session_id:, input:, streaming_behavior: nil, attachments: [])
|
|
199
|
+
rpc_session = fetch_session(session_id)
|
|
200
|
+
normalized_attachments = normalize_attachments(attachments)
|
|
201
|
+
plugin_command, plugin_arguments = plugin_command_turn(input, normalized_attachments)
|
|
202
|
+
content = plugin_command ? input.to_s : user_turn_content(expand_prompt_input(input), normalized_attachments)
|
|
203
|
+
streaming_behavior = validate_streaming_behavior(default_streaming_behavior(rpc_session, streaming_behavior), rpc_session: rpc_session)
|
|
204
|
+
if streaming_behavior == "steer"
|
|
205
|
+
steered_turn = steer_running_turn(rpc_session, content)
|
|
206
|
+
return steered_turn if steered_turn
|
|
207
|
+
|
|
208
|
+
streaming_behavior = "followUp"
|
|
209
|
+
end
|
|
210
|
+
turn = Turn.new(
|
|
211
|
+
id: SecureRandom.uuid,
|
|
212
|
+
session_id: rpc_session.id,
|
|
213
|
+
input: content,
|
|
214
|
+
status: "queued",
|
|
215
|
+
cancel_requested: false,
|
|
216
|
+
cancellation: Cancellation.new,
|
|
217
|
+
created_at: now,
|
|
218
|
+
events: [],
|
|
219
|
+
next_sequence: 1,
|
|
220
|
+
streaming_behavior: streaming_behavior,
|
|
221
|
+
plugin_command_name: plugin_command&.name,
|
|
222
|
+
plugin_arguments: plugin_arguments
|
|
223
|
+
)
|
|
224
|
+
@mutex.synchronize { @turns[turn.id] = turn }
|
|
225
|
+
rpc_session.queue << turn.id
|
|
226
|
+
ensure_worker(rpc_session)
|
|
227
|
+
emit_turn_event(turn, "turnQueued", { status: "queued" })
|
|
228
|
+
turn_payload(turn)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def cancel_turn(turn_id:)
|
|
232
|
+
turn = fetch_turn(turn_id)
|
|
233
|
+
turn.cancel_requested = true
|
|
234
|
+
turn.cancellation&.cancel!
|
|
235
|
+
emit_turn_event(turn, "turnCancelRequested", {})
|
|
236
|
+
if turn.status == "queued"
|
|
237
|
+
finish_turn(turn, "canceled")
|
|
238
|
+
end
|
|
239
|
+
turn_payload(turn)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def turn_status(turn_id:)
|
|
243
|
+
turn_payload(fetch_turn(turn_id))
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def turn_events(turn_id:, after_sequence: 0)
|
|
247
|
+
turn = fetch_turn(turn_id)
|
|
248
|
+
after_sequence = after_sequence.to_i
|
|
249
|
+
{
|
|
250
|
+
turn: turn_payload(turn),
|
|
251
|
+
events: turn.events.select { |event| event[:sequence].to_i > after_sequence }
|
|
252
|
+
}
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def answer_question(session_id:, question_request_id:, answers:)
|
|
256
|
+
rpc_session = fetch_session(session_id)
|
|
257
|
+
rpc_session.prompt.answer(question_request_id, answers)
|
|
258
|
+
{ ok: true }
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def run_command(session_id:, command:, arguments: "")
|
|
262
|
+
name = command.to_s.delete_prefix("/")
|
|
263
|
+
return { ok: false, error: "unsupported", reason: "notImplemented" } if name == "crew"
|
|
264
|
+
return { ok: false, error: "unsupported", reason: "clientClipboardOwnedByUi" } if name == "copy"
|
|
265
|
+
|
|
266
|
+
run_plugin_command(session_id: session_id, command: name, arguments: arguments)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def memory_manager
|
|
270
|
+
Memory::Manager.for_config_dir(@config_dir)
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def memory_status
|
|
274
|
+
manager = memory_manager
|
|
275
|
+
{ enabled: manager.enabled?, autoSummary: manager.auto_summary_enabled?, paths: manager.paths }
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def memory_enable
|
|
279
|
+
memory_manager.enable
|
|
280
|
+
{ enabled: true }
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def memory_disable
|
|
284
|
+
memory_manager.disable
|
|
285
|
+
{ enabled: false }
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def memory_auto_summary_enable
|
|
289
|
+
memory_manager.auto_summary_enable
|
|
290
|
+
{ autoSummary: true }
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def memory_auto_summary_disable
|
|
294
|
+
memory_manager.auto_summary_disable
|
|
295
|
+
{ autoSummary: false }
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def memory_list(include_inactive: false)
|
|
299
|
+
memory_manager.list(include_inactive: include_inactive)
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def memory_add(text:, scope: nil, tags: [])
|
|
303
|
+
{ memory: memory_manager.add_soft(text, scope: scope || "global", tags: tags) }
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def memory_add_core(text:, scope: nil, tags: [])
|
|
307
|
+
{ memory: memory_manager.add_core(text, scope: scope || "global", tags: tags) }
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def memory_forget(id:)
|
|
311
|
+
{ forgotten: memory_manager.forget_memory(id) }
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def memory_promote(id:)
|
|
315
|
+
{ memory: memory_manager.promote_soft_to_core(id) }
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def memory_inspect
|
|
319
|
+
memory_manager.inspect_memory
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def memory_why(session_id: nil)
|
|
323
|
+
if session_id
|
|
324
|
+
rpc_session = fetch_session(session_id)
|
|
325
|
+
return rpc_session.conversation.last_memory_retrieval || memory_manager.explain_retrieval
|
|
326
|
+
end
|
|
327
|
+
memory_manager.explain_retrieval
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def memory_summarize(session_id:)
|
|
331
|
+
rpc_session = fetch_session(session_id)
|
|
332
|
+
records = memory_manager.summarize_conversation(rpc_session.conversation, client: @client)
|
|
333
|
+
persist_memory_state(rpc_session)
|
|
334
|
+
{ memories: records }
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def run_plugin_command(session_id:, command:, arguments: "")
|
|
338
|
+
rpc_session = fetch_session(session_id)
|
|
339
|
+
command = plugin_registry.command_for(command.to_s.delete_prefix("/")) || raise(ArgumentError, "Unknown plugin command: #{command}")
|
|
340
|
+
output = []
|
|
341
|
+
context = PluginRegistry::Context.new(
|
|
342
|
+
conversation: rpc_session.conversation,
|
|
343
|
+
args: arguments.to_s,
|
|
344
|
+
session: rpc_session.session,
|
|
345
|
+
workspace_root: rpc_session.workspace_root,
|
|
346
|
+
say_callback: lambda { |message| output << message.to_s }
|
|
347
|
+
)
|
|
348
|
+
result = command.handler.call(arguments.to_s, context)
|
|
349
|
+
output = rpc_session.plugin_output.shift(rpc_session.plugin_output.length) + output
|
|
350
|
+
{ command: command.name, output: output, result: result.nil? ? nil : result.to_s }
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def plugin_commands
|
|
354
|
+
plugin_registry.commands
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
def available_models
|
|
358
|
+
models = @client.respond_to?(:available_models) ? Array(@client.available_models) : []
|
|
359
|
+
normalized = models.map { |model| normalize_model(model) }
|
|
360
|
+
current = current_model
|
|
361
|
+
normalized << current if normalized.none? { |model| model[:provider] == current[:provider] && model[:id] == current[:id] }
|
|
362
|
+
normalized
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def openrouter_catalog
|
|
366
|
+
models = @client.respond_to?(:openrouter_catalog) ? Array(@client.openrouter_catalog) : []
|
|
367
|
+
models.map { |model| normalize_model(model) }
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def current_model
|
|
371
|
+
provider = @client.respond_to?(:current_provider) ? @client.current_provider : nil
|
|
372
|
+
model = @client.respond_to?(:current_model) ? @client.current_model : nil
|
|
373
|
+
context_window = @client.respond_to?(:current_context_window) ? @client.current_context_window : nil
|
|
374
|
+
normalize_model(provider: provider, id: model, model: model, contextWindow: context_window, current: true)
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
def in_flight_steer_supported?
|
|
378
|
+
supports_in_flight_steer?
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def runtime_state(session_id:)
|
|
382
|
+
rpc_session = fetch_session(session_id)
|
|
383
|
+
model = current_model
|
|
384
|
+
compaction_settings = self.compaction_settings
|
|
385
|
+
auto_compaction_reserve_tokens = compaction_reserve_tokens(
|
|
386
|
+
context_window: model[:contextWindow],
|
|
387
|
+
compaction_settings: compaction_settings
|
|
388
|
+
)
|
|
389
|
+
session = session_payload(rpc_session)
|
|
390
|
+
pending_count = pending_turn_count(rpc_session.id)
|
|
391
|
+
{
|
|
392
|
+
model: model,
|
|
393
|
+
thinkingLevel: model[:reasoningEffort],
|
|
394
|
+
isStreaming: streaming?(rpc_session),
|
|
395
|
+
isCompacting: false,
|
|
396
|
+
steeringMode: supports_in_flight_steer? ? "in-flight" : "one-at-a-time",
|
|
397
|
+
followUpMode: "one-at-a-time",
|
|
398
|
+
sessionFile: session[:path],
|
|
399
|
+
sessionId: session[:persistentId],
|
|
400
|
+
rpcSessionId: session[:id],
|
|
401
|
+
persistentSessionId: session[:persistentId],
|
|
402
|
+
sessionName: session[:name],
|
|
403
|
+
autoCompactionEnabled: compaction_settings.enabled,
|
|
404
|
+
autoCompactionReserveTokens: auto_compaction_reserve_tokens,
|
|
405
|
+
autoRetryEnabled: false,
|
|
406
|
+
defaultProvider: model[:provider],
|
|
407
|
+
defaultModel: default_model_label(model),
|
|
408
|
+
defaultThinkingLevel: model[:reasoningEffort],
|
|
409
|
+
hideThinkingBlock: false,
|
|
410
|
+
quietStartup: false,
|
|
411
|
+
transport: "kward-rpc",
|
|
412
|
+
imageAutoResize: false,
|
|
413
|
+
blockImages: false,
|
|
414
|
+
enabledModels: [],
|
|
415
|
+
enableSkillCommands: true,
|
|
416
|
+
messageCount: message_count(rpc_session.conversation),
|
|
417
|
+
pendingMessageCount: pending_count
|
|
418
|
+
}.compact
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
def runtime_stats(session_id:)
|
|
422
|
+
rpc_session = fetch_session(session_id)
|
|
423
|
+
session = session_payload(rpc_session)
|
|
424
|
+
counts = message_stats(rpc_session.conversation)
|
|
425
|
+
model = current_model
|
|
426
|
+
compaction_settings = self.compaction_settings
|
|
427
|
+
auto_compaction_reserve_tokens = compaction_reserve_tokens(
|
|
428
|
+
context_window: model[:contextWindow],
|
|
429
|
+
compaction_settings: compaction_settings
|
|
430
|
+
)
|
|
431
|
+
{
|
|
432
|
+
sessionFile: session[:path],
|
|
433
|
+
sessionId: session[:persistentId],
|
|
434
|
+
rpcSessionId: session[:id],
|
|
435
|
+
persistentSessionId: session[:persistentId],
|
|
436
|
+
sessionName: session[:name],
|
|
437
|
+
userMessages: counts[:userMessages],
|
|
438
|
+
assistantMessages: counts[:assistantMessages],
|
|
439
|
+
toolCalls: counts[:toolCalls],
|
|
440
|
+
toolResults: counts[:toolResults],
|
|
441
|
+
totalMessages: counts[:totalMessages],
|
|
442
|
+
usingSubscription: model[:provider] == "Codex",
|
|
443
|
+
autoCompactionEnabled: compaction_settings.enabled,
|
|
444
|
+
autoCompactionReserveTokens: auto_compaction_reserve_tokens,
|
|
445
|
+
contextUsage: context_usage(rpc_session, model)
|
|
446
|
+
}.compact
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
def refresh_client_config
|
|
450
|
+
@client.reload_config if @client.respond_to?(:reload_config)
|
|
451
|
+
refresh_session_runtime_contexts
|
|
452
|
+
refresh_session_tool_registries
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
def session_payload(rpc_session)
|
|
456
|
+
{
|
|
457
|
+
id: rpc_session.id,
|
|
458
|
+
persistentId: rpc_session.session.id,
|
|
459
|
+
path: rpc_session.session.path,
|
|
460
|
+
workspaceRoot: rpc_session.workspace_root,
|
|
461
|
+
cwd: rpc_session.session.cwd.to_s.empty? ? rpc_session.workspace_root : rpc_session.session.cwd,
|
|
462
|
+
name: rpc_session.session.name,
|
|
463
|
+
createdAt: rpc_session.session.created_at&.utc&.iso8601(3),
|
|
464
|
+
modifiedAt: session_modified_at(rpc_session.session)&.utc&.iso8601(3),
|
|
465
|
+
parentId: rpc_session.session.parent_id,
|
|
466
|
+
parentPath: rpc_session.session.parent_path
|
|
467
|
+
}
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
def session_modified_at(session)
|
|
471
|
+
File.exist?(session.path) ? File.mtime(session.path) : nil
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
def validate_workspace_root(root)
|
|
475
|
+
expanded = File.expand_path(root.to_s.empty? ? Dir.pwd : root.to_s)
|
|
476
|
+
raise "Workspace root is not an existing directory: #{expanded}" unless File.directory?(expanded)
|
|
477
|
+
|
|
478
|
+
File.realpath(expanded)
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
private
|
|
482
|
+
|
|
483
|
+
def new_conversation(workspace_root: Dir.pwd)
|
|
484
|
+
Conversation.new(
|
|
485
|
+
workspace_root: workspace_root,
|
|
486
|
+
model: (@client.current_model if @client.respond_to?(:current_model)),
|
|
487
|
+
reasoning_effort: (@client.current_reasoning_effort if @client.respond_to?(:current_reasoning_effort)),
|
|
488
|
+
plugin_registry: plugin_registry
|
|
489
|
+
)
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
def refresh_session_runtime_contexts
|
|
493
|
+
model = current_model_id
|
|
494
|
+
reasoning_effort = current_reasoning_effort
|
|
495
|
+
sessions = @mutex.synchronize { @sessions.values }
|
|
496
|
+
sessions.each do |rpc_session|
|
|
497
|
+
rpc_session.conversation.update_runtime_context!(model: model, reasoning_effort: reasoning_effort)
|
|
498
|
+
rpc_session.session.update_runtime(model: model, reasoning_effort: reasoning_effort)
|
|
499
|
+
end
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
def refresh_session_tool_registries
|
|
503
|
+
sessions = @mutex.synchronize { @sessions.values }
|
|
504
|
+
sessions.each { |rpc_session| rebuild_session_tools(rpc_session) }
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
def rebuild_session_tools(rpc_session)
|
|
508
|
+
tool_registry = build_tool_registry(rpc_session.workspace_root, rpc_session.prompt)
|
|
509
|
+
rpc_session.tool_registry = tool_registry
|
|
510
|
+
rpc_session.agent = Agent.new(
|
|
511
|
+
client: @client,
|
|
512
|
+
tool_registry: tool_registry,
|
|
513
|
+
conversation: rpc_session.conversation
|
|
514
|
+
)
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
def compaction_settings
|
|
518
|
+
path = File.join(@config_dir, "config.json")
|
|
519
|
+
Kward::Compaction::Settings.from_config(ConfigFiles.read_config(path))
|
|
520
|
+
rescue StandardError
|
|
521
|
+
Kward::Compaction::Settings.new
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
def compaction_reserve_tokens(context_window:, compaction_settings:)
|
|
525
|
+
return nil unless compaction_settings&.enabled
|
|
526
|
+
return nil unless context_window
|
|
527
|
+
|
|
528
|
+
Kward::Compactor.auto_compaction_reserve_tokens(
|
|
529
|
+
context_window: context_window,
|
|
530
|
+
configured_reserve_tokens: compaction_settings&.reserve_tokens
|
|
531
|
+
)
|
|
532
|
+
end
|
|
533
|
+
|
|
534
|
+
def current_model_id
|
|
535
|
+
@client.respond_to?(:current_model) ? @client.current_model : ModelInfo::DEFAULT_OPENAI_MODEL
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
def current_reasoning_effort
|
|
539
|
+
@client.respond_to?(:current_reasoning_effort) ? @client.current_reasoning_effort : ModelInfo::DEFAULT_REASONING_EFFORT
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
def normalize_model(model)
|
|
543
|
+
ModelInfo.normalize(
|
|
544
|
+
model,
|
|
545
|
+
current_provider: (@client.current_provider if @client.respond_to?(:current_provider)),
|
|
546
|
+
current_model: (@client.current_model if @client.respond_to?(:current_model)),
|
|
547
|
+
current_reasoning_effort: (@client.current_reasoning_effort if @client.respond_to?(:current_reasoning_effort))
|
|
548
|
+
)
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
def default_model_label(model)
|
|
552
|
+
return nil if model[:provider].to_s.empty? || model[:id].to_s.empty?
|
|
553
|
+
|
|
554
|
+
"#{model[:provider]}/#{model[:id]}"
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
def streaming?(rpc_session)
|
|
558
|
+
turn_id = rpc_session.running_turn_id
|
|
559
|
+
return false unless turn_id
|
|
560
|
+
|
|
561
|
+
@mutex.synchronize { @turns[turn_id]&.status == "running" }
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
def pending_turn_count(session_id)
|
|
565
|
+
@mutex.synchronize do
|
|
566
|
+
@turns.values.count { |turn| turn.session_id == session_id && ["queued", "running"].include?(turn.status) }
|
|
567
|
+
end
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
def session_idle?(rpc_session)
|
|
571
|
+
pending_turn_count(rpc_session.id).zero?
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
def active_session_count(workspace_root)
|
|
575
|
+
@mutex.synchronize { @sessions.values.count { |rpc_session| rpc_session.workspace_root == workspace_root } }
|
|
576
|
+
end
|
|
577
|
+
|
|
578
|
+
def active_empty_unnamed_session_info?(info, workspace_root)
|
|
579
|
+
rpc_sessions = @mutex.synchronize { @sessions.values.dup }
|
|
580
|
+
rpc_sessions.any? do |rpc_session|
|
|
581
|
+
rpc_session.workspace_root == workspace_root &&
|
|
582
|
+
File.expand_path(rpc_session.session.path) == File.expand_path(info.path) &&
|
|
583
|
+
session_idle?(rpc_session) &&
|
|
584
|
+
info.name.to_s.strip.empty? &&
|
|
585
|
+
info.message_count.to_i.zero?
|
|
586
|
+
end
|
|
587
|
+
end
|
|
588
|
+
|
|
589
|
+
def message_count(conversation)
|
|
590
|
+
conversation.messages.count { |message| message_role(message) != "system" }
|
|
591
|
+
end
|
|
592
|
+
|
|
593
|
+
def context_usage(rpc_session, model)
|
|
594
|
+
context_parts = if @client.respond_to?(:current_context_parts)
|
|
595
|
+
@client.current_context_parts(rpc_session.conversation.messages, rpc_session.tool_registry.schemas)
|
|
596
|
+
else
|
|
597
|
+
{ provider: model[:provider], model: model[:id], messages: rpc_session.conversation.messages, tools: rpc_session.tool_registry.schemas }
|
|
598
|
+
end
|
|
599
|
+
@context_usage.call(
|
|
600
|
+
provider: model[:provider],
|
|
601
|
+
model: model[:id],
|
|
602
|
+
context_window: model[:contextWindow],
|
|
603
|
+
context_parts: context_parts
|
|
604
|
+
)
|
|
605
|
+
end
|
|
606
|
+
|
|
607
|
+
def message_stats(conversation)
|
|
608
|
+
conversation.messages.each_with_object({ userMessages: 0, assistantMessages: 0, toolCalls: 0, toolResults: 0, totalMessages: 0 }) do |message, counts|
|
|
609
|
+
role = message_role(message)
|
|
610
|
+
next if role == "system"
|
|
611
|
+
|
|
612
|
+
counts[:totalMessages] += 1
|
|
613
|
+
case role
|
|
614
|
+
when "user"
|
|
615
|
+
counts[:userMessages] += 1
|
|
616
|
+
when "assistant"
|
|
617
|
+
counts[:assistantMessages] += 1
|
|
618
|
+
counts[:toolCalls] += tool_calls(message).length
|
|
619
|
+
when "tool", "toolResult"
|
|
620
|
+
counts[:toolResults] += 1
|
|
621
|
+
end
|
|
622
|
+
end
|
|
623
|
+
end
|
|
624
|
+
|
|
625
|
+
def tool_calls(message)
|
|
626
|
+
MessageAccess.tool_calls(message)
|
|
627
|
+
end
|
|
628
|
+
|
|
629
|
+
def message_role(message)
|
|
630
|
+
MessageAccess.role(message)
|
|
631
|
+
end
|
|
632
|
+
|
|
633
|
+
def message_content(message)
|
|
634
|
+
MessageAccess.content(message)
|
|
635
|
+
end
|
|
636
|
+
|
|
637
|
+
def entry_messages(conversation)
|
|
638
|
+
conversation.messages.reject { |message| message_role(message) == "system" }
|
|
639
|
+
end
|
|
640
|
+
|
|
641
|
+
def entry_id(index)
|
|
642
|
+
"message:#{index}"
|
|
643
|
+
end
|
|
644
|
+
|
|
645
|
+
def entry_index(entry_id)
|
|
646
|
+
match = entry_id.to_s.match(/\Amessage:(\d+)\z/)
|
|
647
|
+
raise ArgumentError, "Invalid entryId: #{entry_id}" unless match
|
|
648
|
+
|
|
649
|
+
match[1].to_i
|
|
650
|
+
end
|
|
651
|
+
|
|
652
|
+
def display_message_text(message)
|
|
653
|
+
full_message_text(message).gsub(/\s+/, " ").strip.slice(0, 120).to_s
|
|
654
|
+
end
|
|
655
|
+
|
|
656
|
+
def full_message_text(message)
|
|
657
|
+
content = message["content"] || message[:content]
|
|
658
|
+
text = if content.is_a?(Array)
|
|
659
|
+
content.filter_map { |part| part["text"] || part[:text] }.join("\n")
|
|
660
|
+
else
|
|
661
|
+
content.to_s
|
|
662
|
+
end
|
|
663
|
+
text.strip
|
|
664
|
+
end
|
|
665
|
+
|
|
666
|
+
def supports_in_flight_steer?
|
|
667
|
+
@client.respond_to?(:supports_in_flight_steer?) && @client.supports_in_flight_steer?
|
|
668
|
+
end
|
|
669
|
+
|
|
670
|
+
def default_streaming_behavior(rpc_session, streaming_behavior)
|
|
671
|
+
behavior = streaming_behavior.to_s
|
|
672
|
+
return behavior unless behavior.empty?
|
|
673
|
+
return "steer" if supports_in_flight_steer? && streaming?(rpc_session)
|
|
674
|
+
|
|
675
|
+
"newTurn"
|
|
676
|
+
end
|
|
677
|
+
|
|
678
|
+
def validate_streaming_behavior(streaming_behavior, rpc_session: nil)
|
|
679
|
+
behavior = streaming_behavior.to_s.empty? ? "newTurn" : streaming_behavior.to_s
|
|
680
|
+
raise ArgumentError, "Unsupported streamingBehavior: #{behavior}" unless STREAMING_BEHAVIORS.include?(behavior)
|
|
681
|
+
raise ArgumentError, "Unsupported streamingBehavior: steer" if behavior == "steer" && !supports_in_flight_steer?
|
|
682
|
+
raise ArgumentError, "Unsupported streamingBehavior: steer" if behavior == "steer" && (!rpc_session || !streaming?(rpc_session))
|
|
683
|
+
|
|
684
|
+
behavior
|
|
685
|
+
end
|
|
686
|
+
|
|
687
|
+
def user_turn_content(input, attachments)
|
|
688
|
+
return input.to_s if attachments.empty?
|
|
689
|
+
|
|
690
|
+
[{ type: "text", text: input.to_s }] + attachments
|
|
691
|
+
end
|
|
692
|
+
|
|
693
|
+
def expand_prompt_input(input)
|
|
694
|
+
return input unless input.is_a?(String)
|
|
695
|
+
|
|
696
|
+
PromptCommands.expand(input) || input
|
|
697
|
+
end
|
|
698
|
+
|
|
699
|
+
def plugin_command_turn(input, attachments)
|
|
700
|
+
return [nil, ""] unless input.is_a?(String)
|
|
701
|
+
return [nil, ""] unless attachments.empty?
|
|
702
|
+
|
|
703
|
+
command, arguments = PromptCommands.parse(input)
|
|
704
|
+
return [nil, ""] unless command
|
|
705
|
+
|
|
706
|
+
[plugin_registry.command_for(command), arguments]
|
|
707
|
+
end
|
|
708
|
+
|
|
709
|
+
def normalize_attachments(attachments)
|
|
710
|
+
return [] if attachments.nil?
|
|
711
|
+
raise ArgumentError, "attachments must be an array" unless attachments.is_a?(Array)
|
|
712
|
+
|
|
713
|
+
attachments.map { |attachment| normalize_attachment(attachment) }
|
|
714
|
+
end
|
|
715
|
+
|
|
716
|
+
def normalize_attachment(attachment)
|
|
717
|
+
raise ArgumentError, "attachment must be an object" unless attachment.is_a?(Hash)
|
|
718
|
+
|
|
719
|
+
type = value(attachment, :type).to_s
|
|
720
|
+
raise ArgumentError, "Unsupported attachment type: #{type.empty? ? "unknown" : type}" unless type == "image"
|
|
721
|
+
|
|
722
|
+
mime_type = normalize_attachment_mime_type(value(attachment, :mimeType) || value(attachment, :mime_type) || value(attachment, :media_type))
|
|
723
|
+
raise ArgumentError, "Unsupported image MIME type: #{mime_type.empty? ? "unknown" : mime_type}" unless RPC_IMAGE_MIME_TYPES.include?(mime_type)
|
|
724
|
+
|
|
725
|
+
data = value(attachment, :data).to_s
|
|
726
|
+
raise ArgumentError, "Image attachment data must be valid base64" if data.empty?
|
|
727
|
+
raise ArgumentError, "Image attachment data must be raw base64" if data.start_with?("data:")
|
|
728
|
+
declared_size = value(attachment, :sizeBytes) || value(attachment, :size_bytes)
|
|
729
|
+
raise ArgumentError, "Image attachment is too large" if declared_size && declared_size.to_i > RPC_ATTACHMENT_MAX_BYTES
|
|
730
|
+
|
|
731
|
+
decoded_size = Base64.strict_decode64(data).bytesize
|
|
732
|
+
raise ArgumentError, "Image attachment is too large" if decoded_size > RPC_ATTACHMENT_MAX_BYTES
|
|
733
|
+
|
|
734
|
+
result = { type: "image", data: data, mimeType: mime_type }
|
|
735
|
+
name = value(attachment, :name)
|
|
736
|
+
result[:alt] = name.to_s unless name.to_s.empty?
|
|
737
|
+
result
|
|
738
|
+
rescue ArgumentError => e
|
|
739
|
+
raise e if e.message.start_with?("Unsupported", "Image attachment", "attachment")
|
|
740
|
+
|
|
741
|
+
raise ArgumentError, "Image attachment data must be valid base64"
|
|
742
|
+
end
|
|
743
|
+
|
|
744
|
+
def normalize_attachment_mime_type(mime_type)
|
|
745
|
+
mime_type.to_s.downcase
|
|
746
|
+
end
|
|
747
|
+
|
|
748
|
+
def value(object, key)
|
|
749
|
+
return nil unless object.respond_to?(:key?)
|
|
750
|
+
return object[key] if object.key?(key)
|
|
751
|
+
return object[key.to_s] if object.key?(key.to_s)
|
|
752
|
+
|
|
753
|
+
nil
|
|
754
|
+
end
|
|
755
|
+
|
|
756
|
+
def plugin_registry
|
|
757
|
+
@plugin_registry ||= PluginRegistry.load(reserved_commands: reserved_plugin_command_names)
|
|
758
|
+
end
|
|
759
|
+
|
|
760
|
+
def reserved_plugin_command_names
|
|
761
|
+
PromptCommands::BUILTIN_RESERVED_COMMAND_NAMES + ConfigFiles.prompt_templates(reserved_commands: PromptCommands::BUILTIN_RESERVED_COMMAND_NAMES).map(&:command)
|
|
762
|
+
end
|
|
763
|
+
|
|
764
|
+
def build_rpc_session(store, session, conversation, workspace_root)
|
|
765
|
+
conversation.plugin_registry ||= plugin_registry if conversation.respond_to?(:plugin_registry)
|
|
766
|
+
id = SecureRandom.uuid
|
|
767
|
+
prompt = PromptBridge.new(server: @server, session_id: id)
|
|
768
|
+
tool_registry = build_tool_registry(workspace_root, prompt)
|
|
769
|
+
agent = Agent.new(
|
|
770
|
+
client: @client,
|
|
771
|
+
tool_registry: tool_registry,
|
|
772
|
+
conversation: conversation
|
|
773
|
+
)
|
|
774
|
+
RpcSession.new(
|
|
775
|
+
id: id,
|
|
776
|
+
workspace_root: workspace_root,
|
|
777
|
+
store: store,
|
|
778
|
+
session: session,
|
|
779
|
+
conversation: conversation,
|
|
780
|
+
agent: agent,
|
|
781
|
+
tool_registry: tool_registry,
|
|
782
|
+
prompt: prompt,
|
|
783
|
+
plugin_output: [],
|
|
784
|
+
queue: Queue.new,
|
|
785
|
+
worker: nil,
|
|
786
|
+
running_turn_id: nil
|
|
787
|
+
)
|
|
788
|
+
end
|
|
789
|
+
|
|
790
|
+
def build_tool_registry(workspace_root, prompt)
|
|
791
|
+
ToolRegistry.new(workspace: Workspace.new(root: workspace_root), prompt: prompt)
|
|
792
|
+
end
|
|
793
|
+
|
|
794
|
+
def remember_session(rpc_session)
|
|
795
|
+
@mutex.synchronize { @sessions[rpc_session.id] = rpc_session }
|
|
796
|
+
end
|
|
797
|
+
|
|
798
|
+
def fetch_session(session_id)
|
|
799
|
+
@mutex.synchronize { @sessions[session_id.to_s] } || raise("Unknown session: #{session_id}")
|
|
800
|
+
end
|
|
801
|
+
|
|
802
|
+
def fetch_turn(turn_id)
|
|
803
|
+
@mutex.synchronize { @turns[turn_id.to_s] } || raise("Unknown turn: #{turn_id}")
|
|
804
|
+
end
|
|
805
|
+
|
|
806
|
+
def ensure_worker(rpc_session)
|
|
807
|
+
return if rpc_session.worker&.alive?
|
|
808
|
+
|
|
809
|
+
rpc_session.worker = Thread.new { worker_loop(rpc_session) }
|
|
810
|
+
end
|
|
811
|
+
|
|
812
|
+
def close_rpc_session(rpc_session)
|
|
813
|
+
@mutex.synchronize { @sessions.delete(rpc_session.id) }
|
|
814
|
+
stop_worker(rpc_session)
|
|
815
|
+
rpc_session.session.delete_if_unused if rpc_session.session.respond_to?(:delete_if_unused)
|
|
816
|
+
end
|
|
817
|
+
|
|
818
|
+
def cleanup_other_unused_sessions(current_session)
|
|
819
|
+
rpc_sessions = @mutex.synchronize { @sessions.values.dup }
|
|
820
|
+
rpc_sessions.each do |rpc_session|
|
|
821
|
+
next if rpc_session.id == current_session.id
|
|
822
|
+
next if rpc_session.session.path == current_session.session.path
|
|
823
|
+
next unless session_idle?(rpc_session)
|
|
824
|
+
next unless rpc_session.session.respond_to?(:delete_if_unused)
|
|
825
|
+
next unless rpc_session.session.delete_if_unused
|
|
826
|
+
|
|
827
|
+
@mutex.synchronize { @sessions.delete(rpc_session.id) }
|
|
828
|
+
stop_worker(rpc_session)
|
|
829
|
+
end
|
|
830
|
+
end
|
|
831
|
+
|
|
832
|
+
def stop_worker(rpc_session)
|
|
833
|
+
worker = rpc_session.worker
|
|
834
|
+
return unless worker&.alive?
|
|
835
|
+
return if worker == Thread.current
|
|
836
|
+
|
|
837
|
+
rpc_session.queue << WORKER_STOP
|
|
838
|
+
end
|
|
839
|
+
|
|
840
|
+
def worker_loop(rpc_session)
|
|
841
|
+
loop do
|
|
842
|
+
turn_id = rpc_session.queue.pop
|
|
843
|
+
break if turn_id.equal?(WORKER_STOP)
|
|
844
|
+
|
|
845
|
+
begin
|
|
846
|
+
turn = fetch_turn(turn_id)
|
|
847
|
+
next if turn.status == "canceled"
|
|
848
|
+
|
|
849
|
+
run_turn(rpc_session, turn)
|
|
850
|
+
rescue StandardError => e
|
|
851
|
+
@server.log_error(e)
|
|
852
|
+
end
|
|
853
|
+
end
|
|
854
|
+
ensure
|
|
855
|
+
rpc_session.worker = nil if rpc_session.worker == Thread.current
|
|
856
|
+
end
|
|
857
|
+
|
|
858
|
+
def run_turn(rpc_session, turn)
|
|
859
|
+
rpc_session.running_turn_id = turn.id
|
|
860
|
+
turn.steering = build_steering(turn) if supports_in_flight_steer? && !turn.plugin_command_name
|
|
861
|
+
turn.status = "running"
|
|
862
|
+
turn.started_at = now
|
|
863
|
+
emit_turn_event(turn, "turnStarted", { status: "running" })
|
|
864
|
+
|
|
865
|
+
if turn.cancel_requested
|
|
866
|
+
finish_turn(turn, "canceled")
|
|
867
|
+
return
|
|
868
|
+
end
|
|
869
|
+
|
|
870
|
+
if turn.plugin_command_name
|
|
871
|
+
run_plugin_turn(rpc_session, turn)
|
|
872
|
+
else
|
|
873
|
+
prepare_memory_context(rpc_session.conversation, turn.input)
|
|
874
|
+
rpc_session.agent.ask(turn.input, cancellation: turn.cancellation, steering: turn.steering) do |event|
|
|
875
|
+
next if turn.cancel_requested
|
|
876
|
+
|
|
877
|
+
notify_plugin_transcript_event(rpc_session, event)
|
|
878
|
+
handle_agent_event(turn, event)
|
|
879
|
+
end
|
|
880
|
+
persist_memory_state(rpc_session)
|
|
881
|
+
finish_turn(turn, turn.cancel_requested ? "canceled" : "completed")
|
|
882
|
+
end
|
|
883
|
+
rescue Cancellation::CancelledError
|
|
884
|
+
finish_turn(turn, "canceled")
|
|
885
|
+
rescue StandardError => e
|
|
886
|
+
turn.error = turn_error_payload(e)
|
|
887
|
+
emit_turn_event(turn, "error", turn.error)
|
|
888
|
+
finish_turn(turn, "failed")
|
|
889
|
+
ensure
|
|
890
|
+
turn.steering = nil
|
|
891
|
+
rpc_session.running_turn_id = nil
|
|
892
|
+
end
|
|
893
|
+
|
|
894
|
+
def build_steering(_turn)
|
|
895
|
+
Steering.new
|
|
896
|
+
end
|
|
897
|
+
|
|
898
|
+
def prepare_memory_context(conversation, input)
|
|
899
|
+
manager = memory_manager
|
|
900
|
+
retrieval = manager.retrieve_relevant(input: input, workspace_root: conversation.workspace_root)
|
|
901
|
+
conversation.last_memory_retrieval = retrieval
|
|
902
|
+
conversation.memory_context = manager.memory_block(retrieval)
|
|
903
|
+
conversation.refresh_system_message!
|
|
904
|
+
rescue StandardError => e
|
|
905
|
+
@server.log_error(e)
|
|
906
|
+
nil
|
|
907
|
+
end
|
|
908
|
+
|
|
909
|
+
def persist_memory_state(rpc_session)
|
|
910
|
+
rpc_session.session.update_memory_state(session_memories: rpc_session.conversation.session_memories, last_retrieval: rpc_session.conversation.last_memory_retrieval)
|
|
911
|
+
rescue StandardError
|
|
912
|
+
nil
|
|
913
|
+
end
|
|
914
|
+
|
|
915
|
+
def steer_running_turn(rpc_session, input)
|
|
916
|
+
turn_id = rpc_session.running_turn_id
|
|
917
|
+
turn = turn_id && fetch_turn(turn_id)
|
|
918
|
+
raise ArgumentError, "Unsupported streamingBehavior: steer" unless turn&.status == "running" && turn.steering
|
|
919
|
+
|
|
920
|
+
turn.steering.submit(input)
|
|
921
|
+
turn_payload(turn)
|
|
922
|
+
rescue StandardError
|
|
923
|
+
nil
|
|
924
|
+
end
|
|
925
|
+
|
|
926
|
+
def run_plugin_turn(rpc_session, turn)
|
|
927
|
+
turn.cancellation&.raise_if_cancelled!
|
|
928
|
+
command = plugin_registry.command_for(turn.plugin_command_name) || raise(ArgumentError, "Unknown plugin command: #{turn.plugin_command_name}")
|
|
929
|
+
output = []
|
|
930
|
+
context = PluginRegistry::Context.new(
|
|
931
|
+
conversation: rpc_session.conversation,
|
|
932
|
+
args: turn.plugin_arguments.to_s,
|
|
933
|
+
session: rpc_session.session,
|
|
934
|
+
workspace_root: rpc_session.workspace_root,
|
|
935
|
+
say_callback: lambda { |message| output << message.to_s }
|
|
936
|
+
)
|
|
937
|
+
result = command.handler.call(turn.plugin_arguments.to_s, context)
|
|
938
|
+
answer = (output + [result]).compact.map(&:to_s).reject(&:empty?).join("\n")
|
|
939
|
+
unless answer.empty?
|
|
940
|
+
emit_turn_event(turn, "assistantDelta", { delta: answer })
|
|
941
|
+
emit_turn_event(turn, "answer", { content: answer })
|
|
942
|
+
end
|
|
943
|
+
finish_turn(turn, turn.cancel_requested ? "canceled" : "completed")
|
|
944
|
+
end
|
|
945
|
+
|
|
946
|
+
def notify_plugin_transcript_event(rpc_session, event)
|
|
947
|
+
return if plugin_registry.transcript_event_handlers.empty?
|
|
948
|
+
|
|
949
|
+
context = PluginRegistry::Context.new(
|
|
950
|
+
conversation: rpc_session.conversation,
|
|
951
|
+
session: rpc_session.session,
|
|
952
|
+
workspace_root: rpc_session.workspace_root,
|
|
953
|
+
say_callback: lambda { |message| rpc_session.plugin_output << message.to_s }
|
|
954
|
+
)
|
|
955
|
+
plugin_registry.notify_transcript_event(event, context)
|
|
956
|
+
end
|
|
957
|
+
|
|
958
|
+
def handle_agent_event(turn, event)
|
|
959
|
+
case event
|
|
960
|
+
when Events::ReasoningDelta
|
|
961
|
+
emit_turn_event(turn, "reasoningDelta", { delta: event.delta })
|
|
962
|
+
when Events::AssistantDelta
|
|
963
|
+
emit_turn_event(turn, "assistantDelta", { delta: event.delta })
|
|
964
|
+
when Events::AssistantMessage
|
|
965
|
+
emit_turn_event(turn, "assistantMessage", { message: event.message })
|
|
966
|
+
when Events::Retry
|
|
967
|
+
emit_turn_event(turn, "modelRetry", retry_event_payload(event))
|
|
968
|
+
when Events::Steering
|
|
969
|
+
emit_turn_event(turn, "turnSteered", { input: event.input, createdAt: event.created_at })
|
|
970
|
+
when Events::ToolCall
|
|
971
|
+
emit_turn_event(turn, "toolCall", normalized_tool_event_payload(event.tool_call))
|
|
972
|
+
when Events::ToolResult
|
|
973
|
+
emit_turn_event(turn, "toolResult", normalized_tool_result_event_payload(event.tool_call, event.content))
|
|
974
|
+
when Events::Answer
|
|
975
|
+
emit_turn_event(turn, "answer", { content: event.content })
|
|
976
|
+
end
|
|
977
|
+
end
|
|
978
|
+
|
|
979
|
+
def retry_event_payload(event)
|
|
980
|
+
{
|
|
981
|
+
provider: event.provider,
|
|
982
|
+
model: event.model,
|
|
983
|
+
attempt: event.attempt,
|
|
984
|
+
maxAttempts: event.max_attempts,
|
|
985
|
+
delaySeconds: event.delay_seconds,
|
|
986
|
+
error: event.error,
|
|
987
|
+
requestBytes: event.request_bytes
|
|
988
|
+
}.compact
|
|
989
|
+
end
|
|
990
|
+
|
|
991
|
+
def finish_turn(turn, status)
|
|
992
|
+
return if ["completed", "failed", "canceled"].include?(turn.status)
|
|
993
|
+
|
|
994
|
+
turn.status = status
|
|
995
|
+
turn.finished_at = now
|
|
996
|
+
emit_turn_event(turn, "turnFinished", { status: status, error: turn.error })
|
|
997
|
+
end
|
|
998
|
+
|
|
999
|
+
def emit_turn_event(turn, type, payload)
|
|
1000
|
+
event = {
|
|
1001
|
+
sequence: turn.next_sequence,
|
|
1002
|
+
timestamp: now,
|
|
1003
|
+
sessionId: turn.session_id,
|
|
1004
|
+
turnId: turn.id,
|
|
1005
|
+
type: type,
|
|
1006
|
+
payload: payload
|
|
1007
|
+
}
|
|
1008
|
+
turn.next_sequence += 1
|
|
1009
|
+
turn.events << event
|
|
1010
|
+
turn.events.shift while turn.events.length > RECENT_EVENT_LIMIT
|
|
1011
|
+
@server.notify("turn/event", event)
|
|
1012
|
+
event
|
|
1013
|
+
end
|
|
1014
|
+
|
|
1015
|
+
def emit_session_event(rpc_session, type, payload)
|
|
1016
|
+
@server.notify("session/event", {
|
|
1017
|
+
timestamp: now,
|
|
1018
|
+
sessionId: rpc_session.id,
|
|
1019
|
+
type: type,
|
|
1020
|
+
payload: payload
|
|
1021
|
+
})
|
|
1022
|
+
end
|
|
1023
|
+
|
|
1024
|
+
def turn_payload(turn)
|
|
1025
|
+
{
|
|
1026
|
+
id: turn.id,
|
|
1027
|
+
sessionId: turn.session_id,
|
|
1028
|
+
status: turn.status,
|
|
1029
|
+
cancelRequested: turn.cancel_requested,
|
|
1030
|
+
createdAt: turn.created_at,
|
|
1031
|
+
startedAt: turn.started_at,
|
|
1032
|
+
finishedAt: turn.finished_at,
|
|
1033
|
+
error: turn.error
|
|
1034
|
+
}.compact
|
|
1035
|
+
end
|
|
1036
|
+
|
|
1037
|
+
def session_info_payload(info, workspace_root:)
|
|
1038
|
+
cwd = info.cwd.to_s.empty? ? workspace_root : info.cwd
|
|
1039
|
+
{
|
|
1040
|
+
id: info.id,
|
|
1041
|
+
path: File.expand_path(info.path),
|
|
1042
|
+
cwd: cwd,
|
|
1043
|
+
workspaceRoot: workspace_root,
|
|
1044
|
+
createdAt: info.created_at&.utc&.iso8601(3),
|
|
1045
|
+
modifiedAt: info.modified_at&.utc&.iso8601(3),
|
|
1046
|
+
name: info.name,
|
|
1047
|
+
firstMessage: info.first_message.to_s,
|
|
1048
|
+
messageCount: info.message_count.to_i,
|
|
1049
|
+
parentId: info.parent_id,
|
|
1050
|
+
parentPath: info.parent_path,
|
|
1051
|
+
depth: info.depth.to_i,
|
|
1052
|
+
isLast: info.is_last,
|
|
1053
|
+
ancestorContinues: Array(info.ancestor_continues)
|
|
1054
|
+
}
|
|
1055
|
+
end
|
|
1056
|
+
|
|
1057
|
+
def export_path(rpc_session, path, format)
|
|
1058
|
+
extension = format == "html" ? ".html" : ".md"
|
|
1059
|
+
default_path = rpc_session.session.path.sub(/\.jsonl\z/, extension)
|
|
1060
|
+
ExportPath.resolve(path, workspace_root: rpc_session.workspace_root, default_path: default_path, session_dir: rpc_session.store.session_dir)
|
|
1061
|
+
end
|
|
1062
|
+
|
|
1063
|
+
def export_format(format)
|
|
1064
|
+
TranscriptExport.format(format)
|
|
1065
|
+
end
|
|
1066
|
+
|
|
1067
|
+
def export_content(conversation, format)
|
|
1068
|
+
TranscriptExport.content(conversation, format: format)
|
|
1069
|
+
end
|
|
1070
|
+
|
|
1071
|
+
def normalized_tool_event_payload(tool_call)
|
|
1072
|
+
ToolEventNormalizer.new(tool_call).call_payload(legacy_tool: tool_metadata(tool_call))
|
|
1073
|
+
end
|
|
1074
|
+
|
|
1075
|
+
def normalized_tool_result_event_payload(tool_call, content)
|
|
1076
|
+
ToolEventNormalizer.new(tool_call, content: content).result_payload(legacy_tool: tool_metadata(tool_call))
|
|
1077
|
+
end
|
|
1078
|
+
|
|
1079
|
+
def turn_error_payload(error)
|
|
1080
|
+
{
|
|
1081
|
+
message: error.message,
|
|
1082
|
+
code: error.class.name,
|
|
1083
|
+
fatal: false
|
|
1084
|
+
}
|
|
1085
|
+
end
|
|
1086
|
+
|
|
1087
|
+
def tool_metadata(tool_call)
|
|
1088
|
+
name = ToolCall.name(tool_call)
|
|
1089
|
+
args = ToolCall.arguments(tool_call)
|
|
1090
|
+
|
|
1091
|
+
case name
|
|
1092
|
+
when "edit_file"
|
|
1093
|
+
edits = Array(args["edits"] || args[:edits]).map do |edit|
|
|
1094
|
+
{
|
|
1095
|
+
oldText: edit["old_text"] || edit[:old_text],
|
|
1096
|
+
newText: edit["new_text"] || edit[:new_text]
|
|
1097
|
+
}.compact
|
|
1098
|
+
end
|
|
1099
|
+
first_edit = edits.first || {}
|
|
1100
|
+
{
|
|
1101
|
+
kind: "edit",
|
|
1102
|
+
path: args["path"] || args[:path],
|
|
1103
|
+
edits: edits,
|
|
1104
|
+
oldText: first_edit[:oldText],
|
|
1105
|
+
newText: first_edit[:newText]
|
|
1106
|
+
}.compact
|
|
1107
|
+
when "write_file"
|
|
1108
|
+
{ kind: "write", path: args["path"] || args[:path] }.compact
|
|
1109
|
+
when "run_shell_command"
|
|
1110
|
+
{ kind: "shell", command: args["command"] || args[:command] }.compact
|
|
1111
|
+
else
|
|
1112
|
+
nil
|
|
1113
|
+
end
|
|
1114
|
+
end
|
|
1115
|
+
|
|
1116
|
+
|
|
1117
|
+
def now
|
|
1118
|
+
Time.now.utc.iso8601(3)
|
|
1119
|
+
end
|
|
1120
|
+
end
|
|
1121
|
+
end
|
|
1122
|
+
end
|