kward 0.74.0 → 0.75.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 +35 -0
- data/Gemfile.lock +2 -2
- data/README.md +4 -2
- data/Rakefile +18 -0
- data/doc/configuration.md +56 -3
- data/doc/extensibility.md +6 -19
- data/doc/getting-started.md +1 -1
- data/doc/lifecycle-hooks.md +424 -0
- data/doc/memory.md +1 -1
- data/doc/personas.md +2 -2
- data/doc/plugins.md +1 -1
- data/doc/releasing.md +7 -15
- data/doc/rpc.md +108 -16
- data/doc/session-management.md +9 -9
- data/doc/skills.md +199 -0
- data/doc/usage.md +7 -7
- data/lib/kward/agent.rb +72 -22
- data/lib/kward/auth/anthropic_oauth.rb +5 -87
- data/lib/kward/auth/oauth_helpers.rb +105 -0
- data/lib/kward/auth/openai_oauth.rb +4 -86
- data/lib/kward/cli/commands.rb +9 -2
- data/lib/kward/cli/compaction.rb +7 -0
- data/lib/kward/cli/git.rb +30 -3
- data/lib/kward/cli/hook_commands.rb +258 -0
- data/lib/kward/cli/memory_commands.rb +1 -5
- data/lib/kward/cli/plugins.rb +41 -1
- data/lib/kward/cli/prompt_interface.rb +32 -3
- data/lib/kward/cli/rendering.rb +1 -1
- data/lib/kward/cli/runtime_helpers.rb +44 -5
- data/lib/kward/cli/sessions.rb +13 -0
- data/lib/kward/cli/settings.rb +226 -152
- data/lib/kward/cli/slash_commands.rb +81 -42
- data/lib/kward/cli/tabs.rb +26 -6
- data/lib/kward/cli.rb +26 -3
- data/lib/kward/cli_transcript_formatter.rb +0 -7
- data/lib/kward/compaction/token_estimator.rb +112 -0
- data/lib/kward/compactor.rb +42 -116
- data/lib/kward/config_files.rb +117 -25
- data/lib/kward/deep_copy.rb +42 -0
- data/lib/kward/diff_view_mode.rb +36 -0
- data/lib/kward/events.rb +1 -0
- data/lib/kward/export_path.rb +2 -6
- data/lib/kward/frontmatter.rb +43 -0
- data/lib/kward/hooks/audit_log.rb +121 -0
- data/lib/kward/hooks/catalog.rb +110 -0
- data/lib/kward/hooks/command_handler.rb +66 -0
- data/lib/kward/hooks/config_loader.rb +104 -0
- data/lib/kward/hooks/decision.rb +102 -0
- data/lib/kward/hooks/event.rb +60 -0
- data/lib/kward/hooks/http_handler.rb +72 -0
- data/lib/kward/hooks/manager.rb +158 -0
- data/lib/kward/hooks/matcher.rb +75 -0
- data/lib/kward/hooks.rb +16 -0
- data/lib/kward/markdown_transcript.rb +4 -3
- data/lib/kward/mcp/client.rb +1 -1
- data/lib/kward/mcp/server_config.rb +7 -3
- data/lib/kward/mcp/stdio_transport.rb +7 -1
- data/lib/kward/memory/turn_context.rb +19 -0
- data/lib/kward/message_access.rb +2 -2
- data/lib/kward/message_text.rb +19 -0
- data/lib/kward/model/client.rb +13 -45
- data/lib/kward/model/context_usage.rb +10 -4
- data/lib/kward/model/copilot_models.rb +44 -0
- data/lib/kward/model/model_info.rb +24 -0
- data/lib/kward/model/payloads.rb +15 -17
- data/lib/kward/model/stream_parser.rb +178 -13
- data/lib/kward/pan/server.rb +51 -2
- data/lib/kward/path_guard.rb +13 -0
- data/lib/kward/plugin_registry.rb +86 -25
- data/lib/kward/prompt_interface/editor/controller.rb +60 -1
- data/lib/kward/prompt_interface/editor/renderer.rb +11 -0
- data/lib/kward/prompt_interface/editor/state.rb +5 -1
- data/lib/kward/prompt_interface/selection_prompt.rb +26 -6
- data/lib/kward/prompt_interface.rb +4 -1
- data/lib/kward/prompts/commands.rb +3 -3
- data/lib/kward/prompts.rb +7 -7
- data/lib/kward/rpc/mcp_status.rb +50 -0
- data/lib/kward/rpc/prompt_bridge.rb +38 -4
- data/lib/kward/rpc/runtime_payloads.rb +1 -1
- data/lib/kward/rpc/server.rb +94 -14
- data/lib/kward/rpc/session_manager.rb +258 -33
- data/lib/kward/rpc/session_metrics.rb +1 -1
- data/lib/kward/rpc/session_tree_rows.rb +13 -64
- data/lib/kward/rpc/tool_event_normalizer.rb +16 -2
- data/lib/kward/rpc/transcript_normalizer.rb +9 -10
- data/lib/kward/rpc/turn_context.rb +109 -0
- data/lib/kward/session_store.rb +6 -20
- data/lib/kward/session_tree_nodes.rb +98 -18
- data/lib/kward/session_tree_renderer.rb +6 -61
- data/lib/kward/skills/registry.rb +109 -17
- data/lib/kward/tools/mcp_tool.rb +4 -0
- data/lib/kward/tools/read_skill.rb +9 -4
- data/lib/kward/tools/registry.rb +266 -8
- data/lib/kward/tools/search/code.rb +4 -22
- data/lib/kward/tools/search/web.rb +28 -2
- data/lib/kward/update_check.rb +117 -0
- data/lib/kward/version.rb +1 -1
- data/lib/kward/workers/manager.rb +43 -3
- data/lib/kward/workspace.rb +3 -1
- data/templates/default/fulldoc/html/css/kward.css +22 -0
- data/templates/default/fulldoc/html/js/kward.js +2 -0
- data/templates/default/kward_navigation.rb +4 -2
- data/templates/default/layout/html/setup.rb +2 -0
- metadata +25 -1
|
@@ -11,6 +11,7 @@ require_relative "../conversation"
|
|
|
11
11
|
require_relative "../events"
|
|
12
12
|
require_relative "../export_path"
|
|
13
13
|
require_relative "../memory/manager"
|
|
14
|
+
require_relative "../memory/turn_context"
|
|
14
15
|
require_relative "../message_access"
|
|
15
16
|
require_relative "../message_text"
|
|
16
17
|
require_relative "../session_tree_tool_display"
|
|
@@ -35,6 +36,7 @@ require_relative "session_tree"
|
|
|
35
36
|
require_relative "session_tree_rows"
|
|
36
37
|
require_relative "tool_event_normalizer"
|
|
37
38
|
require_relative "transcript_normalizer"
|
|
39
|
+
require_relative "turn_context"
|
|
38
40
|
|
|
39
41
|
# Namespace for the Kward CLI agent runtime.
|
|
40
42
|
module Kward
|
|
@@ -63,7 +65,7 @@ module Kward
|
|
|
63
65
|
WORKER_STOP = Object.new.freeze
|
|
64
66
|
|
|
65
67
|
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)
|
|
66
|
-
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)
|
|
68
|
+
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, :options, :tool_registry, keyword_init: true)
|
|
67
69
|
|
|
68
70
|
# Creates an object for RPC session lifecycle and turn coordination.
|
|
69
71
|
def initialize(
|
|
@@ -142,6 +144,19 @@ module Kward
|
|
|
142
144
|
.map { |info| session_info_payload(info, workspace_root: root) }
|
|
143
145
|
end
|
|
144
146
|
|
|
147
|
+
def active_sessions
|
|
148
|
+
{ sessions: @mutex.synchronize { @sessions.values.map { |rpc_session| session_payload(rpc_session) } } }
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def tool_schemas(session_id: nil)
|
|
152
|
+
registry = if session_id
|
|
153
|
+
fetch_session(session_id).tool_registry
|
|
154
|
+
else
|
|
155
|
+
build_tool_registry(Dir.pwd, nil)
|
|
156
|
+
end
|
|
157
|
+
{ tools: registry.schemas }
|
|
158
|
+
end
|
|
159
|
+
|
|
145
160
|
# Renames the persisted session attached to an RPC session id.
|
|
146
161
|
def rename_session(session_id:, name:)
|
|
147
162
|
rpc_session = fetch_session(session_id)
|
|
@@ -326,12 +341,14 @@ module Kward
|
|
|
326
341
|
# queue a follow-up, or steer the running turn when the active provider
|
|
327
342
|
# supports native steering. The returned turn id is used for status,
|
|
328
343
|
# cancellation, and event replay.
|
|
329
|
-
def start_turn(session_id:, input:, streaming_behavior: nil, attachments: [])
|
|
344
|
+
def start_turn(session_id:, input:, streaming_behavior: nil, attachments: [], options: {}, context: nil)
|
|
330
345
|
rpc_session = fetch_session(session_id)
|
|
346
|
+
normalized_options = normalize_turn_options(options)
|
|
347
|
+
normalized_context = TurnContext.normalize(context)
|
|
331
348
|
normalized_attachments = normalize_attachments(attachments)
|
|
332
349
|
plugin_command, plugin_arguments = plugin_command_turn(input, normalized_attachments)
|
|
333
350
|
display_input = input.to_s if input.is_a?(String)
|
|
334
|
-
content = plugin_command ? input.to_s : user_turn_content(expand_prompt_input(input), normalized_attachments)
|
|
351
|
+
content = plugin_command ? input.to_s : user_turn_content(turn_input_with_context(expand_prompt_input(input), normalized_context), normalized_attachments)
|
|
335
352
|
streaming_behavior = validate_streaming_behavior(default_streaming_behavior(rpc_session, streaming_behavior), rpc_session: rpc_session)
|
|
336
353
|
if streaming_behavior == "steer"
|
|
337
354
|
return steer_running_turn(rpc_session, content)
|
|
@@ -349,7 +366,9 @@ module Kward
|
|
|
349
366
|
next_sequence: 1,
|
|
350
367
|
streaming_behavior: streaming_behavior,
|
|
351
368
|
plugin_command_name: plugin_command&.name,
|
|
352
|
-
plugin_arguments: plugin_arguments
|
|
369
|
+
plugin_arguments: plugin_arguments,
|
|
370
|
+
options: normalized_options,
|
|
371
|
+
tool_registry: scoped_tool_registry(rpc_session, normalized_options)
|
|
353
372
|
)
|
|
354
373
|
@mutex.synchronize { @turns[turn.id] = turn }
|
|
355
374
|
rpc_session.queue << turn.id
|
|
@@ -382,19 +401,52 @@ module Kward
|
|
|
382
401
|
}
|
|
383
402
|
end
|
|
384
403
|
|
|
404
|
+
def list_turns(session_id: nil, active: false)
|
|
405
|
+
turns = @mutex.synchronize { @turns.values.dup }
|
|
406
|
+
turns.select! { |turn| turn.session_id == session_id.to_s } if session_id
|
|
407
|
+
turns.select! { |turn| ["queued", "running"].include?(turn.status) } if active
|
|
408
|
+
{ turns: turns.map { |turn| turn_payload(turn) } }
|
|
409
|
+
end
|
|
410
|
+
|
|
385
411
|
def answer_question(session_id:, question_request_id:, answers:)
|
|
386
412
|
rpc_session = fetch_session(session_id)
|
|
387
413
|
rpc_session.prompt.answer(question_request_id, answers)
|
|
388
414
|
{ ok: true }
|
|
389
415
|
end
|
|
390
416
|
|
|
417
|
+
def answer_tool_approval(session_id:, approval_request_id:, approved:)
|
|
418
|
+
rpc_session = fetch_session(session_id)
|
|
419
|
+
rpc_session.prompt.answer_tool_approval(approval_request_id, approved: approved)
|
|
420
|
+
{ ok: true }
|
|
421
|
+
end
|
|
422
|
+
|
|
391
423
|
def run_command(session_id:, command:, arguments: "")
|
|
392
424
|
name = command.to_s.delete_prefix("/")
|
|
393
425
|
return { ok: false, error: "unsupported", reason: "clientClipboardOwnedByUi" } if name == "copy"
|
|
426
|
+
return run_skill_command(session_id: session_id, name: name.delete_prefix("skill:")) if name.start_with?("skill:")
|
|
427
|
+
return run_skill_command(session_id: session_id, name: arguments) if name == "skill"
|
|
394
428
|
|
|
395
429
|
run_plugin_command(session_id: session_id, command: name, arguments: arguments)
|
|
396
430
|
end
|
|
397
431
|
|
|
432
|
+
def run_skill_command(session_id:, name:)
|
|
433
|
+
skill_name = name.to_s.strip
|
|
434
|
+
raise ArgumentError, "Missing skill name" if skill_name.empty?
|
|
435
|
+
|
|
436
|
+
rpc_session = fetch_session(session_id)
|
|
437
|
+
tool_call = {
|
|
438
|
+
"id" => "skill_#{skill_name.gsub(/[^a-zA-Z0-9_-]/, "_")}",
|
|
439
|
+
"type" => "function",
|
|
440
|
+
"function" => {
|
|
441
|
+
"name" => "read_skill",
|
|
442
|
+
"arguments" => JSON.dump({ name: skill_name })
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
rpc_session.conversation.append_assistant("role" => "assistant", "content" => nil, "tool_calls" => [tool_call])
|
|
446
|
+
result = rpc_session.tool_registry.dispatch(tool_call, rpc_session.conversation)
|
|
447
|
+
{ command: "skill:#{skill_name}", output: [result.start_with?("Error:") ? result : "Activated skill: #{skill_name}"], result: result }
|
|
448
|
+
end
|
|
449
|
+
|
|
398
450
|
def run_plugin_command(session_id:, command:, arguments: "")
|
|
399
451
|
rpc_session = fetch_session(session_id)
|
|
400
452
|
command = plugin_registry.command_for(command.to_s.delete_prefix("/")) || raise(ArgumentError, "Unknown plugin command: #{command}")
|
|
@@ -420,8 +472,9 @@ module Kward
|
|
|
420
472
|
def current_model
|
|
421
473
|
provider = @client.respond_to?(:current_provider) ? @client.current_provider : nil
|
|
422
474
|
model = @client.respond_to?(:current_model) ? @client.current_model : nil
|
|
475
|
+
reasoning_effort = @client.respond_to?(:current_reasoning_effort) ? @client.current_reasoning_effort : nil
|
|
423
476
|
context_window = @client.respond_to?(:current_context_window) ? @client.current_context_window : nil
|
|
424
|
-
|
|
477
|
+
ModelInfo.current_payload(provider: provider, model: model, reasoning_effort: reasoning_effort, context_window: context_window)
|
|
425
478
|
end
|
|
426
479
|
|
|
427
480
|
def session_model(rpc_session)
|
|
@@ -431,13 +484,11 @@ module Kward
|
|
|
431
484
|
reasoning_effort = rpc_session.conversation.reasoning_effort || current_reasoning_effort
|
|
432
485
|
reasoning_effort = nil unless ModelInfo.reasoning_supported?(provider, model)
|
|
433
486
|
context_window = context_window_for(provider, model)
|
|
434
|
-
|
|
487
|
+
ModelInfo.current_payload(
|
|
435
488
|
provider: provider,
|
|
436
|
-
id: model,
|
|
437
489
|
model: model,
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
current: true
|
|
490
|
+
reasoning_effort: reasoning_effort,
|
|
491
|
+
context_window: context_window
|
|
441
492
|
)
|
|
442
493
|
end
|
|
443
494
|
|
|
@@ -503,6 +554,7 @@ module Kward
|
|
|
503
554
|
sessions.each do |rpc_session|
|
|
504
555
|
rpc_session.conversation.plugin_registry = registry if rpc_session.conversation.respond_to?(:plugin_registry=)
|
|
505
556
|
rpc_session.conversation.refresh_system_message! if rpc_session.conversation.respond_to?(:refresh_system_message!)
|
|
557
|
+
rebuild_session_tools(rpc_session)
|
|
506
558
|
if registry.footer_renderer
|
|
507
559
|
start_footer_worker(rpc_session)
|
|
508
560
|
emit_footer_update(rpc_session)
|
|
@@ -563,12 +615,25 @@ module Kward
|
|
|
563
615
|
end
|
|
564
616
|
|
|
565
617
|
def rebuild_session_tools(rpc_session)
|
|
566
|
-
|
|
618
|
+
hook_context = lifecycle_hook_context(
|
|
619
|
+
conversation: rpc_session.conversation,
|
|
620
|
+
session: rpc_session.session,
|
|
621
|
+
workspace_root: rpc_session.workspace_root
|
|
622
|
+
)
|
|
623
|
+
hook_manager = lifecycle_hook_manager(rpc_session.workspace_root)
|
|
624
|
+
tool_registry = build_tool_registry(
|
|
625
|
+
rpc_session.workspace_root,
|
|
626
|
+
rpc_session.prompt,
|
|
627
|
+
hook_manager: hook_manager,
|
|
628
|
+
hook_context: hook_context
|
|
629
|
+
)
|
|
567
630
|
rpc_session.tool_registry = tool_registry
|
|
568
631
|
rpc_session.agent = Agent.new(
|
|
569
632
|
client: @client,
|
|
570
633
|
tool_registry: tool_registry,
|
|
571
|
-
conversation: rpc_session.conversation
|
|
634
|
+
conversation: rpc_session.conversation,
|
|
635
|
+
hook_manager: hook_manager,
|
|
636
|
+
hook_context: hook_context
|
|
572
637
|
)
|
|
573
638
|
end
|
|
574
639
|
|
|
@@ -602,12 +667,12 @@ module Kward
|
|
|
602
667
|
id = model[:id] || model["id"] || model[:model] || model["model"]
|
|
603
668
|
model = model.merge(contextWindow: context_window_for(provider, id))
|
|
604
669
|
end
|
|
605
|
-
ModelInfo.
|
|
606
|
-
model,
|
|
670
|
+
ModelInfo.normalize_available(
|
|
671
|
+
[model],
|
|
607
672
|
current_provider: (@client.current_provider if @client.respond_to?(:current_provider)),
|
|
608
673
|
current_model: (@client.current_model if @client.respond_to?(:current_model)),
|
|
609
674
|
current_reasoning_effort: (@client.current_reasoning_effort if @client.respond_to?(:current_reasoning_effort))
|
|
610
|
-
)
|
|
675
|
+
).first
|
|
611
676
|
end
|
|
612
677
|
|
|
613
678
|
def context_window_for(provider, model)
|
|
@@ -702,12 +767,7 @@ module Kward
|
|
|
702
767
|
end
|
|
703
768
|
|
|
704
769
|
def display_message_text(message)
|
|
705
|
-
|
|
706
|
-
end
|
|
707
|
-
|
|
708
|
-
def truncate_tree_text(text)
|
|
709
|
-
normalized = text.to_s.gsub(/\s+/, " ").strip
|
|
710
|
-
normalized.length > 120 ? "#{normalized.slice(0, 117)}..." : normalized
|
|
770
|
+
MessageText.preview(message)
|
|
711
771
|
end
|
|
712
772
|
|
|
713
773
|
def full_message_text(message)
|
|
@@ -761,6 +821,93 @@ module Kward
|
|
|
761
821
|
AttachmentNormalizer.new(max_bytes: RPC_ATTACHMENT_MAX_BYTES, mime_types: RPC_IMAGE_MIME_TYPES).normalize(attachments)
|
|
762
822
|
end
|
|
763
823
|
|
|
824
|
+
def turn_input_with_context(input, context)
|
|
825
|
+
return input unless context
|
|
826
|
+
|
|
827
|
+
[input.to_s, TurnContext.prompt(context)].reject(&:empty?).join("\n\n")
|
|
828
|
+
end
|
|
829
|
+
|
|
830
|
+
def normalize_turn_options(options)
|
|
831
|
+
options = {} if options.nil?
|
|
832
|
+
raise ArgumentError, "turn options must be an object" unless options.is_a?(Hash)
|
|
833
|
+
|
|
834
|
+
provider = option_value(options, "provider")
|
|
835
|
+
model = option_value(options, "model")
|
|
836
|
+
model = option_value(model, "id") || option_value(model, "model") if model.is_a?(Hash)
|
|
837
|
+
reasoning = option_value(options, "reasoningEffort") || option_value(options, "reasoning")
|
|
838
|
+
approval_mode = blank_to_nil(option_value(options, "approvalMode"))
|
|
839
|
+
raise ArgumentError, "Unsupported approvalMode: #{approval_mode}" if approval_mode && !%w[none ask].include?(approval_mode)
|
|
840
|
+
allowed_tools = option_array(options, "allowedTools")
|
|
841
|
+
disabled_tools = option_array(options, "disabledTools")
|
|
842
|
+
raise ArgumentError, "allowedTools and disabledTools cannot both be set" if allowed_tools && disabled_tools
|
|
843
|
+
|
|
844
|
+
{
|
|
845
|
+
provider: blank_to_nil(provider),
|
|
846
|
+
model: blank_to_nil(model),
|
|
847
|
+
reasoning: blank_to_nil(reasoning),
|
|
848
|
+
approval_mode: approval_mode,
|
|
849
|
+
allowed_tools: allowed_tools,
|
|
850
|
+
disabled_tools: disabled_tools
|
|
851
|
+
}.compact
|
|
852
|
+
end
|
|
853
|
+
|
|
854
|
+
def option_value(hash, key)
|
|
855
|
+
hash[key] || hash[key.to_sym]
|
|
856
|
+
end
|
|
857
|
+
|
|
858
|
+
def option_array(hash, key)
|
|
859
|
+
value = option_value(hash, key)
|
|
860
|
+
return nil if value.nil?
|
|
861
|
+
raise ArgumentError, "#{key} must be an array" unless value.is_a?(Array)
|
|
862
|
+
|
|
863
|
+
value.map(&:to_s).reject(&:empty?)
|
|
864
|
+
end
|
|
865
|
+
|
|
866
|
+
def blank_to_nil(value)
|
|
867
|
+
value = value.to_s if value.is_a?(Symbol)
|
|
868
|
+
value.to_s.empty? ? nil : value
|
|
869
|
+
end
|
|
870
|
+
|
|
871
|
+
def scoped_tool_registry(rpc_session, options)
|
|
872
|
+
names = scoped_tool_names(rpc_session, options)
|
|
873
|
+
approval = tool_approval_callback(rpc_session, options)
|
|
874
|
+
return nil unless names || approval
|
|
875
|
+
|
|
876
|
+
build_tool_registry(
|
|
877
|
+
rpc_session.workspace_root,
|
|
878
|
+
rpc_session.prompt,
|
|
879
|
+
allowed_tool_names: names,
|
|
880
|
+
tool_approval: approval,
|
|
881
|
+
hook_manager: lifecycle_hook_manager(rpc_session.workspace_root),
|
|
882
|
+
hook_context: plugin_context(rpc_session, say_callback: lambda { |message| rpc_session.plugin_output << message.to_s })
|
|
883
|
+
)
|
|
884
|
+
end
|
|
885
|
+
|
|
886
|
+
def tool_approval_callback(rpc_session, options)
|
|
887
|
+
return nil unless options[:approval_mode] == "ask"
|
|
888
|
+
|
|
889
|
+
lambda do |tool_call:, name:, args:, cancellation:|
|
|
890
|
+
fields = ToolMetadata.normalized_tool_fields(tool_call)
|
|
891
|
+
rpc_session.prompt.request_tool_approval(
|
|
892
|
+
tool_call_id: fields[:toolCallId] || ToolCall.id(tool_call),
|
|
893
|
+
tool_name: fields[:toolName] || name,
|
|
894
|
+
args: fields[:args] || ToolCall.camelize_args(args),
|
|
895
|
+
cancellation: cancellation
|
|
896
|
+
)
|
|
897
|
+
end
|
|
898
|
+
end
|
|
899
|
+
|
|
900
|
+
def scoped_tool_names(rpc_session, options)
|
|
901
|
+
return options[:allowed_tools] if options[:allowed_tools]
|
|
902
|
+
return nil unless options[:disabled_tools]
|
|
903
|
+
|
|
904
|
+
disabled = options[:disabled_tools].to_h { |name| [name, true] }
|
|
905
|
+
rpc_session.tool_registry.schemas.filter_map do |schema|
|
|
906
|
+
name = schema.dig(:function, :name) || schema.dig("function", "name")
|
|
907
|
+
name unless disabled[name]
|
|
908
|
+
end
|
|
909
|
+
end
|
|
910
|
+
|
|
764
911
|
def plugin_registry
|
|
765
912
|
@plugin_registry ||= PluginRegistry.load(reserved_commands: reserved_plugin_command_names)
|
|
766
913
|
end
|
|
@@ -778,11 +925,15 @@ module Kward
|
|
|
778
925
|
conversation.plugin_registry ||= plugin_registry if conversation.respond_to?(:plugin_registry)
|
|
779
926
|
id = SecureRandom.uuid
|
|
780
927
|
prompt = PromptBridge.new(server: @server, session_id: id)
|
|
781
|
-
|
|
928
|
+
hook_context = lifecycle_hook_context(conversation: conversation, session: session, workspace_root: workspace_root)
|
|
929
|
+
hook_manager = lifecycle_hook_manager(workspace_root)
|
|
930
|
+
tool_registry = build_tool_registry(workspace_root, prompt, hook_manager: hook_manager, hook_context: hook_context)
|
|
782
931
|
agent = Agent.new(
|
|
783
932
|
client: @client,
|
|
784
933
|
tool_registry: tool_registry,
|
|
785
|
-
conversation: conversation
|
|
934
|
+
conversation: conversation,
|
|
935
|
+
hook_manager: hook_manager,
|
|
936
|
+
hook_context: hook_context
|
|
786
937
|
)
|
|
787
938
|
RpcSession.new(
|
|
788
939
|
id: id,
|
|
@@ -800,8 +951,59 @@ module Kward
|
|
|
800
951
|
)
|
|
801
952
|
end
|
|
802
953
|
|
|
803
|
-
def build_tool_registry(workspace_root, prompt)
|
|
804
|
-
ToolRegistry.new(
|
|
954
|
+
def build_tool_registry(workspace_root, prompt, allowed_tool_names: nil, tool_approval: nil, hook_manager: nil, hook_context: nil)
|
|
955
|
+
ToolRegistry.new(
|
|
956
|
+
workspace: configured_workspace(workspace_root),
|
|
957
|
+
prompt: prompt,
|
|
958
|
+
allowed_tool_names: allowed_tool_names,
|
|
959
|
+
tool_approval: tool_approval,
|
|
960
|
+
hook_manager: hook_manager,
|
|
961
|
+
hook_context: hook_context
|
|
962
|
+
)
|
|
963
|
+
end
|
|
964
|
+
|
|
965
|
+
def lifecycle_hook_manager(workspace_root)
|
|
966
|
+
manager = Hooks::ConfigLoader.new(ConfigFiles.lifecycle_hooks_config(workspace_root)).manager
|
|
967
|
+
manager.on_result = method(:emit_lifecycle_hook_event)
|
|
968
|
+
plugin_registry.hook_handlers.each do |hook|
|
|
969
|
+
manager.register(hook.event, id: hook.id, source: hook.path, order: hook.order, match: hook.match, failure_policy: hook.failure_policy) do |event, context|
|
|
970
|
+
hook.handler.call(event, context)
|
|
971
|
+
end
|
|
972
|
+
end
|
|
973
|
+
manager
|
|
974
|
+
end
|
|
975
|
+
|
|
976
|
+
def emit_lifecycle_hook_event(event, result)
|
|
977
|
+
@server.notify("hook/event", {
|
|
978
|
+
event: {
|
|
979
|
+
id: event.id,
|
|
980
|
+
name: event.name,
|
|
981
|
+
phase: event.phase,
|
|
982
|
+
timestamp: event.timestamp.iso8601,
|
|
983
|
+
session: event.session,
|
|
984
|
+
turn: event.turn,
|
|
985
|
+
workspace: event.workspace,
|
|
986
|
+
frontend: event.frontend,
|
|
987
|
+
agent: event.agent,
|
|
988
|
+
payloadKeys: event.payload.keys.map(&:to_s).sort
|
|
989
|
+
},
|
|
990
|
+
result: {
|
|
991
|
+
decision: result.decision.decision,
|
|
992
|
+
warnings: result.warnings,
|
|
993
|
+
messages: result.messages,
|
|
994
|
+
decisionCount: result.decisions.length
|
|
995
|
+
}
|
|
996
|
+
})
|
|
997
|
+
end
|
|
998
|
+
|
|
999
|
+
def lifecycle_hook_context(conversation:, session:, workspace_root:)
|
|
1000
|
+
PluginRegistry::Context.new(
|
|
1001
|
+
conversation: conversation,
|
|
1002
|
+
args: "",
|
|
1003
|
+
session: session,
|
|
1004
|
+
workspace_root: workspace_root,
|
|
1005
|
+
say_callback: lambda { |message| @server.notify("hook/message", { message: message.to_s }) }
|
|
1006
|
+
)
|
|
805
1007
|
end
|
|
806
1008
|
|
|
807
1009
|
def configured_workspace(root)
|
|
@@ -924,6 +1126,8 @@ module Kward
|
|
|
924
1126
|
# turns. Keep event translation near this boundary so CLI rendering and RPC
|
|
925
1127
|
# protocol details do not leak into `Agent`.
|
|
926
1128
|
def run_turn(rpc_session, turn)
|
|
1129
|
+
previous_turn_id = Thread.current[:kward_rpc_turn_id]
|
|
1130
|
+
Thread.current[:kward_rpc_turn_id] = turn.id
|
|
927
1131
|
rpc_session.running_turn_id = turn.id
|
|
928
1132
|
turn.steering = build_steering(turn) if supports_in_flight_steer? && !turn.plugin_command_name
|
|
929
1133
|
turn.status = "running"
|
|
@@ -940,13 +1144,14 @@ module Kward
|
|
|
940
1144
|
else
|
|
941
1145
|
auto_name_session(rpc_session, turn.display_input || turn.input)
|
|
942
1146
|
prepare_memory_context(rpc_session.conversation, turn.input)
|
|
943
|
-
rpc_session.agent.ask(turn.input, display_input: turn_display_input(turn), cancellation: turn.cancellation, steering: turn.steering) do |event|
|
|
1147
|
+
rpc_session.agent.ask(turn.input, display_input: turn_display_input(turn), cancellation: turn.cancellation, steering: turn.steering, options: turn.options || {}, tool_registry: turn.tool_registry) do |event|
|
|
944
1148
|
next if turn.cancel_requested
|
|
945
1149
|
|
|
946
1150
|
notify_plugin_transcript_event(rpc_session, event)
|
|
947
1151
|
handle_agent_event(turn, event)
|
|
948
1152
|
end
|
|
949
1153
|
persist_memory_state(rpc_session)
|
|
1154
|
+
auto_summarize_memory(rpc_session) unless turn.cancel_requested
|
|
950
1155
|
finish_turn(turn, turn.cancel_requested ? "canceled" : "completed")
|
|
951
1156
|
end
|
|
952
1157
|
rescue Cancellation::CancelledError
|
|
@@ -956,6 +1161,7 @@ module Kward
|
|
|
956
1161
|
emit_turn_event(turn, "error", turn.error)
|
|
957
1162
|
finish_turn(turn, "failed")
|
|
958
1163
|
ensure
|
|
1164
|
+
Thread.current[:kward_rpc_turn_id] = previous_turn_id
|
|
959
1165
|
turn.steering = nil
|
|
960
1166
|
rpc_session.running_turn_id = nil
|
|
961
1167
|
end
|
|
@@ -983,11 +1189,7 @@ module Kward
|
|
|
983
1189
|
end
|
|
984
1190
|
|
|
985
1191
|
def prepare_memory_context(conversation, input)
|
|
986
|
-
manager
|
|
987
|
-
retrieval = manager.retrieve_relevant(input: input, workspace_root: conversation.workspace_root)
|
|
988
|
-
conversation.last_memory_retrieval = retrieval
|
|
989
|
-
conversation.memory_context = manager.memory_block(retrieval)
|
|
990
|
-
conversation.refresh_system_message!
|
|
1192
|
+
Memory::TurnContext.apply(conversation: conversation, input: input, manager: memory_manager)
|
|
991
1193
|
rescue StandardError => e
|
|
992
1194
|
@server.log_error(e)
|
|
993
1195
|
nil
|
|
@@ -999,6 +1201,17 @@ module Kward
|
|
|
999
1201
|
nil
|
|
1000
1202
|
end
|
|
1001
1203
|
|
|
1204
|
+
def auto_summarize_memory(rpc_session)
|
|
1205
|
+
manager = memory_manager
|
|
1206
|
+
return unless manager.enabled? && manager.auto_summary_enabled?
|
|
1207
|
+
|
|
1208
|
+
manager.summarize_conversation(rpc_session.conversation, client: @client)
|
|
1209
|
+
persist_memory_state(rpc_session)
|
|
1210
|
+
rescue StandardError => e
|
|
1211
|
+
@server.log_error(e)
|
|
1212
|
+
nil
|
|
1213
|
+
end
|
|
1214
|
+
|
|
1002
1215
|
def steer_running_turn(rpc_session, input)
|
|
1003
1216
|
turn_id = rpc_session.running_turn_id
|
|
1004
1217
|
turn = turn_id && fetch_turn(turn_id)
|
|
@@ -1055,6 +1268,8 @@ module Kward
|
|
|
1055
1268
|
emit_turn_event(turn, "steeringApplied", { count: event.count })
|
|
1056
1269
|
when Events::ToolCall
|
|
1057
1270
|
emit_turn_event(turn, "toolCall", normalized_tool_event_payload(event.tool_call))
|
|
1271
|
+
when Events::ToolUpdate
|
|
1272
|
+
emit_turn_event(turn, "toolUpdate", normalized_tool_update_event_payload(event.tool_call, event.content, elapsed_ms: event.elapsed_ms))
|
|
1058
1273
|
when Events::ToolResult
|
|
1059
1274
|
emit_turn_event(turn, "toolResult", normalized_tool_result_event_payload(event.tool_call, event.content))
|
|
1060
1275
|
when Events::Answer
|
|
@@ -1184,11 +1399,21 @@ module Kward
|
|
|
1184
1399
|
end
|
|
1185
1400
|
|
|
1186
1401
|
def normalized_tool_event_payload(tool_call)
|
|
1187
|
-
ToolEventNormalizer.new(tool_call).call_payload
|
|
1402
|
+
ToolEventNormalizer.new(tool_call, tool_registry: current_turn_tool_registry).call_payload
|
|
1403
|
+
end
|
|
1404
|
+
|
|
1405
|
+
def normalized_tool_update_event_payload(tool_call, content, elapsed_ms: nil)
|
|
1406
|
+
ToolEventNormalizer.new(tool_call, content: content, tool_registry: current_turn_tool_registry).update_payload(elapsed_ms: elapsed_ms)
|
|
1188
1407
|
end
|
|
1189
1408
|
|
|
1190
1409
|
def normalized_tool_result_event_payload(tool_call, content)
|
|
1191
|
-
ToolEventNormalizer.new(tool_call, content: content).result_payload
|
|
1410
|
+
ToolEventNormalizer.new(tool_call, content: content, tool_registry: current_turn_tool_registry).result_payload
|
|
1411
|
+
end
|
|
1412
|
+
|
|
1413
|
+
def current_turn_tool_registry
|
|
1414
|
+
return unless Thread.current[:kward_rpc_turn_id]
|
|
1415
|
+
|
|
1416
|
+
@mutex.synchronize { @turns[Thread.current[:kward_rpc_turn_id]]&.tool_registry }
|
|
1192
1417
|
end
|
|
1193
1418
|
|
|
1194
1419
|
def turn_error_payload(error)
|
|
@@ -34,7 +34,7 @@ module Kward
|
|
|
34
34
|
|
|
35
35
|
def context_usage(rpc_session, model, client:)
|
|
36
36
|
context_parts = if client.respond_to?(:current_context_parts)
|
|
37
|
-
client.current_context_parts(rpc_session.conversation.context_messages, rpc_session.tool_registry.schemas)
|
|
37
|
+
client.current_context_parts(rpc_session.conversation.context_messages, rpc_session.tool_registry.schemas, provider: model[:provider], model: model[:id])
|
|
38
38
|
else
|
|
39
39
|
{
|
|
40
40
|
provider: model[:provider],
|
|
@@ -13,7 +13,7 @@ module Kward
|
|
|
13
13
|
# this class owns only the mechanics of flattening tree nodes into the row
|
|
14
14
|
# fields sent over JSON-RPC. Keeping row presentation here prevents the RPC
|
|
15
15
|
# session coordinator from accumulating rendering details while preserving the
|
|
16
|
-
# exact
|
|
16
|
+
# exact RPC payload shape.
|
|
17
17
|
class SessionTreeRows
|
|
18
18
|
# @param roots [Array<Hash>] tree roots returned by `SessionStore#session_tree`
|
|
19
19
|
# @param current_leaf [String, nil] active persisted tree leaf id
|
|
@@ -29,54 +29,28 @@ module Kward
|
|
|
29
29
|
# @return [Array<Hash>] rows for the `session/tree` RPC method
|
|
30
30
|
def rows
|
|
31
31
|
tree_nodes = SessionTreeNodes.new(roots: @roots, current_leaf: @current_leaf)
|
|
32
|
-
active_path = tree_nodes.active_path
|
|
33
32
|
tool_calls_by_id = tree_nodes.tool_calls
|
|
34
|
-
visible_roots = tree_nodes.visible_roots
|
|
35
|
-
multiple_roots = visible_roots.length > 1
|
|
36
|
-
result = []
|
|
37
33
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
end.reverse
|
|
41
|
-
|
|
42
|
-
until stack.empty?
|
|
43
|
-
node, indent, just_branched, show_connector, is_last, gutters, virtual_root_child = stack.pop
|
|
44
|
-
entry = node[:source]["entry"] || {}
|
|
34
|
+
tree_nodes.layout_rows.map do |row|
|
|
35
|
+
entry = row[:entry]
|
|
45
36
|
entry_id = entry["id"].to_s
|
|
46
37
|
formatted = tree_entry_display(entry, tool_calls_by_id)
|
|
47
|
-
|
|
48
|
-
result << {
|
|
38
|
+
{
|
|
49
39
|
entryId: entry_id,
|
|
50
40
|
parentId: entry["parentId"],
|
|
51
41
|
role: formatted[:role],
|
|
52
42
|
text: formatted[:text],
|
|
53
43
|
current: !@current_leaf.to_s.empty? && entry_id == @current_leaf.to_s,
|
|
54
|
-
depth:
|
|
55
|
-
isLast: is_last,
|
|
56
|
-
ancestorContinues: gutters.map { |gutter| gutter[:show] },
|
|
57
|
-
activePath: active_path
|
|
44
|
+
depth: row[:depth],
|
|
45
|
+
isLast: row[:is_last],
|
|
46
|
+
ancestorContinues: row[:gutters].map { |gutter| gutter[:show] },
|
|
47
|
+
activePath: row[:active_path],
|
|
58
48
|
selectable: @selectable.call(entry),
|
|
59
|
-
label:
|
|
60
|
-
labelTimestamp:
|
|
61
|
-
prefix:
|
|
49
|
+
label: row[:source]["label"] || entry["resolvedLabel"],
|
|
50
|
+
labelTimestamp: row[:source]["labelTimestamp"],
|
|
51
|
+
prefix: row[:prefix]
|
|
62
52
|
}.compact
|
|
63
|
-
|
|
64
|
-
children = node[:children].sort_by { |child| tree_nodes.contains_active_path?(child, active_path) ? 0 : 1 }
|
|
65
|
-
multiple_children = children.length > 1
|
|
66
|
-
child_indent = if multiple_children
|
|
67
|
-
indent + 1
|
|
68
|
-
elsif just_branched && indent.positive?
|
|
69
|
-
indent + 1
|
|
70
|
-
else
|
|
71
|
-
indent
|
|
72
|
-
end
|
|
73
|
-
connector_position = [display_indent - 1, 0].max
|
|
74
|
-
child_gutters = show_connector && !virtual_root_child ? gutters + [{ position: connector_position, show: !is_last }] : gutters
|
|
75
|
-
children.each_with_index.reverse_each do |child, index|
|
|
76
|
-
stack << [child, child_indent, multiple_children, multiple_children, index == children.length - 1, child_gutters, false]
|
|
77
|
-
end
|
|
78
53
|
end
|
|
79
|
-
result
|
|
80
54
|
end
|
|
81
55
|
|
|
82
56
|
private
|
|
@@ -97,31 +71,6 @@ module Kward
|
|
|
97
71
|
{ role: entry["type"].to_s.empty? ? "entry" : entry["type"].to_s, text: entry["type"].to_s }
|
|
98
72
|
end
|
|
99
73
|
|
|
100
|
-
def tree_prefix(display_indent, gutters, show_connector, is_last, foldable)
|
|
101
|
-
return "" if display_indent.to_i <= 0
|
|
102
|
-
|
|
103
|
-
connector_position = show_connector ? display_indent - 1 : -1
|
|
104
|
-
(0...(display_indent * 3)).map do |index|
|
|
105
|
-
level = index / 3
|
|
106
|
-
position = index % 3
|
|
107
|
-
gutter = gutters.find { |candidate| candidate[:position] == level }
|
|
108
|
-
|
|
109
|
-
if gutter
|
|
110
|
-
position.zero? && gutter[:show] ? "│" : " "
|
|
111
|
-
elsif show_connector && level == connector_position
|
|
112
|
-
if position.zero?
|
|
113
|
-
is_last ? "└" : "├"
|
|
114
|
-
elsif position == 1
|
|
115
|
-
foldable ? "⊟" : "─"
|
|
116
|
-
else
|
|
117
|
-
" "
|
|
118
|
-
end
|
|
119
|
-
else
|
|
120
|
-
" "
|
|
121
|
-
end
|
|
122
|
-
end.join
|
|
123
|
-
end
|
|
124
|
-
|
|
125
74
|
def format_tool_result(message, tool_calls_by_id)
|
|
126
75
|
tool_call = tool_calls_by_id[MessageAccess.tool_call_id(message).to_s]
|
|
127
76
|
return SessionTreeToolDisplay.label(tool_call) if tool_call
|
|
@@ -132,11 +81,11 @@ module Kward
|
|
|
132
81
|
end
|
|
133
82
|
|
|
134
83
|
def display_message_text(message)
|
|
135
|
-
|
|
84
|
+
MessageText.preview(message)
|
|
136
85
|
end
|
|
137
86
|
|
|
138
87
|
def truncate_tree_text(text)
|
|
139
|
-
|
|
88
|
+
MessageText.truncate_preview(text)
|
|
140
89
|
end
|
|
141
90
|
end
|
|
142
91
|
end
|
|
@@ -7,9 +7,10 @@ module Kward
|
|
|
7
7
|
module RPC
|
|
8
8
|
# Converts tool calls and results into RPC event payloads.
|
|
9
9
|
class ToolEventNormalizer
|
|
10
|
-
def initialize(tool_call, content: nil)
|
|
10
|
+
def initialize(tool_call, content: nil, tool_registry: nil)
|
|
11
11
|
@tool_call = tool_call
|
|
12
12
|
@content = content
|
|
13
|
+
@tool_registry = tool_registry
|
|
13
14
|
@fields = ToolMetadata.normalized_tool_fields(@tool_call)
|
|
14
15
|
end
|
|
15
16
|
|
|
@@ -17,10 +18,17 @@ module Kward
|
|
|
17
18
|
{
|
|
18
19
|
toolCallId: @fields[:toolCallId],
|
|
19
20
|
toolName: @fields[:toolName],
|
|
20
|
-
args: @fields[:args]
|
|
21
|
+
args: @fields[:args],
|
|
22
|
+
metadata: metadata
|
|
21
23
|
}.compact
|
|
22
24
|
end
|
|
23
25
|
|
|
26
|
+
def update_payload(elapsed_ms: nil)
|
|
27
|
+
payload = call_payload.merge(delta: { content: @content.to_s })
|
|
28
|
+
payload[:elapsedMs] = elapsed_ms if elapsed_ms
|
|
29
|
+
payload
|
|
30
|
+
end
|
|
31
|
+
|
|
24
32
|
def result_payload
|
|
25
33
|
call_payload.merge(
|
|
26
34
|
content: @content,
|
|
@@ -43,6 +51,12 @@ module Kward
|
|
|
43
51
|
|
|
44
52
|
private
|
|
45
53
|
|
|
54
|
+
def metadata
|
|
55
|
+
return unless @tool_registry&.respond_to?(:metadata_for)
|
|
56
|
+
|
|
57
|
+
@tool_registry.metadata_for(@fields[:toolName])
|
|
58
|
+
end
|
|
59
|
+
|
|
46
60
|
def normalized_result
|
|
47
61
|
text = @content.to_s
|
|
48
62
|
is_error = ToolMetadata.error_result?(text)
|