anima-core 1.3.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 +23 -26
- data/README.md +118 -104
- 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 +16 -5
- 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 +23 -11
- data/app/models/message.rb +46 -48
- data/app/models/pending_message.rb +407 -12
- data/app/models/pinned_message.rb +8 -3
- data/app/models/session.rb +660 -566
- data/app/models/snapshot.rb +11 -21
- data/bin/inspect-cassette +157 -0
- data/bin/release +212 -0
- data/bin/with-llms +20 -0
- data/config/application.rb +1 -0
- data/config/database.yml +1 -0
- data/config/initializers/event_subscribers.rb +71 -4
- data/config/initializers/inflections.rb +3 -1
- data/db/cable_structure.sql +9 -0
- data/db/migrate/20260330120000_add_source_to_pending_messages.rb +8 -0
- data/db/migrate/20260401180000_add_api_metrics_to_messages.rb +7 -0
- data/db/migrate/20260401210935_remove_recalled_message_ids_from_sessions.rb +5 -0
- data/db/migrate/20260403080031_add_initial_cwd_to_sessions.rb +5 -0
- 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 +61 -0
- data/db/structure.sql +133 -0
- data/lib/agents/registry.rb +1 -1
- data/lib/anima/cli.rb +41 -13
- data/lib/anima/installer.rb +13 -0
- data/lib/anima/settings.rb +16 -36
- 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 +8 -9
- data/lib/events/subscribers/session_state_broadcaster.rb +33 -0
- data/lib/events/subscribers/subagent_message_router.rb +28 -34
- 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 +46 -199
- data/lib/mcp/client_manager.rb +41 -46
- data/lib/mcp/stdio_transport.rb +9 -5
- data/lib/{analytical_brain → melete}/runner.rb +73 -68
- data/lib/{analytical_brain → melete}/tools/activate_skill.rb +3 -3
- data/lib/{analytical_brain → melete}/tools/assign_nickname.rb +3 -3
- data/lib/{analytical_brain → melete}/tools/everything_is_ready.rb +2 -2
- data/lib/{analytical_brain → melete}/tools/finish_goal.rb +6 -3
- data/lib/melete/tools/goal_messaging.rb +29 -0
- data/lib/{analytical_brain → melete}/tools/read_workflow.rb +4 -4
- data/lib/{analytical_brain → melete}/tools/rename_session.rb +3 -3
- data/lib/{analytical_brain → melete}/tools/set_goal.rb +6 -2
- data/lib/{analytical_brain → melete}/tools/update_goal.rb +9 -5
- 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 +123 -165
- 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/providers/anthropic.rb +112 -7
- data/lib/shell_session.rb +290 -432
- data/lib/skills/definition.rb +2 -2
- data/lib/skills/registry.rb +1 -1
- data/lib/tools/base.rb +16 -1
- data/lib/tools/bash.rb +25 -55
- data/lib/tools/edit.rb +2 -0
- data/lib/tools/mark_goal_completed.rb +4 -5
- data/lib/tools/read.rb +2 -0
- data/lib/tools/registry.rb +85 -4
- data/lib/tools/response_truncator.rb +1 -1
- data/lib/tools/{recall.rb → search_messages.rb} +19 -21
- data/lib/tools/spawn_specialist.rb +22 -14
- data/lib/tools/spawn_subagent.rb +30 -20
- data/lib/tools/subagent_prompts.rb +17 -19
- 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 +393 -149
- data/lib/tui/braille_spinner.rb +7 -7
- data/lib/tui/cable_client.rb +9 -16
- data/lib/tui/decorators/base_decorator.rb +47 -6
- data/lib/tui/decorators/bash_decorator.rb +1 -1
- data/lib/tui/decorators/edit_decorator.rb +4 -2
- data/lib/tui/decorators/read_decorator.rb +4 -2
- data/lib/tui/decorators/think_decorator.rb +2 -2
- data/lib/tui/decorators/web_get_decorator.rb +1 -1
- data/lib/tui/decorators/write_decorator.rb +4 -2
- data/lib/tui/flash.rb +19 -14
- data/lib/tui/formatting.rb +20 -9
- data/lib/tui/input_buffer.rb +6 -6
- data/lib/tui/message_store.rb +165 -28
- data/lib/tui/performance_logger.rb +2 -3
- data/lib/tui/screens/chat.rb +149 -79
- data/lib/tui/settings.rb +93 -0
- data/lib/workflows/definition.rb +3 -3
- data/lib/workflows/registry.rb +1 -1
- data/skills/github.md +38 -0
- data/templates/config.toml +16 -32
- data/templates/tui.toml +209 -0
- data/workflows/review_pr.md +18 -14
- metadata +98 -29
- 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 -29
- data/app/models/concerns/message/broadcasting.rb +0 -85
- data/config/initializers/fts5_schema_dump.rb +0 -21
- data/lib/agent_loop.rb +0 -186
- data/lib/analytical_brain/tools/deactivate_skill.rb +0 -39
- data/lib/analytical_brain/tools/deactivate_workflow.rb +0 -34
- data/lib/environment_probe.rb +0 -232
- data/lib/events/agent_message.rb +0 -11
- 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 -200
- data/lib/mneme/passive_recall.rb +0 -69
|
@@ -1,199 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
# Executes an LLM agent loop as a background job with retry logic
|
|
4
|
-
# for transient failures (network errors, rate limits, server errors).
|
|
5
|
-
#
|
|
6
|
-
# Supports two modes:
|
|
7
|
-
#
|
|
8
|
-
# **Immediate Persist (message_id provided):** The user message was already
|
|
9
|
-
# persisted and broadcast by the caller (e.g. {SessionChannel#speak}).
|
|
10
|
-
# The job verifies LLM delivery — if the first API call fails, the
|
|
11
|
-
# message is deleted and a {Events::BounceBack} is emitted so clients
|
|
12
|
-
# can restore the text to the input field.
|
|
13
|
-
#
|
|
14
|
-
# **Standard (no message_id):** Processes already-persisted messages (e.g.
|
|
15
|
-
# after pending message promotion). Uses ActiveJob retry/discard for
|
|
16
|
-
# error handling.
|
|
17
|
-
#
|
|
18
|
-
# @example Immediate Persist — message already saved by SessionChannel
|
|
19
|
-
# AgentRequestJob.perform_later(session.id, message_id: 42)
|
|
20
|
-
#
|
|
21
|
-
# @example Standard — pending message processing
|
|
22
|
-
# AgentRequestJob.perform_later(session.id)
|
|
23
|
-
class AgentRequestJob < ApplicationJob
|
|
24
|
-
queue_as :default
|
|
25
|
-
|
|
26
|
-
# ActionCable action signaling clients to prompt for an API token.
|
|
27
|
-
AUTH_REQUIRED_ACTION = "authentication_required"
|
|
28
|
-
|
|
29
|
-
# Standard path only — immediate persist handles its own errors.
|
|
30
|
-
retry_on Providers::Anthropic::TransientError,
|
|
31
|
-
wait: :polynomially_longer, attempts: 5 do |job, error|
|
|
32
|
-
Events::Bus.emit(Events::SystemMessage.new(
|
|
33
|
-
content: "Failed after multiple retries: #{error.message}",
|
|
34
|
-
session_id: job.arguments.first
|
|
35
|
-
))
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
discard_on ActiveRecord::RecordNotFound
|
|
39
|
-
discard_on Providers::Anthropic::AuthenticationError do |job, error|
|
|
40
|
-
session_id = job.arguments.first
|
|
41
|
-
Events::Bus.emit(Events::SystemMessage.new(
|
|
42
|
-
content: "Authentication failed: #{error.message}",
|
|
43
|
-
session_id: session_id
|
|
44
|
-
))
|
|
45
|
-
ActionCable.server.broadcast(
|
|
46
|
-
"session_#{session_id}",
|
|
47
|
-
{"action" => AUTH_REQUIRED_ACTION, "message" => error.message}
|
|
48
|
-
)
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
# @param session_id [Integer] ID of the session to process
|
|
52
|
-
# @param message_id [Integer, nil] ID of a pre-persisted user message (triggers delivery verification)
|
|
53
|
-
def perform(session_id, message_id: nil)
|
|
54
|
-
session = Session.find(session_id)
|
|
55
|
-
|
|
56
|
-
# Atomic: only one job processes a session at a time.
|
|
57
|
-
return unless claim_processing(session_id)
|
|
58
|
-
|
|
59
|
-
run_analytical_brain_blocking(session)
|
|
60
|
-
|
|
61
|
-
agent_loop = AgentLoop.new(session: session)
|
|
62
|
-
|
|
63
|
-
if message_id
|
|
64
|
-
deliver_persisted_message(session, message_id, agent_loop)
|
|
65
|
-
else
|
|
66
|
-
agent_loop.run
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
# Process any pending messages queued while we were busy.
|
|
70
|
-
loop do
|
|
71
|
-
promoted = session.promote_pending_messages!
|
|
72
|
-
break if promoted == 0
|
|
73
|
-
agent_loop.run
|
|
74
|
-
end
|
|
75
|
-
|
|
76
|
-
session.schedule_analytical_brain!
|
|
77
|
-
ensure
|
|
78
|
-
release_processing(session_id)
|
|
79
|
-
clear_interrupt(session_id)
|
|
80
|
-
agent_loop&.finalize
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
private
|
|
84
|
-
|
|
85
|
-
# Verifies LLM delivery for a pre-persisted user message.
|
|
86
|
-
#
|
|
87
|
-
# The message was already created and broadcast by the caller, so
|
|
88
|
-
# the user sees their message immediately. This method makes the
|
|
89
|
-
# first LLM API call — if it fails, the message is deleted and a
|
|
90
|
-
# {Events::BounceBack} notifies clients to remove the phantom
|
|
91
|
-
# message and restore the text to the input field. For
|
|
92
|
-
# {Providers::Anthropic::AuthenticationError}, an additional
|
|
93
|
-
# +authentication_required+ broadcast prompts the client to show
|
|
94
|
-
# the token entry dialog.
|
|
95
|
-
#
|
|
96
|
-
# Unlike the standard path (which uses +retry_on+ / +discard_on+),
|
|
97
|
-
# all errors here are caught and swallowed after emitting a
|
|
98
|
-
# BounceBack — the job completes normally so ActiveJob does not
|
|
99
|
-
# retry a message the user will re-send manually.
|
|
100
|
-
#
|
|
101
|
-
# After successful delivery, continues the agent loop (tool
|
|
102
|
-
# execution, subsequent API calls).
|
|
103
|
-
#
|
|
104
|
-
# @param session [Session] the conversation session
|
|
105
|
-
# @param message_id [Integer] database ID of the pre-persisted user message
|
|
106
|
-
# @param agent_loop [AgentLoop] agent loop instance (reused for continuation)
|
|
107
|
-
def deliver_persisted_message(session, message_id, agent_loop)
|
|
108
|
-
message = Message.find_by(id: message_id, session_id: session.id)
|
|
109
|
-
# Message may have been deleted between SessionChannel#speak and job
|
|
110
|
-
# execution (e.g. user recalled the message). Exit silently — there
|
|
111
|
-
# is nothing to deliver or bounce back.
|
|
112
|
-
return unless message
|
|
113
|
-
|
|
114
|
-
content = message.payload["content"]
|
|
115
|
-
|
|
116
|
-
begin
|
|
117
|
-
agent_loop.deliver!
|
|
118
|
-
rescue => error
|
|
119
|
-
message.destroy!
|
|
120
|
-
Events::Bus.emit(Events::BounceBack.new(
|
|
121
|
-
content: content,
|
|
122
|
-
error: error.message,
|
|
123
|
-
session_id: session.id,
|
|
124
|
-
message_id: message_id
|
|
125
|
-
))
|
|
126
|
-
broadcast_auth_required(session.id, error) if error.is_a?(Providers::Anthropic::AuthenticationError)
|
|
127
|
-
return
|
|
128
|
-
end
|
|
129
|
-
|
|
130
|
-
agent_loop.run
|
|
131
|
-
end
|
|
132
|
-
|
|
133
|
-
def broadcast_auth_required(session_id, error)
|
|
134
|
-
ActionCable.server.broadcast(
|
|
135
|
-
"session_#{session_id}",
|
|
136
|
-
{"action" => AUTH_REQUIRED_ACTION, "message" => error.message}
|
|
137
|
-
)
|
|
138
|
-
end
|
|
139
|
-
|
|
140
|
-
# Runs the analytical brain synchronously before the main agent loop.
|
|
141
|
-
# Respects the blocking_on_user_message setting and session guards
|
|
142
|
-
# (skips sub-agents and sessions with too few messages).
|
|
143
|
-
def run_analytical_brain_blocking(session)
|
|
144
|
-
return unless Anima::Settings.analytical_brain_blocking_on_user_message
|
|
145
|
-
return if session.sub_agent?
|
|
146
|
-
|
|
147
|
-
AnalyticalBrain::Runner.new(session).call
|
|
148
|
-
rescue => error
|
|
149
|
-
# The analytical brain is best-effort: skill activation enhances the
|
|
150
|
-
# response but the main agent must still reply even if it fails.
|
|
151
|
-
msg = "FAILED (blocking) session=#{session.id}: #{error.class}: #{error.message}"
|
|
152
|
-
Rails.logger.error("Analytical brain #{msg}")
|
|
153
|
-
AnalyticalBrain.logger.error("#{msg}\n#{error.backtrace&.first(10)&.join("\n")}")
|
|
154
|
-
end
|
|
155
|
-
|
|
156
|
-
# Sets the session's processing flag atomically. Returns true if this
|
|
157
|
-
# job claimed the lock, false if another job already holds it.
|
|
158
|
-
# Broadcasts +session_state: llm_generating+ and the state change to
|
|
159
|
-
# the parent session's HUD.
|
|
160
|
-
def claim_processing(session_id)
|
|
161
|
-
claimed = Session.where(id: session_id, processing: false).update_all(processing: true) == 1
|
|
162
|
-
if claimed
|
|
163
|
-
session = Session.find_by(id: session_id)
|
|
164
|
-
session&.broadcast_session_state("llm_generating")
|
|
165
|
-
session&.broadcast_children_update_to_parent
|
|
166
|
-
end
|
|
167
|
-
claimed
|
|
168
|
-
end
|
|
169
|
-
|
|
170
|
-
# Clears the processing flag so the session can accept new jobs.
|
|
171
|
-
# Broadcasts +session_state: idle+ to the session stream (replaces
|
|
172
|
-
# the old +processing_stopped+ action) and +children_updated+ to the
|
|
173
|
-
# parent session's HUD.
|
|
174
|
-
def release_processing(session_id)
|
|
175
|
-
Session.where(id: session_id).update_all(processing: false)
|
|
176
|
-
session = Session.find_by(id: session_id)
|
|
177
|
-
session&.broadcast_session_state("idle")
|
|
178
|
-
session&.broadcast_children_update_to_parent
|
|
179
|
-
end
|
|
180
|
-
|
|
181
|
-
# Safety-net clearing of the interrupt flag.
|
|
182
|
-
def clear_interrupt(session_id)
|
|
183
|
-
Session.where(id: session_id, interrupt_requested: true).update_all(interrupt_requested: false)
|
|
184
|
-
end
|
|
185
|
-
|
|
186
|
-
# Emits a system message before each retry so the user sees
|
|
187
|
-
# "retrying..." instead of nothing.
|
|
188
|
-
def retry_job(options = {})
|
|
189
|
-
error = options[:error]
|
|
190
|
-
wait = options[:wait]
|
|
191
|
-
|
|
192
|
-
Events::Bus.emit(Events::SystemMessage.new(
|
|
193
|
-
content: "#{error.message} — retrying in #{wait.to_i}s...",
|
|
194
|
-
session_id: arguments.first
|
|
195
|
-
))
|
|
196
|
-
|
|
197
|
-
super
|
|
198
|
-
end
|
|
199
|
-
end
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
# Runs the analytical brain — a phantom LLM loop that observes the main
|
|
4
|
-
# session and performs background maintenance (currently: session naming).
|
|
5
|
-
#
|
|
6
|
-
# Replaces {GenerateSessionNameJob} with a tool-based architecture that
|
|
7
|
-
# future tickets will expand with skill activation, goal tracking, etc.
|
|
8
|
-
#
|
|
9
|
-
# Scheduling guards live in {Session#schedule_analytical_brain!} — this
|
|
10
|
-
# job always runs when called.
|
|
11
|
-
#
|
|
12
|
-
# @example
|
|
13
|
-
# AnalyticalBrainJob.perform_later(session.id)
|
|
14
|
-
class AnalyticalBrainJob < ApplicationJob
|
|
15
|
-
queue_as :default
|
|
16
|
-
|
|
17
|
-
retry_on Providers::Anthropic::TransientError,
|
|
18
|
-
wait: :polynomially_longer, attempts: 3
|
|
19
|
-
|
|
20
|
-
discard_on ActiveRecord::RecordNotFound
|
|
21
|
-
discard_on Providers::Anthropic::AuthenticationError
|
|
22
|
-
|
|
23
|
-
# @param session_id [Integer] the main Session to analyze
|
|
24
|
-
def perform(session_id)
|
|
25
|
-
brain_log = AnalyticalBrain.logger
|
|
26
|
-
session = Session.find(session_id)
|
|
27
|
-
brain_log.info("async job started for session=#{session_id}")
|
|
28
|
-
AnalyticalBrain::Runner.new(session).call
|
|
29
|
-
rescue => error
|
|
30
|
-
brain_log.error("FAILED (async) session=#{session_id}: #{error.class}: #{error.message}")
|
|
31
|
-
raise
|
|
32
|
-
end
|
|
33
|
-
end
|
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
# Counts tokens in a message's payload via the Anthropic API and
|
|
4
|
-
# caches the result on the message record. Enqueued automatically
|
|
5
|
-
# after each LLM message is created.
|
|
6
|
-
class CountMessageTokensJob < ApplicationJob
|
|
7
|
-
queue_as :default
|
|
8
|
-
|
|
9
|
-
retry_on Providers::Anthropic::Error, wait: :polynomially_longer, attempts: 3
|
|
10
|
-
discard_on ActiveRecord::RecordNotFound
|
|
11
|
-
|
|
12
|
-
# @param message_id [Integer] the Message record to count tokens for
|
|
13
|
-
def perform(message_id)
|
|
14
|
-
message = Message.find(message_id)
|
|
15
|
-
return if already_counted?(message)
|
|
16
|
-
|
|
17
|
-
provider = Providers::Anthropic.new
|
|
18
|
-
api_messages = [{role: message.api_role, content: message.payload["content"].to_s}]
|
|
19
|
-
|
|
20
|
-
token_count = provider.count_tokens(
|
|
21
|
-
model: Anima::Settings.model,
|
|
22
|
-
messages: api_messages
|
|
23
|
-
)
|
|
24
|
-
|
|
25
|
-
# Guard against parallel jobs: reload and re-check before writing.
|
|
26
|
-
# Uses update! (not update_all) so {Message::Broadcasting} after_update_commit
|
|
27
|
-
# broadcasts the updated token count to connected clients.
|
|
28
|
-
message.reload
|
|
29
|
-
return if already_counted?(message)
|
|
30
|
-
|
|
31
|
-
message.update!(token_count: token_count)
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
private
|
|
35
|
-
|
|
36
|
-
def already_counted?(message)
|
|
37
|
-
message.token_count > 0
|
|
38
|
-
end
|
|
39
|
-
end
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
# Runs passive recall after goal updates — searches message history for
|
|
4
|
-
# context relevant to active goals and caches results on the session
|
|
5
|
-
# for viewport injection.
|
|
6
|
-
#
|
|
7
|
-
# Idempotent: multiple enqueues for the same session safely overwrite
|
|
8
|
-
# each other's results — last one wins.
|
|
9
|
-
#
|
|
10
|
-
# @example
|
|
11
|
-
# PassiveRecallJob.perform_later(session.id)
|
|
12
|
-
class PassiveRecallJob < ApplicationJob
|
|
13
|
-
queue_as :default
|
|
14
|
-
|
|
15
|
-
discard_on ActiveRecord::RecordNotFound
|
|
16
|
-
|
|
17
|
-
# @param session_id [Integer]
|
|
18
|
-
def perform(session_id)
|
|
19
|
-
session = Session.find(session_id)
|
|
20
|
-
results = Mneme::PassiveRecall.new(session).call
|
|
21
|
-
|
|
22
|
-
if results.any?
|
|
23
|
-
session.update_column(:recalled_message_ids, results.map(&:message_id))
|
|
24
|
-
Mneme.logger.info("session=#{session_id} — passive recall found #{results.size} memories")
|
|
25
|
-
elsif session.recalled_message_ids.present?
|
|
26
|
-
session.update_column(:recalled_message_ids, [])
|
|
27
|
-
end
|
|
28
|
-
end
|
|
29
|
-
end
|
|
@@ -1,85 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
# Broadcasts Message records to connected WebSocket clients via ActionCable.
|
|
4
|
-
# Follows the Turbo Streams pattern: messages are broadcast on both create
|
|
5
|
-
# and update, with an action type so clients can distinguish append from
|
|
6
|
-
# replace operations.
|
|
7
|
-
#
|
|
8
|
-
# Each broadcast includes the Message's database ID, enabling clients to
|
|
9
|
-
# maintain an ID-indexed store for efficient in-place updates (e.g. when
|
|
10
|
-
# token counts arrive asynchronously from {CountMessageTokensJob}).
|
|
11
|
-
#
|
|
12
|
-
# When a new message pushes old messages out of the LLM's context window,
|
|
13
|
-
# the broadcast includes `evicted_message_ids` so clients can remove
|
|
14
|
-
# phantom messages that the agent no longer knows about.
|
|
15
|
-
#
|
|
16
|
-
# @example Create broadcast payload
|
|
17
|
-
# {
|
|
18
|
-
# "type" => "user_message", "content" => "hello", ...,
|
|
19
|
-
# "id" => 42, "action" => "create",
|
|
20
|
-
# "rendered" => { "basic" => { "role" => "user", "content" => "hello" } }
|
|
21
|
-
# }
|
|
22
|
-
#
|
|
23
|
-
# @example Broadcast with viewport evictions
|
|
24
|
-
# {
|
|
25
|
-
# "type" => "agent_message", "content" => "...", ...,
|
|
26
|
-
# "id" => 99, "action" => "create",
|
|
27
|
-
# "evicted_message_ids" => [101, 102, 103]
|
|
28
|
-
# }
|
|
29
|
-
#
|
|
30
|
-
# @example Update broadcast payload (e.g. token count arrives)
|
|
31
|
-
# {
|
|
32
|
-
# "type" => "user_message", "content" => "hello", ...,
|
|
33
|
-
# "id" => 42, "action" => "update",
|
|
34
|
-
# "rendered" => { "debug" => { "role" => "user", "content" => "hello", "tokens" => 15 } }
|
|
35
|
-
# }
|
|
36
|
-
module Message::Broadcasting
|
|
37
|
-
extend ActiveSupport::Concern
|
|
38
|
-
|
|
39
|
-
ACTION_CREATE = "create"
|
|
40
|
-
ACTION_UPDATE = "update"
|
|
41
|
-
|
|
42
|
-
included do
|
|
43
|
-
after_create_commit :broadcast_create
|
|
44
|
-
after_update_commit :broadcast_update
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
private
|
|
48
|
-
|
|
49
|
-
def broadcast_create
|
|
50
|
-
broadcast_message(action: ACTION_CREATE)
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
def broadcast_update
|
|
54
|
-
broadcast_message(action: ACTION_UPDATE)
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
# Decorates the message for the session's current view mode and broadcasts
|
|
58
|
-
# the payload to the session's ActionCable stream. Includes viewport
|
|
59
|
-
# eviction metadata so clients can remove messages the LLM has forgotten.
|
|
60
|
-
#
|
|
61
|
-
# @param action [String] ACTION_CREATE or ACTION_UPDATE — tells clients how to handle the message
|
|
62
|
-
def broadcast_message(action:)
|
|
63
|
-
return unless session_id
|
|
64
|
-
|
|
65
|
-
session = Session.find_by(id: session_id)
|
|
66
|
-
return unless session
|
|
67
|
-
|
|
68
|
-
mode = session.view_mode
|
|
69
|
-
decorator = MessageDecorator.for(self)
|
|
70
|
-
broadcast_payload = payload.merge("id" => id, "action" => action)
|
|
71
|
-
|
|
72
|
-
if decorator
|
|
73
|
-
broadcast_payload["rendered"] = {mode => decorator.render(mode)}
|
|
74
|
-
end
|
|
75
|
-
|
|
76
|
-
evicted_ids = session.recalculate_viewport!
|
|
77
|
-
broadcast_payload["evicted_message_ids"] = evicted_ids if evicted_ids.any?
|
|
78
|
-
|
|
79
|
-
# The nil? branch fires on every broadcast until boundary initializes, but
|
|
80
|
-
# schedule_mneme! returns early after setting the boundary — cost is one DB read + write.
|
|
81
|
-
session.schedule_mneme! if evicted_ids.any? || session.mneme_boundary_message_id.nil?
|
|
82
|
-
|
|
83
|
-
ActionCable.server.broadcast("session_#{session_id}", broadcast_payload)
|
|
84
|
-
end
|
|
85
|
-
end
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
# Skip FTS5 virtual tables during Ruby schema dump.
|
|
4
|
-
# Rails' schema dumper can't express contentless FTS5 tables — the
|
|
5
|
-
# migration handles creation. The schema.rb omits the virtual table,
|
|
6
|
-
# and db:prepare runs pending migrations to recreate it.
|
|
7
|
-
ActiveSupport.on_load(:active_record) do
|
|
8
|
-
require "active_record/connection_adapters/sqlite3/schema_dumper"
|
|
9
|
-
|
|
10
|
-
ActiveRecord::ConnectionAdapters::SQLite3::SchemaDumper.prepend(Module.new do
|
|
11
|
-
private
|
|
12
|
-
|
|
13
|
-
def virtual_tables(stream)
|
|
14
|
-
# Intentionally empty — FTS5 tables are managed by migrations.
|
|
15
|
-
# The default implementation crashes on contentless FTS5 arguments.
|
|
16
|
-
end
|
|
17
|
-
end)
|
|
18
|
-
rescue LoadError
|
|
19
|
-
# Not using SQLite3 adapter — nothing to patch.
|
|
20
|
-
nil
|
|
21
|
-
end
|
data/lib/agent_loop.rb
DELETED
|
@@ -1,186 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
# Orchestrates the LLM agent loop: accepts user input, runs the tool-use
|
|
4
|
-
# cycle via {LLM::Client}, and emits events through {Events::Bus}.
|
|
5
|
-
#
|
|
6
|
-
# Extracted from {TUI::Screens::Chat} so the same agent logic can run from
|
|
7
|
-
# the TUI, a background job, or an Action Cable channel.
|
|
8
|
-
#
|
|
9
|
-
# @note Not thread-safe. Callers must serialize concurrent access
|
|
10
|
-
# (e.g. {AgentRequestJob} uses session-level processing locks).
|
|
11
|
-
#
|
|
12
|
-
# @example Basic usage
|
|
13
|
-
# loop = AgentLoop.new(session: session)
|
|
14
|
-
# loop.run
|
|
15
|
-
# loop.finalize
|
|
16
|
-
#
|
|
17
|
-
# @example With dependency injection (testing)
|
|
18
|
-
# loop = AgentLoop.new(session: session, client: mock_client, registry: mock_registry)
|
|
19
|
-
# loop.run
|
|
20
|
-
class AgentLoop
|
|
21
|
-
# @return [Session] the conversation session this loop operates on
|
|
22
|
-
attr_reader :session
|
|
23
|
-
|
|
24
|
-
# @param session [Session] the conversation session
|
|
25
|
-
# @param shell_session [ShellSession, nil] injectable persistent shell;
|
|
26
|
-
# created automatically if not provided
|
|
27
|
-
# @param client [LLM::Client, nil] injectable LLM client;
|
|
28
|
-
# created lazily on first {#run} call if not provided
|
|
29
|
-
# @param registry [Tools::Registry, nil] injectable tool registry;
|
|
30
|
-
# built lazily on first {#run} call if not provided
|
|
31
|
-
def initialize(session:, shell_session: nil, client: nil, registry: nil)
|
|
32
|
-
@session = session
|
|
33
|
-
@shell_session = shell_session || ShellSession.new(session_id: session.id)
|
|
34
|
-
@client = client
|
|
35
|
-
@registry = registry
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
# Makes the first LLM API call to verify delivery. Called inside the
|
|
39
|
-
# Bounce Back transaction — if this raises, the user event rolls back.
|
|
40
|
-
#
|
|
41
|
-
# Caches the first response so the subsequent {#run} call can continue
|
|
42
|
-
# from it without duplicating the API call.
|
|
43
|
-
#
|
|
44
|
-
# @return [void]
|
|
45
|
-
# @raise [Providers::Anthropic::Error] on any LLM delivery failure
|
|
46
|
-
def deliver!
|
|
47
|
-
@client ||= LLM::Client.new
|
|
48
|
-
@registry ||= build_tool_registry
|
|
49
|
-
|
|
50
|
-
messages = @session.messages_for_llm
|
|
51
|
-
options = build_llm_options
|
|
52
|
-
|
|
53
|
-
@first_response = @client.provider.create_message(
|
|
54
|
-
model: @client.model,
|
|
55
|
-
messages: messages,
|
|
56
|
-
max_tokens: @client.max_tokens,
|
|
57
|
-
tools: @registry.schemas,
|
|
58
|
-
**options
|
|
59
|
-
)
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
# Runs the LLM tool-use loop on persisted session messages.
|
|
63
|
-
#
|
|
64
|
-
# When a cached first response exists (from {#deliver!}), continues
|
|
65
|
-
# from that response without a redundant API call. Otherwise makes
|
|
66
|
-
# a fresh call — used for pending message processing and the standard
|
|
67
|
-
# path.
|
|
68
|
-
#
|
|
69
|
-
# Lets errors propagate — designed for callers like {AgentRequestJob}
|
|
70
|
-
# that handle retries and need errors to bubble up.
|
|
71
|
-
#
|
|
72
|
-
# @return [String, nil] the agent's response text, or nil when interrupted
|
|
73
|
-
# @raise [Providers::Anthropic::TransientError] on retryable network/server errors
|
|
74
|
-
# @raise [Providers::Anthropic::AuthenticationError] on auth failures
|
|
75
|
-
def run
|
|
76
|
-
@client ||= LLM::Client.new
|
|
77
|
-
@registry ||= build_tool_registry
|
|
78
|
-
|
|
79
|
-
messages = @session.messages_for_llm
|
|
80
|
-
options = build_llm_options
|
|
81
|
-
|
|
82
|
-
first_resp = @first_response
|
|
83
|
-
@first_response = nil
|
|
84
|
-
|
|
85
|
-
response = @client.chat_with_tools(
|
|
86
|
-
messages, registry: @registry, session_id: @session.id,
|
|
87
|
-
first_response: first_resp, **options
|
|
88
|
-
)
|
|
89
|
-
return unless response
|
|
90
|
-
|
|
91
|
-
Events::Bus.emit(Events::AgentMessage.new(content: response, session_id: @session.id))
|
|
92
|
-
response
|
|
93
|
-
end
|
|
94
|
-
|
|
95
|
-
# Clean up the underlying {ShellSession} PTY and resources.
|
|
96
|
-
# Safe to call multiple times — subsequent calls are no-ops.
|
|
97
|
-
def finalize
|
|
98
|
-
@shell_session&.finalize
|
|
99
|
-
end
|
|
100
|
-
|
|
101
|
-
# Tool classes available to all sessions by default.
|
|
102
|
-
# @return [Array<Class<Tools::Base>>]
|
|
103
|
-
STANDARD_TOOLS = [Tools::Bash, Tools::Read, Tools::Write, Tools::Edit, Tools::WebGet, Tools::Think, Tools::Remember, Tools::Recall].freeze
|
|
104
|
-
|
|
105
|
-
# Tools that bypass {Session#granted_tools} filtering.
|
|
106
|
-
# The agent's reasoning depends on these regardless of task scope.
|
|
107
|
-
# @return [Array<Class<Tools::Base>>]
|
|
108
|
-
ALWAYS_GRANTED_TOOLS = [Tools::Think].freeze
|
|
109
|
-
|
|
110
|
-
# Name-to-class mapping for tool restriction validation and registry building.
|
|
111
|
-
# @return [Hash{String => Class<Tools::Base>}]
|
|
112
|
-
STANDARD_TOOLS_BY_NAME = STANDARD_TOOLS.index_by(&:tool_name).freeze
|
|
113
|
-
|
|
114
|
-
private
|
|
115
|
-
|
|
116
|
-
# Assembles LLM options (system prompt, environment context).
|
|
117
|
-
# Broadcasts the full debug context (system prompt + tool schemas)
|
|
118
|
-
# to debug-mode TUI clients on every LLM request.
|
|
119
|
-
# @return [Hash] options for {LLM::Client#chat_with_tools}
|
|
120
|
-
def build_llm_options
|
|
121
|
-
options = {}
|
|
122
|
-
unless @session.sub_agent?
|
|
123
|
-
env_context = EnvironmentProbe.to_prompt(@shell_session.pwd)
|
|
124
|
-
end
|
|
125
|
-
prompt = @session.system_prompt(environment_context: env_context)
|
|
126
|
-
options[:system] = prompt if prompt
|
|
127
|
-
@session.broadcast_debug_context(system: prompt, tools: @registry&.schemas)
|
|
128
|
-
options
|
|
129
|
-
end
|
|
130
|
-
|
|
131
|
-
# Builds the tool registry appropriate for this session type.
|
|
132
|
-
# Main sessions get standard tools + spawn_subagent + spawn_specialist.
|
|
133
|
-
# Sub-agents get granted standard tools only (no spawning, no nesting).
|
|
134
|
-
# Sub-agent results are delivered through natural text messages routed
|
|
135
|
-
# by {Events::Subscribers::SubagentMessageRouter}.
|
|
136
|
-
# When {Session#granted_tools} is nil, all standard tools are granted.
|
|
137
|
-
# MCP tools from configured servers are registered for all session types.
|
|
138
|
-
#
|
|
139
|
-
# @return [Tools::Registry] registry with available tools
|
|
140
|
-
def build_tool_registry
|
|
141
|
-
context = {shell_session: @shell_session, session: @session}
|
|
142
|
-
registry = Tools::Registry.new(context: context)
|
|
143
|
-
|
|
144
|
-
granted_standard_tools.each { |tool| registry.register(tool) }
|
|
145
|
-
|
|
146
|
-
if @session.sub_agent?
|
|
147
|
-
registry.register(Tools::MarkGoalCompleted)
|
|
148
|
-
else
|
|
149
|
-
registry.register(Tools::SpawnSubagent)
|
|
150
|
-
registry.register(Tools::SpawnSpecialist)
|
|
151
|
-
registry.register(Tools::OpenIssue)
|
|
152
|
-
end
|
|
153
|
-
|
|
154
|
-
register_mcp_tools(registry)
|
|
155
|
-
|
|
156
|
-
registry
|
|
157
|
-
end
|
|
158
|
-
|
|
159
|
-
# Loads tools from configured MCP servers and adds them to the registry.
|
|
160
|
-
# Warnings are emitted as system messages — visible to both the user
|
|
161
|
-
# (in verbose mode) and the LLM (via CONTEXT_TYPES) so the agent can
|
|
162
|
-
# explain config issues instead of guessing.
|
|
163
|
-
#
|
|
164
|
-
# @param registry [Tools::Registry] the registry to add MCP tools to
|
|
165
|
-
# @return [void]
|
|
166
|
-
def register_mcp_tools(registry)
|
|
167
|
-
warnings = Mcp::ClientManager.new.register_tools(registry)
|
|
168
|
-
warnings.each do |message|
|
|
169
|
-
Events::Bus.emit(Events::SystemMessage.new(content: message, session_id: @session.id))
|
|
170
|
-
end
|
|
171
|
-
end
|
|
172
|
-
|
|
173
|
-
# Standard tools available to this session.
|
|
174
|
-
# Returns all when {Session#granted_tools} is nil (no restriction).
|
|
175
|
-
# Returns only matching tools when granted_tools is an array,
|
|
176
|
-
# always including {ALWAYS_GRANTED_TOOLS}.
|
|
177
|
-
#
|
|
178
|
-
# @return [Array<Class<Tools::Base>>] tool classes to register
|
|
179
|
-
def granted_standard_tools
|
|
180
|
-
granted = @session.granted_tools
|
|
181
|
-
return STANDARD_TOOLS unless granted
|
|
182
|
-
|
|
183
|
-
explicitly_granted = granted.filter_map { |name| STANDARD_TOOLS_BY_NAME[name] }
|
|
184
|
-
(ALWAYS_GRANTED_TOOLS + explicitly_granted).uniq
|
|
185
|
-
end
|
|
186
|
-
end
|
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module AnalyticalBrain
|
|
4
|
-
module Tools
|
|
5
|
-
# Deactivates a domain knowledge skill on the main session.
|
|
6
|
-
# The skill's content is removed from the main agent's system prompt.
|
|
7
|
-
class DeactivateSkill < ::Tools::Base
|
|
8
|
-
def self.tool_name = "deactivate_skill"
|
|
9
|
-
|
|
10
|
-
def self.description = "Remove domain knowledge that is no longer relevant."
|
|
11
|
-
|
|
12
|
-
def self.input_schema
|
|
13
|
-
{
|
|
14
|
-
type: "object",
|
|
15
|
-
properties: {
|
|
16
|
-
skill_name: {type: "string"}
|
|
17
|
-
},
|
|
18
|
-
required: %w[skill_name]
|
|
19
|
-
}
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
# @param main_session [Session] the session to deactivate the skill on
|
|
23
|
-
def initialize(main_session:, **)
|
|
24
|
-
@main_session = main_session
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
# @param input [Hash<String, Object>] with "skill_name" key
|
|
28
|
-
# @return [String] confirmation message
|
|
29
|
-
# @return [Hash] with :error key on validation failure
|
|
30
|
-
def execute(input)
|
|
31
|
-
skill_name = input["skill_name"].to_s.strip
|
|
32
|
-
return {error: "Skill name cannot be blank"} if skill_name.empty?
|
|
33
|
-
|
|
34
|
-
@main_session.deactivate_skill(skill_name)
|
|
35
|
-
"Deactivated skill: #{skill_name}"
|
|
36
|
-
end
|
|
37
|
-
end
|
|
38
|
-
end
|
|
39
|
-
end
|
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module AnalyticalBrain
|
|
4
|
-
module Tools
|
|
5
|
-
# Deactivates the current workflow on the main session.
|
|
6
|
-
# The workflow's content is removed from the main agent's system prompt.
|
|
7
|
-
class DeactivateWorkflow < ::Tools::Base
|
|
8
|
-
def self.tool_name = "deactivate_workflow"
|
|
9
|
-
|
|
10
|
-
def self.description = "Deactivate the current workflow when it is complete or no longer relevant."
|
|
11
|
-
|
|
12
|
-
def self.input_schema
|
|
13
|
-
{
|
|
14
|
-
type: "object",
|
|
15
|
-
properties: {},
|
|
16
|
-
required: []
|
|
17
|
-
}
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
# @param main_session [Session] the session to deactivate the workflow on
|
|
21
|
-
def initialize(main_session:, **)
|
|
22
|
-
@main_session = main_session
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
# @param input [Hash<String, Object>] (no parameters needed)
|
|
26
|
-
# @return [String] confirmation message
|
|
27
|
-
def execute(_input)
|
|
28
|
-
previous = @main_session.active_workflow
|
|
29
|
-
@main_session.deactivate_workflow
|
|
30
|
-
previous ? "Deactivated workflow: #{previous}" : "No workflow was active"
|
|
31
|
-
end
|
|
32
|
-
end
|
|
33
|
-
end
|
|
34
|
-
end
|