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
|
@@ -14,7 +14,7 @@ module Kward
|
|
|
14
14
|
class Registry
|
|
15
15
|
SkillSource = Struct.new(:root, :label, :scope, :precedence, keyword_init: true)
|
|
16
16
|
|
|
17
|
-
def initialize(config_dir:, workspace_root:, project_skills_trusted:, skill_class:, max_file_bytes:, markdown_parser:, inside_directory:)
|
|
17
|
+
def initialize(config_dir:, workspace_root:, project_skills_trusted:, skill_class:, max_file_bytes:, markdown_parser:, inside_directory:, warning_sink: nil)
|
|
18
18
|
@config_dir = config_dir
|
|
19
19
|
@workspace_root = workspace_root
|
|
20
20
|
@project_skills_trusted = project_skills_trusted
|
|
@@ -22,6 +22,7 @@ module Kward
|
|
|
22
22
|
@max_file_bytes = max_file_bytes
|
|
23
23
|
@markdown_parser = markdown_parser
|
|
24
24
|
@inside_directory = inside_directory
|
|
25
|
+
@warning_sink = warning_sink
|
|
25
26
|
end
|
|
26
27
|
|
|
27
28
|
# Returns discovered, validated skills in precedence order.
|
|
@@ -36,7 +37,7 @@ module Kward
|
|
|
36
37
|
next unless skill
|
|
37
38
|
|
|
38
39
|
if seen[skill.name]
|
|
39
|
-
|
|
40
|
+
emit_warning "Warning: skipping duplicate Kward skill #{skill.name.inspect}: #{path}"
|
|
40
41
|
next
|
|
41
42
|
end
|
|
42
43
|
|
|
@@ -45,7 +46,7 @@ module Kward
|
|
|
45
46
|
end
|
|
46
47
|
end
|
|
47
48
|
rescue StandardError => e
|
|
48
|
-
|
|
49
|
+
emit_warning "Warning: skipping Kward skills: #{e.message}"
|
|
49
50
|
[]
|
|
50
51
|
end
|
|
51
52
|
|
|
@@ -99,13 +100,13 @@ module Kward
|
|
|
99
100
|
def scan_source(source)
|
|
100
101
|
return [] unless Dir.exist?(source.root)
|
|
101
102
|
if source.scope == :project && !@project_skills_trusted
|
|
102
|
-
|
|
103
|
+
emit_warning "Warning: skipping #{source.label} in #{source.root}: project skills are not trusted"
|
|
103
104
|
return []
|
|
104
105
|
end
|
|
105
106
|
|
|
106
107
|
Dir.glob(File.join(source.root, "*", "SKILL.md")).sort
|
|
107
108
|
rescue StandardError => e
|
|
108
|
-
|
|
109
|
+
emit_warning "Warning: skipping #{source.label} in #{source.root}: #{e.message}"
|
|
109
110
|
[]
|
|
110
111
|
end
|
|
111
112
|
|
|
@@ -151,12 +152,12 @@ module Kward
|
|
|
151
152
|
return warn_skip(path, "missing description") if description.empty?
|
|
152
153
|
return warn_skip(path, "description exceeds 1024 characters") if description.length > 1024
|
|
153
154
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
155
|
+
emit_warning "Warning: Kward skill #{path}: name does not match parent directory" if name != File.basename(File.dirname(path))
|
|
156
|
+
emit_warning "Warning: Kward skill #{path}: name exceeds 64 characters" if name.length > 64
|
|
157
|
+
emit_warning "Warning: Kward skill #{path}: name contains invalid characters" unless valid_name?(name)
|
|
157
158
|
|
|
158
159
|
compatibility = optional_text(frontmatter["compatibility"])
|
|
159
|
-
|
|
160
|
+
emit_warning "Warning: Kward skill #{path}: compatibility exceeds 500 characters" if compatibility && compatibility.length > 500
|
|
160
161
|
|
|
161
162
|
@skill_class.new(
|
|
162
163
|
name: name,
|
|
@@ -169,7 +170,7 @@ module Kward
|
|
|
169
170
|
allowed_tools: optional_text(frontmatter["allowed-tools"])
|
|
170
171
|
)
|
|
171
172
|
rescue StandardError => e
|
|
172
|
-
|
|
173
|
+
emit_warning "Warning: skipping Kward skill #{path}: #{e.message}"
|
|
173
174
|
nil
|
|
174
175
|
end
|
|
175
176
|
|
|
@@ -183,9 +184,13 @@ module Kward
|
|
|
183
184
|
end
|
|
184
185
|
|
|
185
186
|
def warn_skip(path, reason)
|
|
186
|
-
|
|
187
|
+
emit_warning "Warning: skipping Kward skill #{path}: #{reason}"
|
|
187
188
|
nil
|
|
188
189
|
end
|
|
190
|
+
|
|
191
|
+
def emit_warning(message)
|
|
192
|
+
@warning_sink ? @warning_sink.call(message) : warn(message)
|
|
193
|
+
end
|
|
189
194
|
end
|
|
190
195
|
end
|
|
191
196
|
end
|
data/lib/kward/tab_driver.rb
CHANGED
|
@@ -3,11 +3,12 @@ module Kward
|
|
|
3
3
|
# Adapts a session-backed agent to the tab runtime interface. Plugin tab
|
|
4
4
|
# drivers implement the same small surface without becoming Kward sessions.
|
|
5
5
|
class SessionTabDriver
|
|
6
|
-
attr_reader :session, :agent
|
|
6
|
+
attr_reader :session, :agent, :worktree
|
|
7
7
|
|
|
8
|
-
def initialize(session:, agent:)
|
|
8
|
+
def initialize(session:, agent:, worktree: nil)
|
|
9
9
|
@session = session
|
|
10
10
|
@agent = agent
|
|
11
|
+
@worktree = worktree
|
|
11
12
|
end
|
|
12
13
|
|
|
13
14
|
def messages
|
|
@@ -26,7 +27,9 @@ module Kward
|
|
|
26
27
|
end
|
|
27
28
|
|
|
28
29
|
def descriptor
|
|
29
|
-
{ "kind" => "session", "session_path" => session.path }
|
|
30
|
+
descriptor = { "kind" => "session", "session_path" => session.path }
|
|
31
|
+
descriptor["worktree"] = worktree.descriptor if worktree
|
|
32
|
+
descriptor
|
|
30
33
|
end
|
|
31
34
|
|
|
32
35
|
def session?
|
|
@@ -20,13 +20,14 @@ module Kward
|
|
|
20
20
|
DEFAULT_MAX_BYTES = 10 * 1024 * 1024
|
|
21
21
|
|
|
22
22
|
# Creates an object for telemetry event logging.
|
|
23
|
-
def initialize(config_path: ConfigFiles.config_path, log_dir: nil, max_bytes: DEFAULT_MAX_BYTES, clock: Time, monotonic_clock: Process, error_output: $stderr)
|
|
23
|
+
def initialize(config_path: ConfigFiles.config_path, log_dir: nil, max_bytes: DEFAULT_MAX_BYTES, clock: Time, monotonic_clock: Process, error_output: $stderr, warning_sink: nil)
|
|
24
24
|
@config_path = config_path
|
|
25
25
|
@log_dir = log_dir
|
|
26
26
|
@max_bytes = max_bytes.to_i.positive? ? max_bytes.to_i : DEFAULT_MAX_BYTES
|
|
27
27
|
@clock = clock
|
|
28
28
|
@monotonic_clock = monotonic_clock
|
|
29
29
|
@error_output = error_output
|
|
30
|
+
@warning_sink = warning_sink
|
|
30
31
|
@mutex = Mutex.new
|
|
31
32
|
@warned = false
|
|
32
33
|
end
|
|
@@ -192,7 +193,9 @@ module Kward
|
|
|
192
193
|
return if @warned
|
|
193
194
|
|
|
194
195
|
@warned = true
|
|
195
|
-
|
|
196
|
+
message = "Warning: telemetry logging failed: #{error.message}"
|
|
197
|
+
sink = @warning_sink || ConfigFiles.warning_sink
|
|
198
|
+
sink ? sink.call(message) : @error_output&.puts(message)
|
|
196
199
|
rescue StandardError
|
|
197
200
|
nil
|
|
198
201
|
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
require_relative "base"
|
|
2
|
+
|
|
3
|
+
# Namespace for model-callable tool wrappers.
|
|
4
|
+
module Kward
|
|
5
|
+
module Tools
|
|
6
|
+
# Tool wrapper for the trusted host-side Git commit operation.
|
|
7
|
+
class GitCommit < Base
|
|
8
|
+
def initialize(committer:)
|
|
9
|
+
@committer = committer
|
|
10
|
+
super(
|
|
11
|
+
"git_commit",
|
|
12
|
+
"Stage and commit changes in the active worktree. Omit paths to include all current changes.",
|
|
13
|
+
properties: {
|
|
14
|
+
message: { type: "string", description: "Commit message." },
|
|
15
|
+
paths: { type: "array", items: { type: "string" }, description: "Optional workspace-relative paths to include." }
|
|
16
|
+
},
|
|
17
|
+
required: ["message"]
|
|
18
|
+
)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def call(args, _conversation, cancellation: nil)
|
|
22
|
+
cancellation&.raise_if_cancelled!
|
|
23
|
+
message = argument(args, :message, "").to_s
|
|
24
|
+
paths = argument(args, :paths, nil)
|
|
25
|
+
return "Error: commit message is required" if message.strip.empty?
|
|
26
|
+
return "Error: paths must be an array" unless paths.nil? || paths.is_a?(Array)
|
|
27
|
+
return "Error: paths must contain strings" if paths && paths.any? { |path| !path.is_a?(String) }
|
|
28
|
+
|
|
29
|
+
paths = nil if paths&.empty?
|
|
30
|
+
result = @committer.call(message: message, paths: paths)
|
|
31
|
+
output = result[:output].to_s.strip
|
|
32
|
+
return "Git commit succeeded\n#{output}" if result[:success]
|
|
33
|
+
|
|
34
|
+
failure = output.empty? ? "Git commit failed." : "Git commit failed.\n#{output}"
|
|
35
|
+
"Error: #{failure}"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
data/lib/kward/tools/registry.rb
CHANGED
|
@@ -8,6 +8,7 @@ require_relative "context_for_task"
|
|
|
8
8
|
require_relative "edit_file"
|
|
9
9
|
require_relative "fetch_content"
|
|
10
10
|
require_relative "fetch_raw"
|
|
11
|
+
require_relative "git_commit"
|
|
11
12
|
require_relative "list_directory"
|
|
12
13
|
require_relative "mcp_tool"
|
|
13
14
|
require_relative "read_file"
|
|
@@ -78,7 +79,7 @@ module Kward
|
|
|
78
79
|
# @param web_search_enabled [Boolean, nil] override for web search exposure
|
|
79
80
|
# @param skills [Array<ConfigFiles::Skill>, nil] override discovered skills
|
|
80
81
|
# @param ask_user_question_enabled [Boolean, nil] override question exposure
|
|
81
|
-
def initialize(workspace: Workspace.new, prompt: nil, web_search: WebSearch.new, web_fetch: WebFetch.new, code_search: CodeSearch.new, web_search_enabled: nil, skills: nil, ask_user_question_enabled: nil, allowed_tool_names: nil, tool_output_compactor: ToolOutputCompactor.new, telemetry_logger: TelemetryLogger.new, context_budget_meter: nil, mcp_clients: nil, tool_approval: nil, approval_for_allowed_tools: false, permission_policy: nil, hook_manager: nil, hook_context: nil)
|
|
82
|
+
def initialize(workspace: Workspace.new, prompt: nil, web_search: WebSearch.new, web_fetch: WebFetch.new, code_search: CodeSearch.new, web_search_enabled: nil, skills: nil, ask_user_question_enabled: nil, allowed_tool_names: nil, tool_output_compactor: ToolOutputCompactor.new, telemetry_logger: TelemetryLogger.new, context_budget_meter: nil, mcp_clients: nil, tool_approval: nil, approval_for_allowed_tools: false, permission_policy: nil, hook_manager: nil, hook_context: nil, git_committer: nil)
|
|
82
83
|
@workspace = workspace
|
|
83
84
|
@prompt = prompt
|
|
84
85
|
@web_search = web_search
|
|
@@ -96,6 +97,7 @@ module Kward
|
|
|
96
97
|
@permission_policy = permission_policy || Permissions::Policy.from_config(ConfigFiles.read_config)
|
|
97
98
|
@hook_manager = hook_manager
|
|
98
99
|
@hook_context = hook_context
|
|
100
|
+
@git_committer = git_committer
|
|
99
101
|
@mcp_clients = if mcp_clients
|
|
100
102
|
mcp_clients
|
|
101
103
|
elsif @allowed_tool_names
|
|
@@ -484,6 +486,7 @@ module Kward
|
|
|
484
486
|
|
|
485
487
|
def build_schema_tools
|
|
486
488
|
tools = @tools.values_at(*CORE_TOOL_NAMES)
|
|
489
|
+
tools << @tools["git_commit"] if @tools["git_commit"]
|
|
487
490
|
tools.concat(@tools.values_at("web_search", "fetch_content", "fetch_raw")) if web_search_available?
|
|
488
491
|
tools.concat(@tools.values.select { |tool| tool.is_a?(Tools::MCPTool) })
|
|
489
492
|
tools << @tools["read_skill"] if skills_available?
|
|
@@ -492,7 +495,9 @@ module Kward
|
|
|
492
495
|
end
|
|
493
496
|
|
|
494
497
|
def all_tools
|
|
495
|
-
|
|
498
|
+
tools = core_tools
|
|
499
|
+
tools << Tools::GitCommit.new(committer: @git_committer) if @git_committer
|
|
500
|
+
tools + [
|
|
496
501
|
Tools::WebSearch.new(web_search: @web_search),
|
|
497
502
|
Tools::FetchContent.new(web_fetch: @web_fetch),
|
|
498
503
|
Tools::FetchRaw.new(web_fetch: @web_fetch),
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
require "base64"
|
|
2
|
+
require "digest"
|
|
3
|
+
require "thread"
|
|
4
|
+
require_relative "../deep_copy"
|
|
5
|
+
require_relative "store"
|
|
6
|
+
|
|
7
|
+
module Kward
|
|
8
|
+
module Transport
|
|
9
|
+
# Adapts the existing session manager to the transport host contract.
|
|
10
|
+
#
|
|
11
|
+
# The adapter deliberately uses the manager's public session and turn
|
|
12
|
+
# methods. It can be replaced by a frontend-neutral runtime gateway later
|
|
13
|
+
# without changing transport plugins.
|
|
14
|
+
class Gateway
|
|
15
|
+
POLL_INTERVAL = 0.05
|
|
16
|
+
TERMINAL_STATUSES = %w[completed canceled failed].freeze
|
|
17
|
+
|
|
18
|
+
def initialize(session_manager:, transport_id:, storage: nil, poll_interval: POLL_INTERVAL)
|
|
19
|
+
@session_manager = session_manager
|
|
20
|
+
@transport_id = transport_id.to_s
|
|
21
|
+
@storage = storage || Store.new(@transport_id)
|
|
22
|
+
@poll_interval = poll_interval
|
|
23
|
+
@subscriptions = []
|
|
24
|
+
@interaction_subscribers = []
|
|
25
|
+
@mutex = Mutex.new
|
|
26
|
+
@session_manager.subscribe_events { |method, payload| handle_runtime_event(method, payload) } if @session_manager.respond_to?(:subscribe_events)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def resolve_transport_session(transport_id:, conversation:, actor:, workspace_root: nil, name: nil, execution_profile: nil)
|
|
30
|
+
raise ArgumentError, "transport id does not match gateway" unless transport_id.to_s == @transport_id
|
|
31
|
+
if conversation.respond_to?(:transport_id) && conversation.transport_id.to_s != @transport_id
|
|
32
|
+
raise ArgumentError, "conversation transport does not match gateway"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
binding_key = binding_key_for(conversation)
|
|
36
|
+
binding = @storage.get(binding_key)
|
|
37
|
+
session = if binding
|
|
38
|
+
resume_bound_session(binding, execution_profile: execution_profile)
|
|
39
|
+
else
|
|
40
|
+
@session_manager.create_session(workspace_root: workspace_root || Dir.pwd, name: name, **execution_profile_arguments(execution_profile))
|
|
41
|
+
end
|
|
42
|
+
persist_binding(binding_key, session)
|
|
43
|
+
session_handle(session)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def start_transport_turn(session_id:, input:, attachments: [], options: {}, streaming_behavior: nil, execution_profile: nil)
|
|
47
|
+
payload = @session_manager.start_turn(
|
|
48
|
+
session_id: session_id,
|
|
49
|
+
input: input,
|
|
50
|
+
attachments: normalize_attachments(attachments, execution_profile: execution_profile),
|
|
51
|
+
options: profile_options(options, execution_profile),
|
|
52
|
+
streaming_behavior: streaming_behavior,
|
|
53
|
+
execution_profile: execution_profile
|
|
54
|
+
)
|
|
55
|
+
{ id: payload.fetch(:id), session_id: payload.fetch(:sessionId) }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def transport_transcript(session_id:)
|
|
59
|
+
@session_manager.transcript(session_id: session_id)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def transport_turn_events(turn_id:, after: nil)
|
|
63
|
+
payload = @session_manager.turn_events(turn_id: turn_id, after_sequence: after.to_i)
|
|
64
|
+
Array(payload[:events]).map { |event| normalize_event(event) }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def transport_turn_status(turn_id:)
|
|
68
|
+
@session_manager.turn_status(turn_id: turn_id)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def cancel_transport_turn(turn_id:)
|
|
72
|
+
@session_manager.cancel_turn(turn_id: turn_id)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def answer_transport_interaction(session_id:, request_id:, answer:)
|
|
76
|
+
if answer == true || answer == false
|
|
77
|
+
@session_manager.answer_tool_approval(
|
|
78
|
+
session_id: session_id,
|
|
79
|
+
approval_request_id: request_id,
|
|
80
|
+
approved: answer
|
|
81
|
+
)
|
|
82
|
+
else
|
|
83
|
+
answers = answer.is_a?(Array) ? answer : [{ question: request_id, answer: answer.to_s }]
|
|
84
|
+
@session_manager.answer_question(
|
|
85
|
+
session_id: session_id,
|
|
86
|
+
question_request_id: request_id,
|
|
87
|
+
answers: answers
|
|
88
|
+
)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def subscribe_transport_interactions(&block)
|
|
93
|
+
raise ArgumentError, "interaction subscription requires a block" unless block
|
|
94
|
+
|
|
95
|
+
@mutex.synchronize { @interaction_subscribers << block }
|
|
96
|
+
block
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def subscribe_transport_turn(turn_id:, after: nil)
|
|
100
|
+
cursor = after.to_i
|
|
101
|
+
thread = Thread.new do
|
|
102
|
+
loop do
|
|
103
|
+
events = transport_turn_events(turn_id: turn_id, after: cursor)
|
|
104
|
+
events.each do |event|
|
|
105
|
+
cursor = event.sequence
|
|
106
|
+
yield event
|
|
107
|
+
end
|
|
108
|
+
status = transport_turn_status(turn_id: turn_id)
|
|
109
|
+
break if TERMINAL_STATUSES.include?(status[:status].to_s)
|
|
110
|
+
|
|
111
|
+
sleep @poll_interval
|
|
112
|
+
end
|
|
113
|
+
rescue StandardError
|
|
114
|
+
nil
|
|
115
|
+
end
|
|
116
|
+
@mutex.synchronize { @subscriptions << thread }
|
|
117
|
+
thread
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def shutdown
|
|
121
|
+
subscriptions = @mutex.synchronize do
|
|
122
|
+
current = @subscriptions
|
|
123
|
+
@subscriptions = []
|
|
124
|
+
@interaction_subscribers = []
|
|
125
|
+
current
|
|
126
|
+
end
|
|
127
|
+
subscriptions.each(&:kill)
|
|
128
|
+
subscriptions.each(&:join)
|
|
129
|
+
nil
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
private
|
|
133
|
+
|
|
134
|
+
def resume_bound_session(binding, execution_profile: nil)
|
|
135
|
+
@session_manager.resume_session(
|
|
136
|
+
path: binding.fetch("path"),
|
|
137
|
+
workspace_root: binding.fetch("workspace_root"),
|
|
138
|
+
include_transcript: false,
|
|
139
|
+
**execution_profile_arguments(execution_profile)
|
|
140
|
+
)
|
|
141
|
+
rescue StandardError
|
|
142
|
+
@session_manager.create_session(workspace_root: binding.fetch("workspace_root"), **execution_profile_arguments(execution_profile))
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def execution_profile_arguments(profile)
|
|
146
|
+
profile ? { execution_profile: profile } : {}
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def persist_binding(key, session)
|
|
150
|
+
@storage.put(key, {
|
|
151
|
+
"path" => session.fetch(:path),
|
|
152
|
+
"workspace_root" => session.fetch(:workspaceRoot)
|
|
153
|
+
})
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def session_handle(session)
|
|
157
|
+
Host::SessionHandle.new(
|
|
158
|
+
id: session.fetch(:id),
|
|
159
|
+
workspace_root: session.fetch(:workspaceRoot),
|
|
160
|
+
name: session[:name]
|
|
161
|
+
)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def binding_key_for(conversation)
|
|
165
|
+
external_id = conversation.respond_to?(:external_id) ? conversation.external_id : conversation.to_s
|
|
166
|
+
digest = Digest::SHA256.hexdigest("#{@transport_id}\0#{external_id}")
|
|
167
|
+
"binding:#{digest}"
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def handle_runtime_event(method, payload)
|
|
171
|
+
request = case method
|
|
172
|
+
when "ui/question"
|
|
173
|
+
questions = Array(payload[:questions] || payload["questions"])
|
|
174
|
+
Transport.interaction_request(
|
|
175
|
+
id: payload[:questionRequestId] || payload["questionRequestId"],
|
|
176
|
+
session_id: payload[:sessionId] || payload["sessionId"],
|
|
177
|
+
turn_id: payload[:turnId] || payload["turnId"] || "unknown",
|
|
178
|
+
kind: :question,
|
|
179
|
+
prompt: questions.map { |question| question[:question] || question["question"] }.join("\\n"),
|
|
180
|
+
choices: questions
|
|
181
|
+
)
|
|
182
|
+
when "tool/approvalRequested"
|
|
183
|
+
Transport.interaction_request(
|
|
184
|
+
id: payload[:approvalRequestId] || payload["approvalRequestId"],
|
|
185
|
+
session_id: payload[:sessionId] || payload["sessionId"],
|
|
186
|
+
turn_id: payload[:turnId] || payload["turnId"] || "unknown",
|
|
187
|
+
kind: :tool_approval,
|
|
188
|
+
prompt: "Allow #{payload[:toolName] || payload["toolName"]}?",
|
|
189
|
+
choices: [{ id: "approve", label: "Approve" }, { id: "deny", label: "Deny" }],
|
|
190
|
+
metadata: { tool_call_id: payload[:toolCallId] || payload["toolCallId"], args: payload[:args] || payload["args"] }
|
|
191
|
+
)
|
|
192
|
+
end
|
|
193
|
+
return unless request
|
|
194
|
+
|
|
195
|
+
subscribers = @mutex.synchronize { @interaction_subscribers.dup }
|
|
196
|
+
subscribers.each do |subscriber|
|
|
197
|
+
begin
|
|
198
|
+
subscriber.call(request)
|
|
199
|
+
rescue StandardError
|
|
200
|
+
nil
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def profile_options(options, profile)
|
|
206
|
+
return options unless profile
|
|
207
|
+
|
|
208
|
+
options = options.dup
|
|
209
|
+
case profile.tool_mode
|
|
210
|
+
when :none
|
|
211
|
+
options.delete("disabledTools")
|
|
212
|
+
options.delete(:disabled_tools)
|
|
213
|
+
options["allowedTools"] = []
|
|
214
|
+
when :allowlist
|
|
215
|
+
options.delete("disabledTools")
|
|
216
|
+
options.delete(:disabled_tools)
|
|
217
|
+
options["allowedTools"] = profile.allowed_tools
|
|
218
|
+
end
|
|
219
|
+
options["approvalMode"] = "none" if profile.approval_mode == :deny
|
|
220
|
+
options["approvalMode"] = "ask" if profile.approval_mode == :ask
|
|
221
|
+
options
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def normalize_attachments(attachments, execution_profile: nil)
|
|
225
|
+
if execution_profile && !execution_profile.attachments && !Array(attachments).empty?
|
|
226
|
+
raise ArgumentError, "transport execution profile does not allow attachments"
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
Array(attachments).map do |attachment|
|
|
230
|
+
raise ArgumentError, "transport attachment must be a Transport::Attachment" unless attachment.is_a?(Attachment)
|
|
231
|
+
raise ArgumentError, "transport attachment URLs are not supported by the session gateway" if attachment.url
|
|
232
|
+
|
|
233
|
+
{
|
|
234
|
+
type: "image",
|
|
235
|
+
data: Base64.strict_encode64(attachment.data),
|
|
236
|
+
mimeType: attachment.mime_type,
|
|
237
|
+
name: attachment.name
|
|
238
|
+
}.compact
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def normalize_event(event)
|
|
243
|
+
Transport.turn_event(
|
|
244
|
+
type: event.fetch(:type),
|
|
245
|
+
session_id: event.fetch(:sessionId),
|
|
246
|
+
turn_id: event.fetch(:turnId),
|
|
247
|
+
sequence: event.fetch(:sequence),
|
|
248
|
+
payload: event.fetch(:payload, {})
|
|
249
|
+
)
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|