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,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
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
require "stringio"
|
|
2
|
+
require_relative "../config_files"
|
|
3
|
+
require_relative "../rpc/server"
|
|
4
|
+
require_relative "gateway"
|
|
5
|
+
require_relative "manager"
|
|
6
|
+
require_relative "plugin_chat_gateway"
|
|
7
|
+
|
|
8
|
+
module Kward
|
|
9
|
+
module Transport
|
|
10
|
+
# Builds the Kward runtime needed by foreground transport processes.
|
|
11
|
+
class Runtime
|
|
12
|
+
attr_reader :manager
|
|
13
|
+
|
|
14
|
+
def initialize(client:)
|
|
15
|
+
@server = RPC::Server.new(
|
|
16
|
+
input: StringIO.new,
|
|
17
|
+
output: StringIO.new,
|
|
18
|
+
error_output: $stderr,
|
|
19
|
+
client: client
|
|
20
|
+
)
|
|
21
|
+
@manager = Manager.new(
|
|
22
|
+
registry: @server.session_manager.plugin_registry,
|
|
23
|
+
gateway: lambda { |transport_id|
|
|
24
|
+
Gateway.new(session_manager: @server.session_manager, transport_id: transport_id)
|
|
25
|
+
},
|
|
26
|
+
plugin_chat_gateway: lambda { |transport_id|
|
|
27
|
+
PluginChatGateway.new(runtime: @server.plugin_chat_manager.runtime, transport_id: transport_id)
|
|
28
|
+
},
|
|
29
|
+
config_root: ConfigFiles.config_dir,
|
|
30
|
+
config_provider: ->(transport_id) { ConfigFiles.transport_config(transport_id) }
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def shutdown
|
|
35
|
+
@manager.shutdown
|
|
36
|
+
@server.shutdown
|
|
37
|
+
nil
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
require "json"
|
|
3
|
+
require "thread"
|
|
4
|
+
require "time"
|
|
5
|
+
require_relative "../config_files"
|
|
6
|
+
require_relative "../deep_copy"
|
|
7
|
+
require_relative "../private_file"
|
|
8
|
+
|
|
9
|
+
module Kward
|
|
10
|
+
module Transport
|
|
11
|
+
# Small private JSON store scoped to one transport plugin.
|
|
12
|
+
class Store
|
|
13
|
+
KEY_PATTERN = /\A[A-Za-z0-9][A-Za-z0-9:._-]*\z/.freeze
|
|
14
|
+
DEFAULT_MAX_PROCESSED_KEYS = 10_000
|
|
15
|
+
|
|
16
|
+
attr_reader :transport_id
|
|
17
|
+
|
|
18
|
+
def initialize(transport_id, root: ConfigFiles.config_dir)
|
|
19
|
+
@transport_id = validate_key(transport_id, "transport id")
|
|
20
|
+
@path = File.join(File.expand_path(root), "transports", @transport_id, "state.json")
|
|
21
|
+
@mutex = Mutex.new
|
|
22
|
+
@values, @processed = load_state
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def get(key)
|
|
26
|
+
key = validate_key(key, "storage key")
|
|
27
|
+
@mutex.synchronize { copy(@values[key]) }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def put(key, value)
|
|
31
|
+
key = validate_key(key, "storage key")
|
|
32
|
+
@mutex.synchronize do
|
|
33
|
+
@values[key] = copy(value)
|
|
34
|
+
persist
|
|
35
|
+
end
|
|
36
|
+
value
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def delete(key)
|
|
40
|
+
key = validate_key(key, "storage key")
|
|
41
|
+
@mutex.synchronize do
|
|
42
|
+
present = @values.key?(key)
|
|
43
|
+
value = @values.delete(key)
|
|
44
|
+
persist if present
|
|
45
|
+
copy(value)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Claims an external event key. Returns false when the key was already
|
|
50
|
+
# claimed, making duplicate webhook and polling deliveries harmless.
|
|
51
|
+
def claim(key, max_keys: DEFAULT_MAX_PROCESSED_KEYS)
|
|
52
|
+
key = validate_key(key, "idempotency key")
|
|
53
|
+
max_keys = Integer(max_keys)
|
|
54
|
+
raise ArgumentError, "max_keys must be positive" unless max_keys.positive?
|
|
55
|
+
|
|
56
|
+
@mutex.synchronize do
|
|
57
|
+
return false if @processed.key?(key)
|
|
58
|
+
|
|
59
|
+
@processed[key] = Time.now.utc.iso8601
|
|
60
|
+
@processed.shift while @processed.length > max_keys
|
|
61
|
+
persist
|
|
62
|
+
true
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def load_state
|
|
69
|
+
return [{}, {}] unless File.file?(@path)
|
|
70
|
+
|
|
71
|
+
state = JSON.parse(File.read(@path))
|
|
72
|
+
values = state.fetch("values", {})
|
|
73
|
+
processed = state.fetch("processed", {})
|
|
74
|
+
unless values.is_a?(Hash) && processed.is_a?(Hash)
|
|
75
|
+
raise JSON::ParserError, "transport state must contain JSON objects"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
[values, processed]
|
|
79
|
+
rescue JSON::ParserError => e
|
|
80
|
+
raise "Invalid transport state #{@path}: #{e.message}"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def persist
|
|
84
|
+
PrivateFile.write_json(@path, "values" => @values, "processed" => @processed)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def validate_key(value, name)
|
|
88
|
+
value = value.to_s
|
|
89
|
+
raise ArgumentError, "#{name} is required" unless value.match?(KEY_PATTERN)
|
|
90
|
+
|
|
91
|
+
value
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def copy(value)
|
|
95
|
+
return nil if value.nil?
|
|
96
|
+
|
|
97
|
+
DeepCopy.dup(value)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
require "logger"
|
|
2
|
+
require_relative "deep_copy"
|
|
3
|
+
|
|
4
|
+
# Namespace for frontend-neutral external transport contracts.
|
|
5
|
+
module Kward
|
|
6
|
+
module Transport
|
|
7
|
+
STREAMING_MODES = %i[native edit aggregate none].freeze
|
|
8
|
+
|
|
9
|
+
Actor = Data.define(:id, :display_name, :metadata)
|
|
10
|
+
Attachment = Data.define(:name, :mime_type, :data, :url, :metadata)
|
|
11
|
+
ConversationKey = Data.define(:transport_id, :external_id)
|
|
12
|
+
InboundMessage = Data.define(
|
|
13
|
+
:conversation,
|
|
14
|
+
:message_id,
|
|
15
|
+
:actor,
|
|
16
|
+
:text,
|
|
17
|
+
:attachments,
|
|
18
|
+
:reply_context,
|
|
19
|
+
:idempotency_key,
|
|
20
|
+
:metadata
|
|
21
|
+
)
|
|
22
|
+
TurnEvent = Data.define(:type, :session_id, :turn_id, :sequence, :payload)
|
|
23
|
+
InteractionRequest = Data.define(
|
|
24
|
+
:id,
|
|
25
|
+
:session_id,
|
|
26
|
+
:turn_id,
|
|
27
|
+
:kind,
|
|
28
|
+
:prompt,
|
|
29
|
+
:choices,
|
|
30
|
+
:expires_at,
|
|
31
|
+
:metadata
|
|
32
|
+
)
|
|
33
|
+
Capabilities = Data.define(:inbound, :outbound, :streaming, :limits)
|
|
34
|
+
ExecutionProfile = Data.define(
|
|
35
|
+
:id,
|
|
36
|
+
:tool_mode,
|
|
37
|
+
:allowed_tools,
|
|
38
|
+
:disabled_tools,
|
|
39
|
+
:plugin_commands,
|
|
40
|
+
:approval_mode,
|
|
41
|
+
:memory,
|
|
42
|
+
:attachments,
|
|
43
|
+
:workspace_mode,
|
|
44
|
+
:prompt_context
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
TOOL_MODES = %i[all none allowlist].freeze
|
|
48
|
+
APPROVAL_MODES = %i[default ask deny].freeze
|
|
49
|
+
MEMORY_MODES = %i[default none].freeze
|
|
50
|
+
WORKSPACE_MODES = %i[session fixed].freeze
|
|
51
|
+
|
|
52
|
+
module_function
|
|
53
|
+
|
|
54
|
+
def actor(id:, display_name: nil, metadata: {})
|
|
55
|
+
Actor.new(
|
|
56
|
+
id: required_string(id, "actor id"),
|
|
57
|
+
display_name: optional_string(display_name),
|
|
58
|
+
metadata: frozen_copy(metadata)
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def attachment(name: nil, mime_type:, data: nil, url: nil, metadata: {})
|
|
63
|
+
raise ArgumentError, "attachment requires data or url" if data.nil? && url.nil?
|
|
64
|
+
raise ArgumentError, "attachment cannot contain both data and url" unless data.nil? || url.nil?
|
|
65
|
+
|
|
66
|
+
Attachment.new(
|
|
67
|
+
name: optional_string(name),
|
|
68
|
+
mime_type: required_string(mime_type, "attachment MIME type"),
|
|
69
|
+
data: data&.dup&.freeze,
|
|
70
|
+
url: url && required_string(url, "attachment URL"),
|
|
71
|
+
metadata: frozen_copy(metadata)
|
|
72
|
+
)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def conversation_key(transport_id:, external_id:)
|
|
76
|
+
ConversationKey.new(
|
|
77
|
+
transport_id: required_string(transport_id, "transport id"),
|
|
78
|
+
external_id: required_string(external_id, "external conversation id")
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def inbound_message(conversation:, message_id:, actor:, text: "", attachments: [], reply_context: {}, idempotency_key:, metadata: {})
|
|
83
|
+
unless conversation.is_a?(ConversationKey)
|
|
84
|
+
raise ArgumentError, "conversation must be a Transport::ConversationKey"
|
|
85
|
+
end
|
|
86
|
+
unless actor.is_a?(Actor)
|
|
87
|
+
raise ArgumentError, "actor must be a Transport::Actor"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
InboundMessage.new(
|
|
91
|
+
conversation: conversation,
|
|
92
|
+
message_id: required_string(message_id, "message id"),
|
|
93
|
+
actor: actor,
|
|
94
|
+
text: text.to_s.freeze,
|
|
95
|
+
attachments: frozen_copy(Array(attachments)),
|
|
96
|
+
reply_context: frozen_copy(reply_context),
|
|
97
|
+
idempotency_key: required_string(idempotency_key, "idempotency key"),
|
|
98
|
+
metadata: frozen_copy(metadata)
|
|
99
|
+
)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def turn_event(type:, session_id:, turn_id:, sequence:, payload: {})
|
|
103
|
+
TurnEvent.new(
|
|
104
|
+
type: required_string(type, "turn event type"),
|
|
105
|
+
session_id: required_string(session_id, "session id"),
|
|
106
|
+
turn_id: required_string(turn_id, "turn id"),
|
|
107
|
+
sequence: Integer(sequence),
|
|
108
|
+
payload: frozen_copy(payload)
|
|
109
|
+
)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def interaction_request(id:, session_id:, turn_id:, kind:, prompt:, choices: [], expires_at: nil, metadata: {})
|
|
113
|
+
InteractionRequest.new(
|
|
114
|
+
id: required_string(id, "interaction id"),
|
|
115
|
+
session_id: required_string(session_id, "session id"),
|
|
116
|
+
turn_id: required_string(turn_id, "turn id"),
|
|
117
|
+
kind: required_string(kind, "interaction kind"),
|
|
118
|
+
prompt: required_string(prompt, "interaction prompt"),
|
|
119
|
+
choices: frozen_copy(Array(choices)),
|
|
120
|
+
expires_at: expires_at,
|
|
121
|
+
metadata: frozen_copy(metadata)
|
|
122
|
+
)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def execution_profile(
|
|
126
|
+
id:,
|
|
127
|
+
tool_mode: :all,
|
|
128
|
+
allowed_tools: nil,
|
|
129
|
+
disabled_tools: nil,
|
|
130
|
+
plugin_commands: true,
|
|
131
|
+
approval_mode: :default,
|
|
132
|
+
memory: :default,
|
|
133
|
+
attachments: true,
|
|
134
|
+
workspace_mode: :session,
|
|
135
|
+
prompt_context: nil
|
|
136
|
+
)
|
|
137
|
+
id = required_string(id, "execution profile id")
|
|
138
|
+
tool_mode = tool_mode.to_sym
|
|
139
|
+
approval_mode = approval_mode.to_sym
|
|
140
|
+
memory = memory.to_sym
|
|
141
|
+
workspace_mode = workspace_mode.to_sym
|
|
142
|
+
raise ArgumentError, "unsupported tool mode: #{tool_mode}" unless TOOL_MODES.include?(tool_mode)
|
|
143
|
+
raise ArgumentError, "unsupported approval mode: #{approval_mode}" unless APPROVAL_MODES.include?(approval_mode)
|
|
144
|
+
raise ArgumentError, "unsupported memory mode: #{memory}" unless MEMORY_MODES.include?(memory)
|
|
145
|
+
raise ArgumentError, "unsupported workspace mode: #{workspace_mode}" unless WORKSPACE_MODES.include?(workspace_mode)
|
|
146
|
+
raise ArgumentError, "allowlist tool mode requires allowed_tools" if tool_mode == :allowlist && Array(allowed_tools).empty?
|
|
147
|
+
raise ArgumentError, "none tool mode cannot define allowed_tools" if tool_mode == :none && allowed_tools
|
|
148
|
+
raise ArgumentError, "allowed_tools and disabled_tools cannot both be set" if allowed_tools && disabled_tools
|
|
149
|
+
|
|
150
|
+
ExecutionProfile.new(
|
|
151
|
+
id: id,
|
|
152
|
+
tool_mode: tool_mode,
|
|
153
|
+
allowed_tools: frozen_symbols(allowed_tools),
|
|
154
|
+
disabled_tools: frozen_symbols(disabled_tools),
|
|
155
|
+
plugin_commands: !!plugin_commands,
|
|
156
|
+
approval_mode: approval_mode,
|
|
157
|
+
memory: memory,
|
|
158
|
+
attachments: !!attachments,
|
|
159
|
+
workspace_mode: workspace_mode,
|
|
160
|
+
prompt_context: prompt_context.nil? ? nil : prompt_context.to_s.freeze
|
|
161
|
+
)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def capabilities(inbound: [], outbound: [], streaming: :none, limits: {})
|
|
165
|
+
streaming = streaming.to_sym
|
|
166
|
+
raise ArgumentError, "unsupported streaming mode: #{streaming}" unless STREAMING_MODES.include?(streaming)
|
|
167
|
+
|
|
168
|
+
Capabilities.new(
|
|
169
|
+
inbound: frozen_symbols(inbound),
|
|
170
|
+
outbound: frozen_symbols(outbound),
|
|
171
|
+
streaming: streaming,
|
|
172
|
+
limits: frozen_copy(limits)
|
|
173
|
+
)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def required_string(value, name)
|
|
177
|
+
value = value.to_s
|
|
178
|
+
raise ArgumentError, "#{name} is required" if value.empty?
|
|
179
|
+
|
|
180
|
+
value.freeze
|
|
181
|
+
end
|
|
182
|
+
private_class_method :required_string
|
|
183
|
+
|
|
184
|
+
def optional_string(value)
|
|
185
|
+
value.nil? ? nil : value.to_s.freeze
|
|
186
|
+
end
|
|
187
|
+
private_class_method :optional_string
|
|
188
|
+
|
|
189
|
+
def frozen_symbols(values)
|
|
190
|
+
Array(values).map { |value| value.to_sym }.uniq.freeze
|
|
191
|
+
end
|
|
192
|
+
private_class_method :frozen_symbols
|
|
193
|
+
|
|
194
|
+
def frozen_copy(value)
|
|
195
|
+
DeepCopy.freeze(DeepCopy.dup(value))
|
|
196
|
+
end
|
|
197
|
+
private_class_method :frozen_copy
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
require_relative "transport/store"
|
|
202
|
+
require_relative "transport/host"
|
|
203
|
+
require_relative "transport/manager"
|
|
204
|
+
require_relative "transport/gateway"
|