kward 0.77.0 → 0.79.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/.github/workflows/ci.yml +7 -2
- data/.github/workflows/pages.yml +1 -1
- data/CHANGELOG.md +50 -0
- data/Gemfile.lock +2 -2
- data/README.md +5 -2
- data/doc/agent-tools.md +1 -1
- data/doc/code-search.md +9 -6
- data/doc/configuration.md +106 -1
- data/doc/extensibility.md +2 -0
- data/doc/getting-started.md +1 -1
- data/doc/local-models.md +130 -0
- data/doc/permissions.md +180 -0
- data/doc/plugins.md +58 -0
- data/doc/releasing.md +1 -1
- data/doc/rpc.md +73 -1
- data/doc/sandboxing.md +120 -0
- data/doc/security.md +15 -5
- data/doc/skills.md +10 -0
- data/doc/tabs.md +4 -3
- data/doc/web-search.md +4 -2
- data/doc/workspace-tools.md +3 -3
- data/kward.gemspec +1 -1
- data/lib/kward/auth/anthropic_oauth.rb +2 -2
- data/lib/kward/auth/github_oauth.rb +3 -3
- data/lib/kward/auth/oauth_helpers.rb +4 -2
- data/lib/kward/auth/openai_oauth.rb +2 -1
- data/lib/kward/cli/doctor.rb +21 -0
- data/lib/kward/cli/plugins.rb +22 -0
- data/lib/kward/cli/rendering.rb +7 -1
- data/lib/kward/cli/runtime_helpers.rb +15 -1
- data/lib/kward/cli/settings.rb +66 -4
- data/lib/kward/cli/slash_commands.rb +109 -1
- data/lib/kward/cli/tabs.rb +140 -34
- data/lib/kward/cli.rb +31 -10
- data/lib/kward/config_files.rb +90 -1
- data/lib/kward/conversation.rb +14 -1
- data/lib/kward/hooks/http_handler.rb +2 -1
- data/lib/kward/http.rb +18 -0
- data/lib/kward/local_command_runner.rb +2 -2
- data/lib/kward/model/client.rb +173 -15
- data/lib/kward/model/model_info.rb +11 -1
- data/lib/kward/model/payloads.rb +7 -1
- data/lib/kward/model/stream_parser.rb +58 -26
- data/lib/kward/openrouter_model_cache.rb +2 -1
- data/lib/kward/pan/index.html.erb +50 -0
- data/lib/kward/pan/server.rb +49 -2
- data/lib/kward/permissions/policy.rb +171 -0
- data/lib/kward/plugin_registry.rb +54 -2
- data/lib/kward/prompt_interface/approval_prompt.rb +62 -0
- data/lib/kward/prompt_interface/editor/controller.rb +36 -3
- data/lib/kward/prompt_interface/question_prompt.rb +12 -3
- data/lib/kward/prompt_interface.rb +20 -0
- data/lib/kward/prompts/commands.rb +2 -0
- data/lib/kward/prompts.rb +16 -5
- data/lib/kward/rpc/plugin_chat_manager.rb +302 -0
- data/lib/kward/rpc/server.rb +76 -3
- data/lib/kward/rpc/session_manager.rb +43 -8
- data/lib/kward/rpc/transcript_normalizer.rb +14 -5
- data/lib/kward/sandbox/capabilities.rb +39 -0
- data/lib/kward/sandbox/command_runner.rb +28 -0
- data/lib/kward/sandbox/environment.rb +24 -0
- data/lib/kward/sandbox/linux_bubblewrap_runner.rb +71 -0
- data/lib/kward/sandbox/macos_seatbelt_runner.rb +96 -0
- data/lib/kward/sandbox/passthrough_runner.rb +13 -0
- data/lib/kward/sandbox/policy.rb +74 -0
- data/lib/kward/sandbox/runner_factory.rb +55 -0
- data/lib/kward/sandbox/unavailable_runner.rb +21 -0
- data/lib/kward/sandbox.rb +9 -0
- data/lib/kward/session_store.rb +26 -0
- data/lib/kward/skills/capture.rb +144 -0
- data/lib/kward/starter_pack_installer.rb +3 -1
- data/lib/kward/tab_driver.rb +87 -0
- data/lib/kward/tab_store.rb +74 -12
- data/lib/kward/tools/code_search.rb +8 -2
- data/lib/kward/tools/fetch_content.rb +4 -2
- data/lib/kward/tools/fetch_raw.rb +3 -1
- data/lib/kward/tools/registry.rb +56 -8
- data/lib/kward/tools/search/code.rb +46 -12
- data/lib/kward/tools/search/web.rb +60 -17
- data/lib/kward/tools/search/web_fetch.rb +206 -38
- data/lib/kward/tools/web_search.rb +3 -1
- data/lib/kward/update_check.rb +2 -1
- data/lib/kward/version.rb +1 -1
- data/lib/kward/workspace.rb +18 -3
- data/lib/kward/workspace_factory.rb +17 -0
- data/templates/default/fulldoc/html/js/kward.js +1 -0
- data/templates/default/kward_navigation.rb +5 -2
- data/templates/default/layout/html/layout.erb +2 -0
- data/templates/default/layout/html/setup.rb +2 -0
- metadata +22 -2
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# Namespace for the Kward CLI agent runtime.
|
|
2
|
+
module Kward
|
|
3
|
+
# Adapts a session-backed agent to the tab runtime interface. Plugin tab
|
|
4
|
+
# drivers implement the same small surface without becoming Kward sessions.
|
|
5
|
+
class SessionTabDriver
|
|
6
|
+
attr_reader :session, :agent
|
|
7
|
+
|
|
8
|
+
def initialize(session:, agent:)
|
|
9
|
+
@session = session
|
|
10
|
+
@agent = agent
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def messages
|
|
14
|
+
agent.conversation.messages
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def conversation
|
|
18
|
+
agent.conversation
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def submit(input, display_input:, cancellation:, steering: nil, &block)
|
|
22
|
+
options = { cancellation: cancellation }
|
|
23
|
+
options[:display_input] = display_input unless display_input.nil?
|
|
24
|
+
options[:steering] = steering if steering
|
|
25
|
+
agent.ask(input, **options, &block)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def descriptor
|
|
29
|
+
{ "kind" => "session", "session_path" => session.path }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def session?
|
|
33
|
+
true
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def supports_steering?
|
|
37
|
+
true
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def assistant_label
|
|
41
|
+
nil
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Represents a persisted plugin tab whose provider plugin is unavailable.
|
|
46
|
+
# Its descriptor is retained so reinstalling the plugin restores the tab.
|
|
47
|
+
class UnavailableTabDriver
|
|
48
|
+
attr_reader :descriptor
|
|
49
|
+
|
|
50
|
+
def initialize(descriptor:, message:)
|
|
51
|
+
@descriptor = descriptor
|
|
52
|
+
@message = message
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def messages
|
|
56
|
+
[{ role: "assistant", content: @message }]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def submit(*)
|
|
60
|
+
raise @message
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def session?
|
|
64
|
+
false
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def supports_steering?
|
|
68
|
+
false
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def assistant_label
|
|
72
|
+
"Plugin"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Dependencies made available to a plugin tab factory. The host deliberately
|
|
77
|
+
# exposes provider transport and frontend-neutral facts, not CLI internals or
|
|
78
|
+
# workspace session state.
|
|
79
|
+
class PluginTabHost
|
|
80
|
+
attr_reader :client, :workspace_root
|
|
81
|
+
|
|
82
|
+
def initialize(client:, workspace_root:)
|
|
83
|
+
@client = client
|
|
84
|
+
@workspace_root = workspace_root
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
data/lib/kward/tab_store.rb
CHANGED
|
@@ -5,32 +5,45 @@ require_relative "private_file"
|
|
|
5
5
|
|
|
6
6
|
# Namespace for the Kward CLI agent runtime.
|
|
7
7
|
module Kward
|
|
8
|
-
# Persists the terminal UI's open
|
|
8
|
+
# Persists the terminal UI's open tabs per workspace.
|
|
9
9
|
class TabStore
|
|
10
|
+
VERSION = 2
|
|
11
|
+
|
|
10
12
|
def initialize(config_dir: ConfigFiles.config_dir, cwd: Dir.pwd)
|
|
11
13
|
@config_dir = config_dir
|
|
12
14
|
@cwd = File.expand_path(cwd)
|
|
13
15
|
end
|
|
14
16
|
|
|
15
17
|
def load
|
|
16
|
-
return
|
|
18
|
+
return empty_state unless File.file?(path)
|
|
17
19
|
|
|
18
20
|
data = JSON.parse(File.read(path))
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
{ "session_paths" => paths, "labels" => labels, "active_index" => active_index }
|
|
21
|
+
return typed_state(data) if data["tabs"].is_a?(Array)
|
|
22
|
+
|
|
23
|
+
legacy_state(data)
|
|
23
24
|
rescue JSON::ParserError
|
|
24
|
-
|
|
25
|
+
empty_state
|
|
25
26
|
end
|
|
26
27
|
|
|
27
|
-
|
|
28
|
-
|
|
28
|
+
# `session_paths` remains supported for callers and layouts written before
|
|
29
|
+
# typed tab descriptors were introduced.
|
|
30
|
+
def save(session_paths: nil, tabs: nil, active_index:, labels: [])
|
|
31
|
+
tabs ||= Array(session_paths).filter_map.with_index do |session_path, index|
|
|
32
|
+
path = session_path.to_s
|
|
33
|
+
next if path.empty?
|
|
34
|
+
|
|
35
|
+
{
|
|
36
|
+
"kind" => "session",
|
|
37
|
+
"session_path" => path,
|
|
38
|
+
"label" => Array(labels)[index].to_s
|
|
39
|
+
}
|
|
40
|
+
end
|
|
41
|
+
tabs = normalize_tabs(tabs)
|
|
29
42
|
PrivateFile.write_json(path, {
|
|
43
|
+
"version" => VERSION,
|
|
30
44
|
"cwd" => @cwd,
|
|
31
|
-
"
|
|
32
|
-
"
|
|
33
|
-
"active_index" => [[active_index.to_i, 0].max, [paths.length - 1, 0].max].min
|
|
45
|
+
"tabs" => tabs,
|
|
46
|
+
"active_index" => [[active_index.to_i, 0].max, [tabs.length - 1, 0].max].min
|
|
34
47
|
})
|
|
35
48
|
end
|
|
36
49
|
|
|
@@ -40,6 +53,55 @@ module Kward
|
|
|
40
53
|
|
|
41
54
|
private
|
|
42
55
|
|
|
56
|
+
def empty_state
|
|
57
|
+
{ "tabs" => [], "session_paths" => [], "labels" => [], "active_index" => 0 }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def typed_state(data)
|
|
61
|
+
tabs = normalize_tabs(data["tabs"])
|
|
62
|
+
state_for(tabs, data["active_index"])
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def legacy_state(data)
|
|
66
|
+
paths = Array(data["session_paths"]).map(&:to_s).reject(&:empty?)
|
|
67
|
+
labels = Array(data["labels"]).map(&:to_s)
|
|
68
|
+
tabs = paths.each_with_index.map do |session_path, index|
|
|
69
|
+
{ "kind" => "session", "session_path" => session_path, "label" => labels[index].to_s }
|
|
70
|
+
end
|
|
71
|
+
state_for(tabs, data["active_index"])
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def state_for(tabs, active_index)
|
|
75
|
+
{
|
|
76
|
+
"tabs" => tabs,
|
|
77
|
+
"session_paths" => tabs.filter_map { |tab| tab["session_path"] if tab["kind"] == "session" },
|
|
78
|
+
"labels" => tabs.map { |tab| tab["label"].to_s },
|
|
79
|
+
"active_index" => [[active_index.to_i, 0].max, [tabs.length - 1, 0].max].min
|
|
80
|
+
}
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def normalize_tabs(tabs)
|
|
84
|
+
session_paths = {}
|
|
85
|
+
|
|
86
|
+
Array(tabs).filter_map do |tab|
|
|
87
|
+
next unless tab.is_a?(Hash)
|
|
88
|
+
|
|
89
|
+
normalized = tab.transform_keys(&:to_s)
|
|
90
|
+
kind = normalized["kind"].to_s
|
|
91
|
+
next if kind.empty?
|
|
92
|
+
next if kind == "session" && normalized["session_path"].to_s.empty?
|
|
93
|
+
|
|
94
|
+
if kind == "session"
|
|
95
|
+
session_path = File.expand_path(normalized["session_path"].to_s, @cwd)
|
|
96
|
+
next if session_paths[session_path]
|
|
97
|
+
|
|
98
|
+
session_paths[session_path] = true
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
normalized
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
43
105
|
def workspace_key
|
|
44
106
|
Digest::SHA256.hexdigest(@cwd)[0, 24]
|
|
45
107
|
end
|
|
@@ -37,7 +37,11 @@ module Kward
|
|
|
37
37
|
},
|
|
38
38
|
path: {
|
|
39
39
|
type: "string",
|
|
40
|
-
description: "Repo-relative path."
|
|
40
|
+
description: "Repo-relative path; inferred from GitHub blob URLs when omitted."
|
|
41
|
+
},
|
|
42
|
+
ref: {
|
|
43
|
+
type: "string",
|
|
44
|
+
description: "Git branch, tag, or commit; inferred from GitHub blob URLs when omitted."
|
|
41
45
|
},
|
|
42
46
|
start_line: {
|
|
43
47
|
type: "integer",
|
|
@@ -63,7 +67,9 @@ module Kward
|
|
|
63
67
|
# Executes the tool and returns model-facing output text.
|
|
64
68
|
def call(args, _conversation, cancellation: nil)
|
|
65
69
|
cancellation&.raise_if_cancelled!
|
|
66
|
-
@code_search.call(args)
|
|
70
|
+
return @code_search.call(args) unless cancellation
|
|
71
|
+
|
|
72
|
+
@code_search.call(args, cancellation: cancellation)
|
|
67
73
|
end
|
|
68
74
|
end
|
|
69
75
|
end
|
|
@@ -12,7 +12,7 @@ module Kward
|
|
|
12
12
|
@web_fetch = web_fetch
|
|
13
13
|
super(
|
|
14
14
|
"fetch_content",
|
|
15
|
-
"Fetch a specific URL and extract readable bounded content.",
|
|
15
|
+
"Fetch a specific URL and extract readable bounded content, including links and simple tables.",
|
|
16
16
|
properties: {
|
|
17
17
|
url: {
|
|
18
18
|
type: "string",
|
|
@@ -34,7 +34,9 @@ module Kward
|
|
|
34
34
|
|
|
35
35
|
# Executes the tool and returns model-facing output text.
|
|
36
36
|
def call(args, _conversation, cancellation: nil)
|
|
37
|
-
@web_fetch.fetch_content(args)
|
|
37
|
+
return @web_fetch.fetch_content(args) unless cancellation
|
|
38
|
+
|
|
39
|
+
@web_fetch.fetch_content(args, cancellation: cancellation)
|
|
38
40
|
end
|
|
39
41
|
end
|
|
40
42
|
end
|
|
@@ -33,7 +33,9 @@ module Kward
|
|
|
33
33
|
|
|
34
34
|
# Executes the tool and returns model-facing output text.
|
|
35
35
|
def call(args, _conversation, cancellation: nil)
|
|
36
|
-
@web_fetch.fetch_raw(args)
|
|
36
|
+
return @web_fetch.fetch_raw(args) unless cancellation
|
|
37
|
+
|
|
38
|
+
@web_fetch.fetch_raw(args, cancellation: cancellation)
|
|
37
39
|
end
|
|
38
40
|
end
|
|
39
41
|
end
|
data/lib/kward/tools/registry.rb
CHANGED
|
@@ -22,6 +22,7 @@ require_relative "search/web"
|
|
|
22
22
|
require_relative "search/web_fetch"
|
|
23
23
|
require_relative "tool_call"
|
|
24
24
|
require_relative "../mcp/server_config"
|
|
25
|
+
require_relative "../permissions/policy"
|
|
25
26
|
require_relative "../telemetry/logger"
|
|
26
27
|
require_relative "../tool_output_compactor"
|
|
27
28
|
require_relative "../workspace"
|
|
@@ -48,6 +49,19 @@ module Kward
|
|
|
48
49
|
# explicit tool errors.
|
|
49
50
|
# @api public
|
|
50
51
|
class ToolRegistry
|
|
52
|
+
CORE_TOOL_NAMES = %w[
|
|
53
|
+
list_directory
|
|
54
|
+
read_file
|
|
55
|
+
write_file
|
|
56
|
+
edit_file
|
|
57
|
+
run_shell_command
|
|
58
|
+
code_search
|
|
59
|
+
summarize_file_structure
|
|
60
|
+
context_for_task
|
|
61
|
+
context_budget_stats
|
|
62
|
+
retrieve_tool_output
|
|
63
|
+
].freeze
|
|
64
|
+
|
|
51
65
|
# Tool schemas advertised to the model for the current frontend and config.
|
|
52
66
|
#
|
|
53
67
|
# @return [Array<Hash>] tool schemas currently advertised to the model
|
|
@@ -64,7 +78,7 @@ module Kward
|
|
|
64
78
|
# @param web_search_enabled [Boolean, nil] override for web search exposure
|
|
65
79
|
# @param skills [Array<ConfigFiles::Skill>, nil] override discovered skills
|
|
66
80
|
# @param ask_user_question_enabled [Boolean, nil] override question exposure
|
|
67
|
-
def initialize(workspace: Workspace.new, prompt: nil, web_search: WebSearch.new, web_fetch: WebFetch.new, code_search: CodeSearch.new, web_search_enabled: nil, skills: nil, ask_user_question_enabled: nil, allowed_tool_names: nil, tool_output_compactor: ToolOutputCompactor.new, telemetry_logger: TelemetryLogger.new, context_budget_meter: nil, mcp_clients: nil, tool_approval: nil, hook_manager: nil, hook_context: nil)
|
|
81
|
+
def initialize(workspace: Workspace.new, prompt: nil, web_search: WebSearch.new, web_fetch: WebFetch.new, code_search: CodeSearch.new, web_search_enabled: nil, skills: nil, ask_user_question_enabled: nil, allowed_tool_names: nil, tool_output_compactor: ToolOutputCompactor.new, telemetry_logger: TelemetryLogger.new, context_budget_meter: nil, mcp_clients: nil, tool_approval: nil, approval_for_allowed_tools: false, permission_policy: nil, hook_manager: nil, hook_context: nil)
|
|
68
82
|
@workspace = workspace
|
|
69
83
|
@prompt = prompt
|
|
70
84
|
@web_search = web_search
|
|
@@ -78,6 +92,8 @@ module Kward
|
|
|
78
92
|
@telemetry_logger = telemetry_logger
|
|
79
93
|
@context_budget_meter = context_budget_meter
|
|
80
94
|
@tool_approval = tool_approval
|
|
95
|
+
@approval_for_allowed_tools = approval_for_allowed_tools == true
|
|
96
|
+
@permission_policy = permission_policy || Permissions::Policy.from_config(ConfigFiles.read_config)
|
|
81
97
|
@hook_manager = hook_manager
|
|
82
98
|
@hook_context = hook_context
|
|
83
99
|
@mcp_clients = if mcp_clients
|
|
@@ -114,10 +130,25 @@ module Kward
|
|
|
114
130
|
hook_denied_content(before_tool, "tool call denied: #{name}")
|
|
115
131
|
elsif before_tool.approval_required? && hook_approval_denied?(before_tool, tool_call, name, args, cancellation)
|
|
116
132
|
hook_denied_content(before_tool, "tool call approval denied: #{name}")
|
|
117
|
-
elsif tool_approval_denied?(tool_call, name, args, cancellation)
|
|
118
|
-
"Declined: tool execution denied by user: #{name}"
|
|
119
133
|
else
|
|
120
|
-
|
|
134
|
+
permission_decision = @permission_policy.decision_for(name, args, source: source_for_tool(tool))
|
|
135
|
+
if permission_decision.denied?
|
|
136
|
+
"Declined: #{permission_decision.reason}: #{name}"
|
|
137
|
+
elsif permission_decision.approval_required?
|
|
138
|
+
approval = permission_approval_result(tool_call, name, args, cancellation)
|
|
139
|
+
if approval == :allow_for_session
|
|
140
|
+
@permission_policy.allow_for_session!(name)
|
|
141
|
+
execute_tool_with_hooks(tool, name, args, tool_call, conversation, cancellation)
|
|
142
|
+
elsif approval == true
|
|
143
|
+
execute_tool_with_hooks(tool, name, args, tool_call, conversation, cancellation)
|
|
144
|
+
else
|
|
145
|
+
approval_denied_content(approval, name)
|
|
146
|
+
end
|
|
147
|
+
elsif @approval_for_allowed_tools && tool_approval_denied?(tool_call, name, args, cancellation)
|
|
148
|
+
"Declined: tool execution denied by user: #{name}"
|
|
149
|
+
else
|
|
150
|
+
execute_tool_with_hooks(tool, name, args, tool_call, conversation, cancellation)
|
|
151
|
+
end
|
|
121
152
|
end
|
|
122
153
|
else
|
|
123
154
|
"Unknown tool: #{name}"
|
|
@@ -184,6 +215,8 @@ module Kward
|
|
|
184
215
|
|
|
185
216
|
def source_for_tool(tool)
|
|
186
217
|
case tool
|
|
218
|
+
when Tools::MCPTool
|
|
219
|
+
"mcp"
|
|
187
220
|
when Tools::WebSearch, Tools::FetchContent, Tools::FetchRaw
|
|
188
221
|
"web"
|
|
189
222
|
when Tools::ReadSkill
|
|
@@ -420,6 +453,19 @@ module Kward
|
|
|
420
453
|
(metadata[:source] || metadata["source"]).to_s == "mcp"
|
|
421
454
|
end
|
|
422
455
|
|
|
456
|
+
def permission_approval_result(tool_call, name, args, cancellation)
|
|
457
|
+
return false unless @tool_approval
|
|
458
|
+
|
|
459
|
+
@tool_approval.call(tool_call: tool_call, name: name, args: args, cancellation: cancellation)
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
def approval_denied_content(approval, name)
|
|
463
|
+
message = approval[:denied_message] if approval.is_a?(Hash)
|
|
464
|
+
return "Declined: #{message}" unless message.to_s.empty?
|
|
465
|
+
|
|
466
|
+
"Declined: tool execution denied by user: #{name}"
|
|
467
|
+
end
|
|
468
|
+
|
|
423
469
|
def tool_approval_denied?(tool_call, name, args, cancellation)
|
|
424
470
|
return false unless @tool_approval
|
|
425
471
|
|
|
@@ -429,13 +475,15 @@ module Kward
|
|
|
429
475
|
def build_tools
|
|
430
476
|
tools = all_tools
|
|
431
477
|
tools = tools.select { |tool| @allowed_tool_names.include?(tool.name) } if @allowed_tool_names
|
|
432
|
-
tools.
|
|
478
|
+
tools.each_with_object({}) do |tool, result|
|
|
479
|
+
raise ArgumentError, "Duplicate tool name: #{tool.name}" if result.key?(tool.name)
|
|
480
|
+
|
|
481
|
+
result[tool.name] = tool
|
|
482
|
+
end
|
|
433
483
|
end
|
|
434
484
|
|
|
435
485
|
def build_schema_tools
|
|
436
|
-
tools = @tools.values_at(
|
|
437
|
-
"list_directory", "read_file", "write_file", "edit_file", "run_shell_command", "code_search", "summarize_file_structure", "context_for_task", "context_budget_stats", "retrieve_tool_output"
|
|
438
|
-
)
|
|
486
|
+
tools = @tools.values_at(*CORE_TOOL_NAMES)
|
|
439
487
|
tools.concat(@tools.values_at("web_search", "fetch_content", "fetch_raw")) if web_search_available?
|
|
440
488
|
tools.concat(@tools.values.select { |tool| tool.is_a?(Tools::MCPTool) })
|
|
441
489
|
tools << @tools["read_skill"] if skills_available?
|
|
@@ -33,7 +33,10 @@ module Kward
|
|
|
33
33
|
@max_output_bytes = max_output_bytes
|
|
34
34
|
end
|
|
35
35
|
|
|
36
|
-
def call(args)
|
|
36
|
+
def call(args = nil, cancellation: nil, **keyword_args)
|
|
37
|
+
args ||= keyword_args
|
|
38
|
+
cancellation&.raise_if_cancelled!
|
|
39
|
+
@cancellation = cancellation
|
|
37
40
|
action = value(args, "action").to_s
|
|
38
41
|
return "Error: action must be one of: #{ACTIONS.join(", ")}" unless ACTIONS.include?(action)
|
|
39
42
|
|
|
@@ -47,6 +50,8 @@ module Kward
|
|
|
47
50
|
when "refresh_cache" then refresh_cache(args)
|
|
48
51
|
when "clear_cache" then clear_cache(args)
|
|
49
52
|
end
|
|
53
|
+
rescue Cancellation::CancelledError
|
|
54
|
+
raise
|
|
50
55
|
rescue StandardError => e
|
|
51
56
|
"Error: code_search failed: #{redact(e.message)}"
|
|
52
57
|
end
|
|
@@ -129,13 +134,16 @@ module Kward
|
|
|
129
134
|
query = value(args, "query").to_s
|
|
130
135
|
return "Error: query is required" if query.strip.empty?
|
|
131
136
|
|
|
132
|
-
path, =
|
|
137
|
+
path, revision = synchronized_repo(repo, value(args, "ref") || repo[:requested_ref])
|
|
133
138
|
max_results = bounded_max_results(value(args, "max_results"))
|
|
134
139
|
context = bounded_integer(value(args, "context_lines"), DEFAULT_CONTEXT_LINES, 0, 5)
|
|
135
140
|
matches = search_files(path, query, max_results: max_results, context_lines: context)
|
|
136
|
-
|
|
141
|
+
if matches.empty?
|
|
142
|
+
suffix = revision == "unknown" ? "" : " at #{revision}"
|
|
143
|
+
return "Error: no matches found in #{repo[:full_name]}#{suffix}"
|
|
144
|
+
end
|
|
137
145
|
|
|
138
|
-
lines = ["# Code search", "- Repository: #{repo[:full_name]}", "- Query: #{query}", ""]
|
|
146
|
+
lines = ["# Code search", "- Repository: #{repo[:full_name]}", "- Revision: #{revision}", "- Query: #{query}", ""]
|
|
139
147
|
matches.each do |match|
|
|
140
148
|
lines << "## #{match[:path]}:#{match[:line]}"
|
|
141
149
|
lines << "```"
|
|
@@ -148,10 +156,10 @@ module Kward
|
|
|
148
156
|
def repo_read(args)
|
|
149
157
|
repo = require_repo(args)
|
|
150
158
|
return repo if repo.is_a?(String)
|
|
151
|
-
file = value(args, "path") || value(args, "file")
|
|
159
|
+
file = value(args, "path") || value(args, "file") || repo[:requested_path]
|
|
152
160
|
return "Error: path is required" if file.to_s.empty?
|
|
153
161
|
|
|
154
|
-
root, =
|
|
162
|
+
root, revision = synchronized_repo(repo, value(args, "ref") || repo[:requested_ref])
|
|
155
163
|
target = safe_existing_path(root, file.to_s)
|
|
156
164
|
return "Error: path is not a file: #{file}" unless File.file?(target)
|
|
157
165
|
return "Error: file is too large: #{file}" if File.size(target) > MAX_FILE_BYTES
|
|
@@ -161,7 +169,7 @@ module Kward
|
|
|
161
169
|
lines = File.readlines(target, chomp: true)
|
|
162
170
|
selected = lines.drop(start_line - 1).first(line_count)
|
|
163
171
|
numbered = selected.each_with_index.map { |line, index| "#{start_line + index}: #{line}" }
|
|
164
|
-
truncate((["# Code read", "- Repository: #{repo[:full_name]}", "- Path: #{file}", ""] + numbered).join("\n"))
|
|
172
|
+
truncate((["# Code read", "- Repository: #{repo[:full_name]}", "- Revision: #{revision}", "- Path: #{file}", ""] + numbered).join("\n"))
|
|
165
173
|
rescue ArgumentError => e
|
|
166
174
|
"Error: #{e.message}"
|
|
167
175
|
end
|
|
@@ -231,10 +239,7 @@ module Kward
|
|
|
231
239
|
path = cache_path(repo)
|
|
232
240
|
FileUtils.mkdir_p(@cache_root, mode: 0o700)
|
|
233
241
|
if Dir.exist?(File.join(path, ".git"))
|
|
234
|
-
if refresh
|
|
235
|
-
@git_runner.run("fetch", "--depth", "1", "origin", chdir: path)
|
|
236
|
-
@git_runner.run("reset", "--hard", "FETCH_HEAD", chdir: path)
|
|
237
|
-
end
|
|
242
|
+
synchronize_checkout(path) if refresh
|
|
238
243
|
return [path, false]
|
|
239
244
|
end
|
|
240
245
|
|
|
@@ -243,11 +248,35 @@ module Kward
|
|
|
243
248
|
[path, true]
|
|
244
249
|
end
|
|
245
250
|
|
|
251
|
+
def synchronized_repo(repo, ref)
|
|
252
|
+
path = cache_path(repo)
|
|
253
|
+
lock_path = "#{path}.lock"
|
|
254
|
+
FileUtils.mkdir_p(@cache_root, mode: 0o700)
|
|
255
|
+
File.open(lock_path, File::RDWR | File::CREAT, 0o600) do |lock|
|
|
256
|
+
lock.flock(File::LOCK_EX)
|
|
257
|
+
path, created = ensure_repo(repo, refresh: false)
|
|
258
|
+
synchronize_checkout(path, ref: ref) unless created && ref.to_s.empty?
|
|
259
|
+
revision = @git_runner.run("rev-parse", "HEAD", chdir: path).strip
|
|
260
|
+
revision = "unknown" if revision.empty?
|
|
261
|
+
[path, revision]
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def synchronize_checkout(path, ref: nil)
|
|
266
|
+
if ref.to_s.empty?
|
|
267
|
+
@git_runner.run("fetch", "--depth", "1", "origin", chdir: path)
|
|
268
|
+
else
|
|
269
|
+
@git_runner.run("fetch", "--depth", "1", "origin", ref.to_s, chdir: path)
|
|
270
|
+
end
|
|
271
|
+
@git_runner.run("reset", "--hard", "FETCH_HEAD", chdir: path)
|
|
272
|
+
end
|
|
273
|
+
|
|
246
274
|
def search_files(root, query, max_results:, context_lines:)
|
|
247
275
|
matches = []
|
|
248
276
|
base = File.realpath(root)
|
|
249
277
|
scanned = 0
|
|
250
278
|
Dir.glob(File.join(root, "**", "*"), File::FNM_DOTMATCH).sort.each do |path|
|
|
279
|
+
@cancellation&.raise_if_cancelled!
|
|
251
280
|
next if matches.length >= max_results || scanned >= MAX_SCANNED_FILES
|
|
252
281
|
next if skip_search_path?(base, path)
|
|
253
282
|
|
|
@@ -327,7 +356,12 @@ module Kward
|
|
|
327
356
|
return nil unless owner.match?(%r{\A[A-Za-z0-9_.-]+\z}) && name.match?(%r{\A[A-Za-z0-9_.-]+\z})
|
|
328
357
|
|
|
329
358
|
full_name = "#{owner}/#{name}"
|
|
330
|
-
{ full_name: full_name, html_url: "https://github.com/#{full_name}", clone_url: "https://github.com/#{full_name}.git" }
|
|
359
|
+
result = { full_name: full_name, html_url: "https://github.com/#{full_name}", clone_url: "https://github.com/#{full_name}.git" }
|
|
360
|
+
if parts[2] == "blob" && parts.length >= 5
|
|
361
|
+
result[:requested_ref] = parts[3]
|
|
362
|
+
result[:requested_path] = parts[4..].join("/")
|
|
363
|
+
end
|
|
364
|
+
result
|
|
331
365
|
rescue URI::InvalidURIError
|
|
332
366
|
nil
|
|
333
367
|
end
|
|
@@ -4,6 +4,7 @@ require "net/http"
|
|
|
4
4
|
require "nokogiri"
|
|
5
5
|
require "uri"
|
|
6
6
|
require_relative "../../config_files"
|
|
7
|
+
require_relative "../../http"
|
|
7
8
|
|
|
8
9
|
# Namespace for the Kward CLI agent runtime.
|
|
9
10
|
module Kward
|
|
@@ -49,7 +50,9 @@ module Kward
|
|
|
49
50
|
true
|
|
50
51
|
end
|
|
51
52
|
|
|
52
|
-
def search(args)
|
|
53
|
+
def search(args = nil, cancellation: nil, **keyword_args)
|
|
54
|
+
args ||= keyword_args
|
|
55
|
+
cancellation&.raise_if_cancelled!
|
|
53
56
|
queries = args_value(args, "queries")
|
|
54
57
|
return "Error: queries must be an array with 1-#{MAX_QUERIES} strings" unless valid_queries?(queries)
|
|
55
58
|
|
|
@@ -60,36 +63,44 @@ module Kward
|
|
|
60
63
|
provider = normalize_provider(provider_value)
|
|
61
64
|
return "Error: provider must be one of: #{PROVIDERS.join(", ")}" unless provider
|
|
62
65
|
|
|
66
|
+
recency_value = args_value(args, "recency_filter") || args_value(args, "recencyFilter")
|
|
67
|
+
recency = normalize_recency(recency_value)
|
|
68
|
+
return "Error: recency_filter must be one of: day, week, month, year" if recency_value && !recency
|
|
69
|
+
|
|
63
70
|
options = {
|
|
64
71
|
max_results: max_results,
|
|
65
|
-
recency_filter:
|
|
72
|
+
recency_filter: recency,
|
|
66
73
|
domain_filter: normalize_domain_filter(args_value(args, "domain_filter") || args_value(args, "domainFilter")),
|
|
67
74
|
provider: provider
|
|
68
75
|
}
|
|
69
76
|
|
|
70
|
-
sections = ["# Web search"
|
|
77
|
+
sections = ["# Web search"]
|
|
71
78
|
failures = []
|
|
72
|
-
|
|
79
|
+
succeeded = 0
|
|
73
80
|
|
|
74
81
|
queries.each do |query|
|
|
75
|
-
|
|
76
|
-
|
|
82
|
+
cancellation&.raise_if_cancelled!
|
|
83
|
+
response, error = search_query(query, options, cancellation: cancellation)
|
|
84
|
+
succeeded += 1 if successful_response?(response)
|
|
77
85
|
failures << "#{query}: #{error}" if error && !successful_response?(response)
|
|
78
86
|
sections << format_query_results(query, response, error)
|
|
79
87
|
end
|
|
80
88
|
|
|
81
|
-
|
|
89
|
+
if succeeded.zero?
|
|
82
90
|
return "Error: web_search found no results\n#{failures.map { |failure| "- #{failure}" }.join("\n")}".strip
|
|
83
91
|
end
|
|
84
92
|
|
|
93
|
+
sections.insert(1, "- Queries: #{queries.length}\n- Succeeded: #{succeeded}\n- Failed: #{queries.length - succeeded}")
|
|
94
|
+
sections.insert(2, "Use fetch_content with a result URL to verify human-readable pages, or fetch_raw for specs, JSON, YAML, XML, and other machine-readable resources.")
|
|
85
95
|
truncate_output(sections.join("\n\n"))
|
|
86
96
|
end
|
|
87
97
|
|
|
88
98
|
private
|
|
89
99
|
|
|
90
|
-
def search_query(query, options)
|
|
100
|
+
def search_query(query, options, cancellation: nil)
|
|
91
101
|
errors = []
|
|
92
102
|
provider_order(options[:provider]).each do |provider|
|
|
103
|
+
cancellation&.raise_if_cancelled!
|
|
93
104
|
begin
|
|
94
105
|
response = case provider
|
|
95
106
|
when "exa"
|
|
@@ -514,7 +525,7 @@ module Kward
|
|
|
514
525
|
lines << answer
|
|
515
526
|
end
|
|
516
527
|
|
|
517
|
-
results = response.results || []
|
|
528
|
+
results = unique_results(response.results || [])
|
|
518
529
|
unless results.empty?
|
|
519
530
|
lines << "Sources:" unless answer.empty?
|
|
520
531
|
results.each_with_index do |result, index|
|
|
@@ -527,6 +538,25 @@ module Kward
|
|
|
527
538
|
lines.join("\n")
|
|
528
539
|
end
|
|
529
540
|
|
|
541
|
+
def unique_results(results)
|
|
542
|
+
seen = {}
|
|
543
|
+
results.each_with_object([]) do |result, unique|
|
|
544
|
+
url = clean_result_url(result.url)
|
|
545
|
+
uri = URI.parse(url)
|
|
546
|
+
next unless %w[http https].include?(uri.scheme) && uri.host
|
|
547
|
+
|
|
548
|
+
uri.fragment = nil
|
|
549
|
+
key = uri.to_s
|
|
550
|
+
next if seen[key]
|
|
551
|
+
|
|
552
|
+
seen[key] = true
|
|
553
|
+
result.url = key
|
|
554
|
+
unique << result
|
|
555
|
+
rescue URI::InvalidURIError
|
|
556
|
+
next
|
|
557
|
+
end
|
|
558
|
+
end
|
|
559
|
+
|
|
530
560
|
def answer_from_results(results)
|
|
531
561
|
results.filter_map do |result|
|
|
532
562
|
excerpt = result.excerpt.to_s.strip
|
|
@@ -590,7 +620,7 @@ module Kward
|
|
|
590
620
|
{
|
|
591
621
|
"Accept" => accept,
|
|
592
622
|
"Accept-Language" => "en-US,en;q=0.9",
|
|
593
|
-
"User-Agent" =>
|
|
623
|
+
"User-Agent" => Http.user_agent,
|
|
594
624
|
"Sec-Fetch-Dest" => "document",
|
|
595
625
|
"Sec-Fetch-Mode" => "navigate",
|
|
596
626
|
"Sec-Fetch-Site" => "none",
|
|
@@ -720,14 +750,14 @@ module Kward
|
|
|
720
750
|
|
|
721
751
|
# HTTP adapter used by web-search providers and fallbacks.
|
|
722
752
|
class NetHttpClient
|
|
723
|
-
Response = Struct.new(:code, :body, :headers, keyword_init: true)
|
|
753
|
+
Response = Struct.new(:code, :body, :headers, :truncated, keyword_init: true)
|
|
724
754
|
|
|
725
|
-
def initialize(user_agent:
|
|
755
|
+
def initialize(user_agent: Http.user_agent)
|
|
726
756
|
@user_agent = user_agent
|
|
727
757
|
end
|
|
728
758
|
|
|
729
|
-
def get(url, headers: {})
|
|
730
|
-
request(url, Net::HTTP::Get, headers: headers)
|
|
759
|
+
def get(url, headers: {}, max_bytes: nil)
|
|
760
|
+
request(url, Net::HTTP::Get, headers: headers, max_bytes: max_bytes)
|
|
731
761
|
end
|
|
732
762
|
|
|
733
763
|
def get_json(url, headers: {})
|
|
@@ -756,15 +786,28 @@ module Kward
|
|
|
756
786
|
|
|
757
787
|
private
|
|
758
788
|
|
|
759
|
-
def request(url, request_class, headers: {})
|
|
789
|
+
def request(url, request_class, headers: {}, max_bytes: nil)
|
|
760
790
|
uri = URI.parse(url)
|
|
761
791
|
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: HTTP_TIMEOUT_SECONDS, read_timeout: HTTP_TIMEOUT_SECONDS) do |http|
|
|
762
792
|
http_request = request_class.new(uri)
|
|
763
793
|
http_request["User-Agent"] = @user_agent if @user_agent
|
|
764
794
|
headers.each { |key, value| http_request[key] = value }
|
|
765
795
|
yield http_request if block_given?
|
|
766
|
-
response =
|
|
767
|
-
|
|
796
|
+
response = nil
|
|
797
|
+
body = +""
|
|
798
|
+
truncated = false
|
|
799
|
+
http.request(http_request) do |http_response|
|
|
800
|
+
response = http_response
|
|
801
|
+
http_response.read_body do |chunk|
|
|
802
|
+
if max_bytes && body.bytesize + chunk.bytesize > max_bytes
|
|
803
|
+
body << chunk.byteslice(0, max_bytes - body.bytesize)
|
|
804
|
+
truncated = true
|
|
805
|
+
break
|
|
806
|
+
end
|
|
807
|
+
body << chunk
|
|
808
|
+
end
|
|
809
|
+
end
|
|
810
|
+
Response.new(code: response.code, body: body, headers: response.each_header.to_h, truncated: truncated)
|
|
768
811
|
end
|
|
769
812
|
end
|
|
770
813
|
|