kward 0.79.0 → 0.80.1
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 +65 -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/skills.md +2 -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 +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/agent.rb +11 -6
- 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/memory_commands.rb +2 -2
- data/lib/kward/cli/plugins.rb +1 -0
- data/lib/kward/cli/prompt_interface.rb +44 -1
- data/lib/kward/cli/rendering.rb +6 -2
- data/lib/kward/cli/runtime_helpers.rb +5 -2
- data/lib/kward/cli/sessions.rb +1 -1
- data/lib/kward/cli/settings.rb +97 -33
- data/lib/kward/cli/slash_commands.rb +1 -1
- data/lib/kward/cli/tabs.rb +103 -37
- data/lib/kward/cli/transports.rb +67 -0
- data/lib/kward/cli/worktrees.rb +360 -0
- data/lib/kward/cli.rb +22 -0
- data/lib/kward/compactor.rb +7 -2
- data/lib/kward/config_files.rb +51 -12
- data/lib/kward/conversation.rb +13 -7
- data/lib/kward/git_worktree_manager.rb +291 -0
- data/lib/kward/hooks/audit_log.rb +5 -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 +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 +99 -19
- 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 +219 -19
- 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/templates.rb +11 -6
- 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/skills/registry.rb +16 -11
- data/lib/kward/tab_driver.rb +6 -3
- data/lib/kward/telemetry/logger.rb +5 -2
- 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,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
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
require "thread"
|
|
2
|
+
require "time"
|
|
3
|
+
require_relative "host"
|
|
4
|
+
|
|
5
|
+
module Kward
|
|
6
|
+
module Transport
|
|
7
|
+
# Starts and supervises registered transport adapters.
|
|
8
|
+
class Manager
|
|
9
|
+
Entry = Struct.new(:type, :host, :adapter, :state, :error, :started_at, :stopped_at, keyword_init: true)
|
|
10
|
+
|
|
11
|
+
def initialize(registry:, gateway: nil, plugin_chat_gateway: nil, config_root: nil, config_provider: nil, policy: nil, logger: nil)
|
|
12
|
+
@registry = registry
|
|
13
|
+
@gateway = gateway
|
|
14
|
+
@plugin_chat_gateway = plugin_chat_gateway
|
|
15
|
+
@config_root = config_root
|
|
16
|
+
@config_provider = config_provider
|
|
17
|
+
@policy = policy
|
|
18
|
+
@logger = logger || Logger.new($stderr)
|
|
19
|
+
@entries = {}
|
|
20
|
+
@mutex = Mutex.new
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def list
|
|
24
|
+
@registry.transports.map { |type| descriptor(type) }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def status(name_or_id = nil)
|
|
28
|
+
return status_for(resolve_type(name_or_id)) if name_or_id
|
|
29
|
+
|
|
30
|
+
list.map { |type| status_for(type) }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def start(name_or_id, config: nil, workspace_root: nil)
|
|
34
|
+
type = resolve_type(name_or_id)
|
|
35
|
+
@mutex.synchronize do
|
|
36
|
+
raise "Transport #{type.id} is already running" if running_entry?(type.id)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
config ||= @config_provider&.call(type.id) || {}
|
|
40
|
+
config = config.merge("workspace" => workspace_root.to_s) if workspace_root
|
|
41
|
+
gateway = @gateway.respond_to?(:call) ? @gateway.call(type.id) : @gateway
|
|
42
|
+
plugin_chat_gateway = @plugin_chat_gateway.respond_to?(:call) ? @plugin_chat_gateway.call(type.id) : @plugin_chat_gateway
|
|
43
|
+
host = Host.new(
|
|
44
|
+
transport_id: type.id,
|
|
45
|
+
gateway: gateway,
|
|
46
|
+
plugin_chat_gateway: plugin_chat_gateway,
|
|
47
|
+
config: config,
|
|
48
|
+
storage: @config_root && Store.new(type.id, root: @config_root),
|
|
49
|
+
policy: @policy,
|
|
50
|
+
logger: @logger,
|
|
51
|
+
execution_profile: type.execution_profile
|
|
52
|
+
)
|
|
53
|
+
adapter = type.handler.call(host, host.config)
|
|
54
|
+
unless adapter.respond_to?(:start) && adapter.respond_to?(:stop)
|
|
55
|
+
raise "Transport #{type.id} must implement start and stop"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
entry = Entry.new(type: type, host: host, adapter: adapter, state: "starting")
|
|
59
|
+
@mutex.synchronize { @entries[type.id] = entry }
|
|
60
|
+
begin
|
|
61
|
+
adapter.start
|
|
62
|
+
@mutex.synchronize do
|
|
63
|
+
entry.state = "running"
|
|
64
|
+
entry.started_at = Time.now.utc
|
|
65
|
+
entry.error = nil
|
|
66
|
+
end
|
|
67
|
+
adapter
|
|
68
|
+
rescue StandardError => e
|
|
69
|
+
@mutex.synchronize do
|
|
70
|
+
entry.state = "failed"
|
|
71
|
+
entry.error = e.message
|
|
72
|
+
entry.stopped_at = Time.now.utc
|
|
73
|
+
end
|
|
74
|
+
log_error(type, e)
|
|
75
|
+
raise
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def stop(name_or_id)
|
|
80
|
+
type = resolve_type(name_or_id)
|
|
81
|
+
entry = @mutex.synchronize { @entries[type.id] }
|
|
82
|
+
return false unless entry
|
|
83
|
+
|
|
84
|
+
begin
|
|
85
|
+
entry.adapter.stop
|
|
86
|
+
ensure
|
|
87
|
+
entry.host.shutdown
|
|
88
|
+
@mutex.synchronize do
|
|
89
|
+
entry.state = "stopped"
|
|
90
|
+
entry.stopped_at = Time.now.utc
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
true
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def restart(name_or_id, config: nil)
|
|
97
|
+
stop(name_or_id)
|
|
98
|
+
start(name_or_id, config: config)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def reload(registry)
|
|
102
|
+
shutdown
|
|
103
|
+
@mutex.synchronize { @registry = registry }
|
|
104
|
+
list
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def shutdown
|
|
108
|
+
entries = @mutex.synchronize { @entries.values.dup }
|
|
109
|
+
entries.each { |entry| stop(entry.type.id) if entry.state == "running" || entry.state == "starting" }
|
|
110
|
+
nil
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
private
|
|
114
|
+
|
|
115
|
+
def resolve_type(name_or_id)
|
|
116
|
+
type = @registry.transport_for(name_or_id) || @registry.transport_for_id(name_or_id)
|
|
117
|
+
raise ArgumentError, "Unknown transport: #{name_or_id}" unless type
|
|
118
|
+
|
|
119
|
+
type
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def running_entry?(id)
|
|
123
|
+
entry = @entries[id]
|
|
124
|
+
entry && %w[starting running].include?(entry.state)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def descriptor(type)
|
|
128
|
+
{
|
|
129
|
+
id: type.id,
|
|
130
|
+
name: type.name,
|
|
131
|
+
capabilities: type.capabilities.to_h,
|
|
132
|
+
execution_profile: profile_payload(type.execution_profile),
|
|
133
|
+
path: type.path
|
|
134
|
+
}
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def status_for(type)
|
|
138
|
+
entry = @mutex.synchronize { @entries[type.id] }
|
|
139
|
+
descriptor(type).merge(
|
|
140
|
+
state: entry&.state || "stopped",
|
|
141
|
+
error: entry&.error,
|
|
142
|
+
health: health_for(entry),
|
|
143
|
+
started_at: entry&.started_at,
|
|
144
|
+
stopped_at: entry&.stopped_at
|
|
145
|
+
)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def profile_payload(profile)
|
|
149
|
+
return nil unless profile
|
|
150
|
+
|
|
151
|
+
{
|
|
152
|
+
id: profile.id,
|
|
153
|
+
tool_mode: profile.tool_mode,
|
|
154
|
+
allowed_tools: profile.allowed_tools,
|
|
155
|
+
disabled_tools: profile.disabled_tools,
|
|
156
|
+
plugin_commands: profile.plugin_commands,
|
|
157
|
+
approval_mode: profile.approval_mode,
|
|
158
|
+
memory: profile.memory,
|
|
159
|
+
attachments: profile.attachments,
|
|
160
|
+
workspace_mode: profile.workspace_mode
|
|
161
|
+
}
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def health_for(entry)
|
|
165
|
+
return nil unless entry&.adapter&.respond_to?(:health)
|
|
166
|
+
|
|
167
|
+
entry.adapter.health
|
|
168
|
+
rescue StandardError => e
|
|
169
|
+
{ state: "error", message: e.message }
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def log_error(type, error)
|
|
173
|
+
@logger.error("Transport #{type.id} failed to start: #{error.class}: #{error.message}")
|
|
174
|
+
rescue StandardError
|
|
175
|
+
nil
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
require "base64"
|
|
2
|
+
require "thread"
|
|
3
|
+
require_relative "../plugin_chat_runtime"
|
|
4
|
+
|
|
5
|
+
module Kward
|
|
6
|
+
module Transport
|
|
7
|
+
# Adapts the shared plugin-chat runtime to the transport host contract.
|
|
8
|
+
class PluginChatGateway
|
|
9
|
+
POLL_INTERVAL = 0.05
|
|
10
|
+
TERMINAL_STATUSES = %w[completed canceled failed].freeze
|
|
11
|
+
IMAGE_MIME_TYPES = %w[image/png image/jpeg image/gif image/webp].freeze
|
|
12
|
+
|
|
13
|
+
def initialize(runtime:, transport_id:, poll_interval: POLL_INTERVAL)
|
|
14
|
+
@runtime = runtime
|
|
15
|
+
@transport_id = transport_id.to_s
|
|
16
|
+
@poll_interval = poll_interval
|
|
17
|
+
@subscriptions = []
|
|
18
|
+
@mutex = Mutex.new
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def resolve_transport_chat(transport_id:, type_id:, conversation:, actor:, scope_key:, descriptor: {}, workspace_root: nil)
|
|
22
|
+
validate_transport_id!(transport_id)
|
|
23
|
+
validate_conversation!(conversation)
|
|
24
|
+
validate_actor!(actor)
|
|
25
|
+
chat = @runtime.open(
|
|
26
|
+
type_id: type_id,
|
|
27
|
+
surface: :transport,
|
|
28
|
+
scope_key: scope_key,
|
|
29
|
+
descriptor: transport_descriptor(conversation, actor, descriptor),
|
|
30
|
+
workspace_root: workspace_root || Dir.pwd
|
|
31
|
+
)
|
|
32
|
+
Host::PluginChatHandle.new(
|
|
33
|
+
id: chat.id,
|
|
34
|
+
type_id: chat.type.id,
|
|
35
|
+
scope_key: chat.scope_key,
|
|
36
|
+
name: chat.type.title
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def start_transport_plugin_chat_turn(chat_id:, actor:, input:, attachments: [], streaming_behavior: nil, execution_profile: nil)
|
|
41
|
+
if execution_profile && !execution_profile.attachments && !Array(attachments).empty?
|
|
42
|
+
raise ArgumentError, "transport execution profile does not allow attachments"
|
|
43
|
+
end
|
|
44
|
+
unless streaming_behavior.nil? || %i[aggregate none].include?(streaming_behavior.to_sym)
|
|
45
|
+
raise ArgumentError, "plugin chat turns do not support #{streaming_behavior} streaming"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
turn = @runtime.start_turn(
|
|
49
|
+
chat_id: chat_id,
|
|
50
|
+
input: @runtime.input_with_attachments(input, normalize_attachments(attachments)),
|
|
51
|
+
display_input: input.to_s,
|
|
52
|
+
context: { actor: actor }
|
|
53
|
+
)
|
|
54
|
+
Host::PluginChatTurnHandle.new(id: turn.id, chat_id: turn.chat_id)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def transport_plugin_chat_transcript(chat_id:)
|
|
58
|
+
chat = fetch_chat(chat_id)
|
|
59
|
+
{ messages: chat.driver.messages }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def transport_plugin_chat_turn_events(turn_id:, after: nil)
|
|
63
|
+
@runtime.turn_events(turn_id: turn_id, after_sequence: after.to_i).map do |event|
|
|
64
|
+
Transport.turn_event(
|
|
65
|
+
type: event.fetch(:type),
|
|
66
|
+
session_id: event.fetch(:chatId),
|
|
67
|
+
turn_id: event.fetch(:turnId),
|
|
68
|
+
sequence: event.fetch(:sequence),
|
|
69
|
+
payload: event.fetch(:payload, {})
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def transport_plugin_chat_turn_status(turn_id:)
|
|
75
|
+
turn_payload(@runtime.turn_status(turn_id: turn_id))
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def cancel_transport_plugin_chat_turn(turn_id:)
|
|
79
|
+
@runtime.cancel_turn(turn_id: turn_id)
|
|
80
|
+
nil
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def subscribe_transport_plugin_chat_turn(turn_id:, after: nil)
|
|
84
|
+
cursor = after.to_i
|
|
85
|
+
thread = Thread.new do
|
|
86
|
+
loop do
|
|
87
|
+
events = transport_plugin_chat_turn_events(turn_id: turn_id, after: cursor)
|
|
88
|
+
events.each do |event|
|
|
89
|
+
cursor = event.sequence
|
|
90
|
+
yield event
|
|
91
|
+
end
|
|
92
|
+
status = transport_plugin_chat_turn_status(turn_id: turn_id)
|
|
93
|
+
break if TERMINAL_STATUSES.include?(status[:status].to_s)
|
|
94
|
+
|
|
95
|
+
sleep @poll_interval
|
|
96
|
+
end
|
|
97
|
+
rescue StandardError
|
|
98
|
+
nil
|
|
99
|
+
end
|
|
100
|
+
@mutex.synchronize { @subscriptions << thread }
|
|
101
|
+
thread
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def shutdown
|
|
105
|
+
subscriptions = @mutex.synchronize do
|
|
106
|
+
current = @subscriptions
|
|
107
|
+
@subscriptions = []
|
|
108
|
+
current
|
|
109
|
+
end
|
|
110
|
+
subscriptions.each(&:kill)
|
|
111
|
+
subscriptions.each(&:join)
|
|
112
|
+
nil
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
def validate_transport_id!(transport_id)
|
|
118
|
+
return if transport_id.to_s == @transport_id
|
|
119
|
+
|
|
120
|
+
raise ArgumentError, "transport id does not match plugin chat gateway"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def validate_conversation!(conversation)
|
|
124
|
+
unless conversation.is_a?(ConversationKey)
|
|
125
|
+
raise ArgumentError, "conversation must be a Transport::ConversationKey"
|
|
126
|
+
end
|
|
127
|
+
return if conversation.transport_id == @transport_id
|
|
128
|
+
|
|
129
|
+
raise ArgumentError, "conversation transport does not match plugin chat gateway"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def validate_actor!(actor)
|
|
133
|
+
return if actor.is_a?(Actor)
|
|
134
|
+
|
|
135
|
+
raise ArgumentError, "actor must be a Transport::Actor"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def fetch_chat(chat_id)
|
|
139
|
+
@runtime.chat(chat_id) || raise(ArgumentError, "Unknown plugin chat: #{chat_id}")
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def transport_descriptor(conversation, actor, descriptor)
|
|
143
|
+
{
|
|
144
|
+
"kind" => "transport",
|
|
145
|
+
"transport_id" => @transport_id,
|
|
146
|
+
"conversation" => conversation.to_h,
|
|
147
|
+
"actor" => actor.to_h
|
|
148
|
+
}.merge(descriptor.transform_keys(&:to_s))
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def normalize_attachments(attachments)
|
|
152
|
+
Array(attachments).map do |attachment|
|
|
153
|
+
unless attachment.is_a?(Attachment)
|
|
154
|
+
raise ArgumentError, "transport attachment must be a Transport::Attachment"
|
|
155
|
+
end
|
|
156
|
+
if attachment.url
|
|
157
|
+
raise ArgumentError, "plugin chat attachment URLs are not supported"
|
|
158
|
+
end
|
|
159
|
+
unless IMAGE_MIME_TYPES.include?(attachment.mime_type.to_s.downcase)
|
|
160
|
+
raise ArgumentError, "unsupported plugin chat attachment MIME type: #{attachment.mime_type}"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
{
|
|
164
|
+
mimeType: attachment.mime_type,
|
|
165
|
+
data: Base64.strict_encode64(attachment.data),
|
|
166
|
+
alt: attachment.name
|
|
167
|
+
}.compact
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def turn_payload(turn)
|
|
172
|
+
turn.mutex.synchronize do
|
|
173
|
+
{
|
|
174
|
+
id: turn.id,
|
|
175
|
+
chatId: turn.chat_id,
|
|
176
|
+
status: turn.status,
|
|
177
|
+
createdAt: turn.created_at,
|
|
178
|
+
startedAt: turn.started_at,
|
|
179
|
+
finishedAt: turn.finished_at,
|
|
180
|
+
error: turn.error
|
|
181
|
+
}.compact
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|