anima-core 1.4.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.reek.yml +18 -20
- data/README.md +61 -95
- data/agents/thoughts-analyzer.md +12 -7
- data/anima-core.gemspec +1 -0
- data/app/channels/session_channel.rb +38 -58
- data/app/decorators/agent_message_decorator.rb +7 -2
- data/app/decorators/message_decorator.rb +31 -100
- data/app/decorators/pending_from_melete_decorator.rb +36 -0
- data/app/decorators/pending_from_melete_goal_decorator.rb +13 -0
- data/app/decorators/pending_from_melete_skill_decorator.rb +19 -0
- data/app/decorators/pending_from_melete_workflow_decorator.rb +13 -0
- data/app/decorators/pending_from_mneme_decorator.rb +44 -0
- data/app/decorators/pending_message_decorator.rb +94 -0
- data/app/decorators/pending_subagent_decorator.rb +46 -0
- data/app/decorators/pending_tool_response_decorator.rb +51 -0
- data/app/decorators/pending_user_message_decorator.rb +22 -0
- data/app/decorators/system_message_decorator.rb +5 -0
- data/app/decorators/tool_call_decorator.rb +13 -2
- data/app/decorators/tool_response_decorator.rb +2 -2
- data/app/decorators/user_message_decorator.rb +7 -2
- data/app/jobs/count_tokens_job.rb +23 -0
- data/app/jobs/drain_job.rb +169 -0
- data/app/jobs/melete_enrichment_job/goal_change_listener.rb +52 -0
- data/app/jobs/melete_enrichment_job.rb +48 -0
- data/app/jobs/mneme_enrichment_job.rb +46 -0
- data/app/jobs/tool_execution_job.rb +87 -0
- data/app/models/concerns/token_estimation.rb +54 -0
- data/app/models/goal.rb +21 -10
- data/app/models/message.rb +47 -36
- data/app/models/pending_message.rb +276 -29
- data/app/models/pinned_message.rb +8 -3
- data/app/models/session.rb +468 -432
- data/app/models/snapshot.rb +11 -21
- data/bin/inspect-cassette +17 -4
- data/config/application.rb +1 -0
- data/config/initializers/event_subscribers.rb +71 -4
- data/config/initializers/inflections.rb +3 -1
- data/db/cable_structure.sql +3 -3
- data/db/migrate/20260407170803_remove_viewport_message_ids_from_sessions.rb +5 -0
- data/db/migrate/20260407180400_remove_mneme_snapshot_pointer_columns_from_sessions.rb +6 -0
- data/db/migrate/20260411120553_add_token_count_to_pinned_messages.rb +5 -0
- data/db/migrate/20260411172926_remove_active_skills_and_workflow_from_sessions.rb +6 -0
- data/db/migrate/20260412110625_replace_processing_with_aasm_state.rb +6 -0
- data/db/migrate/20260418150323_add_kind_and_message_type_to_pending_messages.rb +6 -0
- data/db/migrate/20260419120000_add_drain_fields_to_pending_messages.rb +7 -0
- data/db/migrate/20260419130000_drop_pending_messages_kind_default.rb +5 -0
- data/db/migrate/20260419140000_add_drain_indexes_to_pending_messages.rb +8 -0
- data/db/migrate/20260420100000_add_hud_visibility_to_sessions.rb +15 -0
- data/db/queue_structure.sql +13 -13
- data/db/structure.sql +44 -31
- data/lib/agents/registry.rb +1 -1
- data/lib/anima/settings.rb +7 -33
- data/lib/anima/version.rb +1 -1
- data/lib/events/authentication_required.rb +24 -0
- data/lib/events/bounce_back.rb +4 -4
- data/lib/events/eviction_completed.rb +28 -0
- data/lib/events/goal_created.rb +28 -0
- data/lib/events/goal_updated.rb +32 -0
- data/lib/events/llm_responded.rb +35 -0
- data/lib/events/message_created.rb +27 -0
- data/lib/events/message_updated.rb +25 -0
- data/lib/events/session_state_changed.rb +30 -0
- data/lib/events/skill_activated.rb +28 -0
- data/lib/events/start_melete.rb +36 -0
- data/lib/events/start_mneme.rb +33 -0
- data/lib/events/start_processing.rb +32 -0
- data/lib/events/subagent_evicted.rb +31 -0
- data/lib/events/subscribers/active_state_broadcaster.rb +27 -0
- data/lib/events/subscribers/authentication_broadcaster.rb +34 -0
- data/lib/events/subscribers/drain_kickoff.rb +20 -0
- data/lib/events/subscribers/eviction_broadcaster.rb +26 -0
- data/lib/events/subscribers/llm_response_handler.rb +111 -0
- data/lib/events/subscribers/melete_kickoff.rb +24 -0
- data/lib/events/subscribers/message_broadcaster.rb +34 -0
- data/lib/events/subscribers/mneme_kickoff.rb +24 -0
- data/lib/events/subscribers/mneme_scheduler.rb +21 -0
- data/lib/events/subscribers/persister.rb +6 -8
- data/lib/events/subscribers/session_state_broadcaster.rb +33 -0
- data/lib/events/subscribers/subagent_message_router.rb +26 -29
- data/lib/events/subscribers/subagent_visibility_broadcaster.rb +33 -0
- data/lib/events/subscribers/tool_response_creator.rb +33 -0
- data/lib/events/subscribers/transient_broadcaster.rb +1 -1
- data/lib/events/tool_executed.rb +34 -0
- data/lib/events/workflow_activated.rb +27 -0
- data/lib/llm/client.rb +41 -201
- data/lib/mcp/client_manager.rb +41 -46
- data/lib/mcp/stdio_transport.rb +9 -5
- data/lib/{analytical_brain → melete}/runner.rb +63 -68
- data/lib/{analytical_brain → melete}/tools/activate_skill.rb +1 -1
- data/lib/{analytical_brain → melete}/tools/assign_nickname.rb +2 -2
- data/lib/{analytical_brain → melete}/tools/everything_is_ready.rb +2 -2
- data/lib/{analytical_brain → melete}/tools/finish_goal.rb +3 -3
- data/lib/{analytical_brain → melete}/tools/goal_messaging.rb +4 -3
- data/lib/{analytical_brain → melete}/tools/read_workflow.rb +2 -2
- data/lib/{analytical_brain → melete}/tools/rename_session.rb +3 -3
- data/lib/{analytical_brain → melete}/tools/set_goal.rb +1 -1
- data/lib/{analytical_brain → melete}/tools/update_goal.rb +4 -4
- data/lib/{analytical_brain.rb → melete.rb} +6 -3
- data/lib/mneme/base_runner.rb +121 -0
- data/lib/mneme/l2_runner.rb +14 -20
- data/lib/mneme/recall_runner.rb +132 -0
- data/lib/mneme/runner.rb +118 -171
- data/lib/mneme/search.rb +104 -62
- data/lib/mneme/tools/nothing_to_surface.rb +25 -0
- data/lib/mneme/tools/save_snapshot.rb +2 -10
- data/lib/mneme/tools/surface_memory.rb +89 -0
- data/lib/mneme.rb +11 -5
- data/lib/shell_session.rb +287 -612
- data/lib/skills/definition.rb +2 -2
- data/lib/skills/registry.rb +1 -1
- data/lib/tools/base.rb +16 -0
- data/lib/tools/bash.rb +25 -57
- data/lib/tools/edit.rb +2 -0
- data/lib/tools/read.rb +2 -0
- data/lib/tools/registry.rb +79 -3
- data/lib/tools/{recall.rb → search_messages.rb} +19 -21
- data/lib/tools/spawn_specialist.rb +16 -10
- data/lib/tools/spawn_subagent.rb +20 -14
- data/lib/tools/subagent_prompts.rb +4 -4
- data/lib/tools/think.rb +1 -1
- data/lib/tools/{remember.rb → view_messages.rb} +10 -10
- data/lib/tools/write.rb +2 -0
- data/lib/tui/app.rb +5 -4
- data/lib/tui/braille_spinner.rb +7 -7
- data/lib/tui/decorators/base_decorator.rb +24 -3
- data/lib/tui/message_store.rb +93 -44
- data/lib/tui/screens/chat.rb +94 -20
- data/lib/tui/settings.rb +9 -2
- data/lib/workflows/definition.rb +3 -3
- data/lib/workflows/registry.rb +1 -1
- data/skills/github.md +38 -0
- data/templates/config.toml +4 -23
- data/workflows/review_pr.md +18 -14
- metadata +86 -28
- data/app/jobs/agent_request_job.rb +0 -199
- data/app/jobs/analytical_brain_job.rb +0 -33
- data/app/jobs/count_message_tokens_job.rb +0 -39
- data/app/jobs/passive_recall_job.rb +0 -24
- data/app/models/concerns/message/broadcasting.rb +0 -86
- data/lib/agent_loop.rb +0 -215
- data/lib/analytical_brain/tools/deactivate_skill.rb +0 -40
- data/lib/analytical_brain/tools/deactivate_workflow.rb +0 -35
- data/lib/events/agent_message.rb +0 -25
- data/lib/events/subscribers/message_collector.rb +0 -64
- data/lib/events/tool_call.rb +0 -31
- data/lib/events/tool_response.rb +0 -33
- data/lib/mneme/compressed_viewport.rb +0 -204
- data/lib/mneme/passive_recall.rb +0 -138
data/lib/llm/client.rb
CHANGED
|
@@ -1,15 +1,26 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module LLM
|
|
4
|
-
# Convenience layer over {Providers::Anthropic} for
|
|
5
|
-
#
|
|
4
|
+
# Convenience layer over {Providers::Anthropic} for phantom sessions
|
|
5
|
+
# (Mneme, Melete, Mneme::L2Runner) that need a multi-round tool-use
|
|
6
|
+
# loop driven from plain Ruby objects rather than the main drain
|
|
7
|
+
# pipeline.
|
|
8
|
+
#
|
|
9
|
+
# The main agent loop does NOT use this class — {DrainJob} talks to
|
|
10
|
+
# the provider directly and emits {Events::LLMResponded} for
|
|
11
|
+
# {Events::Subscribers::LLMResponseHandler} to process. The tool loop
|
|
12
|
+
# here is deliberately minimal: no events, no AASM transitions, no
|
|
13
|
+
# interrupt handling — phantom sessions don't interact with those
|
|
14
|
+
# machineries.
|
|
6
15
|
#
|
|
7
16
|
# @example
|
|
8
17
|
# registry = Tools::Registry.new
|
|
9
|
-
# registry.register(Tools::
|
|
10
|
-
# client.chat_with_tools(messages, registry: registry
|
|
18
|
+
# registry.register(Tools::SaveSnapshot)
|
|
19
|
+
# client.chat_with_tools(messages, registry: registry)
|
|
11
20
|
class Client
|
|
12
|
-
# Synthetic tool_result when a tool is
|
|
21
|
+
# Synthetic tool_result text shown when a tool run is aborted by the
|
|
22
|
+
# user's Escape press. Mirrored into the interrupt subsystem so both
|
|
23
|
+
# the bash tool and any future interrupt handler share the phrasing.
|
|
13
24
|
INTERRUPT_MESSAGE = "Your human wants your attention"
|
|
14
25
|
|
|
15
26
|
# @return [Providers::Anthropic] the underlying API provider
|
|
@@ -33,31 +44,20 @@ module LLM
|
|
|
33
44
|
@logger = logger
|
|
34
45
|
end
|
|
35
46
|
|
|
36
|
-
#
|
|
37
|
-
#
|
|
38
|
-
#
|
|
39
|
-
#
|
|
40
|
-
# Emits {Events::ToolCall} and {Events::ToolResponse} events for each
|
|
41
|
-
# tool interaction so they're persisted and visible in the event stream.
|
|
47
|
+
# Runs a minimal multi-round tool-use cycle: call the LLM, execute
|
|
48
|
+
# any requested tools, feed results back, repeat until the LLM
|
|
49
|
+
# produces a final text response.
|
|
42
50
|
#
|
|
43
|
-
#
|
|
44
|
-
#
|
|
51
|
+
# Intended for phantom sessions (Mneme, Melete). No events are
|
|
52
|
+
# emitted and no persistence happens — the caller is responsible
|
|
53
|
+
# for capturing whatever state the tool runs produce.
|
|
45
54
|
#
|
|
46
55
|
# @param messages [Array<Hash>] conversation messages in Anthropic format
|
|
47
56
|
# @param registry [Tools::Registry] registered tools to make available
|
|
48
|
-
# @param session_id [Integer, String] session ID for emitted events
|
|
49
|
-
# @param first_response [Hash, nil] pre-fetched first API response from
|
|
50
|
-
# {AgentLoop#deliver!}. Skips the first API call when provided so
|
|
51
|
-
# the Bounce Back transaction doesn't duplicate work.
|
|
52
|
-
# @param between_rounds [#call, nil] callback invoked after each tool
|
|
53
|
-
# round completes, before the next LLM request. Must return an
|
|
54
|
-
# +Array<String>+ of message contents to inject (e.g. promoted
|
|
55
|
-
# pending messages). Injected as +text+ blocks alongside
|
|
56
|
-
# +tool_result+ blocks so the LLM sees them in the next round.
|
|
57
57
|
# @param options [Hash] additional API parameters (e.g. +system:+)
|
|
58
|
-
# @return [Hash
|
|
58
|
+
# @return [Hash] +:text+ (String) and +:api_metrics+ (Hash)
|
|
59
59
|
# @raise [Providers::Anthropic::Error] on API errors
|
|
60
|
-
def chat_with_tools(messages, registry:,
|
|
60
|
+
def chat_with_tools(messages, registry:, **options)
|
|
61
61
|
messages = messages.dup
|
|
62
62
|
rounds = 0
|
|
63
63
|
last_api_metrics = nil
|
|
@@ -69,49 +69,26 @@ module LLM
|
|
|
69
69
|
return {text: "[Tool loop exceeded #{max_rounds} rounds — halting]", api_metrics: last_api_metrics}
|
|
70
70
|
end
|
|
71
71
|
|
|
72
|
-
response =
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
tools: registry.schemas,
|
|
81
|
-
include_metrics: true,
|
|
82
|
-
**options
|
|
83
|
-
)
|
|
84
|
-
end
|
|
72
|
+
response = provider.create_message(
|
|
73
|
+
model: model,
|
|
74
|
+
messages: messages,
|
|
75
|
+
max_tokens: max_tokens,
|
|
76
|
+
tools: registry.schemas,
|
|
77
|
+
include_metrics: true,
|
|
78
|
+
**options
|
|
79
|
+
)
|
|
85
80
|
|
|
86
|
-
# Capture api_metrics from ApiResponse wrapper (nil for pre-fetched first_response)
|
|
87
81
|
last_api_metrics = response.api_metrics if response.respond_to?(:api_metrics)
|
|
88
82
|
|
|
89
83
|
log(:debug, "stop_reason=#{response["stop_reason"]} content_types=#{(response["content"] || []).map { |b| b["type"] }.join(",")}")
|
|
90
84
|
|
|
91
85
|
if response["stop_reason"] == "tool_use"
|
|
92
|
-
tool_results = execute_tools(response, registry
|
|
93
|
-
promoted = promote_between_rounds(between_rounds)
|
|
94
|
-
|
|
95
|
-
# Dual injection: user messages go as text blocks within the current
|
|
96
|
-
# tool_results turn (same speaker); sub-agent messages append as
|
|
97
|
-
# separate assistant→user turn pairs (distinct tool invocations).
|
|
98
|
-
promoted[:texts].each { |text| tool_results << {type: "text", text: text} }
|
|
99
|
-
|
|
86
|
+
tool_results = execute_tools(response, registry)
|
|
100
87
|
messages += [
|
|
101
88
|
{role: "assistant", content: response["content"]},
|
|
102
89
|
{role: "user", content: tool_results}
|
|
103
90
|
]
|
|
104
|
-
|
|
105
|
-
messages.concat(promoted[:pairs])
|
|
106
|
-
|
|
107
|
-
return nil if handle_interrupt!(session_id)
|
|
108
91
|
else
|
|
109
|
-
# Discard the text response if the user pressed Escape while
|
|
110
|
-
# the API was generating it. Without this check the interrupt
|
|
111
|
-
# flag set during the blocking API call would be silently
|
|
112
|
-
# cleared by the ensure block in AgentRequestJob.
|
|
113
|
-
return nil if handle_interrupt!(session_id)
|
|
114
|
-
|
|
115
92
|
return {text: extract_text(response), api_metrics: last_api_metrics}
|
|
116
93
|
end
|
|
117
94
|
end
|
|
@@ -119,26 +96,12 @@ module LLM
|
|
|
119
96
|
|
|
120
97
|
private
|
|
121
98
|
|
|
122
|
-
# Invokes the between_rounds callback and returns promoted messages
|
|
123
|
-
# split by injection strategy.
|
|
124
|
-
#
|
|
125
|
-
# @param between_rounds [#call, nil] callback returning
|
|
126
|
-
# +{texts: Array<String>, pairs: Array<Hash>}+
|
|
127
|
-
# @return [Hash{Symbol => Array}] +:texts+ for user messages (text blocks
|
|
128
|
-
# in current tool_results), +:pairs+ for sub-agent messages (separate
|
|
129
|
-
# conversation turns)
|
|
130
|
-
def promote_between_rounds(between_rounds)
|
|
131
|
-
return {texts: [], pairs: []} unless between_rounds
|
|
132
|
-
between_rounds.call
|
|
133
|
-
end
|
|
134
|
-
|
|
135
99
|
def build_provider(provider)
|
|
136
100
|
provider || Providers::Anthropic.new
|
|
137
101
|
end
|
|
138
102
|
|
|
139
103
|
def extract_text(response)
|
|
140
104
|
content = response["content"] || []
|
|
141
|
-
|
|
142
105
|
content
|
|
143
106
|
.select { |block| block["type"] == "text" }
|
|
144
107
|
.map { |block| block["text"] }
|
|
@@ -150,157 +113,36 @@ module LLM
|
|
|
150
113
|
content.select { |block| block["type"] == "tool_use" }
|
|
151
114
|
end
|
|
152
115
|
|
|
153
|
-
# Executes
|
|
154
|
-
#
|
|
155
|
-
#
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
# @param response [Hash] Anthropic API response with tool_use content blocks
|
|
159
|
-
# @param registry [Tools::Registry] tool registry for dispatch
|
|
160
|
-
# @param session_id [Integer, String] session ID for events
|
|
161
|
-
# @return [Array<Hash>] tool_result content blocks for the next API call
|
|
162
|
-
def execute_tools(response, registry, session_id)
|
|
163
|
-
tool_uses = extract_tool_uses(response)
|
|
164
|
-
results = []
|
|
165
|
-
interrupted = false
|
|
166
|
-
|
|
167
|
-
tool_uses.each_with_index do |tool_use, index|
|
|
168
|
-
# Check-only here; clearing happens in handle_interrupt! after the loop
|
|
169
|
-
interrupted ||= interrupt_requested?(session_id)
|
|
170
|
-
if interrupted
|
|
171
|
-
remaining = tool_uses[index..]
|
|
172
|
-
results.concat(interrupt_remaining_tools(remaining, session_id)) if remaining&.any?
|
|
173
|
-
break
|
|
174
|
-
end
|
|
175
|
-
results << execute_single_tool(tool_use, registry, session_id)
|
|
176
|
-
end
|
|
177
|
-
|
|
178
|
-
results
|
|
179
|
-
end
|
|
180
|
-
|
|
181
|
-
# Creates synthetic "Your human wants your attention" results for all tools in the list.
|
|
182
|
-
#
|
|
183
|
-
# @param tool_uses [Array<Hash>] remaining tool_use content blocks
|
|
184
|
-
# @param session_id [Integer, String] session ID for events
|
|
185
|
-
# @return [Array<Hash>] tool_result content blocks
|
|
186
|
-
def interrupt_remaining_tools(tool_uses, session_id)
|
|
187
|
-
tool_uses.map { |tool_use| interrupt_tool(tool_use, session_id) }
|
|
116
|
+
# Executes every +tool_use+ block from the response and returns
|
|
117
|
+
# matching +tool_result+ blocks. Always emits a result — a missing
|
|
118
|
+
# result permanently corrupts the Anthropic conversation history.
|
|
119
|
+
def execute_tools(response, registry)
|
|
120
|
+
extract_tool_uses(response).map { |tool_use| execute_single_tool(tool_use, registry) }
|
|
188
121
|
end
|
|
189
122
|
|
|
190
|
-
|
|
191
|
-
# tool raises. Per the Anthropic tool-use protocol, every tool_use must
|
|
192
|
-
# have a matching tool_result; a missing result permanently corrupts the
|
|
193
|
-
# conversation history and breaks the session.
|
|
194
|
-
#
|
|
195
|
-
# Falls back to SecureRandom.uuid when Anthropic omits the tool_use id,
|
|
196
|
-
# ensuring the ToolCall/ToolResponse pair always shares a valid identifier.
|
|
197
|
-
def execute_single_tool(tool_use, registry, session_id)
|
|
123
|
+
def execute_single_tool(tool_use, registry)
|
|
198
124
|
name = tool_use["name"]
|
|
199
125
|
id = tool_use["id"] || SecureRandom.uuid
|
|
200
126
|
input = tool_use["input"] || {}
|
|
201
|
-
timeout = input["timeout"] || Anima::Settings.tool_timeout
|
|
202
127
|
|
|
203
128
|
log(:debug, "tool_call: #{name}(#{input.to_json})")
|
|
204
129
|
|
|
205
|
-
broadcast_session_state(session_id, "tool_executing", tool: name)
|
|
206
|
-
|
|
207
|
-
Events::Bus.emit(Events::ToolCall.new(
|
|
208
|
-
content: "Calling #{name}", tool_name: name,
|
|
209
|
-
tool_input: input, tool_use_id: id, timeout: timeout,
|
|
210
|
-
session_id: session_id
|
|
211
|
-
))
|
|
212
|
-
|
|
213
130
|
result = registry.execute(name, input)
|
|
214
131
|
result = ToolDecorator.call(name, result)
|
|
215
132
|
result_content = format_tool_result(result)
|
|
216
133
|
result_content = truncate_tool_result(result_content, registry, name)
|
|
217
|
-
log(:debug, "tool_result: #{name} → #{result_content.to_s.truncate(200)}")
|
|
218
134
|
|
|
219
|
-
|
|
220
|
-
content: result_content, tool_name: name, tool_use_id: id,
|
|
221
|
-
success: !result.is_a?(Hash) || !result.key?(:error),
|
|
222
|
-
session_id: session_id
|
|
223
|
-
))
|
|
135
|
+
log(:debug, "tool_result: #{name} → #{result_content.to_s.truncate(200)}")
|
|
224
136
|
|
|
225
137
|
{type: "tool_result", tool_use_id: id, content: result_content}
|
|
226
138
|
rescue => error
|
|
227
139
|
error_detail = "#{error.class}: #{error.message}"
|
|
228
140
|
Rails.logger.error("Tool #{name} raised #{error_detail}")
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
# Emission can fail (e.g. encoding errors in ActionCable/SQLite),
|
|
232
|
-
# but losing the tool_result would permanently corrupt the session.
|
|
233
|
-
begin
|
|
234
|
-
Events::Bus.emit(Events::ToolResponse.new(
|
|
235
|
-
content: error_content, tool_name: name, tool_use_id: id,
|
|
236
|
-
success: false, session_id: session_id
|
|
237
|
-
))
|
|
238
|
-
rescue => emit_error
|
|
239
|
-
Rails.logger.error("ToolResponse emission failed: #{emit_error.class}: #{emit_error.message}")
|
|
240
|
-
end
|
|
241
|
-
|
|
242
|
-
{type: "tool_result", tool_use_id: id, content: error_content}
|
|
243
|
-
end
|
|
244
|
-
|
|
245
|
-
# Creates a synthetic "Your human wants your attention" result for a tool that was not
|
|
246
|
-
# executed due to user interrupt. Emits both ToolCall and ToolResponse
|
|
247
|
-
# events so the TUI shows the interrupted tool in the event stream.
|
|
248
|
-
#
|
|
249
|
-
# @param tool_use [Hash] Anthropic tool_use content block
|
|
250
|
-
# @param session_id [Integer, String] session ID for events
|
|
251
|
-
# @return [Hash] tool_result content block
|
|
252
|
-
def interrupt_tool(tool_use, session_id)
|
|
253
|
-
name = tool_use["name"]
|
|
254
|
-
id = tool_use["id"] || SecureRandom.uuid
|
|
255
|
-
input = tool_use["input"] || {}
|
|
256
|
-
|
|
257
|
-
Events::Bus.emit(Events::ToolCall.new(
|
|
258
|
-
content: "Skipped #{name} — your human wants your attention", tool_name: name,
|
|
259
|
-
tool_input: input, tool_use_id: id, session_id: session_id
|
|
260
|
-
))
|
|
261
|
-
|
|
262
|
-
Events::Bus.emit(Events::ToolResponse.new(
|
|
263
|
-
content: INTERRUPT_MESSAGE, tool_name: name, tool_use_id: id,
|
|
264
|
-
success: false, session_id: session_id
|
|
265
|
-
))
|
|
266
|
-
|
|
267
|
-
{type: "tool_result", tool_use_id: id, content: INTERRUPT_MESSAGE}
|
|
268
|
-
end
|
|
269
|
-
|
|
270
|
-
# Checks whether the session has a pending interrupt flag.
|
|
271
|
-
#
|
|
272
|
-
# @param session_id [Integer, String] session to check
|
|
273
|
-
# @return [Boolean] true when interrupt is pending
|
|
274
|
-
def interrupt_requested?(session_id)
|
|
275
|
-
Session.where(id: session_id, interrupt_requested: true).exists?
|
|
276
|
-
end
|
|
277
|
-
|
|
278
|
-
# Atomically checks for a pending interrupt and clears it in one query.
|
|
279
|
-
# Used at loop boundaries (after tools, before LLM text return) to
|
|
280
|
-
# short-circuit the agent loop when the user presses Escape.
|
|
281
|
-
#
|
|
282
|
-
# @param session_id [Integer, String] session to check
|
|
283
|
-
# @return [Boolean] true when interrupt was detected and cleared
|
|
284
|
-
def handle_interrupt!(session_id)
|
|
285
|
-
Session.where(id: session_id, interrupt_requested: true)
|
|
286
|
-
.update_all(interrupt_requested: false) > 0
|
|
287
|
-
end
|
|
288
|
-
|
|
289
|
-
# Broadcasts a session state transition to all subscribed clients.
|
|
290
|
-
# Delegates to {Session#broadcast_session_state} which handles both
|
|
291
|
-
# the session's own stream and the parent's stream for HUD updates.
|
|
292
|
-
#
|
|
293
|
-
# @param session_id [Integer, String] session to broadcast for
|
|
294
|
-
# @param state [String] one of "idle", "llm_generating", "tool_executing", "interrupting"
|
|
295
|
-
# @param tool [String, nil] tool name when state is "tool_executing"
|
|
296
|
-
# @return [void]
|
|
297
|
-
def broadcast_session_state(session_id, state, tool: nil)
|
|
298
|
-
Session.find_by(id: session_id)&.broadcast_session_state(state, tool: tool)
|
|
141
|
+
{type: "tool_result", tool_use_id: id, content: format_tool_result(error: error_detail)}
|
|
299
142
|
end
|
|
300
143
|
|
|
301
144
|
def log(level, message)
|
|
302
145
|
return unless @logger
|
|
303
|
-
|
|
304
146
|
@logger.public_send(level, message)
|
|
305
147
|
end
|
|
306
148
|
|
|
@@ -308,8 +150,6 @@ module LLM
|
|
|
308
150
|
result.is_a?(Hash) ? result.to_json : result.to_s
|
|
309
151
|
end
|
|
310
152
|
|
|
311
|
-
# Applies head+tail truncation when a tool result exceeds the tool's
|
|
312
|
-
# configured character threshold. Skips tools that opt out (e.g. read).
|
|
313
153
|
def truncate_tool_result(content, registry, tool_name)
|
|
314
154
|
threshold = registry.truncation_threshold(tool_name)
|
|
315
155
|
return content unless threshold
|
data/lib/mcp/client_manager.rb
CHANGED
|
@@ -3,81 +3,76 @@
|
|
|
3
3
|
require "mcp"
|
|
4
4
|
|
|
5
5
|
module Mcp
|
|
6
|
-
#
|
|
7
|
-
# {Tools::Registry}. Each configured server (HTTP or stdio) gets
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
6
|
+
# Connects to MCP servers and registers their tools with
|
|
7
|
+
# {Tools::Registry}. Each configured server (HTTP or stdio) gets a
|
|
8
|
+
# dedicated {MCP::Client} instance, cached for the worker's
|
|
9
|
+
# lifetime. Connection failures are logged and skipped — a
|
|
10
|
+
# misconfigured or unavailable server does not prevent other servers
|
|
11
|
+
# or built-in tools from working.
|
|
11
12
|
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
13
|
+
# Spawned stdio processes are reaped on worker exit via
|
|
14
|
+
# {Mcp::StdioTransport.cleanup_all}.
|
|
15
|
+
#
|
|
16
|
+
# The cache is built once on the first {#register_tools} call and
|
|
17
|
+
# never invalidated; edits to +mcp.toml+ require a worker restart.
|
|
15
18
|
#
|
|
16
19
|
# @example
|
|
17
|
-
#
|
|
18
|
-
# manager.register_tools(registry)
|
|
20
|
+
# Mcp::ClientManager.shared.register_tools(registry)
|
|
19
21
|
class ClientManager
|
|
22
|
+
# Lazily-instantiated process-wide manager. Production code should
|
|
23
|
+
# call {.shared}; {.new} is reserved for tests and internal use.
|
|
24
|
+
# @return [Mcp::ClientManager]
|
|
25
|
+
def self.shared
|
|
26
|
+
@shared ||= new
|
|
27
|
+
end
|
|
28
|
+
|
|
20
29
|
# @param config [Mcp::Config] injectable config for testing
|
|
21
30
|
def initialize(config: Config.new(logger: Rails.logger))
|
|
22
31
|
@config = config
|
|
23
32
|
end
|
|
24
33
|
|
|
25
|
-
# Connects to
|
|
26
|
-
#
|
|
27
|
-
#
|
|
34
|
+
# Connects to every configured MCP server on first call, caches
|
|
35
|
+
# the resulting tool wrappers, and registers them in the given
|
|
36
|
+
# registry.
|
|
28
37
|
#
|
|
29
38
|
# @param registry [Tools::Registry] the registry to add tools to
|
|
30
|
-
# @return [Array<String>] warning messages
|
|
39
|
+
# @return [Array<String>] warning messages from configuration plus
|
|
40
|
+
# any per-server load failures
|
|
31
41
|
def register_tools(registry)
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
@config.warnings + warnings
|
|
42
|
+
load_servers if @wrappers.nil?
|
|
43
|
+
@wrappers.each { |wrapper| registry.register(wrapper) }
|
|
44
|
+
@config.warnings + @warnings
|
|
36
45
|
end
|
|
37
46
|
|
|
38
47
|
private
|
|
39
48
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
def register_transport_tools(servers
|
|
49
|
+
def load_servers
|
|
50
|
+
@wrappers = []
|
|
51
|
+
@warnings = []
|
|
52
|
+
register_transport_tools(@config.http_servers) { |server| build_http_client(server) }
|
|
53
|
+
register_transport_tools(@config.stdio_servers) { |server| build_stdio_client(server) }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def register_transport_tools(servers)
|
|
48
57
|
servers.each do |server|
|
|
49
58
|
client = yield(server)
|
|
50
|
-
|
|
59
|
+
wrappers = client.tools.map { |mcp_tool|
|
|
60
|
+
Tools::McpTool.new(server_name: server[:name], mcp_client: client, mcp_tool: mcp_tool)
|
|
61
|
+
}
|
|
62
|
+
@wrappers.concat(wrappers)
|
|
63
|
+
Rails.logger.info("MCP: registered #{wrappers.size} tools from #{server[:name]}")
|
|
51
64
|
rescue => error
|
|
52
65
|
message = "MCP: failed to load tools from #{server[:name]}: #{error.message}"
|
|
53
66
|
Rails.logger.warn(message)
|
|
54
|
-
warnings << message
|
|
67
|
+
@warnings << message
|
|
55
68
|
end
|
|
56
69
|
end
|
|
57
70
|
|
|
58
|
-
# Fetches tools from an MCP client and registers them with
|
|
59
|
-
# namespaced names in the registry.
|
|
60
|
-
#
|
|
61
|
-
# @param server_name [String] server name for tool namespacing
|
|
62
|
-
# @param client [MCP::Client] connected MCP client
|
|
63
|
-
# @param registry [Tools::Registry] registry to register tools in
|
|
64
|
-
def register_server_tools(server_name, client, registry)
|
|
65
|
-
count = client.tools.map { |mcp_tool|
|
|
66
|
-
Tools::McpTool.new(server_name: server_name, mcp_client: client, mcp_tool: mcp_tool)
|
|
67
|
-
}.each { |wrapper| registry.register(wrapper) }.size
|
|
68
|
-
|
|
69
|
-
Rails.logger.info("MCP: registered #{count} tools from #{server_name}")
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
# @param server [Hash] server config with +:url+ and +:headers+
|
|
73
|
-
# @return [MCP::Client]
|
|
74
71
|
def build_http_client(server)
|
|
75
72
|
transport = MCP::Client::HTTP.new(url: server[:url], headers: server[:headers])
|
|
76
73
|
MCP::Client.new(transport: transport)
|
|
77
74
|
end
|
|
78
75
|
|
|
79
|
-
# @param server [Hash] server config with +:command+, +:args+, +:env+
|
|
80
|
-
# @return [MCP::Client]
|
|
81
76
|
def build_stdio_client(server)
|
|
82
77
|
transport = StdioTransport.new(command: server[:command], args: server[:args], env: server[:env])
|
|
83
78
|
MCP::Client.new(transport: transport)
|
data/lib/mcp/stdio_transport.rb
CHANGED
|
@@ -115,8 +115,11 @@ module Mcp
|
|
|
115
115
|
@wait_thread&.alive? || false
|
|
116
116
|
end
|
|
117
117
|
|
|
118
|
+
# +pgroup: true+ so {#terminate_process} can group-signal the
|
|
119
|
+
# entire descendant tree — npm/npx wrappers leak their +node+
|
|
120
|
+
# children otherwise.
|
|
118
121
|
def spawn_process
|
|
119
|
-
@stdin, @stdout, @wait_thread = Open3.popen2(@env, @command, *@args)
|
|
122
|
+
@stdin, @stdout, @wait_thread = Open3.popen2(@env, @command, *@args, pgroup: true)
|
|
120
123
|
@stdin.set_encoding("UTF-8")
|
|
121
124
|
@stdout.set_encoding("UTF-8")
|
|
122
125
|
self.class.register(self)
|
|
@@ -164,14 +167,15 @@ module Mcp
|
|
|
164
167
|
@stdout&.close rescue IOError # rubocop:disable Style/RescueModifier
|
|
165
168
|
end
|
|
166
169
|
|
|
167
|
-
# Sends SIGTERM
|
|
168
|
-
#
|
|
170
|
+
# Sends SIGTERM to the process group; escalates to SIGKILL on the
|
|
171
|
+
# group after +GRACEFUL_SHUTDOWN_TIMEOUT+ seconds. Negative PID
|
|
172
|
+
# signals the whole group (see {#spawn_process}).
|
|
169
173
|
def terminate_process
|
|
170
174
|
return unless @wait_thread
|
|
171
175
|
|
|
172
176
|
pid = @wait_thread.pid
|
|
173
177
|
begin
|
|
174
|
-
Process.kill("TERM", pid)
|
|
178
|
+
Process.kill("TERM", -pid)
|
|
175
179
|
rescue Errno::ESRCH, Errno::EPERM
|
|
176
180
|
return
|
|
177
181
|
end
|
|
@@ -181,7 +185,7 @@ module Mcp
|
|
|
181
185
|
_, status = Process.wait2(pid, Process::WNOHANG)
|
|
182
186
|
break if status
|
|
183
187
|
if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
|
|
184
|
-
Process.kill("KILL", pid) rescue Errno::ESRCH # rubocop:disable Style/RescueModifier
|
|
188
|
+
Process.kill("KILL", -pid) rescue Errno::ESRCH # rubocop:disable Style/RescueModifier
|
|
185
189
|
Process.wait(pid) rescue Errno::ECHILD # rubocop:disable Style/RescueModifier
|
|
186
190
|
break
|
|
187
191
|
end
|