kward 0.79.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 +6 -0
- data/CHANGELOG.md +58 -1
- data/Gemfile.lock +2 -2
- data/README.md +31 -38
- data/doc/agent-tools.md +9 -10
- 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 +69 -26
- data/doc/context-budgeting.md +6 -6
- data/doc/context-tools.md +2 -2
- data/doc/editor.md +7 -7
- data/doc/extensibility.md +7 -29
- data/doc/files.md +6 -6
- data/doc/getting-started.md +2 -2
- data/doc/git.md +8 -2
- data/doc/lifecycle-hooks.md +5 -4
- data/doc/local-models.md +2 -2
- data/doc/mcp.md +5 -5
- data/doc/permissions.md +13 -4
- data/doc/plugins.md +39 -4
- data/doc/prompt-templates.md +105 -0
- data/doc/providers.md +140 -0
- data/doc/releasing.md +10 -2
- data/doc/rpc.md +54 -31
- data/doc/sandboxing.md +22 -0
- data/doc/security.md +22 -7
- data/doc/session-management.md +2 -4
- 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 +2 -2
- 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/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/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 +97 -33
- 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 +20 -0
- data/lib/kward/config_files.rb +26 -3
- data/lib/kward/conversation.rb +13 -7
- data/lib/kward/git_worktree_manager.rb +291 -0
- data/lib/kward/model/azure_openai_config.rb +54 -0
- data/lib/kward/model/catalog.rb +113 -0
- data/lib/kward/model/client.rb +225 -20
- data/lib/kward/model/model_info.rb +49 -0
- data/lib/kward/model/payloads.rb +63 -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 +83 -13
- data/lib/kward/permissions/policy.rb +18 -4
- data/lib/kward/plugin_chat_runtime.rb +374 -0
- data/lib/kward/plugin_registry.rb +80 -7
- 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 +7 -7
- 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 +53 -12
- data/lib/kward/prompts/commands.rb +1 -1
- data/lib/kward/prompts.rb +6 -5
- 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 +55 -196
- data/lib/kward/rpc/prompt_bridge.rb +8 -4
- data/lib/kward/rpc/redactor.rb +1 -1
- data/lib/kward/rpc/server.rb +60 -10
- data/lib/kward/rpc/session_manager.rb +109 -30
- data/lib/kward/session_store.rb +84 -24
- data/lib/kward/tab_driver.rb +6 -3
- data/lib/kward/tools/git_commit.rb +39 -0
- data/lib/kward/tools/registry.rb +7 -2
- 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_factory.rb +19 -2
- data/templates/default/fulldoc/html/js/kward.js +2 -0
- data/templates/default/kward_navigation.rb +7 -2
- data/templates/default/layout/html/setup.rb +4 -0
- metadata +26 -1
|
@@ -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"
|
|
@@ -78,7 +79,7 @@ module Kward
|
|
|
78
79
|
# @param web_search_enabled [Boolean, nil] override for web search exposure
|
|
79
80
|
# @param skills [Array<ConfigFiles::Skill>, nil] override discovered skills
|
|
80
81
|
# @param ask_user_question_enabled [Boolean, nil] override question exposure
|
|
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)
|
|
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)
|
|
82
83
|
@workspace = workspace
|
|
83
84
|
@prompt = prompt
|
|
84
85
|
@web_search = web_search
|
|
@@ -96,6 +97,7 @@ module Kward
|
|
|
96
97
|
@permission_policy = permission_policy || Permissions::Policy.from_config(ConfigFiles.read_config)
|
|
97
98
|
@hook_manager = hook_manager
|
|
98
99
|
@hook_context = hook_context
|
|
100
|
+
@git_committer = git_committer
|
|
99
101
|
@mcp_clients = if mcp_clients
|
|
100
102
|
mcp_clients
|
|
101
103
|
elsif @allowed_tool_names
|
|
@@ -484,6 +486,7 @@ module Kward
|
|
|
484
486
|
|
|
485
487
|
def build_schema_tools
|
|
486
488
|
tools = @tools.values_at(*CORE_TOOL_NAMES)
|
|
489
|
+
tools << @tools["git_commit"] if @tools["git_commit"]
|
|
487
490
|
tools.concat(@tools.values_at("web_search", "fetch_content", "fetch_raw")) if web_search_available?
|
|
488
491
|
tools.concat(@tools.values.select { |tool| tool.is_a?(Tools::MCPTool) })
|
|
489
492
|
tools << @tools["read_skill"] if skills_available?
|
|
@@ -492,7 +495,9 @@ module Kward
|
|
|
492
495
|
end
|
|
493
496
|
|
|
494
497
|
def all_tools
|
|
495
|
-
|
|
498
|
+
tools = core_tools
|
|
499
|
+
tools << Tools::GitCommit.new(committer: @git_committer) if @git_committer
|
|
500
|
+
tools + [
|
|
496
501
|
Tools::WebSearch.new(web_search: @web_search),
|
|
497
502
|
Tools::FetchContent.new(web_fetch: @web_fetch),
|
|
498
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
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
require_relative "../deep_copy"
|
|
2
|
+
require_relative "store"
|
|
3
|
+
|
|
4
|
+
module Kward
|
|
5
|
+
module Transport
|
|
6
|
+
# Public dependencies made available to a running transport adapter.
|
|
7
|
+
class Host
|
|
8
|
+
class UnavailableGateway < StandardError; end
|
|
9
|
+
class PolicyDenied < StandardError; end
|
|
10
|
+
|
|
11
|
+
SessionHandle = Data.define(:id, :workspace_root, :name)
|
|
12
|
+
TurnHandle = Data.define(:id, :session_id)
|
|
13
|
+
PluginChatHandle = Data.define(:id, :type_id, :scope_key, :name)
|
|
14
|
+
PluginChatTurnHandle = Data.define(:id, :chat_id)
|
|
15
|
+
|
|
16
|
+
# Creates a host for one registered transport instance.
|
|
17
|
+
def initialize(transport_id:, gateway: nil, plugin_chat_gateway: nil, config: {}, storage: nil, policy: nil, logger: nil, execution_profile: nil)
|
|
18
|
+
unless execution_profile.nil? || execution_profile.is_a?(ExecutionProfile)
|
|
19
|
+
raise ArgumentError, "execution_profile must be a Transport::ExecutionProfile"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
@transport_id = transport_id.to_s.freeze
|
|
23
|
+
@gateway = gateway
|
|
24
|
+
@plugin_chat_gateway = plugin_chat_gateway
|
|
25
|
+
@config = freeze_copy(config)
|
|
26
|
+
@storage = storage || Store.new(@transport_id)
|
|
27
|
+
@policy = policy || AllowAllPolicy.new
|
|
28
|
+
@execution_profile = execution_profile
|
|
29
|
+
@logger = logger || Logger.new($stderr)
|
|
30
|
+
@sessions = Sessions.new(self)
|
|
31
|
+
@plugin_chats = PluginChats.new(self)
|
|
32
|
+
@interactions = Interactions.new(self)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
attr_reader :transport_id, :config, :storage, :policy, :execution_profile, :logger
|
|
36
|
+
|
|
37
|
+
def sessions
|
|
38
|
+
@sessions
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def interactions
|
|
42
|
+
@interactions
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def plugin_chats
|
|
46
|
+
@plugin_chats
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def gateway
|
|
50
|
+
return @gateway if @gateway
|
|
51
|
+
|
|
52
|
+
raise UnavailableGateway, "transport host has no session gateway"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def authorize!(action, **attributes)
|
|
56
|
+
return true if policy.authorize(action: action, transport_id: @transport_id, **attributes) == true
|
|
57
|
+
|
|
58
|
+
raise PolicyDenied, "Transport policy denied #{action}"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def plugin_chat_gateway
|
|
62
|
+
return @plugin_chat_gateway if @plugin_chat_gateway
|
|
63
|
+
|
|
64
|
+
raise UnavailableGateway, "transport host has no plugin chat gateway"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Reads a transport secret from private config or an explicit environment
|
|
68
|
+
# variable without exposing it through status or logging helpers.
|
|
69
|
+
def secret(key, env: nil)
|
|
70
|
+
key = key.to_s
|
|
71
|
+
raise ArgumentError, "secret key is required" if key.empty?
|
|
72
|
+
|
|
73
|
+
value = config[key]
|
|
74
|
+
value = ENV.fetch(env.to_s) if value.nil? && env
|
|
75
|
+
value = ENV[default_secret_env_name(key)] if value.nil?
|
|
76
|
+
value.to_s unless value.nil?
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def shutdown
|
|
80
|
+
@gateway&.shutdown
|
|
81
|
+
@plugin_chat_gateway&.shutdown
|
|
82
|
+
nil
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Resolves an external conversation to a normal Kward session.
|
|
86
|
+
class Sessions
|
|
87
|
+
def initialize(host)
|
|
88
|
+
@host = host
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def resolve(conversation:, actor:, workspace_root: nil, name: nil)
|
|
92
|
+
workspace_root = fixed_workspace_root(workspace_root)
|
|
93
|
+
@host.authorize!(:resolve_session, conversation: conversation, actor: actor, workspace_root: workspace_root)
|
|
94
|
+
result = @host.gateway.resolve_transport_session(
|
|
95
|
+
transport_id: @host.transport_id,
|
|
96
|
+
conversation: conversation,
|
|
97
|
+
actor: actor,
|
|
98
|
+
workspace_root: workspace_root,
|
|
99
|
+
name: name,
|
|
100
|
+
execution_profile: @host.execution_profile
|
|
101
|
+
)
|
|
102
|
+
Session.new(@host, normalize_handle(result), actor)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
def fixed_workspace_root(workspace_root)
|
|
108
|
+
return workspace_root unless @host.execution_profile&.workspace_mode == :fixed
|
|
109
|
+
|
|
110
|
+
configured = @host.config["workspace"] || @host.config[:workspace]
|
|
111
|
+
raise ArgumentError, "fixed execution profile requires a configured workspace" if configured.to_s.empty?
|
|
112
|
+
|
|
113
|
+
configured.to_s
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def normalize_handle(result)
|
|
117
|
+
return result if result.is_a?(SessionHandle)
|
|
118
|
+
return SessionHandle.new(**result.transform_keys(&:to_sym)) if result.is_a?(Hash)
|
|
119
|
+
|
|
120
|
+
raise TypeError, "session gateway returned an invalid session handle"
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Narrow session operations exposed to a transport.
|
|
125
|
+
class Session
|
|
126
|
+
attr_reader :id, :workspace_root, :name
|
|
127
|
+
|
|
128
|
+
def initialize(host, handle, actor)
|
|
129
|
+
@host = host
|
|
130
|
+
@actor = actor
|
|
131
|
+
@id = handle.id.to_s.freeze
|
|
132
|
+
@workspace_root = handle.workspace_root.to_s.freeze
|
|
133
|
+
@name = handle.name&.to_s&.freeze
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def start_turn(input, attachments: [], options: {}, streaming_behavior: nil)
|
|
137
|
+
@host.authorize!(:start_turn, actor: @actor, session_id: @id, input: input.to_s)
|
|
138
|
+
result = @host.gateway.start_transport_turn(
|
|
139
|
+
session_id: @id,
|
|
140
|
+
input: input.to_s,
|
|
141
|
+
attachments: attachments,
|
|
142
|
+
options: options,
|
|
143
|
+
streaming_behavior: streaming_behavior,
|
|
144
|
+
execution_profile: @host.execution_profile
|
|
145
|
+
)
|
|
146
|
+
handle = result.is_a?(TurnHandle) ? result : TurnHandle.new(**result.transform_keys(&:to_sym))
|
|
147
|
+
Turn.new(@host, handle, @actor)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def transcript
|
|
151
|
+
@host.authorize!(:read_transcript, actor: @actor, session_id: @id)
|
|
152
|
+
@host.gateway.transport_transcript(session_id: @id)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def answer_interaction(request_id:, answer:)
|
|
156
|
+
if @host.execution_profile&.approval_mode == :deny
|
|
157
|
+
raise PolicyDenied, "Transport execution profile does not allow interactions"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
@host.authorize!(:answer_interaction, actor: @actor, session_id: @id, request_id: request_id)
|
|
161
|
+
@host.gateway.answer_transport_interaction(session_id: @id, request_id: request_id, answer: answer)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Resolves an external conversation to a plugin-owned chat.
|
|
166
|
+
class PluginChats
|
|
167
|
+
def initialize(host)
|
|
168
|
+
@host = host
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def resolve(type_id:, conversation:, actor:, scope_key: nil, descriptor: {})
|
|
172
|
+
scope_key ||= conversation.external_id
|
|
173
|
+
@host.authorize!(:resolve_plugin_chat, type_id: type_id.to_s, conversation: conversation, actor: actor, scope_key: scope_key)
|
|
174
|
+
result = @host.plugin_chat_gateway.resolve_transport_chat(
|
|
175
|
+
transport_id: @host.transport_id,
|
|
176
|
+
type_id: type_id,
|
|
177
|
+
conversation: conversation,
|
|
178
|
+
actor: actor,
|
|
179
|
+
scope_key: scope_key,
|
|
180
|
+
descriptor: descriptor,
|
|
181
|
+
workspace_root: workspace_root
|
|
182
|
+
)
|
|
183
|
+
PluginChat.new(@host, normalize_handle(result), actor)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
private
|
|
187
|
+
|
|
188
|
+
def workspace_root
|
|
189
|
+
if @host.execution_profile&.workspace_mode == :fixed
|
|
190
|
+
configured = @host.config["workspace"] || @host.config[:workspace]
|
|
191
|
+
raise ArgumentError, "fixed execution profile requires a configured workspace" if configured.to_s.empty?
|
|
192
|
+
|
|
193
|
+
return configured.to_s
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
@host.config["workspace"] || @host.config[:workspace] || Dir.pwd
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def normalize_handle(result)
|
|
200
|
+
return result if result.is_a?(PluginChatHandle)
|
|
201
|
+
return PluginChatHandle.new(**result.transform_keys(&:to_sym)) if result.is_a?(Hash)
|
|
202
|
+
|
|
203
|
+
raise TypeError, "plugin chat gateway returned an invalid chat handle"
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Narrow plugin-chat operations exposed to a transport.
|
|
208
|
+
class PluginChat
|
|
209
|
+
attr_reader :id, :type_id, :scope_key, :name
|
|
210
|
+
|
|
211
|
+
def initialize(host, handle, actor)
|
|
212
|
+
@host = host
|
|
213
|
+
@actor = actor
|
|
214
|
+
@id = handle.id.to_s.freeze
|
|
215
|
+
@type_id = handle.type_id.to_s.freeze
|
|
216
|
+
@scope_key = handle.scope_key.to_s.freeze
|
|
217
|
+
@name = handle.name&.to_s&.freeze
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def start_turn(input, attachments: [], options: {}, streaming_behavior: nil)
|
|
221
|
+
@host.authorize!(:start_plugin_chat_turn, actor: @actor, chat_id: @id, type_id: @type_id, input: input.to_s)
|
|
222
|
+
if @host.execution_profile && !@host.execution_profile.attachments && !Array(attachments).empty?
|
|
223
|
+
raise PolicyDenied, "Transport execution profile does not allow attachments"
|
|
224
|
+
end
|
|
225
|
+
unless options.nil? || options.empty?
|
|
226
|
+
raise ArgumentError, "plugin chat turns do not support options"
|
|
227
|
+
end
|
|
228
|
+
unless streaming_behavior.nil? || %i[aggregate none].include?(streaming_behavior.to_sym)
|
|
229
|
+
raise ArgumentError, "plugin chat turns do not support #{streaming_behavior} streaming"
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
result = @host.plugin_chat_gateway.start_transport_plugin_chat_turn(
|
|
233
|
+
chat_id: @id,
|
|
234
|
+
actor: @actor,
|
|
235
|
+
input: input,
|
|
236
|
+
attachments: attachments,
|
|
237
|
+
streaming_behavior: streaming_behavior,
|
|
238
|
+
execution_profile: @host.execution_profile
|
|
239
|
+
)
|
|
240
|
+
handle = result.is_a?(PluginChatTurnHandle) ? result : PluginChatTurnHandle.new(**result.transform_keys(&:to_sym))
|
|
241
|
+
PluginChatTurn.new(@host, handle, @actor)
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def transcript
|
|
245
|
+
@host.authorize!(:read_plugin_chat_transcript, actor: @actor, chat_id: @id, type_id: @type_id)
|
|
246
|
+
@host.plugin_chat_gateway.transport_plugin_chat_transcript(chat_id: @id)
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Handle for an asynchronously running plugin-chat turn.
|
|
251
|
+
class PluginChatTurn
|
|
252
|
+
attr_reader :id, :chat_id
|
|
253
|
+
|
|
254
|
+
def initialize(host, handle, actor)
|
|
255
|
+
@host = host
|
|
256
|
+
@actor = actor
|
|
257
|
+
@id = handle.id.to_s.freeze
|
|
258
|
+
@chat_id = handle.chat_id.to_s.freeze
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def subscribe(after: nil, &block)
|
|
262
|
+
raise ArgumentError, "plugin chat turn subscription requires a block" unless block
|
|
263
|
+
|
|
264
|
+
@host.plugin_chat_gateway.subscribe_transport_plugin_chat_turn(turn_id: @id, after: after, &block)
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def events(after: nil)
|
|
268
|
+
@host.plugin_chat_gateway.transport_plugin_chat_turn_events(turn_id: @id, after: after)
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def status
|
|
272
|
+
@host.plugin_chat_gateway.transport_plugin_chat_turn_status(turn_id: @id)
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def cancel
|
|
276
|
+
@host.authorize!(:cancel_plugin_chat_turn, actor: @actor, chat_id: @chat_id, turn_id: @id)
|
|
277
|
+
@host.plugin_chat_gateway.cancel_transport_plugin_chat_turn(turn_id: @id)
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# Subscribes to questions and tool approval requests for this transport.
|
|
282
|
+
class Interactions
|
|
283
|
+
def initialize(host)
|
|
284
|
+
@host = host
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def subscribe(&block)
|
|
288
|
+
raise ArgumentError, "interaction subscription requires a block" unless block
|
|
289
|
+
|
|
290
|
+
@host.gateway.subscribe_transport_interactions(&block)
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# Handle for an asynchronously running Kward turn.
|
|
295
|
+
class Turn
|
|
296
|
+
attr_reader :id, :session_id
|
|
297
|
+
|
|
298
|
+
def initialize(host, handle, actor)
|
|
299
|
+
@host = host
|
|
300
|
+
@actor = actor
|
|
301
|
+
@id = handle.id.to_s.freeze
|
|
302
|
+
@session_id = handle.session_id.to_s.freeze
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def subscribe(after: nil, &block)
|
|
306
|
+
raise ArgumentError, "turn subscription requires a block" unless block
|
|
307
|
+
|
|
308
|
+
@host.gateway.subscribe_transport_turn(turn_id: @id, after: after, &block)
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def events(after: nil)
|
|
312
|
+
@host.gateway.transport_turn_events(turn_id: @id, after: after)
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def status
|
|
316
|
+
@host.gateway.transport_turn_status(turn_id: @id)
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def cancel
|
|
320
|
+
@host.authorize!(:cancel_turn, actor: @actor, session_id: @session_id, turn_id: @id)
|
|
321
|
+
@host.gateway.cancel_transport_turn(turn_id: @id)
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# Default policy used by the host until a configured policy is supplied.
|
|
326
|
+
class AllowAllPolicy
|
|
327
|
+
def authorize(**)
|
|
328
|
+
true
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
private
|
|
333
|
+
|
|
334
|
+
def default_secret_env_name(key)
|
|
335
|
+
"KWARD_TRANSPORT_#{@transport_id.gsub(/[^A-Za-z0-9]/, "_").upcase}_#{key.gsub(/[^A-Za-z0-9]/, "_").upcase}"
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def freeze_copy(value)
|
|
339
|
+
DeepCopy.freeze(DeepCopy.dup(value))
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
end
|