kward 0.66.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.
Files changed (93) hide show
  1. checksums.yaml +7 -0
  2. data/.yardopts +9 -0
  3. data/CHANGELOG.md +12 -0
  4. data/Gemfile +8 -0
  5. data/Gemfile.lock +90 -0
  6. data/LICENSE +21 -0
  7. data/README.md +101 -0
  8. data/Rakefile +20 -0
  9. data/doc/authentication.md +105 -0
  10. data/doc/code-search.md +56 -0
  11. data/doc/configuration.md +310 -0
  12. data/doc/extensibility.md +186 -0
  13. data/doc/getting-started.md +127 -0
  14. data/doc/memory.md +192 -0
  15. data/doc/plugins.md +223 -0
  16. data/doc/releasing.md +36 -0
  17. data/doc/rpc.md +635 -0
  18. data/doc/usage.md +179 -0
  19. data/doc/web-search.md +28 -0
  20. data/exe/kward +5 -0
  21. data/kward.gemspec +33 -0
  22. data/lib/kward/agent.rb +234 -0
  23. data/lib/kward/ansi.rb +276 -0
  24. data/lib/kward/auth/file.rb +11 -0
  25. data/lib/kward/auth/github_oauth.rb +222 -0
  26. data/lib/kward/auth/openai_oauth.rb +323 -0
  27. data/lib/kward/auth/openrouter_api_key.rb +40 -0
  28. data/lib/kward/cancellation.rb +54 -0
  29. data/lib/kward/cli.rb +2122 -0
  30. data/lib/kward/clipboard.rb +84 -0
  31. data/lib/kward/compactor.rb +998 -0
  32. data/lib/kward/config_files.rb +564 -0
  33. data/lib/kward/conversation.rb +148 -0
  34. data/lib/kward/events.rb +13 -0
  35. data/lib/kward/export_path.rb +28 -0
  36. data/lib/kward/image_attachments.rb +331 -0
  37. data/lib/kward/markdown_transcript.rb +72 -0
  38. data/lib/kward/memory/manager.rb +652 -0
  39. data/lib/kward/message_access.rb +42 -0
  40. data/lib/kward/model/chat_invocation.rb +23 -0
  41. data/lib/kward/model/client.rb +875 -0
  42. data/lib/kward/model/context_overflow.rb +55 -0
  43. data/lib/kward/model/context_usage.rb +104 -0
  44. data/lib/kward/model/model_info.rb +188 -0
  45. data/lib/kward/model/retry_message.rb +11 -0
  46. data/lib/kward/model/stream_parser.rb +205 -0
  47. data/lib/kward/pan/index.html.erb +143 -0
  48. data/lib/kward/pan/server.rb +397 -0
  49. data/lib/kward/plugin_registry.rb +327 -0
  50. data/lib/kward/private_file.rb +18 -0
  51. data/lib/kward/prompt_interface.rb +2437 -0
  52. data/lib/kward/prompts/commands.rb +50 -0
  53. data/lib/kward/prompts/templates.rb +60 -0
  54. data/lib/kward/prompts.rb +58 -0
  55. data/lib/kward/resources/avatar_kward_logo.rb +48 -0
  56. data/lib/kward/resources/pixel_logo.rb +230 -0
  57. data/lib/kward/rpc/auth_manager.rb +265 -0
  58. data/lib/kward/rpc/config_manager.rb +58 -0
  59. data/lib/kward/rpc/prompt_bridge.rb +104 -0
  60. data/lib/kward/rpc/redactor.rb +47 -0
  61. data/lib/kward/rpc/server.rb +639 -0
  62. data/lib/kward/rpc/session_manager.rb +1122 -0
  63. data/lib/kward/rpc/tool_event_normalizer.rb +68 -0
  64. data/lib/kward/rpc/tool_metadata.rb +80 -0
  65. data/lib/kward/rpc/transcript_normalizer.rb +307 -0
  66. data/lib/kward/rpc/transport.rb +58 -0
  67. data/lib/kward/session_diff.rb +125 -0
  68. data/lib/kward/session_store.rb +493 -0
  69. data/lib/kward/skills/registry.rb +76 -0
  70. data/lib/kward/starter_pack_installer.rb +110 -0
  71. data/lib/kward/steering.rb +56 -0
  72. data/lib/kward/telemetry/logger.rb +195 -0
  73. data/lib/kward/telemetry/stats.rb +466 -0
  74. data/lib/kward/tools/ask_user_question.rb +107 -0
  75. data/lib/kward/tools/base.rb +45 -0
  76. data/lib/kward/tools/code_search.rb +65 -0
  77. data/lib/kward/tools/edit_file.rb +41 -0
  78. data/lib/kward/tools/list_directory.rb +21 -0
  79. data/lib/kward/tools/read_file.rb +30 -0
  80. data/lib/kward/tools/read_skill.rb +27 -0
  81. data/lib/kward/tools/registry.rb +117 -0
  82. data/lib/kward/tools/run_shell_command.rb +28 -0
  83. data/lib/kward/tools/search/code.rb +445 -0
  84. data/lib/kward/tools/search/web.rb +747 -0
  85. data/lib/kward/tools/tool_call.rb +87 -0
  86. data/lib/kward/tools/web_search.rb +48 -0
  87. data/lib/kward/tools/write_file.rb +29 -0
  88. data/lib/kward/transcript_export.rb +40 -0
  89. data/lib/kward/version.rb +4 -0
  90. data/lib/kward/workspace.rb +377 -0
  91. data/lib/kward.rb +6 -0
  92. data/lib/main.rb +3 -0
  93. metadata +232 -0
data/doc/usage.md ADDED
@@ -0,0 +1,179 @@
1
+ # Usage
2
+
3
+ Kward's most common commands are:
4
+
5
+ ```bash
6
+ kward # interactive chat
7
+ kward "Explain this project" # one-shot prompt
8
+ kward --install-starter-pack # optional first-time setup
9
+ kward rpc # experimental JSON-RPC backend
10
+ ```
11
+
12
+ When running from source, replace `kward` with `ruby lib/main.rb`.
13
+
14
+ ## Starter pack
15
+
16
+ Install Kward's starter prompts and base `AGENTS.md` into your config directory, usually `~/.kward`:
17
+
18
+ ```bash
19
+ kward --install-starter-pack
20
+ ```
21
+
22
+ The installer downloads the pinned `kaiwood/kward-starter-pack` `v1.0.0` release, creates the config directory and base `config.json` if needed, and copies only starter-pack instruction/prompt files. It preserves the starter-pack layout in your config directory and skips files that already exist.
23
+
24
+ ## Interactive chat
25
+
26
+ Interactive mode opens a terminal composer and saves the conversation as a per-workspace session. Use it when you want Kward to inspect files, make changes, run commands, or continue a conversation over time.
27
+
28
+ The composer stays available while assistant and tool output streams. If you press Enter while a response is still running, Kward queues your next prompt and sends it after the current turn finishes. Press Ctrl+C while a response is running to stop the current turn and return to the composer.
29
+
30
+ ## One-shot prompts
31
+
32
+ Pass a prompt as command-line text to ask once and exit:
33
+
34
+ ```bash
35
+ kward "Summarize the changes in this repository"
36
+ ```
37
+
38
+ Kward also accepts piped input:
39
+
40
+ ```bash
41
+ git diff | kward "Review this diff"
42
+ ```
43
+
44
+ One-shot prompts do not use Kward memory.
45
+
46
+ ## Workspace tools
47
+
48
+ Kward can use these tools during a turn:
49
+
50
+ - `list_directory` and `read_file` to inspect the workspace.
51
+ - `write_file` and `edit_file` to create or change files.
52
+ - `run_shell_command` to run confirmed local commands.
53
+ - `web_search` to search the live web.
54
+ - `code_search` to find packages, clone public GitHub repositories into cache, and read bounded source snippets.
55
+ - `ask_user_question` to ask structured clarification questions.
56
+
57
+ Safety rules:
58
+
59
+ - Existing files must be read in the current conversation before Kward can write or edit them.
60
+ - Every write, edit, and shell command asks for confirmation first.
61
+ - Text file reads and edits are capped at 256 KiB per file.
62
+ - When successful tool results include unified diffs, the composer status shows live session totals such as `+700|-572`.
63
+
64
+ ## Slash commands
65
+
66
+ Use slash commands in interactive mode for local Kward actions:
67
+
68
+ | Command | Purpose |
69
+ | --- | --- |
70
+ | `/exit`, `/quit` | Leave the session. |
71
+ | `/new` | Start a fresh session. |
72
+ | `/resume [path]` | Resume a saved session, or pick one when no path is given. |
73
+ | `/name [name]` | Name or clear the current session name. |
74
+ | `/clone` | Duplicate the current session. |
75
+ | `/copy [last\|transcript]` | Copy clean assistant text or the Markdown transcript to the clipboard. |
76
+ | `/export [path]` | Export the transcript as Markdown. |
77
+ | `/compact [instructions]` | Summarize older conversation into a checkpoint and keep recent context. |
78
+ | `/model` | Choose or type the default model. |
79
+ | `/openrouter/catalog` | List the full OpenRouter model catalog. |
80
+ | `/reasoning` | Choose reasoning effort. |
81
+ | `/settings` | Configure overlay alignment and width. |
82
+ | `/login` | Log in from inside the session. |
83
+ | `/status` | Show status and auto-compaction information. |
84
+ | `/stats [range]` | Show local telemetry stats when logging is enabled. |
85
+ | `/memory ...` | Manage opt-in memory. |
86
+ | `/redraw` | Refresh the terminal after resize or drawing glitches. |
87
+
88
+ Prompt templates can add more slash commands. Plugin commands can also appear when trusted local plugins are installed.
89
+
90
+ ## Sessions
91
+
92
+ Interactive chats are stored as JSONL files under `~/.kward/sessions/`. Sessions are per workspace.
93
+
94
+ Useful session commands:
95
+
96
+ - `/resume` opens a picker for recent sessions.
97
+ - `/name <name>` gives the current session a human-readable name.
98
+ - `/clone` creates a new independent copy. Cloned sessions remember their parent and appear indented in the resume picker.
99
+ - `/copy` and `/copy last` copy the latest assistant response without terminal borders or ANSI styling. `/copy transcript` copies the clean Markdown transcript. Mouse selection may still include terminal UI chrome.
100
+ - `/export [path]` writes a Markdown transcript. Explicit paths are resolved relative to the current workspace and must stay inside the workspace or Kward session directory.
101
+ - `/compact [instructions]` summarizes older conversation into a structured Ruby-aware checkpoint. Text after `/compact ` is freeform focus text, not parsed as flags.
102
+
103
+ After compaction, Kward may need to re-read files before future edits because compacting clears remembered read-file state.
104
+
105
+ ## Auto-compaction
106
+
107
+ Auto-compaction is enabled by default when Kward can determine the active context window. It reserves part of the model context so conversations can continue longer without suddenly exceeding the limit.
108
+
109
+ Configure it in `config.json`:
110
+
111
+ ```json
112
+ {
113
+ "compaction": {
114
+ "enabled": true,
115
+ "reserve_tokens": 16384,
116
+ "keep_recent_tokens": 20000
117
+ }
118
+ }
119
+ ```
120
+
121
+ Manual `/compact` still works when auto-compaction is disabled.
122
+
123
+ ## Memory
124
+
125
+ Memory is disabled by default and only applies to interactive sessions.
126
+
127
+ Common commands:
128
+
129
+ ```text
130
+ /memory enable
131
+ /memory core <text>
132
+ /memory add <text>
133
+ /memory list
134
+ /memory why
135
+ /memory forget <id>
136
+ /memory disable
137
+ ```
138
+
139
+ See [Memory](memory.md) for storage locations, safety rules, auto-summary, and RPC methods.
140
+
141
+ ## Composer keys
142
+
143
+ - Enter sends.
144
+ - Shift+Enter inserts a newline.
145
+ - Up/Down browse prompt history.
146
+ - Ctrl+D exits from an empty prompt.
147
+ - Ctrl+C stops the current response while assistant or tool output is running.
148
+ - Backspace at the beginning of the prompt removes the most recent image attachment.
149
+
150
+ Multiline input grows the composer up to a capped height.
151
+
152
+ ## Image attachments
153
+
154
+ Kward can attach images when the active model supports images. It detects:
155
+
156
+ - pasted image file paths,
157
+ - Markdown image links,
158
+ - `file://` image URLs,
159
+ - image data URLs.
160
+
161
+ In the interactive composer, pasted image references appear as attachment badges instead of editable filename or base64 text. Transcripts show attachment badges and render the image inline when the terminal advertises a supported inline image protocol, such as iTerm2 or Kitty/WezTerm.
162
+
163
+ ## Pan mode
164
+
165
+ Pan mode starts a minimal LAN web UI with a prompt textarea and transcript:
166
+
167
+ ```bash
168
+ kward --pan-mode --working-directory="/path/to/workspace"
169
+ ```
170
+
171
+ From source:
172
+
173
+ ```bash
174
+ ruby lib/main.rb --pan-mode --working-directory="/path/to/workspace"
175
+ ```
176
+
177
+ Pan mode streams assistant output and tool calls, queues prompts submitted while a turn is running, and saves the conversation as a normal per-workspace session.
178
+
179
+ Configure `pan_mode.username` and `pan_mode.password` before starting it; see [Configuration](configuration.md). Pan mode is reachable on the LAN, so use it only on trusted networks.
data/doc/web-search.md ADDED
@@ -0,0 +1,28 @@
1
+ # Web search
2
+
3
+ Web search lets the agent search the live web. Use it when you need current facts, official docs, release notes, bug reports, pricing pages, or recent announcements.
4
+
5
+ Example prompts:
6
+
7
+ ```text
8
+ Research the current Rails release notes and summarize migration risks.
9
+ Find the official OpenRouter docs for model configuration.
10
+ Check whether this dependency has a recent security advisory.
11
+ ```
12
+
13
+ The `web_search` tool is advertised by default so the agent can use current sources when needed. In `auto` mode the provider fallback order is:
14
+
15
+ 1. Exa API when `EXA_API_KEY` is configured, otherwise keyless Exa MCP (`https://mcp.exa.ai/mcp`)
16
+ 2. Perplexity API when configured and `allow_model_providers` is true
17
+ 3. Gemini API with Google Search grounding when configured and `allow_model_providers` is true
18
+ 4. Legacy DuckDuckGo HTML search, then bundled public SearXNG instances
19
+
20
+ Queries are sent over the network to the selected provider. API keys are never bundled with Kward; configure your own keys only if you want higher limits or alternate providers. Set `web_search.enabled` to `false` to hide the tool. Direct `provider: perplexity` or `provider: gemini` requests still use those providers when keys are configured.
21
+
22
+ Supported arguments:
23
+
24
+ - `queries`: one to four search strings
25
+ - `max_results`: results per query, default 5, capped at 20
26
+ - `provider`: optional `auto`, `exa`, `perplexity`, `gemini`, `legacy`, or `duckduckgo`
27
+ - `recency_filter`: optional `day`, `week`, `month`, or `year`
28
+ - `domain_filter`: optional list of included domains, or excluded domains prefixed with `-`
data/exe/kward ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "kward"
4
+
5
+ Kward::CLI.new.run
data/kward.gemspec ADDED
@@ -0,0 +1,33 @@
1
+ require_relative "lib/kward/version"
2
+
3
+ Gem::Specification.new do |spec|
4
+ spec.name = "kward"
5
+ spec.version = Kward::VERSION
6
+ spec.authors = ["Kai Wood"]
7
+ spec.email = ["kai.wood@icloud.com"]
8
+
9
+ spec.summary = "An extendable Ruby CLI coding agent."
10
+ spec.description = "Kward is a Ruby CLI coding agent with local workspace tools, configurable prompts, web search, sessions, and an experimental JSON-RPC backend."
11
+ spec.homepage = "https://github.com/kaiwood/kward"
12
+ spec.license = "MIT"
13
+ spec.required_ruby_version = ">= 3.2"
14
+
15
+ spec.metadata["rubygems_mfa_required"] = "true"
16
+
17
+ spec.files = Dir.chdir(__dir__) do
18
+ `git ls-files -z`.split("\x0").reject do |file|
19
+ file.start_with?(".ruby-lsp/", "test/", "plan/") || [".gitignore", "AGENTS.md"].include?(file)
20
+ end
21
+ end
22
+ spec.bindir = "exe"
23
+ spec.executables = ["kward"]
24
+ spec.require_paths = ["lib"]
25
+
26
+ spec.add_dependency "base64"
27
+ spec.add_dependency "nokogiri"
28
+ spec.add_dependency "tiktoken_ruby"
29
+ spec.add_dependency "tty-cursor"
30
+ spec.add_dependency "tty-prompt"
31
+ spec.add_dependency "tty-reader"
32
+ spec.add_dependency "tty-screen"
33
+ end
@@ -0,0 +1,234 @@
1
+ require_relative "cancellation"
2
+ require_relative "model/chat_invocation"
3
+ require_relative "compactor"
4
+ require_relative "model/context_overflow"
5
+ require_relative "conversation"
6
+ require_relative "events"
7
+ require_relative "steering"
8
+ require_relative "telemetry/logger"
9
+ require_relative "tools/registry"
10
+
11
+ module Kward
12
+ # Runs model turns, handles context compaction, dispatches tool calls, and
13
+ # streams high-level events back to CLI and RPC callers.
14
+ class Agent
15
+ def initialize(client:, tool_registry: ToolRegistry.new, conversation: Conversation.new, telemetry_logger: TelemetryLogger.new)
16
+ @client = client
17
+ @tool_registry = tool_registry
18
+ @conversation = conversation
19
+ @telemetry_logger = telemetry_logger
20
+ end
21
+
22
+ attr_reader :conversation
23
+
24
+ # Adds a user message, compacts context when needed, and runs the turn.
25
+ #
26
+ # @param input [String] text sent to the model
27
+ # @param display_input [String, nil] alternate text kept for transcripts
28
+ # @yieldparam event [Object] streamed turn event for frontends
29
+ # @return [String] final assistant answer
30
+ def ask(input, display_input: nil, on_reasoning_delta: nil, on_retry: nil, cancellation: nil, steering: nil, &block)
31
+ started_at = @telemetry_logger.monotonic_now
32
+ status = "completed"
33
+ error = nil
34
+ cancellation&.raise_if_cancelled!
35
+ @conversation.refresh_system_message_if_workspace_agents_changed!
36
+ @conversation.append_user(input, display_content: display_input)
37
+ auto_compact_if_needed
38
+ run_turn(on_reasoning_delta: on_reasoning_delta, on_retry: on_retry, cancellation: cancellation, steering: steering, &block)
39
+ rescue StandardError => e
40
+ status = "failed"
41
+ error = e
42
+ raise e
43
+ ensure
44
+ log_turn(duration_ms: @telemetry_logger.duration_ms(started_at), status: status, error: error)
45
+ end
46
+
47
+ # Runs model calls until the assistant returns an answer without pending
48
+ # tool calls, including tool dispatch and one context-overflow retry.
49
+ #
50
+ # @yieldparam event [Object] streamed turn event for frontends
51
+ # @return [String] final assistant answer
52
+ def run_turn(on_reasoning_delta: nil, on_retry: nil, cancellation: nil, steering: nil)
53
+ overflow_retried = false
54
+ steering_state = build_steering_state(steering) do |event|
55
+ yield event if block_given?
56
+ end
57
+ loop do
58
+ cancellation&.raise_if_cancelled!
59
+ begin
60
+ message = chat(on_reasoning_delta: on_reasoning_delta, on_retry: on_retry, cancellation: cancellation, steering: steering) do |event|
61
+ yield event if block_given?
62
+ end
63
+ rescue StandardError => e
64
+ raise if cancellation&.cancelled?
65
+ raise unless !overflow_retried && ContextOverflow.error?(e) && compact_after_context_overflow(e)
66
+
67
+ overflow_retried = true
68
+ next
69
+ end
70
+ yield Events::AssistantMessage.new(message: message) if block_given?
71
+ @conversation.append_assistant(message)
72
+ steered_after_message = append_steering_events(steering_state)
73
+ yield Events::SteeringApplied.new(count: steered_after_message) if block_given? && steered_after_message.positive?
74
+
75
+ tool_calls = message["tool_calls"] || message[:tool_calls] || []
76
+ if tool_calls.empty?
77
+ next if steered_after_message.positive?
78
+
79
+ answer = safe_answer(message.fetch("content", message[:content] || ""))
80
+ yield Events::Answer.new(content: answer) if block_given?
81
+ return answer
82
+ end
83
+
84
+ tool_calls.each do |tool_call|
85
+ cancellation&.raise_if_cancelled!
86
+ yield Events::ToolCall.new(tool_call: tool_call) if block_given?
87
+ tool_started_at = @telemetry_logger.monotonic_now
88
+ content = nil
89
+ status = "completed"
90
+ error = nil
91
+ begin
92
+ content = @tool_registry.dispatch(tool_call, @conversation, cancellation: cancellation)
93
+ rescue StandardError => e
94
+ status = "failed"
95
+ error = e
96
+ raise e
97
+ ensure
98
+ log_tool(tool_call, content: content, duration_ms: @telemetry_logger.duration_ms(tool_started_at), status: status, error: error)
99
+ end
100
+ cancellation&.raise_if_cancelled!
101
+ yield Events::ToolResult.new(tool_call: tool_call, content: content) if block_given?
102
+ end
103
+ steered_after_tools = append_steering_events(steering_state)
104
+ yield Events::SteeringApplied.new(count: steered_after_tools) if block_given? && steered_after_tools.positive?
105
+ end
106
+ ensure
107
+ steering_state&.fetch(:unsubscribe)&.call
108
+ end
109
+
110
+ private
111
+
112
+ def build_steering_state(steering)
113
+ return nil unless steering
114
+
115
+ state = { events: [], appended: 0, mutex: Mutex.new, unsubscribe: nil }
116
+ state[:unsubscribe] = steering.on_submit do |steering_event|
117
+ state[:mutex].synchronize { state[:events] << steering_event }
118
+ yield Events::Steering.new(input: steering_event.input, created_at: steering_event.created_at)
119
+ end
120
+ state
121
+ end
122
+
123
+ def append_steering_events(state)
124
+ return 0 unless state
125
+
126
+ events = state[:mutex].synchronize do
127
+ state[:events][state[:appended]..] || []
128
+ end
129
+ events.each do |event|
130
+ @conversation.append_user(event.input)
131
+ end
132
+ state[:mutex].synchronize { state[:appended] += events.length }
133
+ events.length
134
+ end
135
+
136
+ def log_turn(duration_ms:, status:, error:)
137
+ payload = { "duration_ms" => duration_ms, "status" => status }
138
+ @telemetry_logger.log("performance", "turn", payload)
139
+ log_error("turn_error", error, payload) if error
140
+ end
141
+
142
+ def log_tool(tool_call, content:, duration_ms:, status:, error:)
143
+ payload = {
144
+ "tool_name" => tool_name(tool_call),
145
+ "duration_ms" => duration_ms,
146
+ "status" => status,
147
+ "result_bytes" => content.to_s.bytesize
148
+ }
149
+ @telemetry_logger.log("tools", "tool_call", payload)
150
+ @telemetry_logger.log("performance", "tool_call", payload)
151
+ log_error("tool_error", error, payload) if error
152
+ end
153
+
154
+ def log_error(event, error, payload = {})
155
+ return unless error
156
+
157
+ @telemetry_logger.log("errors", event, payload.merge(TelemetryLogger.error_payload(error)))
158
+ end
159
+
160
+ def tool_name(tool_call)
161
+ function = tool_call["function"] || tool_call[:function] || {}
162
+ function["name"] || function[:name]
163
+ end
164
+
165
+ def auto_compact_if_needed
166
+ context_window = @client.current_context_window if @client.respond_to?(:current_context_window)
167
+ Compactor.new(conversation: @conversation, client: @client).auto_compact_if_needed(context_window: context_window)
168
+ rescue StandardError => e
169
+ warn "Auto-compaction failed: #{e.message}"
170
+ nil
171
+ end
172
+
173
+ def compact_after_context_overflow(error)
174
+ settings = Compaction::Settings.from_config
175
+ return nil unless settings.enabled
176
+
177
+ Compactor.new(conversation: @conversation, client: @client, settings: settings).compact(
178
+ custom_instructions: "The previous model request exceeded the context window. Preserve the current task state and critical details needed to retry."
179
+ )
180
+ rescue Compaction::NothingToCompact, Compaction::AlreadyCompacted, StandardError => compaction_error
181
+ warn "Context overflow recovery failed: #{compaction_error.message}; original error: #{error.message}"
182
+ nil
183
+ end
184
+
185
+ def chat(on_reasoning_delta: nil, on_retry: nil, cancellation: nil, steering: nil)
186
+ reasoning_delta = lambda do |delta|
187
+ cancellation&.raise_if_cancelled!
188
+ on_reasoning_delta&.call(delta)
189
+ yield Events::ReasoningDelta.new(delta: delta) if block_given?
190
+ end
191
+ assistant_delta = lambda do |delta|
192
+ cancellation&.raise_if_cancelled!
193
+ yield Events::AssistantDelta.new(delta: delta) if block_given?
194
+ end
195
+ retry_callback = lambda do |retry_info|
196
+ cancellation&.raise_if_cancelled!
197
+ event = Events::Retry.new(**retry_info)
198
+ on_retry&.call(event)
199
+ yield event if block_given?
200
+ end
201
+ ChatInvocation.call(
202
+ @client,
203
+ @conversation.messages,
204
+ {
205
+ tools: @tool_registry.schemas,
206
+ on_reasoning_delta: reasoning_delta,
207
+ on_assistant_delta: assistant_delta,
208
+ on_retry: retry_callback,
209
+ cancellation: cancellation,
210
+ steering: steering
211
+ }
212
+ )
213
+ end
214
+
215
+ def safe_answer(content)
216
+ text = content.to_s
217
+ return text unless claims_file_edit?(text)
218
+ return text if last_file_change_succeeded?
219
+ return "The file change tool returned an error or declined result, so I did not successfully change the file." if @conversation.last_file_change_result
220
+
221
+ "I have not changed any files. I need to use write_file or edit_file successfully before claiming a file change."
222
+ end
223
+
224
+ def claims_file_edit?(text)
225
+ text.match?(/\b(I|I've|I have)\s+(changed|updated|modified|edited|created|deleted|wrote)\b/i)
226
+ end
227
+
228
+ def last_file_change_succeeded?
229
+ result = @conversation.last_file_change_result
230
+ content = result&.fetch(:content, nil) || result&.fetch("content", "")
231
+ content&.start_with?("Wrote ", "Edited ")
232
+ end
233
+ end
234
+ end