openclacky 1.5.11 → 1.5.13
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/CHANGELOG.md +82 -0
- data/lib/clacky/access_key.rb +59 -0
- data/lib/clacky/agent/cost_tracker.rb +2 -0
- data/lib/clacky/agent/message_compressor_helper.rb +53 -2
- data/lib/clacky/agent/session_serializer.rb +72 -19
- data/lib/clacky/agent/time_machine.rb +10 -1
- data/lib/clacky/agent.rb +107 -26
- data/lib/clacky/agent_config.rb +40 -0
- data/lib/clacky/agent_profile.rb +5 -5
- data/lib/clacky/billing/billing_record.rb +3 -0
- data/lib/clacky/billing/billing_store.rb +31 -5
- data/lib/clacky/billing/platform_billing.rb +231 -0
- data/lib/clacky/brand_config.rb +4 -3
- data/lib/clacky/cancel_flag.rb +23 -0
- data/lib/clacky/cli.rb +4 -2
- data/lib/clacky/client.rb +107 -0
- data/lib/clacky/default_extensions/advisor/advisors/general.md +23 -17
- data/lib/clacky/default_extensions/advisor/hooks/advisor.rb +104 -5
- data/lib/clacky/default_extensions/advisor/panels/advisor/view.js +92 -75
- data/lib/clacky/default_extensions/ext-studio/api/handler.rb +31 -0
- data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +74 -24
- data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +16 -37
- data/lib/clacky/default_extensions/git/ext.yml +1 -1
- data/lib/clacky/default_extensions/git/panels/git/view.js +483 -67
- data/lib/clacky/default_extensions/meeting/ext.yml +2 -1
- data/lib/clacky/default_extensions/time_machine/ext.yml +1 -1
- data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +186 -208
- data/lib/clacky/extension/packager.rb +4 -2
- data/lib/clacky/extension/scaffold/templates/full/panels/dashboard/view.js.erb +2 -3
- data/lib/clacky/extension/verifier.rb +14 -1
- data/lib/clacky/fanout.rb +49 -2
- data/lib/clacky/json_ui_controller.rb +3 -2
- data/lib/clacky/message_format/open_ai.rb +5 -1
- data/lib/clacky/message_format/open_ai_responses.rb +409 -0
- data/lib/clacky/message_history.rb +43 -1
- data/lib/clacky/openai_responses_stream_aggregator.rb +294 -0
- data/lib/clacky/plain_ui_controller.rb +15 -5
- data/lib/clacky/providers.rb +24 -7
- data/lib/clacky/rich_ui/rich_ui_controller.rb +2 -2
- data/lib/clacky/server/channel/channel_manager.rb +93 -18
- data/lib/clacky/server/channel/channel_ui_controller.rb +16 -3
- data/lib/clacky/server/dir_picker.rb +154 -0
- data/lib/clacky/server/git_panel.rb +61 -9
- data/lib/clacky/server/http_server.rb +366 -42
- data/lib/clacky/server/web_ui_controller.rb +7 -5
- data/lib/clacky/session_manager.rb +74 -17
- data/lib/clacky/tools/ask_user.rb +3 -2
- data/lib/clacky/tools/terminal.rb +30 -3
- data/lib/clacky/ui2/markdown_renderer.rb +15 -12
- data/lib/clacky/ui2/strings_cjk_patch.rb +132 -0
- data/lib/clacky/ui2/themes/hacker_theme.rb +1 -1
- data/lib/clacky/ui2/themes/minimal_theme.rb +1 -1
- data/lib/clacky/ui2/ui_controller.rb +2 -2
- data/lib/clacky/ui_interface.rb +2 -2
- data/lib/clacky/utils/model_pricing.rb +92 -4
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +915 -142
- data/lib/clacky/web/components/code-editor.js +33 -1
- data/lib/clacky/web/components/composer.js +315 -0
- data/lib/clacky/web/components/custom-select.js +136 -0
- data/lib/clacky/web/components/mentions.js +655 -0
- data/lib/clacky/web/components/model-picker.js +527 -0
- data/lib/clacky/web/components/onboard.js +29 -55
- data/lib/clacky/web/core/aside.js +3 -3
- data/lib/clacky/web/core/ext.js +38 -0
- data/lib/clacky/web/features/billing/store.js +2 -6
- data/lib/clacky/web/features/billing/view.js +32 -16
- data/lib/clacky/web/features/extensions/view.js +22 -10
- data/lib/clacky/web/features/new-session/store.js +15 -1
- data/lib/clacky/web/features/new-session/view.js +80 -34
- data/lib/clacky/web/features/trash/view.js +31 -19
- data/lib/clacky/web/features/workspace/store.js +1 -0
- data/lib/clacky/web/features/workspace/view.js +766 -80
- data/lib/clacky/web/i18n.js +107 -17
- data/lib/clacky/web/index.html +125 -45
- data/lib/clacky/web/sessions.js +704 -635
- data/lib/clacky/web/settings.js +164 -190
- data/lib/clacky/web/skills.js +11 -17
- data/lib/clacky/web/ws-dispatcher.js +11 -33
- data/lib/clacky.rb +4 -0
- metadata +12 -1
data/lib/clacky/fanout.rb
CHANGED
|
@@ -27,7 +27,14 @@ module Clacky
|
|
|
27
27
|
|
|
28
28
|
# @param jobs [Array<#call>] each job is invoked with no arguments
|
|
29
29
|
# @return [Array<Result>] one entry per job, aligned to the input order
|
|
30
|
-
|
|
30
|
+
#
|
|
31
|
+
# If the calling thread is interrupted (e.g. Thread#raise AgentInterrupted
|
|
32
|
+
# when a newer task supersedes this one) while waiting on the workers, the
|
|
33
|
+
# interrupt is forwarded to every live worker so their jobs — subagents that
|
|
34
|
+
# would otherwise keep calling the LLM on detached threads — unwind through
|
|
35
|
+
# their own ensure blocks (phase_end, transcript capture). The optional
|
|
36
|
+
# on_cancel callback fires first so callers can flip a shared cancel flag.
|
|
37
|
+
def run(jobs, on_cancel: nil)
|
|
31
38
|
return [] if jobs.empty?
|
|
32
39
|
|
|
33
40
|
pending = build_queue(jobs)
|
|
@@ -37,7 +44,20 @@ module Clacky
|
|
|
37
44
|
workers = Array.new([@max_concurrency, jobs.size].min) do
|
|
38
45
|
Clacky::ThreadRegistry.spawn(name: "fanout-worker") { drain(pending, results, deadline) }
|
|
39
46
|
end
|
|
40
|
-
|
|
47
|
+
|
|
48
|
+
begin
|
|
49
|
+
join_all(workers, deadline)
|
|
50
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
51
|
+
# Includes AgentInterrupted. The cleanup below must not itself be cut
|
|
52
|
+
# short by a second asynchronous Thread#raise, so defer any further
|
|
53
|
+
# interrupts on this thread until the workers have been unwound.
|
|
54
|
+
Thread.handle_interrupt(Object => :never) do
|
|
55
|
+
on_cancel&.call
|
|
56
|
+
drain_queue(pending)
|
|
57
|
+
interrupt_workers(workers)
|
|
58
|
+
end
|
|
59
|
+
raise e
|
|
60
|
+
end
|
|
41
61
|
|
|
42
62
|
fill_unfinished(results)
|
|
43
63
|
end
|
|
@@ -69,6 +89,33 @@ module Clacky
|
|
|
69
89
|
Result.new(index: index, value: nil, error: e, duration: monotonic_now - started)
|
|
70
90
|
end
|
|
71
91
|
|
|
92
|
+
private def drain_queue(pending)
|
|
93
|
+
loop { pending.pop(true) }
|
|
94
|
+
rescue ThreadError
|
|
95
|
+
nil
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Forward an interrupt to workers still running a job so they unwind through
|
|
99
|
+
# their own ensure blocks, then give them a bounded window to finish. A
|
|
100
|
+
# worker that ignores the raise is killed so the caller isn't held hostage.
|
|
101
|
+
INTERRUPT_GRACE_SECONDS = 5.0
|
|
102
|
+
|
|
103
|
+
private def interrupt_workers(workers)
|
|
104
|
+
live = workers.select(&:alive?)
|
|
105
|
+
live.each do |worker|
|
|
106
|
+
begin
|
|
107
|
+
worker.raise(Clacky::AgentInterrupted, "Fan-out batch cancelled")
|
|
108
|
+
rescue ThreadError
|
|
109
|
+
nil
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
deadline = monotonic_now + INTERRUPT_GRACE_SECONDS
|
|
113
|
+
live.each do |worker|
|
|
114
|
+
remaining = deadline - monotonic_now
|
|
115
|
+
worker.join(remaining.positive? ? remaining : 0) or worker.kill
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
72
119
|
private def join_all(workers, deadline)
|
|
73
120
|
workers.each do |worker|
|
|
74
121
|
if deadline
|
|
@@ -28,7 +28,7 @@ module Clacky
|
|
|
28
28
|
|
|
29
29
|
# === Output display ===
|
|
30
30
|
|
|
31
|
-
def show_assistant_message(content, files:, interim: false)
|
|
31
|
+
def show_assistant_message(content, files:, interim: false, created_at: nil)
|
|
32
32
|
return if (content.nil? || content.strip.empty?) && files.empty?
|
|
33
33
|
|
|
34
34
|
data = { content: content.to_s }
|
|
@@ -78,8 +78,9 @@ module Clacky
|
|
|
78
78
|
emit("token_usage", **token_data)
|
|
79
79
|
end
|
|
80
80
|
|
|
81
|
-
def show_complete(iterations:, cost:, duration: nil, cache_stats: nil, awaiting_user_feedback: false, cost_source: nil)
|
|
81
|
+
def show_complete(iterations:, cost:, duration: nil, cache_stats: nil, awaiting_user_feedback: false, cost_source: nil, task_id: nil)
|
|
82
82
|
data = { iterations: iterations, cost: cost }
|
|
83
|
+
data[:task_id] = task_id if task_id
|
|
83
84
|
data[:duration] = duration if duration
|
|
84
85
|
data[:cache_stats] = cache_stats if cache_stats
|
|
85
86
|
data[:awaiting_user_feedback] = awaiting_user_feedback if awaiting_user_feedback
|
|
@@ -215,7 +215,11 @@ module Clacky
|
|
|
215
215
|
#
|
|
216
216
|
# Zero side-effects: when reasoning_effort is nil/empty the body is
|
|
217
217
|
# unchanged, preserving the provider default for all models.
|
|
218
|
-
|
|
218
|
+
#
|
|
219
|
+
# Single source of truth for reasoning param mapping — shared with
|
|
220
|
+
# MessageFormat::OpenAIResponses (the Responses API accepts the same
|
|
221
|
+
# top-level fields).
|
|
222
|
+
def self.apply_reasoning_params(body, model, reasoning_effort)
|
|
219
223
|
effort_str = reasoning_effort.to_s
|
|
220
224
|
|
|
221
225
|
if model.to_s.match?(/\Aglm-5[-.]3/i)
|
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clacky
|
|
4
|
+
module MessageFormat
|
|
5
|
+
# Static helpers for the OpenAI Responses API (/v1/responses).
|
|
6
|
+
#
|
|
7
|
+
# The Responses API uses a different request/response shape than the
|
|
8
|
+
# classic Chat Completions API:
|
|
9
|
+
#
|
|
10
|
+
# - Request: `input` array of typed items (not `messages`)
|
|
11
|
+
# - Response: `output` array of typed items (not `choices[].message`)
|
|
12
|
+
# - Token limit field: `max_output_tokens` (not `max_tokens`)
|
|
13
|
+
# - Tool calls are standalone `function_call` output items
|
|
14
|
+
# - Tool results are `function_call_output` input items
|
|
15
|
+
# - Usage: `input_tokens` / `output_tokens` (not `prompt_tokens` / `completion_tokens`)
|
|
16
|
+
#
|
|
17
|
+
# This module converts between the canonical internal format (which is
|
|
18
|
+
# Chat Completions-shaped) and the Responses API shape.
|
|
19
|
+
module OpenAIResponses
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
# ── Request building ──────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
# Build a Responses API request body from canonical messages.
|
|
25
|
+
#
|
|
26
|
+
# @param messages [Array<Hash>] canonical messages (OpenAI chat format)
|
|
27
|
+
# @param model [String]
|
|
28
|
+
# @param tools [Array<Hash>] OpenAI-style tool definitions
|
|
29
|
+
# @param max_tokens [Integer]
|
|
30
|
+
# @param caching_enabled [Boolean] kept for signature compatibility; the
|
|
31
|
+
# Responses API has no content-level cache_control field, so Clacky's
|
|
32
|
+
# cache markers are intentionally not applied on this path
|
|
33
|
+
# @param vision_supported [Boolean] whether the target model accepts images
|
|
34
|
+
# @param reasoning_effort [String, nil] reasoning effort level
|
|
35
|
+
# @return [Hash] Responses API request body
|
|
36
|
+
def build_request_body(messages, model, tools, max_tokens, _caching_enabled, vision_supported: true, reasoning_effort: nil)
|
|
37
|
+
input_items = messages.flat_map { |msg| convert_message_to_input_items(msg, vision_supported: vision_supported) }
|
|
38
|
+
|
|
39
|
+
body = {
|
|
40
|
+
model: model,
|
|
41
|
+
input: input_items,
|
|
42
|
+
max_output_tokens: max_tokens
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if tools&.any?
|
|
46
|
+
# No cache_control markers here: the Responses API has no
|
|
47
|
+
# content-level cache_control field (that is Anthropic syntax).
|
|
48
|
+
# OpenAI's Responses prompt caching is automatic server-side;
|
|
49
|
+
# the request-level prompt_cache_key is a routing hint that does
|
|
50
|
+
# not map to Clacky's breakpoint convention. caching_enabled is
|
|
51
|
+
# kept in the signature for compatibility but intentionally
|
|
52
|
+
# unused on this path.
|
|
53
|
+
body[:tools] = convert_tools_to_responses_format(tools)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
OpenAI.apply_reasoning_params(body, model, reasoning_effort)
|
|
57
|
+
|
|
58
|
+
body
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# ── Canonical message -> Responses API input items ────────────────────────
|
|
62
|
+
|
|
63
|
+
# Convert a single canonical message into one or more Responses API
|
|
64
|
+
# input items.
|
|
65
|
+
#
|
|
66
|
+
# System messages become {type:"message", role:"developer"} items.
|
|
67
|
+
# Assistant messages with tool_calls are split into a message item
|
|
68
|
+
# plus separate function_call items.
|
|
69
|
+
# Tool result messages become function_call_output items.
|
|
70
|
+
#
|
|
71
|
+
# @param msg [Hash] canonical message
|
|
72
|
+
# @param vision_supported [Boolean]
|
|
73
|
+
# @return [Array<Hash>] Responses API input items
|
|
74
|
+
def convert_message_to_input_items(msg, vision_supported:)
|
|
75
|
+
role = msg[:role].to_s
|
|
76
|
+
|
|
77
|
+
# Tool result message -> function_call_output item
|
|
78
|
+
if role == "tool" && msg[:tool_call_id]
|
|
79
|
+
content = msg[:content]
|
|
80
|
+
content = JSON.generate(content) if content.is_a?(Array) || content.is_a?(Hash)
|
|
81
|
+
return [{
|
|
82
|
+
type: "function_call_output",
|
|
83
|
+
call_id: msg[:tool_call_id],
|
|
84
|
+
output: content.to_s
|
|
85
|
+
}]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
items = []
|
|
89
|
+
|
|
90
|
+
# Map system -> developer (Responses API convention; "system" also works
|
|
91
|
+
# but "developer" is the documented role).
|
|
92
|
+
api_role = role == "system" ? "developer" : role
|
|
93
|
+
|
|
94
|
+
# Build the message item (skip when assistant message has no text
|
|
95
|
+
# content but only tool_calls - the Responses API rejects null content)
|
|
96
|
+
content = msg[:content]
|
|
97
|
+
has_content = content && !(content.is_a?(String) && content.empty?) &&
|
|
98
|
+
!(content.is_a?(Array) && content.empty?)
|
|
99
|
+
|
|
100
|
+
if has_content || role != "assistant"
|
|
101
|
+
items << {
|
|
102
|
+
type: "message",
|
|
103
|
+
role: api_role,
|
|
104
|
+
content: normalize_content(content, vision_supported: vision_supported)
|
|
105
|
+
}
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# If the assistant message has tool_calls, emit separate function_call items
|
|
109
|
+
if role == "assistant" && msg[:tool_calls]
|
|
110
|
+
msg[:tool_calls].each do |tc|
|
|
111
|
+
func = tc[:function] || tc # Handle both nested and flat formats
|
|
112
|
+
items << {
|
|
113
|
+
type: "function_call",
|
|
114
|
+
call_id: tc[:id],
|
|
115
|
+
name: func[:name],
|
|
116
|
+
arguments: serialize_arguments(func[:arguments])
|
|
117
|
+
}
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
items
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Normalize canonical content to Responses API content format.
|
|
125
|
+
#
|
|
126
|
+
# String content passes through as-is.
|
|
127
|
+
# Array content: text blocks -> input_text, image_url -> input_image
|
|
128
|
+
# (non-vision models get a text placeholder for images).
|
|
129
|
+
#
|
|
130
|
+
# @param content [String, Array, nil]
|
|
131
|
+
# @param vision_supported [Boolean]
|
|
132
|
+
# @return [String, Array]
|
|
133
|
+
def normalize_content(content, vision_supported:)
|
|
134
|
+
return content.to_s unless content.is_a?(Array)
|
|
135
|
+
|
|
136
|
+
blocks = content.map { |b| normalize_block(b, vision_supported: vision_supported) }.compact
|
|
137
|
+
blocks = [{ type: "input_text", text: "..." }] if blocks.empty?
|
|
138
|
+
blocks
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Normalize a single content block for the Responses API input side.
|
|
142
|
+
#
|
|
143
|
+
# @param block [Hash] canonical content block
|
|
144
|
+
# @param vision_supported [Boolean]
|
|
145
|
+
# @return [Hash, nil]
|
|
146
|
+
def normalize_block(block, vision_supported:)
|
|
147
|
+
return block unless block.is_a?(Hash)
|
|
148
|
+
|
|
149
|
+
case block[:type]
|
|
150
|
+
when "text"
|
|
151
|
+
text = block[:text]
|
|
152
|
+
return nil if text.nil? || text.empty?
|
|
153
|
+
|
|
154
|
+
# Any cache_control marker (Anthropic syntax) is dropped here:
|
|
155
|
+
# the Responses API does not recognize content-level cache_control.
|
|
156
|
+
{ type: "input_text", text: text }
|
|
157
|
+
when "image_url"
|
|
158
|
+
if vision_supported
|
|
159
|
+
# Responses API uses input_image with image_url sub-field
|
|
160
|
+
{ type: "input_image", image_url: block[:image_url] }
|
|
161
|
+
else
|
|
162
|
+
{ type: "input_text", text: "[Image content removed - current model does not support vision input]" }
|
|
163
|
+
end
|
|
164
|
+
else
|
|
165
|
+
block
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# ── Response parsing ──────────────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
# Parse a Responses API response into canonical internal format.
|
|
172
|
+
#
|
|
173
|
+
# @param data [Hash] parsed JSON response body
|
|
174
|
+
# @return [Hash] canonical response: { content, tool_calls, finish_reason, usage, raw_api_usage }
|
|
175
|
+
def parse_response(data)
|
|
176
|
+
output = data["output"] || []
|
|
177
|
+
|
|
178
|
+
# Extract text content from message-type output items
|
|
179
|
+
text_content = extract_output_text(output)
|
|
180
|
+
|
|
181
|
+
# Extract function calls from function_call-type output items
|
|
182
|
+
tool_calls = extract_function_calls(output)
|
|
183
|
+
|
|
184
|
+
# Parse usage (field names differ from Chat Completions)
|
|
185
|
+
usage = data["usage"] || {}
|
|
186
|
+
raw_api_usage = usage.dup
|
|
187
|
+
|
|
188
|
+
usage_data = {
|
|
189
|
+
prompt_tokens: usage["input_tokens"],
|
|
190
|
+
completion_tokens: usage["output_tokens"],
|
|
191
|
+
total_tokens: usage["total_tokens"] || (usage["input_tokens"].to_i + usage["output_tokens"].to_i)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
# Responses API stores cache info under input_tokens_details
|
|
195
|
+
if (details = usage["input_tokens_details"])
|
|
196
|
+
usage_data[:cache_read_input_tokens] = details["cached_tokens"] if details["cached_tokens"].to_i > 0
|
|
197
|
+
usage_data[:cache_creation_input_tokens] = details["cache_write_tokens"] if details["cache_write_tokens"].to_i > 0
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# OpenRouter may also send output_tokens_details
|
|
201
|
+
if (out_details = usage["output_tokens_details"])
|
|
202
|
+
usage_data[:reasoning_tokens] = out_details["reasoning_tokens"] if out_details["reasoning_tokens"].to_i > 0
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
usage_data[:api_cost] = usage["cost"] if usage["cost"]
|
|
206
|
+
|
|
207
|
+
# Determine finish_reason from status + output content
|
|
208
|
+
finish_reason = determine_finish_reason(data, tool_calls)
|
|
209
|
+
|
|
210
|
+
result = {
|
|
211
|
+
content: text_content,
|
|
212
|
+
tool_calls: tool_calls.empty? ? nil : tool_calls,
|
|
213
|
+
finish_reason: finish_reason,
|
|
214
|
+
usage: usage_data,
|
|
215
|
+
raw_api_usage: raw_api_usage
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
# Preserve reasoning content if present in the output
|
|
219
|
+
reasoning = extract_reasoning(output)
|
|
220
|
+
result[:reasoning_content] = reasoning if reasoning
|
|
221
|
+
|
|
222
|
+
result
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# ── Tool result formatting ────────────────────────────────────────────────
|
|
226
|
+
|
|
227
|
+
# Format tool results into canonical messages to append to @messages.
|
|
228
|
+
# Returns canonical format (role: "tool") - conversion to Responses API
|
|
229
|
+
# function_call_output items happens inside convert_message_to_input_items
|
|
230
|
+
# on the next request.
|
|
231
|
+
#
|
|
232
|
+
# @param response [Hash] canonical response from parse_response
|
|
233
|
+
# @param tool_results [Array<Hash>] tool execution results
|
|
234
|
+
# @return [Array<Hash>] canonical tool messages
|
|
235
|
+
def format_tool_results(response, tool_results)
|
|
236
|
+
results_map = tool_results.each_with_object({}) { |r, h| h[r[:id]] = r }
|
|
237
|
+
|
|
238
|
+
response[:tool_calls].map do |tc|
|
|
239
|
+
result = results_map[tc[:id]]
|
|
240
|
+
raw_content = result ? result[:content] : { error: "Tool result missing" }.to_json
|
|
241
|
+
|
|
242
|
+
content = raw_content.is_a?(Array) ? JSON.generate(raw_content) : raw_content
|
|
243
|
+
|
|
244
|
+
{
|
|
245
|
+
role: "tool",
|
|
246
|
+
tool_call_id: tc[:id],
|
|
247
|
+
content: content
|
|
248
|
+
}
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# ── Private helpers ───────────────────────────────────────────────────────
|
|
253
|
+
|
|
254
|
+
# The Responses API expects function_call arguments as a JSON *string*.
|
|
255
|
+
# Canonical tool_calls normally carry a JSON string already, but a Hash
|
|
256
|
+
# or Array value must be JSON-encoded — #to_s would emit a Ruby literal
|
|
257
|
+
# (e.g. {:city=>"Tokyo"}) that the tool side cannot parse.
|
|
258
|
+
private_class_method def self.serialize_arguments(arguments)
|
|
259
|
+
return JSON.generate(arguments) if arguments.is_a?(Hash) || arguments.is_a?(Array)
|
|
260
|
+
|
|
261
|
+
arguments.to_s
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# Extract text content from output items.
|
|
265
|
+
# Looks for message-type items with output_text content blocks.
|
|
266
|
+
#
|
|
267
|
+
# @param output [Array<Hash>] response output items
|
|
268
|
+
# @return [String, nil]
|
|
269
|
+
private_class_method def self.extract_output_text(output)
|
|
270
|
+
texts = []
|
|
271
|
+
|
|
272
|
+
output.each do |item|
|
|
273
|
+
next unless item["type"] == "message"
|
|
274
|
+
|
|
275
|
+
content = item["content"]
|
|
276
|
+
next unless content.is_a?(Array)
|
|
277
|
+
|
|
278
|
+
content.each do |block|
|
|
279
|
+
next unless block["type"] == "output_text"
|
|
280
|
+
texts << block["text"].to_s
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
texts.empty? ? nil : texts.join
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# Extract reasoning content from output items.
|
|
288
|
+
# Supports both shapes seen in the wild:
|
|
289
|
+
# 1. OpenAI official — a "reasoning" block inside a message item,
|
|
290
|
+
# with summary[] and/or content[] of reasoning_text blocks.
|
|
291
|
+
# 2. DeepSeek — a top-level "reasoning" item whose content array
|
|
292
|
+
# carries {"type":"reasoning_text","text":...} blocks.
|
|
293
|
+
# Prefers full reasoning text (reasoning_text blocks); falls back to
|
|
294
|
+
# summary text when only a summary is present.
|
|
295
|
+
#
|
|
296
|
+
# @param output [Array<Hash>] response output items
|
|
297
|
+
# @return [String, nil]
|
|
298
|
+
private_class_method def self.extract_reasoning(output)
|
|
299
|
+
texts = []
|
|
300
|
+
|
|
301
|
+
output.each do |item|
|
|
302
|
+
content = item["content"]
|
|
303
|
+
|
|
304
|
+
case item["type"]
|
|
305
|
+
when "reasoning"
|
|
306
|
+
next unless content.is_a?(Array)
|
|
307
|
+
before = texts.length
|
|
308
|
+
collect_reasoning_texts(content, texts)
|
|
309
|
+
collect_summary_texts(content, texts) if texts.length == before
|
|
310
|
+
when "message"
|
|
311
|
+
next unless content.is_a?(Array)
|
|
312
|
+
content.each do |block|
|
|
313
|
+
case block["type"]
|
|
314
|
+
when "reasoning"
|
|
315
|
+
before = texts.length
|
|
316
|
+
collect_reasoning_texts(block["content"], texts)
|
|
317
|
+
collect_summary_texts(block["summary"], texts) if texts.length == before
|
|
318
|
+
when "output_text"
|
|
319
|
+
# Some providers surface reasoning as output_text blocks
|
|
320
|
+
# carrying a summary attribute.
|
|
321
|
+
collect_summary_texts(block["summary"], texts)
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
texts.empty? ? nil : texts.join
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
private_class_method def self.collect_reasoning_texts(blocks, texts)
|
|
331
|
+
return unless blocks.is_a?(Array)
|
|
332
|
+
blocks.each do |b|
|
|
333
|
+
texts << b["text"].to_s if b.is_a?(Hash) && b["type"] == "reasoning_text" && b["text"]
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
private_class_method def self.collect_summary_texts(summary, texts)
|
|
338
|
+
return unless summary.is_a?(Array)
|
|
339
|
+
summary.each do |b|
|
|
340
|
+
texts << b["text"].to_s if b.is_a?(Hash) && b["type"] == "summary_text" && b["text"]
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# Extract function calls from output items.
|
|
345
|
+
# Converts Responses API function_call items to canonical tool_calls format.
|
|
346
|
+
#
|
|
347
|
+
# @param output [Array<Hash>] response output items
|
|
348
|
+
# @return [Array<Hash>] canonical tool_calls
|
|
349
|
+
private_class_method def self.extract_function_calls(output)
|
|
350
|
+
# each_with_object instead of filter_map to stay Ruby 2.6 compatible.
|
|
351
|
+
output.each_with_object([]) do |item, calls|
|
|
352
|
+
next unless item["type"] == "function_call"
|
|
353
|
+
|
|
354
|
+
calls << {
|
|
355
|
+
id: item["call_id"],
|
|
356
|
+
type: "function",
|
|
357
|
+
name: item["name"],
|
|
358
|
+
arguments: item["arguments"].to_s
|
|
359
|
+
}
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
# Determine the canonical finish_reason from Responses API status + output.
|
|
364
|
+
#
|
|
365
|
+
# @param data [Hash] full response body
|
|
366
|
+
# @param tool_calls [Array<Hash>] extracted tool calls
|
|
367
|
+
# @return [String]
|
|
368
|
+
private_class_method def self.determine_finish_reason(data, tool_calls)
|
|
369
|
+
# If there are tool calls, the model wants to call tools
|
|
370
|
+
return "tool_calls" unless tool_calls.empty?
|
|
371
|
+
|
|
372
|
+
status = data["status"]
|
|
373
|
+
case status
|
|
374
|
+
when "completed" then "stop"
|
|
375
|
+
when "incomplete" then incomplete_finish_reason(data)
|
|
376
|
+
else "stop"
|
|
377
|
+
end
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
# Map an "incomplete" response to a canonical finish_reason via
|
|
381
|
+
# incomplete_details.reason: "content_filter" means the response was
|
|
382
|
+
# blocked by safety filtering (typically empty) and must not be
|
|
383
|
+
# mislabelled as a mere token limit ("length").
|
|
384
|
+
#
|
|
385
|
+
# @param data [Hash] full response body
|
|
386
|
+
# @return [String]
|
|
387
|
+
private_class_method def self.incomplete_finish_reason(data)
|
|
388
|
+
reason = data.dig("incomplete_details", "reason")
|
|
389
|
+
reason == "content_filter" ? "content_filter" : "length"
|
|
390
|
+
end
|
|
391
|
+
# Convert Chat Completions tool definitions to Responses API format.
|
|
392
|
+
# Chat Completions: {type: "function", function: {name:, description:, parameters:}}
|
|
393
|
+
# Responses API: {type: "function", name:, description:, parameters:}
|
|
394
|
+
# Tools already in flat format (no "function" key) pass through unchanged.
|
|
395
|
+
private_class_method def self.convert_tools_to_responses_format(tools)
|
|
396
|
+
tools.map do |tool|
|
|
397
|
+
func = tool[:function] || tool["function"]
|
|
398
|
+
next tool unless func.is_a?(Hash)
|
|
399
|
+
{
|
|
400
|
+
type: tool[:type] || tool["type"] || "function",
|
|
401
|
+
name: func[:name] || func["name"],
|
|
402
|
+
description: func[:description] || func["description"],
|
|
403
|
+
parameters: func[:parameters] || func["parameters"]
|
|
404
|
+
}
|
|
405
|
+
end
|
|
406
|
+
end
|
|
407
|
+
end
|
|
408
|
+
end
|
|
409
|
+
end
|
|
@@ -15,8 +15,11 @@ module Clacky
|
|
|
15
15
|
subagent_instructions subagent_result subagent_transcript token_usage
|
|
16
16
|
compressed_summary chunk_path truncated transient
|
|
17
17
|
chunk_index chunk_count ext_events skill_command skill_command_display
|
|
18
|
+
display_files display_references
|
|
18
19
|
].freeze
|
|
19
20
|
|
|
21
|
+
INTERNAL_CONTENT_BLOCK_FIELDS = %i[image_path image_name].freeze
|
|
22
|
+
|
|
20
23
|
# Cap on persisted ext_events per message. These are milestone events
|
|
21
24
|
# (progress chatter is transient), so a handful per message is the norm —
|
|
22
25
|
# the cap only exists to stop a runaway extension. Oldest are dropped first.
|
|
@@ -96,6 +99,43 @@ module Clacky
|
|
|
96
99
|
self
|
|
97
100
|
end
|
|
98
101
|
|
|
102
|
+
# Settle an interrupted fan-out whose assistant.tool_calls turn is dangling
|
|
103
|
+
# because the batch was cancelled before any tool result was written. Mirrors
|
|
104
|
+
# repair_tool_call_pairing (same helpers, same scan) but PERSISTS the result:
|
|
105
|
+
# for each unanswered tool_call_id it appends a real tool result into
|
|
106
|
+
# @messages marking it interrupted, riding any captured subagent trails on
|
|
107
|
+
# the matching one — the same anchor the normal completion path uses. This
|
|
108
|
+
# keeps the turn protocol-valid so drop_dangling_tool_calls! leaves it alone,
|
|
109
|
+
# and lets replay render the transcripts. transcripts_by_id maps
|
|
110
|
+
# tool_call_id => [trail, ...]. No-op unless the last message is a dangling
|
|
111
|
+
# assistant.tool_calls turn.
|
|
112
|
+
def settle_interrupted_tool_calls(transcripts_by_id = {})
|
|
113
|
+
return self unless pending_tool_calls?
|
|
114
|
+
|
|
115
|
+
assistant = @messages.last
|
|
116
|
+
expected_ids = Array(assistant[:tool_calls]).map { |tc| tc[:id] }.compact
|
|
117
|
+
answered = []
|
|
118
|
+
@messages.reverse_each do |m|
|
|
119
|
+
break if m.equal?(assistant)
|
|
120
|
+
|
|
121
|
+
answered.concat(tool_result_ids(m)) if tool_result_message?(m)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
(expected_ids - answered).each do |id|
|
|
125
|
+
result = {
|
|
126
|
+
role: "tool",
|
|
127
|
+
tool_call_id: id,
|
|
128
|
+
content: '{"interrupted":true,"message":"Interrupted by user before completion"}',
|
|
129
|
+
task_id: assistant[:task_id],
|
|
130
|
+
created_at: Time.now.to_f
|
|
131
|
+
}
|
|
132
|
+
trails = transcripts_by_id[id]
|
|
133
|
+
result[:subagent_transcript] = Array(trails) if trails && !trails.empty?
|
|
134
|
+
@messages << deep_sanitize_utf8(result)
|
|
135
|
+
end
|
|
136
|
+
self
|
|
137
|
+
end
|
|
138
|
+
|
|
99
139
|
# Append a custom extension event onto the most recent message so it is
|
|
100
140
|
# persisted with the session and can be replayed after a reload.
|
|
101
141
|
# Events are anchored to the last message: on replay they are re-emitted
|
|
@@ -320,7 +360,9 @@ module Clacky
|
|
|
320
360
|
next nil
|
|
321
361
|
end
|
|
322
362
|
|
|
323
|
-
block
|
|
363
|
+
next block if (block.keys & INTERNAL_CONTENT_BLOCK_FIELDS).empty?
|
|
364
|
+
|
|
365
|
+
block.reject { |key, _| INTERNAL_CONTENT_BLOCK_FIELDS.include?(key) }
|
|
324
366
|
end
|
|
325
367
|
|
|
326
368
|
return msg if cleaned == content
|