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
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Decorates a +subagent+ {PendingMessage} — a sub-agent's reply that
|
|
4
|
+
# landed on the parent's mailbox via {SubagentMessageRouter}. Promotes
|
|
5
|
+
# into a phantom +from_<nickname>+ tool_call/tool_response pair, but
|
|
6
|
+
# while pending it surfaces as a labeled inbound delivery so the user
|
|
7
|
+
# sees which sub-agent is talking to her.
|
|
8
|
+
#
|
|
9
|
+
# Hidden in basic (matches the promoted tool pair, which is hidden in
|
|
10
|
+
# basic). Visible from verbose with a +[from <nickname>]+ badge.
|
|
11
|
+
class PendingSubagentDecorator < PendingMessageDecorator
|
|
12
|
+
# @return [nil] sub-agent deliveries are hidden in basic mode
|
|
13
|
+
def render_basic
|
|
14
|
+
nil
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# @return [Hash] dimmed sub-agent delivery payload
|
|
18
|
+
def render_verbose
|
|
19
|
+
{
|
|
20
|
+
role: :pending_subagent,
|
|
21
|
+
source: source_name,
|
|
22
|
+
content: truncate_lines(content, max_lines: 3),
|
|
23
|
+
status: "pending"
|
|
24
|
+
}
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @return [Hash] full sub-agent delivery payload
|
|
28
|
+
def render_debug
|
|
29
|
+
{
|
|
30
|
+
role: :pending_subagent,
|
|
31
|
+
source: source_name,
|
|
32
|
+
content: content,
|
|
33
|
+
status: "pending"
|
|
34
|
+
}
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @return [String] Melete transcript line
|
|
38
|
+
def render_melete
|
|
39
|
+
"Sub-agent #{source_name} (pending): #{truncate_middle(content)}"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @return [String] Mneme transcript line
|
|
43
|
+
def render_mneme
|
|
44
|
+
"Sub-agent #{source_name} (pending): #{truncate_middle(content)}"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Decorates a +tool_response+ {PendingMessage} — a tool result waiting in
|
|
4
|
+
# the mailbox before the drain pairs it with its tool_call and feeds the
|
|
5
|
+
# next LLM turn. Mirrors {ToolResponseDecorator}: hidden in basic
|
|
6
|
+
# (aggregated by the tool counter), structured tool output in verbose,
|
|
7
|
+
# full untruncated content in debug — all dimmed via
|
|
8
|
+
# +status: "pending"+.
|
|
9
|
+
class PendingToolResponseDecorator < PendingMessageDecorator
|
|
10
|
+
# @return [nil] tool responses are hidden in basic mode
|
|
11
|
+
def render_basic
|
|
12
|
+
nil
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# @return [Hash] truncated tool response payload tagged as pending
|
|
16
|
+
def render_verbose
|
|
17
|
+
{
|
|
18
|
+
role: :tool_response,
|
|
19
|
+
tool: source_name,
|
|
20
|
+
content: truncate_lines(content, max_lines: 3),
|
|
21
|
+
# nil treated as success; only an explicit false flips the indicator
|
|
22
|
+
# — mirrors {ToolResponseDecorator}'s convention so legacy PMs
|
|
23
|
+
# without an explicit success column don't render as failures.
|
|
24
|
+
success: success != false,
|
|
25
|
+
tool_use_id: tool_use_id,
|
|
26
|
+
status: "pending"
|
|
27
|
+
}
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# @return [Hash] full tool response payload tagged as pending
|
|
31
|
+
def render_debug
|
|
32
|
+
{
|
|
33
|
+
role: :tool_response,
|
|
34
|
+
tool: source_name,
|
|
35
|
+
content: content,
|
|
36
|
+
success: success != false,
|
|
37
|
+
tool_use_id: tool_use_id,
|
|
38
|
+
status: "pending"
|
|
39
|
+
}
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @return [String] Melete transcript line
|
|
43
|
+
def render_melete
|
|
44
|
+
"tool_response #{tool_use_id} (pending): #{truncate_middle(content)}"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @return [String] Mneme transcript line
|
|
48
|
+
def render_mneme
|
|
49
|
+
"tool_response #{tool_use_id} (pending): #{truncate_middle(content)}"
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Decorates a +user_message+ {PendingMessage} — the user's input as it
|
|
4
|
+
# sits in the mailbox between submission and promotion. Mirrors
|
|
5
|
+
# {UserMessageDecorator}'s shape, with +status: "pending"+ added so the
|
|
6
|
+
# TUI dims the entry.
|
|
7
|
+
class PendingUserMessageDecorator < PendingMessageDecorator
|
|
8
|
+
# @return [Hash] dimmed user message payload
|
|
9
|
+
def render_basic
|
|
10
|
+
{role: :user, content: content, status: "pending"}
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# @return [String] Melete transcript line
|
|
14
|
+
def render_melete
|
|
15
|
+
"User (pending): #{truncate_middle(content)}"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# @return [String] Mneme transcript line
|
|
19
|
+
def render_mneme
|
|
20
|
+
"User (pending): #{truncate_middle(content)}"
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -50,8 +50,8 @@ class ToolCallDecorator < MessageDecorator
|
|
|
50
50
|
|
|
51
51
|
# Think calls get full text — the agent's reasoning IS the signal.
|
|
52
52
|
# Other tool calls show tool name + params (compact JSON).
|
|
53
|
-
# @return [String] transcript line for
|
|
54
|
-
def
|
|
53
|
+
# @return [String] transcript line for Melete
|
|
54
|
+
def render_melete
|
|
55
55
|
if think?
|
|
56
56
|
"Think: #{thoughts}"
|
|
57
57
|
else
|
|
@@ -59,6 +59,17 @@ class ToolCallDecorator < MessageDecorator
|
|
|
59
59
|
end
|
|
60
60
|
end
|
|
61
61
|
|
|
62
|
+
# Think calls render as conversation. Regular tool calls return
|
|
63
|
+
# a +:tool_call+ marker for the counter accumulator.
|
|
64
|
+
# @return [String, Symbol] transcript line or counter marker
|
|
65
|
+
def render_mneme
|
|
66
|
+
if think?
|
|
67
|
+
"message #{id} Think: #{thoughts}"
|
|
68
|
+
else
|
|
69
|
+
:tool_call
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
62
73
|
private
|
|
63
74
|
|
|
64
75
|
def think?
|
|
@@ -46,10 +46,10 @@ class ToolResponseDecorator < MessageDecorator
|
|
|
46
46
|
}.merge(token_info)
|
|
47
47
|
end
|
|
48
48
|
|
|
49
|
-
# Think responses ("OK") are noise — excluded from
|
|
49
|
+
# Think responses ("OK") are noise — excluded from Melete's transcript.
|
|
50
50
|
# Other tool responses are compressed to success/failure indicators only.
|
|
51
51
|
# @return [String, nil] ✅ or ❌ indicator, nil for think responses
|
|
52
|
-
def
|
|
52
|
+
def render_melete
|
|
53
53
|
return if think?
|
|
54
54
|
|
|
55
55
|
(payload["success"] != false) ? "\u2705" : "\u274C"
|
|
@@ -19,9 +19,14 @@ class UserMessageDecorator < MessageDecorator
|
|
|
19
19
|
render_verbose.merge(token_info)
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
-
# @return [String] user message for
|
|
22
|
+
# @return [String] user message for Melete, middle-truncated
|
|
23
23
|
# if very long (preserves intent at start and conclusion at end)
|
|
24
|
-
def
|
|
24
|
+
def render_melete
|
|
25
25
|
"User: #{truncate_middle(content)}"
|
|
26
26
|
end
|
|
27
|
+
|
|
28
|
+
# @return [String] transcript line for Mneme's eviction/context zones
|
|
29
|
+
def render_mneme
|
|
30
|
+
"message #{id} User: #{content}"
|
|
31
|
+
end
|
|
27
32
|
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Refines a record's +token_count+ with the real Anthropic tokenizer count,
|
|
4
|
+
# replacing the local heuristic seeded during creation. Accepts any record
|
|
5
|
+
# that includes {TokenEstimation} and implements +#tokenization_text+ —
|
|
6
|
+
# Messages, Snapshots, and PinnedMessages share the same pipeline.
|
|
7
|
+
class CountTokensJob < ApplicationJob
|
|
8
|
+
queue_as :default
|
|
9
|
+
|
|
10
|
+
retry_on Providers::Anthropic::Error, wait: :polynomially_longer, attempts: 3
|
|
11
|
+
discard_on ActiveRecord::RecordNotFound
|
|
12
|
+
|
|
13
|
+
# @param record [ActiveRecord::Base] any record responding to
|
|
14
|
+
# +#tokenization_text+ and +token_count=+
|
|
15
|
+
def perform(record)
|
|
16
|
+
count = Providers::Anthropic.new.count_tokens(
|
|
17
|
+
model: Anima::Settings.model,
|
|
18
|
+
messages: [{role: "user", content: record.tokenization_text}]
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
record.update!(token_count: count)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Drains the PendingMessage mailbox into a single LLM round-trip.
|
|
4
|
+
#
|
|
5
|
+
# One invocation == one half-step of the event-driven agent loop:
|
|
6
|
+
# 1. Claim the session via {Session#start_processing!} (atomic; bails if
|
|
7
|
+
# another drain already holds the session OR the in-flight tool round
|
|
8
|
+
# is incomplete — the AASM guard +tool_round_complete?+ handles both).
|
|
9
|
+
# 2. Promote pending work into the conversation — every tool_response PM
|
|
10
|
+
# of the freshly completed round flushes in one transaction so the
|
|
11
|
+
# LLM sees a whole assistant turn paired with a whole user turn;
|
|
12
|
+
# background phantom pairs flush; one active FIFO message rides
|
|
13
|
+
# along. Promotion lives on {PendingMessage#promote!} — the job only
|
|
14
|
+
# decides what to pick and in which order.
|
|
15
|
+
# 3. Make one LLM API call and emit {Events::LLMResponded}.
|
|
16
|
+
#
|
|
17
|
+
# On the happy path the job never releases the session — state
|
|
18
|
+
# transitions after the emit belong to
|
|
19
|
+
# {Events::Subscribers::LLMResponseHandler} (on text or tool dispatch).
|
|
20
|
+
# {Events::Subscribers::ToolResponseCreator} no longer touches state;
|
|
21
|
+
# the +executing → awaiting+ branch of +start_processing+ closes the
|
|
22
|
+
# tool round and claims in one atomic, lock-protected step.
|
|
23
|
+
#
|
|
24
|
+
# The job DOES release its own claim when there is no responder to do
|
|
25
|
+
# it: an empty mailbox (spurious kickoff) or an exception raised before
|
|
26
|
+
# the LLM call succeeded. Those are lifecycle edges of the claim itself,
|
|
27
|
+
# not hand-offs to responders.
|
|
28
|
+
#
|
|
29
|
+
# @see Events::Subscribers::DrainKickoff — enqueues this job
|
|
30
|
+
# @see Events::LLMResponded — the event emitted on LLM completion
|
|
31
|
+
class DrainJob < ApplicationJob
|
|
32
|
+
queue_as :default
|
|
33
|
+
|
|
34
|
+
# Transient provider errors retry inline within {#call_llm_and_emit}.
|
|
35
|
+
# A job-level +retry_on+ would be a no-op here: {PendingMessage#promote!}
|
|
36
|
+
# destroys the PM rows *before* the LLM call, so a retried job would find
|
|
37
|
+
# an empty mailbox and exit without ever re-issuing the request.
|
|
38
|
+
|
|
39
|
+
discard_on Providers::Anthropic::AuthenticationError do |job, error|
|
|
40
|
+
Events::Bus.emit(Events::AuthenticationRequired.new(
|
|
41
|
+
session_id: job.arguments.first,
|
|
42
|
+
content: error.message
|
|
43
|
+
))
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
discard_on ActiveRecord::RecordNotFound
|
|
47
|
+
|
|
48
|
+
# @param session_id [Integer]
|
|
49
|
+
def perform(session_id)
|
|
50
|
+
@session = Session.find(session_id)
|
|
51
|
+
return unless @session.start_processing!
|
|
52
|
+
|
|
53
|
+
drained = drain_mailbox
|
|
54
|
+
return @session.response_complete! if drained.zero?
|
|
55
|
+
|
|
56
|
+
call_llm_and_emit
|
|
57
|
+
rescue Providers::Anthropic::AuthenticationError => error
|
|
58
|
+
release_after_failure(error) if @session
|
|
59
|
+
raise
|
|
60
|
+
rescue => error
|
|
61
|
+
release_after_failure(error) if @session
|
|
62
|
+
raise unless @active_pm&.bounce_back?
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
# Decides what the upcoming LLM round will carry and promotes those PMs.
|
|
68
|
+
#
|
|
69
|
+
# 1. All +tool_response+ PMs of the just-completed round — the AASM
|
|
70
|
+
# guard guarantees they are all present when we arrive from
|
|
71
|
+
# +:executing+; from +:idle+ this set is empty.
|
|
72
|
+
# 2. Pick one active FIFO message (user_message or subagent) to ride
|
|
73
|
+
# along. If there are no tool_responses AND no active message, the
|
|
74
|
+
# LLM call is a no-op: release the claim and do NOT flush background
|
|
75
|
+
# PMs (they stay in the mailbox for the next turn).
|
|
76
|
+
# 3. Flush background phantom pairs so they sit above the active pick.
|
|
77
|
+
# 4. Promote the active pick.
|
|
78
|
+
#
|
|
79
|
+
# @return [Integer] count of PMs promoted this cycle (0 means "release
|
|
80
|
+
# the claim without calling the LLM")
|
|
81
|
+
def drain_mailbox
|
|
82
|
+
tool_responses = @session.pending_messages.where(message_type: "tool_response").order(:created_at).to_a
|
|
83
|
+
@active_pm = @session.pending_messages.active.where.not(message_type: "tool_response").order(:created_at).first
|
|
84
|
+
|
|
85
|
+
promoted = tool_responses.size + (@active_pm ? 1 : 0)
|
|
86
|
+
return 0 if promoted.zero?
|
|
87
|
+
|
|
88
|
+
tool_responses.each(&:promote!)
|
|
89
|
+
@session.pending_messages.background.find_each(&:promote!)
|
|
90
|
+
@active_pm&.promote!
|
|
91
|
+
|
|
92
|
+
promoted
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def call_llm_and_emit
|
|
96
|
+
prompt = @session.system_prompt
|
|
97
|
+
@session.broadcast_debug_context(system: prompt, tools: registry.schemas)
|
|
98
|
+
|
|
99
|
+
response = with_transient_retry do
|
|
100
|
+
client.provider.create_message(
|
|
101
|
+
model: client.model,
|
|
102
|
+
messages: @session.messages_for_llm,
|
|
103
|
+
max_tokens: client.max_tokens,
|
|
104
|
+
tools: registry.schemas,
|
|
105
|
+
include_metrics: true,
|
|
106
|
+
**(prompt ? {system: prompt} : {})
|
|
107
|
+
)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
Events::Bus.emit(Events::LLMResponded.new(
|
|
111
|
+
session_id: @session.id,
|
|
112
|
+
response: response.to_h.stringify_keys,
|
|
113
|
+
api_metrics: response.api_metrics
|
|
114
|
+
))
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Retries the LLM call in-place on transient provider errors. The
|
|
118
|
+
# polynomial-backoff formula mirrors ActiveJob's +:polynomially_longer+.
|
|
119
|
+
# On final exhaustion the subscriber-visible SystemMessage is emitted
|
|
120
|
+
# before the error re-raises into {#perform}'s rescue path for release.
|
|
121
|
+
TRANSIENT_RETRY_ATTEMPTS = 5
|
|
122
|
+
|
|
123
|
+
def with_transient_retry
|
|
124
|
+
tries = 0
|
|
125
|
+
begin
|
|
126
|
+
yield
|
|
127
|
+
rescue Providers::Anthropic::TransientError => error
|
|
128
|
+
tries += 1
|
|
129
|
+
if tries >= TRANSIENT_RETRY_ATTEMPTS
|
|
130
|
+
Events::Bus.emit(Events::SystemMessage.new(
|
|
131
|
+
content: "Failed after multiple retries: #{error.message}",
|
|
132
|
+
session_id: @session.id
|
|
133
|
+
))
|
|
134
|
+
raise
|
|
135
|
+
end
|
|
136
|
+
sleep(transient_backoff(tries))
|
|
137
|
+
retry
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def transient_backoff(attempt)
|
|
142
|
+
base = attempt**4
|
|
143
|
+
base + (rand * 0.15 * base)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def release_after_failure(error)
|
|
147
|
+
if @active_pm&.bounce_back?
|
|
148
|
+
@session.release_with_bounce_back(@active_pm, error)
|
|
149
|
+
elsif @session.may_response_complete?
|
|
150
|
+
@session.response_complete!
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def client
|
|
155
|
+
@client ||= if @session.sub_agent?
|
|
156
|
+
LLM::Client.new(model: Anima::Settings.subagent_model)
|
|
157
|
+
else
|
|
158
|
+
LLM::Client.new
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def registry
|
|
163
|
+
@registry ||= Tools::Registry.build(session: @session, shell_session: shell_session)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def shell_session
|
|
167
|
+
@shell_session ||= ShellSession.for_session(@session)
|
|
168
|
+
end
|
|
169
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class MeleteEnrichmentJob < ApplicationJob
|
|
4
|
+
# Scoped subscriber that watches the event bus for goal mutation events
|
|
5
|
+
# (+anima.goal.created+, +anima.goal.updated+) belonging to one session,
|
|
6
|
+
# for the duration of a block.
|
|
7
|
+
#
|
|
8
|
+
# Returns +true+ from {.observe} when at least one matching event fired
|
|
9
|
+
# during the block, +false+ otherwise. The subscription is registered
|
|
10
|
+
# before the block runs and removed in an +ensure+ so it is cleaned up
|
|
11
|
+
# even if the block raises.
|
|
12
|
+
#
|
|
13
|
+
# @example
|
|
14
|
+
# goal_changed = GoalChangeListener.observe(session_id: 42) do
|
|
15
|
+
# Melete::Runner.new(session).call
|
|
16
|
+
# end
|
|
17
|
+
class GoalChangeListener
|
|
18
|
+
EVENT_NAMES = [
|
|
19
|
+
"#{Events::Bus::NAMESPACE}.#{Events::GoalCreated::TYPE}",
|
|
20
|
+
"#{Events::Bus::NAMESPACE}.#{Events::GoalUpdated::TYPE}"
|
|
21
|
+
].freeze
|
|
22
|
+
|
|
23
|
+
# @param session_id [Integer] only events whose payload session_id matches count
|
|
24
|
+
# @yield runs while the subscription is active
|
|
25
|
+
# @return [Boolean] whether a matching event fired during the block
|
|
26
|
+
def self.observe(session_id:, &block)
|
|
27
|
+
new(session_id).observe(&block)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def initialize(session_id)
|
|
31
|
+
@session_id = session_id
|
|
32
|
+
@triggered = false
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def observe
|
|
36
|
+
Events::Bus.subscribe(self) do |event|
|
|
37
|
+
EVENT_NAMES.include?(event[:name]) &&
|
|
38
|
+
event[:payload][:session_id] == @session_id
|
|
39
|
+
end
|
|
40
|
+
yield
|
|
41
|
+
@triggered
|
|
42
|
+
ensure
|
|
43
|
+
Events::Bus.unsubscribe(self)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Bus subscriber contract — flips the latch on any matching event.
|
|
47
|
+
# @api private
|
|
48
|
+
def emit(_event)
|
|
49
|
+
@triggered = true
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# First stage of the drain pipeline: runs Melete to activate skills,
|
|
4
|
+
# read workflows, and refine goals. Hands off to either Mneme (when goals
|
|
5
|
+
# changed during this run) or directly to the drain loop.
|
|
6
|
+
#
|
|
7
|
+
# Triggered by {Events::Subscribers::MeleteKickoff} in response to
|
|
8
|
+
# {Events::StartMelete}. Runs the existing synchronous {Melete::Runner}
|
|
9
|
+
# — the event is only the entry/exit plumbing.
|
|
10
|
+
#
|
|
11
|
+
# A {GoalChangeListener} observes {Events::GoalCreated} and
|
|
12
|
+
# {Events::GoalUpdated} for the duration of the runner call. When a goal
|
|
13
|
+
# mutation is heard the job emits {Events::StartMneme} so Mneme recalls
|
|
14
|
+
# against the fresh goal set; otherwise it emits {Events::StartProcessing}
|
|
15
|
+
# and Mneme is skipped — there is no new search seed to recall against.
|
|
16
|
+
#
|
|
17
|
+
# Sub-agents skip Melete entirely (sub-agent nickname assignment is a
|
|
18
|
+
# one-time step, not part of the recurring pipeline). With no runner
|
|
19
|
+
# call, the listener never fires and the job falls through to
|
|
20
|
+
# {Events::StartProcessing}.
|
|
21
|
+
#
|
|
22
|
+
# Exceptions from {Melete::Runner#call} propagate — no defensive rescue.
|
|
23
|
+
# A crashed Melete leaves the session idle with the PM still in the
|
|
24
|
+
# mailbox; the next PM create (or idle-wake re-route) retries the full
|
|
25
|
+
# chain. Swallowing would surface a degraded response to the user without
|
|
26
|
+
# the failure being visible anywhere (anti-pattern per the project's
|
|
27
|
+
# "soft error paths" principle).
|
|
28
|
+
class MeleteEnrichmentJob < ApplicationJob
|
|
29
|
+
queue_as :default
|
|
30
|
+
|
|
31
|
+
discard_on ActiveRecord::RecordNotFound
|
|
32
|
+
|
|
33
|
+
# @param session_id [Integer]
|
|
34
|
+
# @param pending_message_id [Integer, nil] the PM that kicked off the chain
|
|
35
|
+
def perform(session_id, pending_message_id: nil)
|
|
36
|
+
session = Session.find(session_id)
|
|
37
|
+
|
|
38
|
+
goal_changed = GoalChangeListener.observe(session_id: session_id) do
|
|
39
|
+
Melete::Runner.new(session).call unless session.sub_agent?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
next_event_class = goal_changed ? Events::StartMneme : Events::StartProcessing
|
|
43
|
+
Events::Bus.emit(next_event_class.new(
|
|
44
|
+
session_id: session_id,
|
|
45
|
+
pending_message_id: pending_message_id
|
|
46
|
+
))
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Second stage of the drain pipeline: runs Mneme's recall loop so any
|
|
4
|
+
# older memory she judges useful lands in the mailbox as background
|
|
5
|
+
# {PendingMessage}s, then hands off to the drain loop via
|
|
6
|
+
# {Events::StartProcessing}.
|
|
7
|
+
#
|
|
8
|
+
# Triggered by {Events::Subscribers::MnemeKickoff} in response to
|
|
9
|
+
# {Events::StartMneme}, which {MeleteEnrichmentJob} only emits when goals
|
|
10
|
+
# changed during the preceding Melete run. Runs the phantom
|
|
11
|
+
# {Mneme::RecallRunner} loop — the event is only the entry/exit plumbing.
|
|
12
|
+
#
|
|
13
|
+
# Mneme recall is *enrichment* — it adds recalled memories as background
|
|
14
|
+
# phantom pairs but is never required for the primary pipeline to make
|
|
15
|
+
# progress. If recall raises (bad FTS5 input, SQL glitch, …) the handoff
|
|
16
|
+
# to the drain loop must still happen, otherwise the session's user
|
|
17
|
+
# message is stranded in the mailbox with no retry trigger. Exceptions
|
|
18
|
+
# are logged loudly so failures stay visible — they just don't gate the drain.
|
|
19
|
+
class MnemeEnrichmentJob < ApplicationJob
|
|
20
|
+
queue_as :default
|
|
21
|
+
|
|
22
|
+
discard_on ActiveRecord::RecordNotFound
|
|
23
|
+
|
|
24
|
+
# @param session_id [Integer]
|
|
25
|
+
# @param pending_message_id [Integer, nil] the PM that kicked off the chain
|
|
26
|
+
def perform(session_id, pending_message_id: nil)
|
|
27
|
+
session = Session.find(session_id)
|
|
28
|
+
|
|
29
|
+
run_recall(session)
|
|
30
|
+
|
|
31
|
+
Events::Bus.emit(Events::StartProcessing.new(
|
|
32
|
+
session_id: session_id,
|
|
33
|
+
pending_message_id: pending_message_id
|
|
34
|
+
))
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def run_recall(session)
|
|
40
|
+
Mneme::RecallRunner.new(session).call
|
|
41
|
+
rescue => error
|
|
42
|
+
msg = "FAILED (recall) session=#{session.id}: #{error.class}: #{error.message}"
|
|
43
|
+
Rails.logger.error("Mneme #{msg}")
|
|
44
|
+
Mneme.logger.error("#{msg}\n#{error.backtrace&.first(10)&.join("\n")}")
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Runs a single tool on behalf of the session and reports the outcome.
|
|
4
|
+
#
|
|
5
|
+
# Queued by {Events::Subscribers::LLMResponseHandler} when the LLM
|
|
6
|
+
# returns a +tool_use+ block. The session is already in the +:executing+
|
|
7
|
+
# state (transition owned by the response handler). This job:
|
|
8
|
+
#
|
|
9
|
+
# 1. Dispatches the tool via {Tools::Registry}.
|
|
10
|
+
# 2. Truncates and formats the result.
|
|
11
|
+
# 3. Emits {Events::ToolExecuted}.
|
|
12
|
+
#
|
|
13
|
+
# The job does not release the session or create the +tool_response+
|
|
14
|
+
# PendingMessage — that's {Events::Subscribers::ToolResponseCreator}'s
|
|
15
|
+
# job. Event emission is the final act that hands control off.
|
|
16
|
+
class ToolExecutionJob < ApplicationJob
|
|
17
|
+
queue_as :default
|
|
18
|
+
|
|
19
|
+
discard_on ActiveRecord::RecordNotFound
|
|
20
|
+
|
|
21
|
+
# @param session_id [Integer]
|
|
22
|
+
# @param tool_use_id [String] Anthropic-assigned pairing ID
|
|
23
|
+
# @param tool_name [String]
|
|
24
|
+
# @param tool_input [Hash]
|
|
25
|
+
def perform(session_id, tool_use_id:, tool_name:, tool_input:)
|
|
26
|
+
session = Session.find(session_id)
|
|
27
|
+
# ShellSession.for_session returns the conversation's persistent shell
|
|
28
|
+
# — spawned on first use, reused on every subsequent tool call so the
|
|
29
|
+
# agent's cd's and exported env vars survive between calls. We do NOT
|
|
30
|
+
# finalize it here; the shell's lifetime is the Session's lifetime.
|
|
31
|
+
shell_session = ShellSession.for_session(session)
|
|
32
|
+
registry = Tools::Registry.build(session: session, shell_session: shell_session)
|
|
33
|
+
|
|
34
|
+
content, success = execute(registry, tool_name, tool_input, tool_use_id)
|
|
35
|
+
|
|
36
|
+
Events::Bus.emit(Events::ToolExecuted.new(
|
|
37
|
+
session_id: session_id,
|
|
38
|
+
tool_use_id: tool_use_id,
|
|
39
|
+
tool_name: tool_name,
|
|
40
|
+
content: content,
|
|
41
|
+
success: success
|
|
42
|
+
))
|
|
43
|
+
rescue => error
|
|
44
|
+
# A missing {Events::ToolExecuted} would leave the session in +:executing+
|
|
45
|
+
# forever. Always emit a synthetic failure event so
|
|
46
|
+
# {Events::Subscribers::ToolResponseCreator} runs and releases the claim.
|
|
47
|
+
Rails.logger.error("ToolExecutionJob crashed: #{error.class}: #{error.message}")
|
|
48
|
+
Events::Bus.emit(Events::ToolExecuted.new(
|
|
49
|
+
session_id: session_id,
|
|
50
|
+
tool_use_id: tool_use_id,
|
|
51
|
+
tool_name: tool_name,
|
|
52
|
+
content: "#{error.class}: #{error.message}",
|
|
53
|
+
success: false
|
|
54
|
+
))
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
# Always emits something executable back — a missing +tool_result+
|
|
60
|
+
# permanently corrupts the Anthropic conversation history.
|
|
61
|
+
def execute(registry, tool_name, tool_input, tool_use_id)
|
|
62
|
+
result = registry.execute(tool_name, tool_input, tool_use_id: tool_use_id)
|
|
63
|
+
result = ::ToolDecorator.call(tool_name, result)
|
|
64
|
+
content = format_result(result)
|
|
65
|
+
content = truncate(content, registry, tool_name)
|
|
66
|
+
[content, !result.is_a?(Hash) || !result.key?(:error)]
|
|
67
|
+
rescue => error
|
|
68
|
+
Rails.logger.error("Tool #{tool_name} raised #{error.class}: #{error.message}")
|
|
69
|
+
[format_result(error: "#{error.class}: #{error.message}"), false]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def format_result(result)
|
|
73
|
+
result.is_a?(Hash) ? result.to_json : result.to_s
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def truncate(content, registry, tool_name)
|
|
77
|
+
threshold = registry.truncation_threshold(tool_name)
|
|
78
|
+
return content unless threshold
|
|
79
|
+
|
|
80
|
+
lines = ::Tools::ResponseTruncator::HEAD_LINES
|
|
81
|
+
::Tools::ResponseTruncator.truncate(
|
|
82
|
+
content,
|
|
83
|
+
threshold: threshold,
|
|
84
|
+
reason: "#{tool_name} output displays first/last #{lines} lines"
|
|
85
|
+
)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Shared token-count lifecycle for records that ride in the LLM context
|
|
4
|
+
# window. Including models seed {#token_count} with a local heuristic on
|
|
5
|
+
# create and schedule {CountTokensJob} to refine it with the real Anthropic
|
|
6
|
+
# tokenizer count.
|
|
7
|
+
#
|
|
8
|
+
# Non-AR callers (TUI debug display, phantom-pair sizing, byte-cap
|
|
9
|
+
# calculations) use {.estimate_token_count} and {BYTES_PER_TOKEN} as
|
|
10
|
+
# module-level helpers without including the concern.
|
|
11
|
+
#
|
|
12
|
+
# Including models must implement +#tokenization_text+ returning the
|
|
13
|
+
# string whose token count should be estimated and later refined.
|
|
14
|
+
module TokenEstimation
|
|
15
|
+
extend ActiveSupport::Concern
|
|
16
|
+
|
|
17
|
+
# Heuristic: average bytes per token for English prose.
|
|
18
|
+
BYTES_PER_TOKEN = 4
|
|
19
|
+
|
|
20
|
+
# Estimates token count from a string using the {BYTES_PER_TOKEN} heuristic.
|
|
21
|
+
#
|
|
22
|
+
# @param text [String, nil]
|
|
23
|
+
# @return [Integer] estimated token count (0 for blank input)
|
|
24
|
+
def self.estimate_token_count(text)
|
|
25
|
+
(text.to_s.bytesize / BYTES_PER_TOKEN.to_f).ceil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
included do
|
|
29
|
+
before_validation :set_estimated_token_count, on: :create
|
|
30
|
+
after_create :schedule_token_count
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Heuristic token estimate for this record's {#tokenization_text}.
|
|
34
|
+
#
|
|
35
|
+
# @return [Integer]
|
|
36
|
+
def estimate_tokens
|
|
37
|
+
TokenEstimation.estimate_token_count(tokenization_text)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
# Seeds {#token_count} with a local estimate before the record is saved.
|
|
43
|
+
# Respects an explicit positive value passed by the caller (e.g. tests
|
|
44
|
+
# that want deterministic counts).
|
|
45
|
+
def set_estimated_token_count
|
|
46
|
+
return if token_count.to_i.positive?
|
|
47
|
+
|
|
48
|
+
self.token_count = estimate_tokens
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def schedule_token_count
|
|
52
|
+
CountTokensJob.perform_later(self)
|
|
53
|
+
end
|
|
54
|
+
end
|