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,136 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "net/http"
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Kward
|
|
6
|
+
module Telegram
|
|
7
|
+
# Small dependency-free client for the Telegram Bot API.
|
|
8
|
+
class BotApi
|
|
9
|
+
API_BASE_URL = "https://api.telegram.org".freeze
|
|
10
|
+
MAX_RESPONSE_BYTES = 5 * 1024 * 1024
|
|
11
|
+
|
|
12
|
+
class Error < StandardError
|
|
13
|
+
attr_reader :method, :error_code, :parameters
|
|
14
|
+
|
|
15
|
+
def initialize(method, description, error_code: nil, parameters: nil)
|
|
16
|
+
@method = method
|
|
17
|
+
@error_code = error_code
|
|
18
|
+
@parameters = parameters
|
|
19
|
+
super("Telegram #{method} failed: #{description.to_s}")
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
class RateLimitError < Error
|
|
24
|
+
attr_reader :retry_after
|
|
25
|
+
|
|
26
|
+
def initialize(method, description, retry_after:, error_code: nil, parameters: nil)
|
|
27
|
+
@retry_after = Integer(retry_after)
|
|
28
|
+
super(method, description, error_code: error_code, parameters: parameters)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def initialize(token:, requester: nil, base_url: API_BASE_URL)
|
|
33
|
+
@token = token.to_s
|
|
34
|
+
raise ArgumentError, "Telegram bot token is required" if @token.empty?
|
|
35
|
+
|
|
36
|
+
@requester = requester || method(:request)
|
|
37
|
+
@base_url = base_url.to_s.sub(%r{/\z}, "")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def get_me
|
|
41
|
+
call("getMe")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def delete_webhook(drop_pending_updates: false)
|
|
45
|
+
call("deleteWebhook", "drop_pending_updates" => !!drop_pending_updates)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def get_updates(offset: nil, timeout: 25)
|
|
49
|
+
timeout = Integer(timeout)
|
|
50
|
+
raise ArgumentError, "Telegram polling timeout must be between 0 and 50 seconds" unless (0..50).cover?(timeout)
|
|
51
|
+
|
|
52
|
+
params = {
|
|
53
|
+
"timeout" => timeout,
|
|
54
|
+
"allowed_updates" => JSON.generate(%w[message callback_query])
|
|
55
|
+
}
|
|
56
|
+
params["offset"] = Integer(offset) unless offset.nil?
|
|
57
|
+
Array(call("getUpdates", params))
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def answer_callback_query(callback_query_id:, text: nil, show_alert: false)
|
|
61
|
+
params = {
|
|
62
|
+
"callback_query_id" => callback_query_id.to_s,
|
|
63
|
+
"show_alert" => !!show_alert
|
|
64
|
+
}
|
|
65
|
+
params["text"] = text.to_s unless text.nil?
|
|
66
|
+
call("answerCallbackQuery", params)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def send_message(chat_id:, text:, reply_to_message_id: nil, reply_markup: nil)
|
|
70
|
+
text = text.to_s
|
|
71
|
+
raise ArgumentError, "Telegram message text is required" if text.empty?
|
|
72
|
+
|
|
73
|
+
params = { "chat_id" => Integer(chat_id), "text" => text }
|
|
74
|
+
unless reply_to_message_id.nil?
|
|
75
|
+
params["reply_parameters"] = JSON.generate({ "message_id" => Integer(reply_to_message_id) })
|
|
76
|
+
end
|
|
77
|
+
params["reply_markup"] = JSON.generate(reply_markup) unless reply_markup.nil?
|
|
78
|
+
call("sendMessage", params)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def call(method, params = {})
|
|
84
|
+
response = @requester.call(method, params)
|
|
85
|
+
unless response.is_a?(Hash) && response["ok"]
|
|
86
|
+
raise_error(method, response || {})
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
response["result"]
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def raise_error(method, response)
|
|
93
|
+
description = (response["description"] || "unknown API error").to_s.gsub(@token, "[REDACTED]")
|
|
94
|
+
parameters = response["parameters"] || {}
|
|
95
|
+
if response["error_code"].to_i == 429 && parameters["retry_after"]
|
|
96
|
+
raise RateLimitError.new(
|
|
97
|
+
method,
|
|
98
|
+
description,
|
|
99
|
+
retry_after: parameters.fetch("retry_after"),
|
|
100
|
+
error_code: response["error_code"],
|
|
101
|
+
parameters: parameters
|
|
102
|
+
)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
raise Error.new(
|
|
106
|
+
method,
|
|
107
|
+
description,
|
|
108
|
+
error_code: response["error_code"],
|
|
109
|
+
parameters: parameters
|
|
110
|
+
)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def request(method, params)
|
|
114
|
+
uri = URI.parse("#{@base_url}/bot#{@token}/#{method}")
|
|
115
|
+
request = Net::HTTP::Post.new(uri)
|
|
116
|
+
request.set_form_data(params.transform_values(&:to_s))
|
|
117
|
+
|
|
118
|
+
response = Net::HTTP.start(
|
|
119
|
+
uri.host,
|
|
120
|
+
uri.port,
|
|
121
|
+
use_ssl: uri.scheme == "https",
|
|
122
|
+
open_timeout: 5,
|
|
123
|
+
read_timeout: params.fetch("timeout", 10).to_i + 10
|
|
124
|
+
) { |http| http.request(request) }
|
|
125
|
+
body = response.body.to_s
|
|
126
|
+
raise Error.new(method, "response was too large") if body.bytesize > MAX_RESPONSE_BYTES
|
|
127
|
+
|
|
128
|
+
JSON.parse(body)
|
|
129
|
+
rescue JSON::ParserError
|
|
130
|
+
raise Error.new(method, "response was not valid JSON")
|
|
131
|
+
rescue URI::InvalidURIError
|
|
132
|
+
raise Error.new(method, "invalid Telegram API URL")
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
require "thread"
|
|
2
|
+
require_relative "telegram_api"
|
|
3
|
+
|
|
4
|
+
module Kward
|
|
5
|
+
module Telegram
|
|
6
|
+
# Long-polling Telegram adapter backed by normal Kward sessions.
|
|
7
|
+
class Transport
|
|
8
|
+
TELEGRAM_MESSAGE_LIMIT = 4096
|
|
9
|
+
DEFAULT_POLL_TIMEOUT = 25
|
|
10
|
+
MAX_RETRY_DELAY = 30
|
|
11
|
+
|
|
12
|
+
def initialize(host:, config:, api: nil, sleeper: nil)
|
|
13
|
+
@host = host
|
|
14
|
+
@config = config || {}
|
|
15
|
+
@token = host.secret("bot_token", env: "TELEGRAM_BOT_TOKEN")
|
|
16
|
+
@workspace = required_config("workspace")
|
|
17
|
+
@allowed_user_ids = required_ids("allowed_user_ids")
|
|
18
|
+
@allowed_chat_ids = required_ids("allowed_chat_ids")
|
|
19
|
+
@poll_timeout = Integer(@config.fetch("poll_timeout_seconds", DEFAULT_POLL_TIMEOUT))
|
|
20
|
+
raise ArgumentError, "poll_timeout_seconds must be between 0 and 50" unless (0..50).cover?(@poll_timeout)
|
|
21
|
+
|
|
22
|
+
@api = api || BotApi.new(token: @token)
|
|
23
|
+
@sleeper = sleeper || method(:sleep)
|
|
24
|
+
@state_mutex = Mutex.new
|
|
25
|
+
@stop = false
|
|
26
|
+
@thread = nil
|
|
27
|
+
@last_error = nil
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def start
|
|
31
|
+
@api.delete_webhook
|
|
32
|
+
@api.get_me
|
|
33
|
+
@host.interactions.subscribe { |request| handle_interaction(request) }
|
|
34
|
+
@state_mutex.synchronize do
|
|
35
|
+
@stop = false
|
|
36
|
+
@last_error = nil
|
|
37
|
+
@thread = Thread.new { poll_loop }
|
|
38
|
+
end
|
|
39
|
+
nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def stop
|
|
43
|
+
thread = @state_mutex.synchronize do
|
|
44
|
+
@stop = true
|
|
45
|
+
@thread
|
|
46
|
+
end
|
|
47
|
+
return nil unless thread
|
|
48
|
+
|
|
49
|
+
thread.join(@poll_timeout + 10)
|
|
50
|
+
thread.kill if thread.alive?
|
|
51
|
+
@state_mutex.synchronize { @thread = nil }
|
|
52
|
+
nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def health
|
|
56
|
+
@state_mutex.synchronize do
|
|
57
|
+
{
|
|
58
|
+
state: @thread&.alive? ? "running" : "stopped",
|
|
59
|
+
error: @last_error
|
|
60
|
+
}
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def poll_loop
|
|
67
|
+
offset = Integer(@host.storage.get("telegram:offset") || 0)
|
|
68
|
+
until stopping?
|
|
69
|
+
begin
|
|
70
|
+
updates = @api.get_updates(offset: offset, timeout: @poll_timeout)
|
|
71
|
+
updates.each do |update|
|
|
72
|
+
update_id = Integer(update.fetch("update_id"))
|
|
73
|
+
handle_update(update)
|
|
74
|
+
offset = [offset, update_id + 1].max
|
|
75
|
+
@host.storage.put("telegram:offset", offset)
|
|
76
|
+
end
|
|
77
|
+
rescue BotApi::RateLimitError => error
|
|
78
|
+
record_error(error)
|
|
79
|
+
wait_for([error.retry_after, MAX_RETRY_DELAY].min)
|
|
80
|
+
rescue StandardError => error
|
|
81
|
+
record_error(error)
|
|
82
|
+
wait_for(2)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def handle_update(update)
|
|
88
|
+
if update["callback_query"]
|
|
89
|
+
handle_callback(update.fetch("callback_query"))
|
|
90
|
+
elsif update["message"]
|
|
91
|
+
handle_message(update.fetch("message"), update.fetch("update_id"))
|
|
92
|
+
end
|
|
93
|
+
rescue StandardError => error
|
|
94
|
+
record_error(error)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def handle_message(message, update_id)
|
|
98
|
+
text = message["text"].to_s
|
|
99
|
+
return if text.empty?
|
|
100
|
+
|
|
101
|
+
user_id = Integer(message.fetch("from").fetch("id"))
|
|
102
|
+
chat_id = Integer(message.fetch("chat").fetch("id"))
|
|
103
|
+
return unless allowed?(user_id, chat_id)
|
|
104
|
+
return unless @host.storage.claim("telegram:update:#{update_id}")
|
|
105
|
+
|
|
106
|
+
conversation = Kward::Transport.conversation_key(
|
|
107
|
+
transport_id: @host.transport_id,
|
|
108
|
+
external_id: "chat:#{chat_id}"
|
|
109
|
+
)
|
|
110
|
+
actor = Kward::Transport.actor(
|
|
111
|
+
id: "telegram:user:#{user_id}",
|
|
112
|
+
display_name: message.dig("from", "username") || user_id.to_s,
|
|
113
|
+
metadata: { provider: "telegram", user_id: user_id, chat_id: chat_id }
|
|
114
|
+
)
|
|
115
|
+
@host.authorize!(:telegram_message, actor: actor, conversation: conversation)
|
|
116
|
+
|
|
117
|
+
session = @host.sessions.resolve(
|
|
118
|
+
conversation: conversation,
|
|
119
|
+
actor: actor,
|
|
120
|
+
workspace_root: @workspace,
|
|
121
|
+
name: "Telegram chat #{chat_id}"
|
|
122
|
+
)
|
|
123
|
+
remember_session(session, conversation, chat_id, actor)
|
|
124
|
+
|
|
125
|
+
turn = session.start_turn(text)
|
|
126
|
+
@host.storage.put("telegram:turn:#{turn.id}", {
|
|
127
|
+
"chat_id" => chat_id,
|
|
128
|
+
"session_id" => session.id,
|
|
129
|
+
"reply_to_message_id" => message["message_id"]
|
|
130
|
+
})
|
|
131
|
+
turn.subscribe { |event| handle_turn_event(event) }
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def handle_turn_event(event)
|
|
135
|
+
return unless %w[answer assistantMessage assistant_message].include?(event.type.to_s)
|
|
136
|
+
|
|
137
|
+
turn = @host.storage.get("telegram:turn:#{event.turn_id}")
|
|
138
|
+
return unless turn
|
|
139
|
+
return if @host.storage.get("telegram:delivered:#{event.turn_id}")
|
|
140
|
+
|
|
141
|
+
content = event.payload[:content] || event.payload["content"]
|
|
142
|
+
if content.to_s.empty? && %w[assistantMessage assistant_message].include?(event.type.to_s)
|
|
143
|
+
message = event.payload[:message] || event.payload["message"]
|
|
144
|
+
content = message[:content] || message["content"] if message.is_a?(Hash)
|
|
145
|
+
end
|
|
146
|
+
return if content.to_s.empty?
|
|
147
|
+
|
|
148
|
+
split_message(content.to_s).each do |part|
|
|
149
|
+
@api.send_message(
|
|
150
|
+
chat_id: turn.fetch("chat_id"),
|
|
151
|
+
text: part,
|
|
152
|
+
reply_to_message_id: turn["reply_to_message_id"]
|
|
153
|
+
)
|
|
154
|
+
end
|
|
155
|
+
@host.storage.put("telegram:delivered:#{event.turn_id}", true)
|
|
156
|
+
rescue StandardError => error
|
|
157
|
+
record_error(error)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def handle_interaction(request)
|
|
161
|
+
session = @host.storage.get("telegram:session:#{request.session_id}")
|
|
162
|
+
return unless session
|
|
163
|
+
|
|
164
|
+
record = {
|
|
165
|
+
"chat_id" => session.fetch("chat_id"),
|
|
166
|
+
"session_id" => request.session_id,
|
|
167
|
+
"external_id" => session.fetch("external_id"),
|
|
168
|
+
"actor_id" => session.fetch("actor_id"),
|
|
169
|
+
"actor_name" => session["actor_name"],
|
|
170
|
+
"kind" => request.kind.to_s
|
|
171
|
+
}
|
|
172
|
+
buttons = case request.kind.to_s
|
|
173
|
+
when "tool_approval"
|
|
174
|
+
record["answers"] = { "approve" => true, "deny" => false }
|
|
175
|
+
[[
|
|
176
|
+
{ "text" => "Approve", "callback_data" => callback_data(request.id, "approve") },
|
|
177
|
+
{ "text" => "Deny", "callback_data" => callback_data(request.id, "deny") }
|
|
178
|
+
]]
|
|
179
|
+
when "question"
|
|
180
|
+
question, options = question_options(request)
|
|
181
|
+
return if options.empty?
|
|
182
|
+
|
|
183
|
+
record["question"] = question
|
|
184
|
+
record["answers"] = options.each_with_index.to_h { |option, index| [index.to_s, option] }
|
|
185
|
+
[options.each_with_index.map do |option, index|
|
|
186
|
+
{ "text" => option, "callback_data" => callback_data(request.id, index.to_s) }
|
|
187
|
+
end]
|
|
188
|
+
else
|
|
189
|
+
return
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
@host.storage.put("telegram:interaction:#{request.id}", record)
|
|
193
|
+
@api.send_message(
|
|
194
|
+
chat_id: record.fetch("chat_id"),
|
|
195
|
+
text: request.prompt,
|
|
196
|
+
reply_markup: { "inline_keyboard" => buttons }
|
|
197
|
+
)
|
|
198
|
+
rescue StandardError => error
|
|
199
|
+
record_error(error)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def handle_callback(callback)
|
|
203
|
+
data = callback["data"].to_s
|
|
204
|
+
match = /\Akward:([^:]+):([^:]+)\z/.match(data)
|
|
205
|
+
return unless match
|
|
206
|
+
|
|
207
|
+
request_id, choice = match.captures
|
|
208
|
+
chat_id = Integer(callback.fetch("message").fetch("chat").fetch("id"))
|
|
209
|
+
user_id = Integer(callback.fetch("from").fetch("id"))
|
|
210
|
+
return unless allowed?(user_id, chat_id)
|
|
211
|
+
|
|
212
|
+
record = @host.storage.get("telegram:interaction:#{request_id}")
|
|
213
|
+
return unless record && record.fetch("chat_id") == chat_id
|
|
214
|
+
return unless record.fetch("actor_id") == "telegram:user:#{user_id}"
|
|
215
|
+
return if @host.storage.get("telegram:answered:#{request_id}")
|
|
216
|
+
|
|
217
|
+
conversation = Kward::Transport.conversation_key(
|
|
218
|
+
transport_id: @host.transport_id,
|
|
219
|
+
external_id: record.fetch("external_id")
|
|
220
|
+
)
|
|
221
|
+
actor = Kward::Transport.actor(
|
|
222
|
+
id: record.fetch("actor_id"),
|
|
223
|
+
display_name: record["actor_name"],
|
|
224
|
+
metadata: { provider: "telegram", user_id: user_id, chat_id: chat_id }
|
|
225
|
+
)
|
|
226
|
+
session = @host.sessions.resolve(
|
|
227
|
+
conversation: conversation,
|
|
228
|
+
actor: actor,
|
|
229
|
+
workspace_root: @workspace
|
|
230
|
+
)
|
|
231
|
+
answer = record.fetch("answers").fetch(choice)
|
|
232
|
+
answer = [{ question: record.fetch("question"), answer: answer }] if record.fetch("kind") == "question"
|
|
233
|
+
session.answer_interaction(request_id: request_id, answer: answer)
|
|
234
|
+
@host.storage.put("telegram:answered:#{request_id}", true)
|
|
235
|
+
@api.answer_callback_query(callback_query_id: callback.fetch("id"))
|
|
236
|
+
rescue StandardError => error
|
|
237
|
+
record_error(error)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def remember_session(session, conversation, chat_id, actor)
|
|
241
|
+
@host.storage.put("telegram:session:#{session.id}", {
|
|
242
|
+
"chat_id" => chat_id,
|
|
243
|
+
"external_id" => conversation.external_id,
|
|
244
|
+
"actor_id" => actor.id,
|
|
245
|
+
"actor_name" => actor.display_name
|
|
246
|
+
})
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def question_options(request)
|
|
250
|
+
question = request.choices.first || {}
|
|
251
|
+
options = Array(question[:options] || question["options"]).map do |option|
|
|
252
|
+
(option[:label] || option["label"]).to_s
|
|
253
|
+
end.reject(&:empty?)
|
|
254
|
+
[question[:question] || question["question"] || request.prompt, options]
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def callback_data(request_id, choice)
|
|
258
|
+
"kward:#{request_id}:#{choice}"
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def split_message(text)
|
|
262
|
+
text.each_char.each_slice(TELEGRAM_MESSAGE_LIMIT).map(&:join)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def allowed?(user_id, chat_id)
|
|
266
|
+
@allowed_user_ids.include?(user_id) && @allowed_chat_ids.include?(chat_id)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def stopping?
|
|
270
|
+
@state_mutex.synchronize { @stop }
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def wait_for(seconds)
|
|
274
|
+
@sleeper.call(seconds) unless stopping?
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def record_error(error)
|
|
278
|
+
message = error.message.to_s.gsub(@token.to_s, "[REDACTED]")
|
|
279
|
+
summary = "#{error.class}: #{message}"
|
|
280
|
+
@state_mutex.synchronize { @last_error = summary }
|
|
281
|
+
@host.logger.error("Telegram transport error: #{summary}")
|
|
282
|
+
rescue StandardError
|
|
283
|
+
nil
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def required_config(key)
|
|
287
|
+
value = @config[key] || @config[key.to_sym]
|
|
288
|
+
raise ArgumentError, "Telegram transport requires #{key}" if value.to_s.empty?
|
|
289
|
+
|
|
290
|
+
value.to_s
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def required_ids(key)
|
|
294
|
+
values = @config[key] || @config[key.to_sym]
|
|
295
|
+
values = Array(values).map { |value| Integer(value) }
|
|
296
|
+
raise ArgumentError, "Telegram transport requires non-empty #{key}" if values.empty?
|
|
297
|
+
|
|
298
|
+
values.uniq.freeze
|
|
299
|
+
rescue ArgumentError, TypeError
|
|
300
|
+
raise ArgumentError, "Telegram transport #{key} must contain numeric IDs"
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
end
|
data/lib/kward/agent.rb
CHANGED
|
@@ -28,11 +28,12 @@ module Kward
|
|
|
28
28
|
# lowest layer that owns the behavior, and use `Agent` only for cross-step turn
|
|
29
29
|
# coordination.
|
|
30
30
|
class Agent
|
|
31
|
-
def initialize(client:, tool_registry: ToolRegistry.new, conversation: Conversation.new, telemetry_logger:
|
|
31
|
+
def initialize(client:, tool_registry: ToolRegistry.new, conversation: Conversation.new, telemetry_logger: nil, warning_sink: nil, hook_manager: nil, hook_context: nil)
|
|
32
32
|
@client = client
|
|
33
33
|
@tool_registry = tool_registry
|
|
34
34
|
@conversation = conversation
|
|
35
|
-
@
|
|
35
|
+
@warning_sink = warning_sink
|
|
36
|
+
@telemetry_logger = telemetry_logger || TelemetryLogger.new(warning_sink: warning_sink)
|
|
36
37
|
@hook_manager = hook_manager
|
|
37
38
|
@hook_context = hook_context
|
|
38
39
|
end
|
|
@@ -191,9 +192,9 @@ module Kward
|
|
|
191
192
|
|
|
192
193
|
def auto_compact_if_needed
|
|
193
194
|
context_window = @client.current_context_window if @client.respond_to?(:current_context_window)
|
|
194
|
-
Compactor.new(conversation: @conversation, client: @client).auto_compact_if_needed(context_window: context_window)
|
|
195
|
+
Compactor.new(conversation: @conversation, client: @client, warning_sink: @warning_sink).auto_compact_if_needed(context_window: context_window)
|
|
195
196
|
rescue StandardError => e
|
|
196
|
-
|
|
197
|
+
emit_warning "Auto-compaction failed: #{e.message}"
|
|
197
198
|
nil
|
|
198
199
|
end
|
|
199
200
|
|
|
@@ -201,14 +202,18 @@ module Kward
|
|
|
201
202
|
settings = Compaction::Settings.from_config
|
|
202
203
|
return nil unless settings.enabled
|
|
203
204
|
|
|
204
|
-
Compactor.new(conversation: @conversation, client: @client, settings: settings).compact(
|
|
205
|
+
Compactor.new(conversation: @conversation, client: @client, settings: settings, warning_sink: @warning_sink).compact(
|
|
205
206
|
custom_instructions: "The previous model request exceeded the context window. Preserve the current task state and critical details needed to retry."
|
|
206
207
|
)
|
|
207
208
|
rescue Compaction::NothingToCompact, Compaction::AlreadyCompacted, StandardError => compaction_error
|
|
208
|
-
|
|
209
|
+
emit_warning "Context overflow recovery failed: #{compaction_error.message}; original error: #{error.message}"
|
|
209
210
|
nil
|
|
210
211
|
end
|
|
211
212
|
|
|
213
|
+
def emit_warning(message)
|
|
214
|
+
@warning_sink ? @warning_sink.call(message) : warn(message)
|
|
215
|
+
end
|
|
216
|
+
|
|
212
217
|
def chat(on_reasoning_delta: nil, on_retry: nil, cancellation: nil, steering: nil, options: {}, tool_registry: nil)
|
|
213
218
|
reasoning_delta = lambda do |delta|
|
|
214
219
|
cancellation&.raise_if_cancelled!
|
data/lib/kward/ansi.rb
CHANGED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require_relative "../config_files"
|
|
3
|
+
require_relative "../private_file"
|
|
4
|
+
require_relative "../model/provider_catalog"
|
|
5
|
+
|
|
6
|
+
# Namespace for Kward credential storage.
|
|
7
|
+
module Kward
|
|
8
|
+
# Stores API keys outside the non-secret Kward configuration file.
|
|
9
|
+
class APIKeyStore
|
|
10
|
+
def initialize(path: APIKeyStore.default_path, config_path: ConfigFiles.config_path, env: ENV)
|
|
11
|
+
@path = File.expand_path(path)
|
|
12
|
+
@config_path = File.expand_path(config_path)
|
|
13
|
+
@env = env
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
attr_reader :path
|
|
17
|
+
|
|
18
|
+
def self.default_path
|
|
19
|
+
File.join(ConfigFiles.config_dir, "api_keys.json")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def fetch(provider_id)
|
|
23
|
+
provider = ProviderCatalog.fetch(provider_id)
|
|
24
|
+
environment_key(provider) || stored_key(provider.id)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def configured?(provider_id)
|
|
28
|
+
!fetch(provider_id).to_s.empty?
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def store(provider_id, api_key)
|
|
32
|
+
provider = ProviderCatalog.fetch(provider_id)
|
|
33
|
+
raise ArgumentError, "#{provider.name} does not accept an API key" unless provider.api_key?
|
|
34
|
+
|
|
35
|
+
api_key = api_key.to_s.strip
|
|
36
|
+
raise ArgumentError, "API key must be a non-empty string" if api_key.empty?
|
|
37
|
+
|
|
38
|
+
keys = stored_keys
|
|
39
|
+
keys[provider.id] = api_key
|
|
40
|
+
write_keys(keys)
|
|
41
|
+
path
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def delete(provider_id)
|
|
45
|
+
provider = ProviderCatalog.fetch(provider_id)
|
|
46
|
+
keys = stored_keys
|
|
47
|
+
return false unless keys.delete(provider.id)
|
|
48
|
+
|
|
49
|
+
write_keys(keys)
|
|
50
|
+
true
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def stored?(provider_id)
|
|
54
|
+
!stored_key(provider_id).to_s.empty?
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def stored_value(provider_id)
|
|
58
|
+
ProviderCatalog.fetch(provider_id)
|
|
59
|
+
stored_key(provider_id)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def migrate_openrouter_config_key!
|
|
63
|
+
return false if stored?("openrouter")
|
|
64
|
+
|
|
65
|
+
config = ConfigFiles.read_config(@config_path)
|
|
66
|
+
key = config["openrouter_api_key"].to_s.strip
|
|
67
|
+
return false if key.empty?
|
|
68
|
+
|
|
69
|
+
store("openrouter", key)
|
|
70
|
+
ConfigFiles.delete_config_key("openrouter_api_key", @config_path)
|
|
71
|
+
true
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def environment_key(provider)
|
|
77
|
+
provider.api_key_env.map { |name| @env[name].to_s.strip }.find { |key| !key.empty? }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def stored_key(provider_id)
|
|
81
|
+
stored_keys[provider_id.to_s]
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def stored_keys
|
|
85
|
+
return {} unless File.exist?(path)
|
|
86
|
+
|
|
87
|
+
data = JSON.parse(File.read(path))
|
|
88
|
+
raise "Invalid Kward API key file: #{path}" unless data.is_a?(Hash)
|
|
89
|
+
|
|
90
|
+
data.transform_keys(&:to_s).transform_values(&:to_s)
|
|
91
|
+
rescue JSON::ParserError => e
|
|
92
|
+
raise "Invalid Kward API key file: #{path}: #{e.message}"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def write_keys(keys)
|
|
96
|
+
PrivateFile.write_json(path, keys)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|