kward 0.73.1 → 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 +51 -0
- data/Gemfile.lock +2 -2
- data/README.md +4 -2
- data/Rakefile +18 -0
- data/doc/configuration.md +70 -4
- data/doc/extensibility.md +6 -19
- data/doc/getting-started.md +1 -1
- data/doc/lifecycle-hooks.md +424 -0
- data/doc/mcp.md +72 -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 -15
- data/doc/session-management.md +9 -9
- data/doc/skills.md +199 -0
- data/doc/usage.md +14 -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 +12 -2
- data/lib/kward/cli/compaction.rb +7 -0
- data/lib/kward/cli/doctor.rb +18 -10
- data/lib/kward/cli/git.rb +30 -3
- data/lib/kward/cli/hook_commands.rb +258 -0
- data/lib/kward/cli/interactive_turn.rb +9 -3
- 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 +32 -15
- data/lib/kward/cli.rb +64 -4
- 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 +152 -27
- 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 +56 -0
- data/lib/kward/mcp/server_config.rb +59 -0
- data/lib/kward/mcp/stdio_transport.rb +112 -0
- 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 +124 -4
- data/lib/kward/prompt_interface/editor/modes/emacs.rb +4 -0
- data/lib/kward/prompt_interface/editor/modes/modern.rb +10 -4
- data/lib/kward/prompt_interface/editor/modes/vibe.rb +448 -20
- data/lib/kward/prompt_interface/editor/renderer.rb +27 -0
- data/lib/kward/prompt_interface/editor/search.rb +70 -13
- data/lib/kward/prompt_interface/editor/state.rb +72 -13
- data/lib/kward/prompt_interface/editor/vibe_state.rb +12 -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 -13
- 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 +122 -0
- data/lib/kward/tools/read_skill.rb +9 -4
- data/lib/kward/tools/registry.rb +297 -9
- 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 +30 -1
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require_relative "base"
|
|
3
|
+
|
|
4
|
+
# Namespace for the Kward CLI agent runtime.
|
|
5
|
+
module Kward
|
|
6
|
+
module Tools
|
|
7
|
+
# Adapts an MCP server tool to Kward's model-callable tool interface.
|
|
8
|
+
class MCPTool < Base
|
|
9
|
+
attr_reader :server_name, :remote_name
|
|
10
|
+
|
|
11
|
+
def display_name
|
|
12
|
+
"#{server_name}.#{remote_name}"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def initialize(server_name:, client:, tool:)
|
|
16
|
+
@server_name = server_name.to_s
|
|
17
|
+
@client = client
|
|
18
|
+
@tool = tool
|
|
19
|
+
@remote_name = value(tool, "name").to_s
|
|
20
|
+
super(kward_name(@server_name, @remote_name), description, properties: {}, required: [])
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def schema
|
|
24
|
+
{
|
|
25
|
+
type: "function",
|
|
26
|
+
function: {
|
|
27
|
+
name: name,
|
|
28
|
+
description: description,
|
|
29
|
+
parameters: input_schema
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def call(args, _conversation, cancellation: nil)
|
|
35
|
+
cancellation&.raise_if_cancelled!
|
|
36
|
+
result = @client.call_tool(@remote_name, args || {})
|
|
37
|
+
format_result(result)
|
|
38
|
+
rescue StandardError => e
|
|
39
|
+
"MCP tool #{server_name}.#{remote_name} failed: #{e.message}"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.kward_name(server_name, tool_name)
|
|
43
|
+
"#{sanitize_name(server_name)}__#{sanitize_name(tool_name)}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.sanitize_name(value)
|
|
47
|
+
text = value.to_s.gsub(/[^A-Za-z0-9_-]/, "_")
|
|
48
|
+
text = "mcp" if text.empty?
|
|
49
|
+
text[0, 60]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def kward_name(server_name, tool_name)
|
|
55
|
+
self.class.kward_name(server_name, tool_name)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def description
|
|
59
|
+
value(@tool, "description").to_s.empty? ? "MCP tool #{@server_name}.#{@remote_name}" : value(@tool, "description").to_s
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def input_schema
|
|
63
|
+
schema = value(@tool, "inputSchema")
|
|
64
|
+
return empty_object_schema unless schema.is_a?(Hash)
|
|
65
|
+
|
|
66
|
+
schema.empty? ? empty_object_schema : schema
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def empty_object_schema
|
|
70
|
+
{ type: "object", properties: {}, additionalProperties: false }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def format_result(result)
|
|
74
|
+
return "MCP tool #{server_name}.#{remote_name} returned no content." unless result.is_a?(Hash)
|
|
75
|
+
|
|
76
|
+
parts = []
|
|
77
|
+
parts << "MCP tool #{server_name}.#{remote_name} reported an error:" if truthy?(value(result, "isError"))
|
|
78
|
+
parts.concat(format_content(value(result, "content")))
|
|
79
|
+
|
|
80
|
+
structured = value(result, "structuredContent")
|
|
81
|
+
parts << JSON.pretty_generate(structured) unless structured.nil?
|
|
82
|
+
|
|
83
|
+
parts.empty? ? "MCP tool #{server_name}.#{remote_name} returned no content." : parts.join("\n\n")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def format_content(content)
|
|
87
|
+
Array(content).filter_map do |entry|
|
|
88
|
+
next entry.to_s unless entry.is_a?(Hash)
|
|
89
|
+
|
|
90
|
+
case value(entry, "type")
|
|
91
|
+
when "text"
|
|
92
|
+
value(entry, "text").to_s
|
|
93
|
+
when "image"
|
|
94
|
+
"[MCP image content: #{value(entry, "mimeType") || "unknown MIME type"}]"
|
|
95
|
+
when "audio"
|
|
96
|
+
"[MCP audio content: #{value(entry, "mimeType") || "unknown MIME type"}]"
|
|
97
|
+
when "resource_link"
|
|
98
|
+
"[MCP resource link: #{value(entry, "uri") || value(entry, "name") || "unnamed"}]"
|
|
99
|
+
when "resource"
|
|
100
|
+
"[MCP embedded resource: #{resource_label(value(entry, "resource"))}]"
|
|
101
|
+
else
|
|
102
|
+
JSON.generate(entry)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def resource_label(resource)
|
|
108
|
+
return "unnamed" unless resource.is_a?(Hash)
|
|
109
|
+
|
|
110
|
+
value(resource, "uri") || value(resource, "name") || "unnamed"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def truthy?(value)
|
|
114
|
+
value == true || value.to_s == "true"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def value(object, key)
|
|
118
|
+
object[key] || object[key.to_sym]
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -8,12 +8,16 @@ module Kward
|
|
|
8
8
|
# Tool wrapper for reading configured skill instructions.
|
|
9
9
|
class ReadSkill < Base
|
|
10
10
|
# Builds the tool schema and stores the execution dependency.
|
|
11
|
-
def initialize
|
|
11
|
+
def initialize(skills: nil)
|
|
12
|
+
name_property = { type: "string", description: "Skill name." }
|
|
13
|
+
skill_names = Array(skills).map(&:name).compact.sort
|
|
14
|
+
name_property[:enum] = skill_names unless skill_names.empty?
|
|
15
|
+
|
|
12
16
|
super(
|
|
13
17
|
"read_skill",
|
|
14
18
|
"Read configured skill instructions/files.",
|
|
15
19
|
properties: {
|
|
16
|
-
name:
|
|
20
|
+
name: name_property,
|
|
17
21
|
path: { type: "string", description: "Path inside skill; default SKILL.md." }
|
|
18
22
|
},
|
|
19
23
|
required: ["name"]
|
|
@@ -21,11 +25,12 @@ module Kward
|
|
|
21
25
|
end
|
|
22
26
|
|
|
23
27
|
# Executes the tool and returns model-facing output text.
|
|
24
|
-
def call(args,
|
|
28
|
+
def call(args, conversation, cancellation: nil)
|
|
25
29
|
name = argument(args, :name, "")
|
|
26
30
|
path = argument(args, :path)
|
|
31
|
+
workspace_root = conversation.respond_to?(:workspace_root) ? conversation.workspace_root : Dir.pwd
|
|
27
32
|
|
|
28
|
-
ConfigFiles.read_skill_file(name, path)
|
|
33
|
+
ConfigFiles.read_skill_file(name, path, workspace_root: workspace_root)
|
|
29
34
|
end
|
|
30
35
|
end
|
|
31
36
|
end
|
data/lib/kward/tools/registry.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
require_relative "../config_files"
|
|
2
2
|
require_relative "../conversation"
|
|
3
|
+
require_relative "../hooks"
|
|
3
4
|
require_relative "ask_user_question"
|
|
4
5
|
require_relative "code_search"
|
|
5
6
|
require_relative "context_budget_stats"
|
|
@@ -8,6 +9,7 @@ require_relative "edit_file"
|
|
|
8
9
|
require_relative "fetch_content"
|
|
9
10
|
require_relative "fetch_raw"
|
|
10
11
|
require_relative "list_directory"
|
|
12
|
+
require_relative "mcp_tool"
|
|
11
13
|
require_relative "read_file"
|
|
12
14
|
require_relative "read_skill"
|
|
13
15
|
require_relative "run_shell_command"
|
|
@@ -19,6 +21,7 @@ require_relative "search/code"
|
|
|
19
21
|
require_relative "search/web"
|
|
20
22
|
require_relative "search/web_fetch"
|
|
21
23
|
require_relative "tool_call"
|
|
24
|
+
require_relative "../mcp/server_config"
|
|
22
25
|
require_relative "../telemetry/logger"
|
|
23
26
|
require_relative "../tool_output_compactor"
|
|
24
27
|
require_relative "../workspace"
|
|
@@ -60,7 +63,7 @@ module Kward
|
|
|
60
63
|
# @param web_search_enabled [Boolean, nil] override for web search exposure
|
|
61
64
|
# @param skills [Array<ConfigFiles::Skill>, nil] override discovered skills
|
|
62
65
|
# @param ask_user_question_enabled [Boolean, nil] override question exposure
|
|
63
|
-
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, write_lock: nil, writer_id: nil, tool_output_compactor: ToolOutputCompactor.new, telemetry_logger: TelemetryLogger.new, context_budget_meter: nil)
|
|
66
|
+
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, write_lock: nil, writer_id: 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)
|
|
64
67
|
@workspace = workspace
|
|
65
68
|
@prompt = prompt
|
|
66
69
|
@web_search = web_search
|
|
@@ -75,8 +78,18 @@ module Kward
|
|
|
75
78
|
@tool_output_compactor = tool_output_compactor
|
|
76
79
|
@telemetry_logger = telemetry_logger
|
|
77
80
|
@context_budget_meter = context_budget_meter
|
|
81
|
+
@tool_approval = tool_approval
|
|
82
|
+
@hook_manager = hook_manager
|
|
83
|
+
@hook_context = hook_context
|
|
84
|
+
@mcp_clients = if mcp_clients
|
|
85
|
+
mcp_clients
|
|
86
|
+
elsif @allowed_tool_names
|
|
87
|
+
[]
|
|
88
|
+
else
|
|
89
|
+
MCP::ServerConfig.clients_from_config(ConfigFiles.read_config)
|
|
90
|
+
end
|
|
78
91
|
@tools = build_tools.freeze
|
|
79
|
-
@schemas = build_schema_tools.map(
|
|
92
|
+
@schemas = build_schema_tools.map { |tool| schema_with_metadata(tool) }.freeze
|
|
80
93
|
end
|
|
81
94
|
|
|
82
95
|
# Executes a model-requested tool call and appends the result to the
|
|
@@ -96,10 +109,18 @@ module Kward
|
|
|
96
109
|
tool = @tools[name]
|
|
97
110
|
|
|
98
111
|
original_content = if tool
|
|
99
|
-
|
|
112
|
+
before_tool = run_hook("tool_call_before", conversation, payload: tool_payload(name, args, tool_call))
|
|
113
|
+
args = hook_arguments(before_tool, args)
|
|
114
|
+
if before_tool.denied?
|
|
115
|
+
hook_denied_content(before_tool, "tool call denied: #{name}")
|
|
116
|
+
elsif before_tool.approval_required? && hook_approval_denied?(before_tool, tool_call, name, args, cancellation)
|
|
117
|
+
hook_denied_content(before_tool, "tool call approval denied: #{name}")
|
|
118
|
+
elsif mutation_tool?(name) && !write_lock_owned?
|
|
100
119
|
"Workspace write denied: another worker owns the write lock."
|
|
120
|
+
elsif tool_approval_denied?(tool_call, name, args, cancellation)
|
|
121
|
+
"Declined: tool execution denied by user: #{name}"
|
|
101
122
|
else
|
|
102
|
-
tool
|
|
123
|
+
execute_tool_with_hooks(tool, name, args, tool_call, conversation, cancellation)
|
|
103
124
|
end
|
|
104
125
|
else
|
|
105
126
|
"Unknown tool: #{name}"
|
|
@@ -112,7 +133,7 @@ module Kward
|
|
|
112
133
|
end
|
|
113
134
|
|
|
114
135
|
artifact_id = nil
|
|
115
|
-
model_content =
|
|
136
|
+
model_content = compact_tool_output(name, content, original_content, conversation) do
|
|
116
137
|
artifact_id ||= conversation.store_tool_output_artifact(tool_name: name, content: original_content)
|
|
117
138
|
end
|
|
118
139
|
record_context_budget(conversation, name, before: original_content, after: model_content)
|
|
@@ -127,8 +148,73 @@ module Kward
|
|
|
127
148
|
model_content
|
|
128
149
|
end
|
|
129
150
|
|
|
151
|
+
def metadata_for(name)
|
|
152
|
+
tool = @tools[name.to_s]
|
|
153
|
+
return unknown_tool_metadata(name) unless tool
|
|
154
|
+
|
|
155
|
+
tool_metadata(tool)
|
|
156
|
+
end
|
|
157
|
+
|
|
130
158
|
private
|
|
131
159
|
|
|
160
|
+
def schema_with_metadata(tool)
|
|
161
|
+
tool.schema.merge(metadata: tool_metadata(tool))
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def tool_metadata(tool)
|
|
165
|
+
if tool.is_a?(Tools::MCPTool)
|
|
166
|
+
{
|
|
167
|
+
source: "mcp",
|
|
168
|
+
displayName: tool.display_name,
|
|
169
|
+
serverName: tool.server_name,
|
|
170
|
+
remoteName: tool.remote_name
|
|
171
|
+
}
|
|
172
|
+
else
|
|
173
|
+
{
|
|
174
|
+
source: source_for_tool(tool),
|
|
175
|
+
displayName: tool.name
|
|
176
|
+
}
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def source_for_tool(tool)
|
|
181
|
+
case tool
|
|
182
|
+
when Tools::WebSearch, Tools::FetchContent, Tools::FetchRaw
|
|
183
|
+
"web"
|
|
184
|
+
when Tools::ReadSkill
|
|
185
|
+
"skill"
|
|
186
|
+
when Tools::AskUserQuestion
|
|
187
|
+
"ui"
|
|
188
|
+
else
|
|
189
|
+
"builtin"
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def unknown_tool_metadata(name)
|
|
194
|
+
{
|
|
195
|
+
source: "unknown",
|
|
196
|
+
displayName: name.to_s
|
|
197
|
+
}
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def compact_tool_output(name, content, original_content, conversation, &store_artifact)
|
|
201
|
+
before = run_hook("tool_output_compact_before", conversation, payload: {
|
|
202
|
+
tool_name: name,
|
|
203
|
+
bytes: content.bytesize,
|
|
204
|
+
duplicate: content != original_content
|
|
205
|
+
})
|
|
206
|
+
return content if before.denied? || before.approval_required?
|
|
207
|
+
|
|
208
|
+
compacted = @tool_output_compactor.compact(name, content, &store_artifact)
|
|
209
|
+
run_hook("tool_output_compact_after", conversation, payload: {
|
|
210
|
+
tool_name: name,
|
|
211
|
+
bytes_before: content.bytesize,
|
|
212
|
+
bytes_after: compacted.bytesize,
|
|
213
|
+
compacted: compacted != content
|
|
214
|
+
}) if compacted != content
|
|
215
|
+
compacted
|
|
216
|
+
end
|
|
217
|
+
|
|
132
218
|
def record_context_budget(conversation, name, before:, after:)
|
|
133
219
|
meter = conversation.respond_to?(:context_budget_meter) ? conversation.context_budget_meter : @context_budget_meter
|
|
134
220
|
return unless meter
|
|
@@ -161,10 +247,184 @@ module Kward
|
|
|
161
247
|
)
|
|
162
248
|
end
|
|
163
249
|
|
|
250
|
+
def execute_tool_with_hooks(tool, name, args, tool_call, conversation, cancellation)
|
|
251
|
+
args = run_shell_before_hooks(name, args, tool_call, conversation, cancellation)
|
|
252
|
+
run_file_change_before_hooks(name, args, tool_call, conversation, cancellation)
|
|
253
|
+
run_mcp_before_hooks(name, args, tool_call, conversation, cancellation)
|
|
254
|
+
content = tool.call(args, conversation, cancellation: cancellation)
|
|
255
|
+
run_mcp_after_hooks(name, args, content, conversation)
|
|
256
|
+
run_shell_after_hooks(name, args, content, conversation)
|
|
257
|
+
run_file_change_after_hooks(name, args, content, conversation)
|
|
258
|
+
run_hook("tool_call_after", conversation, payload: tool_payload(name, args, tool_call).merge(content: content.to_s))
|
|
259
|
+
content
|
|
260
|
+
rescue HookDenied => e
|
|
261
|
+
e.message
|
|
262
|
+
rescue StandardError => e
|
|
263
|
+
run_mcp_error_hooks(name, args, e, conversation)
|
|
264
|
+
run_hook("tool_call_error", conversation, payload: tool_payload(name, args, tool_call).merge(error: e.message))
|
|
265
|
+
raise
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def run_mcp_before_hooks(name, args, tool_call, conversation, cancellation)
|
|
269
|
+
return unless mcp_tool?(name)
|
|
270
|
+
|
|
271
|
+
result = run_hook("mcp_tool_before", conversation, payload: mcp_payload(name, args))
|
|
272
|
+
if result.denied?
|
|
273
|
+
raise HookDenied, hook_denied_content(result, "MCP tool denied")
|
|
274
|
+
end
|
|
275
|
+
if result.approval_required? && hook_approval_denied?(result, tool_call, name, args, cancellation)
|
|
276
|
+
raise HookDenied, hook_denied_content(result, "MCP tool approval denied")
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def run_mcp_after_hooks(name, args, content, conversation)
|
|
281
|
+
return unless mcp_tool?(name)
|
|
282
|
+
|
|
283
|
+
run_hook("mcp_tool_after", conversation, payload: mcp_payload(name, args).merge(content: content.to_s))
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def run_mcp_error_hooks(name, args, error, conversation)
|
|
287
|
+
return unless mcp_tool?(name)
|
|
288
|
+
|
|
289
|
+
run_hook("mcp_tool_error", conversation, payload: mcp_payload(name, args).merge(error: error.message))
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def run_shell_before_hooks(name, args, tool_call, conversation, cancellation)
|
|
293
|
+
return args unless name.to_s == "run_shell_command"
|
|
294
|
+
|
|
295
|
+
result = run_hook("shell_command_before", conversation, payload: shell_payload(args))
|
|
296
|
+
if result.denied?
|
|
297
|
+
raise HookDenied, hook_denied_content(result, "shell command denied")
|
|
298
|
+
end
|
|
299
|
+
if result.approval_required? && hook_approval_denied?(result, tool_call, name, args, cancellation)
|
|
300
|
+
raise HookDenied, hook_denied_content(result, "shell command approval denied")
|
|
301
|
+
end
|
|
302
|
+
shell_arguments(result.payload, args)
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def run_shell_after_hooks(name, args, content, conversation)
|
|
306
|
+
return unless name.to_s == "run_shell_command"
|
|
307
|
+
|
|
308
|
+
run_hook("shell_command_after", conversation, payload: shell_payload(args).merge(content: content.to_s))
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def run_file_change_before_hooks(name, args, tool_call, conversation, cancellation)
|
|
312
|
+
return unless ToolCall.file_change_tool?(name)
|
|
313
|
+
|
|
314
|
+
result = run_hook("file_change_before", conversation, payload: file_change_payload(name, args))
|
|
315
|
+
if result.denied?
|
|
316
|
+
raise HookDenied, hook_denied_content(result, "file change denied")
|
|
317
|
+
end
|
|
318
|
+
if result.approval_required? && hook_approval_denied?(result, tool_call, name, args, cancellation)
|
|
319
|
+
raise HookDenied, hook_denied_content(result, "file change approval denied")
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def run_file_change_after_hooks(name, args, content, conversation)
|
|
324
|
+
return unless ToolCall.file_change_tool?(name)
|
|
325
|
+
return unless content.to_s.start_with?("Wrote ", "Edited ")
|
|
326
|
+
|
|
327
|
+
run_hook("file_change_after", conversation, payload: file_change_payload(name, args).merge(content: content.to_s))
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
HookDenied = Class.new(StandardError)
|
|
331
|
+
|
|
332
|
+
def run_hook(name, conversation, payload: {})
|
|
333
|
+
return Hooks::Manager::Result.new(event: nil, decision: Hooks::Decision.allow, decisions: [], warnings: [], messages: [], payload: payload) unless @hook_manager
|
|
334
|
+
|
|
335
|
+
@hook_manager.run(Hooks::Event.new(
|
|
336
|
+
name: name,
|
|
337
|
+
workspace: { root: @workspace.respond_to?(:root) ? @workspace.root.to_s : nil },
|
|
338
|
+
payload: payload
|
|
339
|
+
), context: @hook_context || default_hook_context(conversation))
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def default_hook_context(conversation)
|
|
343
|
+
return nil unless defined?(PluginRegistry::Context)
|
|
344
|
+
|
|
345
|
+
PluginRegistry::Context.new(conversation: conversation, workspace_root: @workspace.respond_to?(:root) ? @workspace.root.to_s : Dir.pwd)
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def tool_payload(name, args, tool_call)
|
|
349
|
+
metadata = metadata_for(name)
|
|
350
|
+
{
|
|
351
|
+
tool_name: name,
|
|
352
|
+
arguments: args,
|
|
353
|
+
tool_call_id: tool_call["id"] || tool_call[:id],
|
|
354
|
+
source: metadata[:source] || metadata["source"],
|
|
355
|
+
server_name: metadata[:serverName] || metadata["serverName"],
|
|
356
|
+
remote_name: metadata[:remoteName] || metadata["remoteName"]
|
|
357
|
+
}.compact
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
def hook_arguments(result, fallback)
|
|
361
|
+
payload_arguments = result.payload[:arguments] || result.payload["arguments"]
|
|
362
|
+
payload_arguments.is_a?(Hash) ? payload_arguments : fallback
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def mcp_payload(name, args)
|
|
366
|
+
tool_payload(name, args, {}).slice(:tool_name, :arguments, :source, :server_name, :remote_name)
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
def shell_payload(args)
|
|
370
|
+
{
|
|
371
|
+
tool_name: "run_shell_command",
|
|
372
|
+
command: args[:command] || args["command"],
|
|
373
|
+
timeout_seconds: args[:timeout_seconds] || args["timeout_seconds"] || Workspace::DEFAULT_COMMAND_TIMEOUT_SECONDS,
|
|
374
|
+
cwd: @workspace.respond_to?(:root) ? @workspace.root.to_s : nil
|
|
375
|
+
}.compact
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
def file_change_payload(name, args)
|
|
379
|
+
operation = name.to_s.start_with?("edit") ? "edit" : "write"
|
|
380
|
+
path = args[:path] || args["path"]
|
|
381
|
+
payload = {
|
|
382
|
+
tool_name: name,
|
|
383
|
+
operation: operation,
|
|
384
|
+
path: path,
|
|
385
|
+
files: [{ path: path, operation: operation }]
|
|
386
|
+
}
|
|
387
|
+
if operation == "edit"
|
|
388
|
+
payload[:edits] = args[:edits] || args["edits"]
|
|
389
|
+
else
|
|
390
|
+
payload[:content] = args[:content] || args["content"]
|
|
391
|
+
end
|
|
392
|
+
payload.compact
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
def shell_arguments(payload, fallback)
|
|
396
|
+
fallback.merge(
|
|
397
|
+
command: payload[:command] || payload["command"] || fallback[:command] || fallback["command"],
|
|
398
|
+
timeout_seconds: payload[:timeout_seconds] || payload["timeout_seconds"] || fallback[:timeout_seconds] || fallback["timeout_seconds"]
|
|
399
|
+
)
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def hook_denied_content(result, fallback)
|
|
403
|
+
"Declined: #{result.decision.message || fallback}"
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
def hook_approval_denied?(result, tool_call, name, args, cancellation)
|
|
407
|
+
return true unless @tool_approval
|
|
408
|
+
|
|
409
|
+
approval_args = args.merge("hook_message" => result.decision.message)
|
|
410
|
+
@tool_approval.call(tool_call: tool_call, name: name, args: approval_args, cancellation: cancellation) == false
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
def mcp_tool?(name)
|
|
414
|
+
metadata = metadata_for(name)
|
|
415
|
+
(metadata[:source] || metadata["source"]).to_s == "mcp"
|
|
416
|
+
end
|
|
417
|
+
|
|
164
418
|
def mutation_tool?(name)
|
|
165
419
|
ToolCall.write_lock_required?(name)
|
|
166
420
|
end
|
|
167
421
|
|
|
422
|
+
def tool_approval_denied?(tool_call, name, args, cancellation)
|
|
423
|
+
return false unless @tool_approval
|
|
424
|
+
|
|
425
|
+
@tool_approval.call(tool_call: tool_call, name: name, args: args, cancellation: cancellation) == false
|
|
426
|
+
end
|
|
427
|
+
|
|
168
428
|
def write_lock_owned?
|
|
169
429
|
return true unless @write_lock
|
|
170
430
|
|
|
@@ -182,6 +442,7 @@ module Kward
|
|
|
182
442
|
"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"
|
|
183
443
|
)
|
|
184
444
|
tools.concat(@tools.values_at("web_search", "fetch_content", "fetch_raw")) if web_search_available?
|
|
445
|
+
tools.concat(@tools.values.select { |tool| tool.is_a?(Tools::MCPTool) })
|
|
185
446
|
tools << @tools["read_skill"] if skills_available?
|
|
186
447
|
tools << @tools["ask_user_question"] if ask_user_question_available?
|
|
187
448
|
tools.compact
|
|
@@ -192,9 +453,9 @@ module Kward
|
|
|
192
453
|
Tools::WebSearch.new(web_search: @web_search),
|
|
193
454
|
Tools::FetchContent.new(web_fetch: @web_fetch),
|
|
194
455
|
Tools::FetchRaw.new(web_fetch: @web_fetch),
|
|
195
|
-
Tools::ReadSkill.new,
|
|
456
|
+
Tools::ReadSkill.new(skills: discovered_skills),
|
|
196
457
|
Tools::AskUserQuestion.new(prompt: @prompt)
|
|
197
|
-
]
|
|
458
|
+
] + mcp_tool_values
|
|
198
459
|
end
|
|
199
460
|
|
|
200
461
|
def core_tools
|
|
@@ -219,9 +480,36 @@ module Kward
|
|
|
219
480
|
true
|
|
220
481
|
end
|
|
221
482
|
|
|
483
|
+
def discovered_skills(workspace_root: nil)
|
|
484
|
+
return @skills unless @skills.nil?
|
|
485
|
+
|
|
486
|
+
@discovered_skills ||= {}
|
|
487
|
+
root = workspace_root || (@workspace.respond_to?(:root) ? @workspace.root : Dir.pwd)
|
|
488
|
+
@discovered_skills[root] ||= ConfigFiles.skills(workspace_root: root)
|
|
489
|
+
end
|
|
490
|
+
|
|
222
491
|
def skills_available?
|
|
223
|
-
|
|
224
|
-
|
|
492
|
+
discovered_skills.any?
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
def mcp_tool_values
|
|
496
|
+
@mcp_tool_values ||= build_mcp_tools
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
def build_mcp_tools
|
|
500
|
+
Array(@mcp_clients).flat_map do |client|
|
|
501
|
+
client.list_tools.map do |tool|
|
|
502
|
+
Tools::MCPTool.new(server_name: client.name, client: client, tool: tool)
|
|
503
|
+
end
|
|
504
|
+
rescue StandardError => e
|
|
505
|
+
@telemetry_logger.log(
|
|
506
|
+
"mcp",
|
|
507
|
+
"server_unavailable",
|
|
508
|
+
"server" => client.respond_to?(:name) ? client.name : "unknown",
|
|
509
|
+
"error" => e.message
|
|
510
|
+
)
|
|
511
|
+
[]
|
|
512
|
+
end
|
|
225
513
|
end
|
|
226
514
|
|
|
227
515
|
def ask_user_question_available?
|
|
@@ -5,7 +5,9 @@ require "net/http"
|
|
|
5
5
|
require "open3"
|
|
6
6
|
require "pathname"
|
|
7
7
|
require "uri"
|
|
8
|
+
require_relative "../../path_guard"
|
|
8
9
|
require_relative "../../config_files"
|
|
10
|
+
require_relative "web"
|
|
9
11
|
|
|
10
12
|
# Namespace for the Kward CLI agent runtime.
|
|
11
13
|
module Kward
|
|
@@ -24,7 +26,7 @@ module Kward
|
|
|
24
26
|
ACTIONS = %w[package_search github_search repo_clone repo_search repo_read list_cache refresh_cache clear_cache].freeze
|
|
25
27
|
|
|
26
28
|
# Creates an object for code search and repository cache operations.
|
|
27
|
-
def initialize(cache_root: nil, http_client: NetHttpClient.new, git_runner: GitRunner.new, max_output_bytes: MAX_OUTPUT_BYTES)
|
|
29
|
+
def initialize(cache_root: nil, http_client: WebSearch::NetHttpClient.new(user_agent: "Kward code_search"), git_runner: GitRunner.new, max_output_bytes: MAX_OUTPUT_BYTES)
|
|
28
30
|
@cache_root = File.expand_path(cache_root || ConfigFiles.code_search_cache_dir)
|
|
29
31
|
@http_client = http_client
|
|
30
32
|
@git_runner = git_runner
|
|
@@ -49,26 +51,6 @@ module Kward
|
|
|
49
51
|
"Error: code_search failed: #{redact(e.message)}"
|
|
50
52
|
end
|
|
51
53
|
|
|
52
|
-
# HTTP adapter used by code search registry/package lookups.
|
|
53
|
-
class NetHttpClient
|
|
54
|
-
def get_json(url, headers: {})
|
|
55
|
-
JSON.parse(get_text(url, headers: headers.merge("Accept" => "application/json")))
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
def get_text(url, headers: {})
|
|
59
|
-
uri = URI(url)
|
|
60
|
-
request = Net::HTTP::Get.new(uri)
|
|
61
|
-
request["User-Agent"] = "Kward code_search"
|
|
62
|
-
headers.each { |key, val| request[key] = val }
|
|
63
|
-
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: HTTP_TIMEOUT_SECONDS, read_timeout: HTTP_TIMEOUT_SECONDS) do |http|
|
|
64
|
-
http.request(request)
|
|
65
|
-
end
|
|
66
|
-
raise "HTTP #{response.code} from #{uri.host}" unless response.is_a?(Net::HTTPSuccess)
|
|
67
|
-
|
|
68
|
-
response.body
|
|
69
|
-
end
|
|
70
|
-
end
|
|
71
|
-
|
|
72
54
|
# Git command adapter used by repository cache operations.
|
|
73
55
|
class GitRunner
|
|
74
56
|
def run(*args, chdir: nil)
|
|
@@ -376,7 +358,7 @@ module Kward
|
|
|
376
358
|
end
|
|
377
359
|
|
|
378
360
|
def inside_root?(root, path)
|
|
379
|
-
|
|
361
|
+
PathGuard.inside?(path, root)
|
|
380
362
|
end
|
|
381
363
|
|
|
382
364
|
def inside_cache?(path)
|
|
@@ -54,7 +54,10 @@ module Kward
|
|
|
54
54
|
return "Error: queries must be an array with 1-#{MAX_QUERIES} strings" unless valid_queries?(queries)
|
|
55
55
|
|
|
56
56
|
max_results = bounded_max_results(args_value(args, "max_results") || args_value(args, "num_results"))
|
|
57
|
-
|
|
57
|
+
provider_value = args_value(args, "provider")
|
|
58
|
+
provider_value = config_value("provider") if provider_value.nil?
|
|
59
|
+
provider_value = "auto" if provider_value.nil? || provider_value.to_s.empty?
|
|
60
|
+
provider = normalize_provider(provider_value)
|
|
58
61
|
return "Error: provider must be one of: #{PROVIDERS.join(", ")}" unless provider
|
|
59
62
|
|
|
60
63
|
options = {
|
|
@@ -549,8 +552,10 @@ module Kward
|
|
|
549
552
|
|
|
550
553
|
def args_value(args, key)
|
|
551
554
|
return nil unless args.is_a?(Hash)
|
|
555
|
+
return args[key] if args.key?(key)
|
|
556
|
+
return args[key.to_sym] if args.key?(key.to_sym)
|
|
552
557
|
|
|
553
|
-
|
|
558
|
+
nil
|
|
554
559
|
end
|
|
555
560
|
|
|
556
561
|
def success?(response)
|
|
@@ -717,10 +722,26 @@ module Kward
|
|
|
717
722
|
class NetHttpClient
|
|
718
723
|
Response = Struct.new(:code, :body, :headers, keyword_init: true)
|
|
719
724
|
|
|
725
|
+
def initialize(user_agent: nil)
|
|
726
|
+
@user_agent = user_agent
|
|
727
|
+
end
|
|
728
|
+
|
|
720
729
|
def get(url, headers: {})
|
|
721
730
|
request(url, Net::HTTP::Get, headers: headers)
|
|
722
731
|
end
|
|
723
732
|
|
|
733
|
+
def get_json(url, headers: {})
|
|
734
|
+
JSON.parse(get_text(url, headers: headers.merge("Accept" => "application/json")))
|
|
735
|
+
end
|
|
736
|
+
|
|
737
|
+
def get_text(url, headers: {})
|
|
738
|
+
uri = URI.parse(url)
|
|
739
|
+
response = get(uri.to_s, headers: headers)
|
|
740
|
+
raise "HTTP #{response.code} from #{uri.host}" unless success?(response.code)
|
|
741
|
+
|
|
742
|
+
response.body
|
|
743
|
+
end
|
|
744
|
+
|
|
724
745
|
def post(url, form:, headers: {})
|
|
725
746
|
request(url, Net::HTTP::Post, headers: headers) do |http_request|
|
|
726
747
|
http_request.set_form_data(form)
|
|
@@ -739,12 +760,17 @@ module Kward
|
|
|
739
760
|
uri = URI.parse(url)
|
|
740
761
|
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: HTTP_TIMEOUT_SECONDS, read_timeout: HTTP_TIMEOUT_SECONDS) do |http|
|
|
741
762
|
http_request = request_class.new(uri)
|
|
763
|
+
http_request["User-Agent"] = @user_agent if @user_agent
|
|
742
764
|
headers.each { |key, value| http_request[key] = value }
|
|
743
765
|
yield http_request if block_given?
|
|
744
766
|
response = http.request(http_request)
|
|
745
767
|
Response.new(code: response.code, body: response.body, headers: response.each_header.to_h)
|
|
746
768
|
end
|
|
747
769
|
end
|
|
770
|
+
|
|
771
|
+
def success?(code)
|
|
772
|
+
code.to_i.between?(200, 299)
|
|
773
|
+
end
|
|
748
774
|
end
|
|
749
775
|
end
|
|
750
776
|
end
|