kward 0.78.0 → 0.80.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 +13 -2
- data/.github/workflows/pages.yml +1 -1
- data/CHANGELOG.md +82 -1
- data/Gemfile.lock +2 -2
- data/README.md +33 -38
- data/doc/agent-tools.md +10 -11
- data/doc/api.md +3 -3
- data/doc/authentication.md +79 -110
- data/doc/code-search.md +1 -1
- data/doc/composer.md +1 -1
- data/doc/configuration.md +136 -27
- data/doc/context-budgeting.md +6 -6
- data/doc/context-tools.md +2 -2
- data/doc/editor.md +7 -7
- data/doc/extensibility.md +9 -29
- data/doc/files.md +6 -6
- data/doc/getting-started.md +3 -3
- data/doc/git.md +8 -2
- data/doc/lifecycle-hooks.md +5 -4
- data/doc/local-models.md +130 -0
- data/doc/mcp.md +5 -5
- data/doc/permissions.md +15 -5
- data/doc/plugins.md +39 -4
- data/doc/prompt-templates.md +105 -0
- data/doc/providers.md +140 -0
- data/doc/releasing.md +11 -3
- data/doc/rpc.md +65 -31
- data/doc/sandboxing.md +142 -0
- data/doc/security.md +31 -10
- data/doc/session-management.md +2 -4
- data/doc/skills.md +10 -0
- data/doc/tabs.md +37 -0
- data/doc/telegram.md +138 -0
- data/doc/transports.md +209 -0
- data/doc/usage.md +11 -3
- data/doc/web-search.md +3 -3
- data/doc/workspace-tools.md +4 -4
- data/examples/plugins/stardate_footer.rb +10 -0
- data/examples/plugins/telegram/plugin.rb +39 -0
- data/examples/plugins/telegram/telegram_api.rb +136 -0
- data/examples/plugins/telegram/telegram_transport.rb +304 -0
- data/kward.gemspec +1 -1
- data/lib/kward/ansi.rb +1 -0
- data/lib/kward/auth/api_key_store.rb +99 -0
- data/lib/kward/cli/auth_commands.rb +72 -13
- data/lib/kward/cli/commands.rb +7 -0
- data/lib/kward/cli/doctor.rb +21 -0
- data/lib/kward/cli/git.rb +31 -8
- data/lib/kward/cli/plugins.rb +1 -0
- data/lib/kward/cli/prompt_interface.rb +7 -0
- data/lib/kward/cli/rendering.rb +6 -2
- data/lib/kward/cli/runtime_helpers.rb +4 -2
- data/lib/kward/cli/sessions.rb +1 -1
- data/lib/kward/cli/settings.rb +162 -36
- data/lib/kward/cli/slash_commands.rb +109 -1
- data/lib/kward/cli/tabs.rb +102 -37
- data/lib/kward/cli/transports.rb +67 -0
- data/lib/kward/cli/worktrees.rb +360 -0
- data/lib/kward/cli.rb +22 -1
- data/lib/kward/config_files.rb +106 -4
- data/lib/kward/conversation.rb +27 -8
- data/lib/kward/git_worktree_manager.rb +291 -0
- data/lib/kward/local_command_runner.rb +2 -2
- data/lib/kward/model/azure_openai_config.rb +54 -0
- data/lib/kward/model/catalog.rb +113 -0
- data/lib/kward/model/client.rb +364 -23
- data/lib/kward/model/model_info.rb +60 -1
- data/lib/kward/model/payloads.rb +64 -4
- data/lib/kward/model/provider_catalog.rb +114 -0
- data/lib/kward/model/sources.rb +70 -0
- data/lib/kward/model/stream_parser.rb +141 -39
- data/lib/kward/pan/index.html.erb +50 -0
- data/lib/kward/pan/server.rb +49 -2
- data/lib/kward/permissions/policy.rb +18 -4
- data/lib/kward/plugin_chat_runtime.rb +374 -0
- data/lib/kward/plugin_registry.rb +81 -8
- data/lib/kward/private_file.rb +9 -3
- data/lib/kward/prompt_interface/approval_prompt.rb +2 -0
- data/lib/kward/prompt_interface/composer_renderer.rb +1 -5
- data/lib/kward/prompt_interface/editor/auto_indent.rb +46 -0
- data/lib/kward/prompt_interface/editor/buffer.rb +18 -4
- data/lib/kward/prompt_interface/editor/controller.rb +43 -10
- data/lib/kward/prompt_interface/editor/modes/vibe.rb +2 -2
- data/lib/kward/prompt_interface/editor/renderer.rb +8 -8
- data/lib/kward/prompt_interface/editor/state.rb +3 -9
- data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +184 -9
- data/lib/kward/prompt_interface/file_overlay.rb +2 -2
- data/lib/kward/prompt_interface/git_prompt.rb +7 -3
- data/lib/kward/prompt_interface/interactive/controller.rb +4 -4
- data/lib/kward/prompt_interface/interactive/renderer.rb +4 -1
- data/lib/kward/prompt_interface/interactive/state.rb +2 -2
- data/lib/kward/prompt_interface/project_browser.rb +25 -6
- data/lib/kward/prompt_interface/runtime_state.rb +22 -6
- data/lib/kward/prompt_interface/screen.rb +3 -0
- data/lib/kward/prompt_interface/selection_prompt.rb +7 -1
- data/lib/kward/prompt_interface/transcript_buffer.rb +24 -2
- data/lib/kward/prompt_interface.rb +71 -12
- data/lib/kward/prompts/commands.rb +3 -1
- data/lib/kward/prompts.rb +22 -10
- data/lib/kward/rpc/auth_manager.rb +112 -152
- data/lib/kward/rpc/config_manager.rb +46 -6
- data/lib/kward/rpc/plugin_chat_manager.rb +56 -194
- data/lib/kward/rpc/prompt_bridge.rb +8 -4
- data/lib/kward/rpc/redactor.rb +1 -1
- data/lib/kward/rpc/server.rb +102 -14
- data/lib/kward/rpc/session_manager.rb +151 -38
- 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 +110 -24
- data/lib/kward/skills/capture.rb +144 -0
- data/lib/kward/tab_driver.rb +6 -3
- data/lib/kward/tools/git_commit.rb +39 -0
- data/lib/kward/tools/registry.rb +26 -6
- data/lib/kward/tools/tool_call.rb +1 -0
- data/lib/kward/transport/gateway.rb +253 -0
- data/lib/kward/transport/host.rb +343 -0
- data/lib/kward/transport/manager.rb +179 -0
- data/lib/kward/transport/plugin_chat_gateway.rb +186 -0
- data/lib/kward/transport/runtime.rb +41 -0
- data/lib/kward/transport/store.rb +101 -0
- data/lib/kward/transport.rb +204 -0
- data/lib/kward/version.rb +1 -1
- data/lib/kward/workspace.rb +18 -3
- data/lib/kward/workspace_factory.rb +34 -0
- data/templates/default/fulldoc/html/js/kward.js +3 -0
- data/templates/default/kward_navigation.rb +8 -1
- data/templates/default/layout/html/setup.rb +5 -0
- metadata +41 -2
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
require "json"
|
|
3
|
+
require "tempfile"
|
|
4
|
+
require "yaml"
|
|
5
|
+
require_relative "../compaction/token_estimator"
|
|
6
|
+
require_relative "../config_files"
|
|
7
|
+
require_relative "../message_access"
|
|
8
|
+
|
|
9
|
+
# Namespace for the Kward CLI agent runtime.
|
|
10
|
+
module Kward
|
|
11
|
+
module Skills
|
|
12
|
+
# Generates and persists reviewed personal skills from saved session branches.
|
|
13
|
+
class Capture
|
|
14
|
+
Draft = Struct.new(:content, :source_path, :name, :description, keyword_init: true)
|
|
15
|
+
|
|
16
|
+
class Error < StandardError; end
|
|
17
|
+
class SourceTooLargeError < Error; end
|
|
18
|
+
class InvalidDraftError < Error; end
|
|
19
|
+
class ConflictError < Error; end
|
|
20
|
+
|
|
21
|
+
OUTPUT_TOKEN_RESERVE = 4_096
|
|
22
|
+
PROMPT_TOKEN_RESERVE = 1_024
|
|
23
|
+
NAME_PATTERN = /\A[a-z0-9]+(?:-[a-z0-9]+)*\z/
|
|
24
|
+
|
|
25
|
+
def initialize(session_store:, client:, config_dir: ConfigFiles.config_dir, token_estimator: Compaction::TokenEstimator.new)
|
|
26
|
+
@session_store = session_store
|
|
27
|
+
@client = client
|
|
28
|
+
@config_dir = config_dir
|
|
29
|
+
@token_estimator = token_estimator
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def generate(session_path, cancellation: nil)
|
|
33
|
+
ensure_personal_session_path!(session_path)
|
|
34
|
+
source = @session_store.capture_branch(session_path)
|
|
35
|
+
raise Error, "Session has no active branch to capture" if source[:entries].empty?
|
|
36
|
+
|
|
37
|
+
source_json = JSON.pretty_generate(source)
|
|
38
|
+
ensure_source_fits!(source_json)
|
|
39
|
+
response = @client.chat(draft_messages(source_json), tools: [], cancellation: cancellation)
|
|
40
|
+
draft(source_path: source[:path], content: MessageAccess.content(response).to_s)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def save(content, overwrite: false)
|
|
44
|
+
reviewed_draft = draft(content: content)
|
|
45
|
+
path = skill_path(reviewed_draft.name)
|
|
46
|
+
if File.exist?(path) && !overwrite
|
|
47
|
+
raise ConflictError, "A personal skill named #{reviewed_draft.name.inspect} already exists"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
FileUtils.mkdir_p(File.dirname(path), mode: 0o700)
|
|
51
|
+
write_atomically(path, reviewed_draft.content)
|
|
52
|
+
reviewed_draft
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def skill_path(name)
|
|
56
|
+
File.join(@config_dir, "skills", name.to_s, "SKILL.md")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def ensure_personal_session_path!(session_path)
|
|
62
|
+
sessions_root = File.realpath(File.join(@config_dir, "sessions"))
|
|
63
|
+
source_path = File.realpath(session_path)
|
|
64
|
+
return if source_path.start_with?("#{sessions_root}#{File::SEPARATOR}")
|
|
65
|
+
|
|
66
|
+
raise Error, "Skill capture source must be a persisted Kward session"
|
|
67
|
+
rescue Errno::ENOENT
|
|
68
|
+
raise Error, "Skill capture source must be a persisted Kward session"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def ensure_source_fits!(source_json)
|
|
72
|
+
context_window = @client.current_context_window
|
|
73
|
+
return unless context_window
|
|
74
|
+
|
|
75
|
+
available_tokens = context_window.to_i - OUTPUT_TOKEN_RESERVE - PROMPT_TOKEN_RESERVE
|
|
76
|
+
source_tokens = @token_estimator.estimate_tokens(source_json)
|
|
77
|
+
return if source_tokens <= available_tokens
|
|
78
|
+
|
|
79
|
+
raise SourceTooLargeError,
|
|
80
|
+
"Selected session needs about #{source_tokens} tokens, but only #{[available_tokens, 0].max} are available for capture"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def draft_messages(source_json)
|
|
84
|
+
[
|
|
85
|
+
{
|
|
86
|
+
role: "system",
|
|
87
|
+
content: <<~INSTRUCTIONS
|
|
88
|
+
Create one reusable Kward Agent Skill from the saved session supplied by the user.
|
|
89
|
+
Return only a complete SKILL.md document. Include YAML frontmatter with a lowercase
|
|
90
|
+
hyphenated `name` and a concise `description`, followed by practical reusable
|
|
91
|
+
instructions. Derive guidance from demonstrated workflow, commands, and conventions;
|
|
92
|
+
omit credentials, private values, and project-specific incidental details. Do not
|
|
93
|
+
claim the skill grants permissions or tool access.
|
|
94
|
+
INSTRUCTIONS
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
role: "user",
|
|
98
|
+
content: "Saved session active branch (complete persisted records):\n\n#{source_json}"
|
|
99
|
+
}
|
|
100
|
+
]
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def draft(source_path: nil, content:)
|
|
104
|
+
metadata, body = parse_skill(content)
|
|
105
|
+
name = metadata.fetch("name", "").to_s.strip
|
|
106
|
+
description = metadata.fetch("description", "").to_s.strip
|
|
107
|
+
raise InvalidDraftError, "Skill frontmatter must include a name" if name.empty?
|
|
108
|
+
raise InvalidDraftError, "Skill name must use lowercase letters, digits, and hyphens" unless NAME_PATTERN.match?(name)
|
|
109
|
+
raise InvalidDraftError, "Skill frontmatter must include a description" if description.empty?
|
|
110
|
+
raise InvalidDraftError, "Skill description exceeds 1024 characters" if description.length > 1024
|
|
111
|
+
raise InvalidDraftError, "Skill instructions must not be empty" if body.strip.empty?
|
|
112
|
+
|
|
113
|
+
Draft.new(content: normalize_content(content), source_path: source_path, name: name, description: description)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def parse_skill(content)
|
|
117
|
+
match = content.to_s.match(/\A---\r?\n(.*?)\r?\n---\r?\n(.*)\z/m)
|
|
118
|
+
raise InvalidDraftError, "Skill must begin with YAML frontmatter" unless match
|
|
119
|
+
|
|
120
|
+
metadata = YAML.safe_load(match[1], permitted_classes: [], aliases: false)
|
|
121
|
+
raise InvalidDraftError, "Skill frontmatter must be a mapping" unless metadata.is_a?(Hash)
|
|
122
|
+
|
|
123
|
+
[metadata.transform_keys(&:to_s), match[2]]
|
|
124
|
+
rescue Psych::SyntaxError => error
|
|
125
|
+
raise InvalidDraftError, "Invalid skill frontmatter: #{error.message}"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def normalize_content(content)
|
|
129
|
+
"#{content.to_s.rstrip}\n"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def write_atomically(path, content)
|
|
133
|
+
Tempfile.create(["SKILL", ".tmp"], File.dirname(path)) do |file|
|
|
134
|
+
file.write(content)
|
|
135
|
+
file.flush
|
|
136
|
+
file.fsync
|
|
137
|
+
File.chmod(0o600, file.path)
|
|
138
|
+
File.rename(file.path, path)
|
|
139
|
+
end
|
|
140
|
+
File.chmod(0o600, path)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
data/lib/kward/tab_driver.rb
CHANGED
|
@@ -3,11 +3,12 @@ module Kward
|
|
|
3
3
|
# Adapts a session-backed agent to the tab runtime interface. Plugin tab
|
|
4
4
|
# drivers implement the same small surface without becoming Kward sessions.
|
|
5
5
|
class SessionTabDriver
|
|
6
|
-
attr_reader :session, :agent
|
|
6
|
+
attr_reader :session, :agent, :worktree
|
|
7
7
|
|
|
8
|
-
def initialize(session:, agent:)
|
|
8
|
+
def initialize(session:, agent:, worktree: nil)
|
|
9
9
|
@session = session
|
|
10
10
|
@agent = agent
|
|
11
|
+
@worktree = worktree
|
|
11
12
|
end
|
|
12
13
|
|
|
13
14
|
def messages
|
|
@@ -26,7 +27,9 @@ module Kward
|
|
|
26
27
|
end
|
|
27
28
|
|
|
28
29
|
def descriptor
|
|
29
|
-
{ "kind" => "session", "session_path" => session.path }
|
|
30
|
+
descriptor = { "kind" => "session", "session_path" => session.path }
|
|
31
|
+
descriptor["worktree"] = worktree.descriptor if worktree
|
|
32
|
+
descriptor
|
|
30
33
|
end
|
|
31
34
|
|
|
32
35
|
def session?
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
require_relative "base"
|
|
2
|
+
|
|
3
|
+
# Namespace for model-callable tool wrappers.
|
|
4
|
+
module Kward
|
|
5
|
+
module Tools
|
|
6
|
+
# Tool wrapper for the trusted host-side Git commit operation.
|
|
7
|
+
class GitCommit < Base
|
|
8
|
+
def initialize(committer:)
|
|
9
|
+
@committer = committer
|
|
10
|
+
super(
|
|
11
|
+
"git_commit",
|
|
12
|
+
"Stage and commit changes in the active worktree. Omit paths to include all current changes.",
|
|
13
|
+
properties: {
|
|
14
|
+
message: { type: "string", description: "Commit message." },
|
|
15
|
+
paths: { type: "array", items: { type: "string" }, description: "Optional workspace-relative paths to include." }
|
|
16
|
+
},
|
|
17
|
+
required: ["message"]
|
|
18
|
+
)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def call(args, _conversation, cancellation: nil)
|
|
22
|
+
cancellation&.raise_if_cancelled!
|
|
23
|
+
message = argument(args, :message, "").to_s
|
|
24
|
+
paths = argument(args, :paths, nil)
|
|
25
|
+
return "Error: commit message is required" if message.strip.empty?
|
|
26
|
+
return "Error: paths must be an array" unless paths.nil? || paths.is_a?(Array)
|
|
27
|
+
return "Error: paths must contain strings" if paths && paths.any? { |path| !path.is_a?(String) }
|
|
28
|
+
|
|
29
|
+
paths = nil if paths&.empty?
|
|
30
|
+
result = @committer.call(message: message, paths: paths)
|
|
31
|
+
output = result[:output].to_s.strip
|
|
32
|
+
return "Git commit succeeded\n#{output}" if result[:success]
|
|
33
|
+
|
|
34
|
+
failure = output.empty? ? "Git commit failed." : "Git commit failed.\n#{output}"
|
|
35
|
+
"Error: #{failure}"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
data/lib/kward/tools/registry.rb
CHANGED
|
@@ -8,6 +8,7 @@ require_relative "context_for_task"
|
|
|
8
8
|
require_relative "edit_file"
|
|
9
9
|
require_relative "fetch_content"
|
|
10
10
|
require_relative "fetch_raw"
|
|
11
|
+
require_relative "git_commit"
|
|
11
12
|
require_relative "list_directory"
|
|
12
13
|
require_relative "mcp_tool"
|
|
13
14
|
require_relative "read_file"
|
|
@@ -49,6 +50,19 @@ module Kward
|
|
|
49
50
|
# explicit tool errors.
|
|
50
51
|
# @api public
|
|
51
52
|
class ToolRegistry
|
|
53
|
+
CORE_TOOL_NAMES = %w[
|
|
54
|
+
list_directory
|
|
55
|
+
read_file
|
|
56
|
+
write_file
|
|
57
|
+
edit_file
|
|
58
|
+
run_shell_command
|
|
59
|
+
code_search
|
|
60
|
+
summarize_file_structure
|
|
61
|
+
context_for_task
|
|
62
|
+
context_budget_stats
|
|
63
|
+
retrieve_tool_output
|
|
64
|
+
].freeze
|
|
65
|
+
|
|
52
66
|
# Tool schemas advertised to the model for the current frontend and config.
|
|
53
67
|
#
|
|
54
68
|
# @return [Array<Hash>] tool schemas currently advertised to the model
|
|
@@ -65,7 +79,7 @@ module Kward
|
|
|
65
79
|
# @param web_search_enabled [Boolean, nil] override for web search exposure
|
|
66
80
|
# @param skills [Array<ConfigFiles::Skill>, nil] override discovered skills
|
|
67
81
|
# @param ask_user_question_enabled [Boolean, nil] override question exposure
|
|
68
|
-
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)
|
|
82
|
+
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, git_committer: nil)
|
|
69
83
|
@workspace = workspace
|
|
70
84
|
@prompt = prompt
|
|
71
85
|
@web_search = web_search
|
|
@@ -83,6 +97,7 @@ module Kward
|
|
|
83
97
|
@permission_policy = permission_policy || Permissions::Policy.from_config(ConfigFiles.read_config)
|
|
84
98
|
@hook_manager = hook_manager
|
|
85
99
|
@hook_context = hook_context
|
|
100
|
+
@git_committer = git_committer
|
|
86
101
|
@mcp_clients = if mcp_clients
|
|
87
102
|
mcp_clients
|
|
88
103
|
elsif @allowed_tool_names
|
|
@@ -462,13 +477,16 @@ module Kward
|
|
|
462
477
|
def build_tools
|
|
463
478
|
tools = all_tools
|
|
464
479
|
tools = tools.select { |tool| @allowed_tool_names.include?(tool.name) } if @allowed_tool_names
|
|
465
|
-
tools.
|
|
480
|
+
tools.each_with_object({}) do |tool, result|
|
|
481
|
+
raise ArgumentError, "Duplicate tool name: #{tool.name}" if result.key?(tool.name)
|
|
482
|
+
|
|
483
|
+
result[tool.name] = tool
|
|
484
|
+
end
|
|
466
485
|
end
|
|
467
486
|
|
|
468
487
|
def build_schema_tools
|
|
469
|
-
tools = @tools.values_at(
|
|
470
|
-
|
|
471
|
-
)
|
|
488
|
+
tools = @tools.values_at(*CORE_TOOL_NAMES)
|
|
489
|
+
tools << @tools["git_commit"] if @tools["git_commit"]
|
|
472
490
|
tools.concat(@tools.values_at("web_search", "fetch_content", "fetch_raw")) if web_search_available?
|
|
473
491
|
tools.concat(@tools.values.select { |tool| tool.is_a?(Tools::MCPTool) })
|
|
474
492
|
tools << @tools["read_skill"] if skills_available?
|
|
@@ -477,7 +495,9 @@ module Kward
|
|
|
477
495
|
end
|
|
478
496
|
|
|
479
497
|
def all_tools
|
|
480
|
-
|
|
498
|
+
tools = core_tools
|
|
499
|
+
tools << Tools::GitCommit.new(committer: @git_committer) if @git_committer
|
|
500
|
+
tools + [
|
|
481
501
|
Tools::WebSearch.new(web_search: @web_search),
|
|
482
502
|
Tools::FetchContent.new(web_fetch: @web_fetch),
|
|
483
503
|
Tools::FetchRaw.new(web_fetch: @web_fetch),
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
require "base64"
|
|
2
|
+
require "digest"
|
|
3
|
+
require "thread"
|
|
4
|
+
require_relative "../deep_copy"
|
|
5
|
+
require_relative "store"
|
|
6
|
+
|
|
7
|
+
module Kward
|
|
8
|
+
module Transport
|
|
9
|
+
# Adapts the existing session manager to the transport host contract.
|
|
10
|
+
#
|
|
11
|
+
# The adapter deliberately uses the manager's public session and turn
|
|
12
|
+
# methods. It can be replaced by a frontend-neutral runtime gateway later
|
|
13
|
+
# without changing transport plugins.
|
|
14
|
+
class Gateway
|
|
15
|
+
POLL_INTERVAL = 0.05
|
|
16
|
+
TERMINAL_STATUSES = %w[completed canceled failed].freeze
|
|
17
|
+
|
|
18
|
+
def initialize(session_manager:, transport_id:, storage: nil, poll_interval: POLL_INTERVAL)
|
|
19
|
+
@session_manager = session_manager
|
|
20
|
+
@transport_id = transport_id.to_s
|
|
21
|
+
@storage = storage || Store.new(@transport_id)
|
|
22
|
+
@poll_interval = poll_interval
|
|
23
|
+
@subscriptions = []
|
|
24
|
+
@interaction_subscribers = []
|
|
25
|
+
@mutex = Mutex.new
|
|
26
|
+
@session_manager.subscribe_events { |method, payload| handle_runtime_event(method, payload) } if @session_manager.respond_to?(:subscribe_events)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def resolve_transport_session(transport_id:, conversation:, actor:, workspace_root: nil, name: nil, execution_profile: nil)
|
|
30
|
+
raise ArgumentError, "transport id does not match gateway" unless transport_id.to_s == @transport_id
|
|
31
|
+
if conversation.respond_to?(:transport_id) && conversation.transport_id.to_s != @transport_id
|
|
32
|
+
raise ArgumentError, "conversation transport does not match gateway"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
binding_key = binding_key_for(conversation)
|
|
36
|
+
binding = @storage.get(binding_key)
|
|
37
|
+
session = if binding
|
|
38
|
+
resume_bound_session(binding, execution_profile: execution_profile)
|
|
39
|
+
else
|
|
40
|
+
@session_manager.create_session(workspace_root: workspace_root || Dir.pwd, name: name, **execution_profile_arguments(execution_profile))
|
|
41
|
+
end
|
|
42
|
+
persist_binding(binding_key, session)
|
|
43
|
+
session_handle(session)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def start_transport_turn(session_id:, input:, attachments: [], options: {}, streaming_behavior: nil, execution_profile: nil)
|
|
47
|
+
payload = @session_manager.start_turn(
|
|
48
|
+
session_id: session_id,
|
|
49
|
+
input: input,
|
|
50
|
+
attachments: normalize_attachments(attachments, execution_profile: execution_profile),
|
|
51
|
+
options: profile_options(options, execution_profile),
|
|
52
|
+
streaming_behavior: streaming_behavior,
|
|
53
|
+
execution_profile: execution_profile
|
|
54
|
+
)
|
|
55
|
+
{ id: payload.fetch(:id), session_id: payload.fetch(:sessionId) }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def transport_transcript(session_id:)
|
|
59
|
+
@session_manager.transcript(session_id: session_id)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def transport_turn_events(turn_id:, after: nil)
|
|
63
|
+
payload = @session_manager.turn_events(turn_id: turn_id, after_sequence: after.to_i)
|
|
64
|
+
Array(payload[:events]).map { |event| normalize_event(event) }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def transport_turn_status(turn_id:)
|
|
68
|
+
@session_manager.turn_status(turn_id: turn_id)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def cancel_transport_turn(turn_id:)
|
|
72
|
+
@session_manager.cancel_turn(turn_id: turn_id)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def answer_transport_interaction(session_id:, request_id:, answer:)
|
|
76
|
+
if answer == true || answer == false
|
|
77
|
+
@session_manager.answer_tool_approval(
|
|
78
|
+
session_id: session_id,
|
|
79
|
+
approval_request_id: request_id,
|
|
80
|
+
approved: answer
|
|
81
|
+
)
|
|
82
|
+
else
|
|
83
|
+
answers = answer.is_a?(Array) ? answer : [{ question: request_id, answer: answer.to_s }]
|
|
84
|
+
@session_manager.answer_question(
|
|
85
|
+
session_id: session_id,
|
|
86
|
+
question_request_id: request_id,
|
|
87
|
+
answers: answers
|
|
88
|
+
)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def subscribe_transport_interactions(&block)
|
|
93
|
+
raise ArgumentError, "interaction subscription requires a block" unless block
|
|
94
|
+
|
|
95
|
+
@mutex.synchronize { @interaction_subscribers << block }
|
|
96
|
+
block
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def subscribe_transport_turn(turn_id:, after: nil)
|
|
100
|
+
cursor = after.to_i
|
|
101
|
+
thread = Thread.new do
|
|
102
|
+
loop do
|
|
103
|
+
events = transport_turn_events(turn_id: turn_id, after: cursor)
|
|
104
|
+
events.each do |event|
|
|
105
|
+
cursor = event.sequence
|
|
106
|
+
yield event
|
|
107
|
+
end
|
|
108
|
+
status = transport_turn_status(turn_id: turn_id)
|
|
109
|
+
break if TERMINAL_STATUSES.include?(status[:status].to_s)
|
|
110
|
+
|
|
111
|
+
sleep @poll_interval
|
|
112
|
+
end
|
|
113
|
+
rescue StandardError
|
|
114
|
+
nil
|
|
115
|
+
end
|
|
116
|
+
@mutex.synchronize { @subscriptions << thread }
|
|
117
|
+
thread
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def shutdown
|
|
121
|
+
subscriptions = @mutex.synchronize do
|
|
122
|
+
current = @subscriptions
|
|
123
|
+
@subscriptions = []
|
|
124
|
+
@interaction_subscribers = []
|
|
125
|
+
current
|
|
126
|
+
end
|
|
127
|
+
subscriptions.each(&:kill)
|
|
128
|
+
subscriptions.each(&:join)
|
|
129
|
+
nil
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
private
|
|
133
|
+
|
|
134
|
+
def resume_bound_session(binding, execution_profile: nil)
|
|
135
|
+
@session_manager.resume_session(
|
|
136
|
+
path: binding.fetch("path"),
|
|
137
|
+
workspace_root: binding.fetch("workspace_root"),
|
|
138
|
+
include_transcript: false,
|
|
139
|
+
**execution_profile_arguments(execution_profile)
|
|
140
|
+
)
|
|
141
|
+
rescue StandardError
|
|
142
|
+
@session_manager.create_session(workspace_root: binding.fetch("workspace_root"), **execution_profile_arguments(execution_profile))
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def execution_profile_arguments(profile)
|
|
146
|
+
profile ? { execution_profile: profile } : {}
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def persist_binding(key, session)
|
|
150
|
+
@storage.put(key, {
|
|
151
|
+
"path" => session.fetch(:path),
|
|
152
|
+
"workspace_root" => session.fetch(:workspaceRoot)
|
|
153
|
+
})
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def session_handle(session)
|
|
157
|
+
Host::SessionHandle.new(
|
|
158
|
+
id: session.fetch(:id),
|
|
159
|
+
workspace_root: session.fetch(:workspaceRoot),
|
|
160
|
+
name: session[:name]
|
|
161
|
+
)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def binding_key_for(conversation)
|
|
165
|
+
external_id = conversation.respond_to?(:external_id) ? conversation.external_id : conversation.to_s
|
|
166
|
+
digest = Digest::SHA256.hexdigest("#{@transport_id}\0#{external_id}")
|
|
167
|
+
"binding:#{digest}"
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def handle_runtime_event(method, payload)
|
|
171
|
+
request = case method
|
|
172
|
+
when "ui/question"
|
|
173
|
+
questions = Array(payload[:questions] || payload["questions"])
|
|
174
|
+
Transport.interaction_request(
|
|
175
|
+
id: payload[:questionRequestId] || payload["questionRequestId"],
|
|
176
|
+
session_id: payload[:sessionId] || payload["sessionId"],
|
|
177
|
+
turn_id: payload[:turnId] || payload["turnId"] || "unknown",
|
|
178
|
+
kind: :question,
|
|
179
|
+
prompt: questions.map { |question| question[:question] || question["question"] }.join("\\n"),
|
|
180
|
+
choices: questions
|
|
181
|
+
)
|
|
182
|
+
when "tool/approvalRequested"
|
|
183
|
+
Transport.interaction_request(
|
|
184
|
+
id: payload[:approvalRequestId] || payload["approvalRequestId"],
|
|
185
|
+
session_id: payload[:sessionId] || payload["sessionId"],
|
|
186
|
+
turn_id: payload[:turnId] || payload["turnId"] || "unknown",
|
|
187
|
+
kind: :tool_approval,
|
|
188
|
+
prompt: "Allow #{payload[:toolName] || payload["toolName"]}?",
|
|
189
|
+
choices: [{ id: "approve", label: "Approve" }, { id: "deny", label: "Deny" }],
|
|
190
|
+
metadata: { tool_call_id: payload[:toolCallId] || payload["toolCallId"], args: payload[:args] || payload["args"] }
|
|
191
|
+
)
|
|
192
|
+
end
|
|
193
|
+
return unless request
|
|
194
|
+
|
|
195
|
+
subscribers = @mutex.synchronize { @interaction_subscribers.dup }
|
|
196
|
+
subscribers.each do |subscriber|
|
|
197
|
+
begin
|
|
198
|
+
subscriber.call(request)
|
|
199
|
+
rescue StandardError
|
|
200
|
+
nil
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def profile_options(options, profile)
|
|
206
|
+
return options unless profile
|
|
207
|
+
|
|
208
|
+
options = options.dup
|
|
209
|
+
case profile.tool_mode
|
|
210
|
+
when :none
|
|
211
|
+
options.delete("disabledTools")
|
|
212
|
+
options.delete(:disabled_tools)
|
|
213
|
+
options["allowedTools"] = []
|
|
214
|
+
when :allowlist
|
|
215
|
+
options.delete("disabledTools")
|
|
216
|
+
options.delete(:disabled_tools)
|
|
217
|
+
options["allowedTools"] = profile.allowed_tools
|
|
218
|
+
end
|
|
219
|
+
options["approvalMode"] = "none" if profile.approval_mode == :deny
|
|
220
|
+
options["approvalMode"] = "ask" if profile.approval_mode == :ask
|
|
221
|
+
options
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def normalize_attachments(attachments, execution_profile: nil)
|
|
225
|
+
if execution_profile && !execution_profile.attachments && !Array(attachments).empty?
|
|
226
|
+
raise ArgumentError, "transport execution profile does not allow attachments"
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
Array(attachments).map do |attachment|
|
|
230
|
+
raise ArgumentError, "transport attachment must be a Transport::Attachment" unless attachment.is_a?(Attachment)
|
|
231
|
+
raise ArgumentError, "transport attachment URLs are not supported by the session gateway" if attachment.url
|
|
232
|
+
|
|
233
|
+
{
|
|
234
|
+
type: "image",
|
|
235
|
+
data: Base64.strict_encode64(attachment.data),
|
|
236
|
+
mimeType: attachment.mime_type,
|
|
237
|
+
name: attachment.name
|
|
238
|
+
}.compact
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def normalize_event(event)
|
|
243
|
+
Transport.turn_event(
|
|
244
|
+
type: event.fetch(:type),
|
|
245
|
+
session_id: event.fetch(:sessionId),
|
|
246
|
+
turn_id: event.fetch(:turnId),
|
|
247
|
+
sequence: event.fetch(:sequence),
|
|
248
|
+
payload: event.fetch(:payload, {})
|
|
249
|
+
)
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|