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
|
@@ -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
|
data/app/models/goal.rb
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# A persistent objective tracked by
|
|
3
|
+
# A persistent objective tracked by Melete during a session.
|
|
4
4
|
# Goals form a two-level hierarchy: root goals represent high-level
|
|
5
5
|
# objectives (semantic episodes), while sub-goals are TODO-style steps
|
|
6
6
|
# rendered as checklist items in the agent's system prompt.
|
|
7
7
|
#
|
|
8
|
-
#
|
|
8
|
+
# Melete creates and completes goals; the main agent sees
|
|
9
9
|
# them in its context window but never manages them directly.
|
|
10
10
|
class Goal < ApplicationRecord
|
|
11
11
|
STATUSES = %w[active completed].freeze
|
|
@@ -26,17 +26,19 @@ class Goal < ApplicationRecord
|
|
|
26
26
|
scope :root, -> { where(parent_goal_id: nil) }
|
|
27
27
|
|
|
28
28
|
# @!method self.not_evicted
|
|
29
|
-
# Goals still visible in context (not yet evicted by
|
|
29
|
+
# Goals still visible in context (not yet evicted by Melete).
|
|
30
30
|
# @return [ActiveRecord::Relation]
|
|
31
31
|
scope :not_evicted, -> { where(evicted_at: nil) }
|
|
32
32
|
|
|
33
33
|
# @!method self.evictable
|
|
34
|
-
# Completed goals
|
|
34
|
+
# Completed goals not yet evicted — their phantom pairs remain in the
|
|
35
|
+
# sliding window until Mneme compresses them during the eviction cycle.
|
|
35
36
|
# @return [ActiveRecord::Relation]
|
|
36
37
|
scope :evictable, -> { completed.where(evicted_at: nil) }
|
|
37
38
|
|
|
38
39
|
after_commit :broadcast_goals_update
|
|
39
|
-
after_commit :
|
|
40
|
+
after_commit :emit_goal_created, on: :create
|
|
41
|
+
after_commit :emit_goal_updated, on: :update, if: :saved_change_to_description?
|
|
40
42
|
|
|
41
43
|
# @return [Boolean] true if this goal has been completed
|
|
42
44
|
def completed? = status == "completed"
|
|
@@ -55,7 +57,7 @@ class Goal < ApplicationRecord
|
|
|
55
57
|
# the semantic episode that spawned them has ended.
|
|
56
58
|
#
|
|
57
59
|
# Uses +update_all+ to avoid N per-record +after_commit+ broadcasts;
|
|
58
|
-
# the caller ({
|
|
60
|
+
# the caller ({Melete::Tools::FinishGoal}) wraps the whole
|
|
59
61
|
# operation in a transaction so the root goal's single broadcast
|
|
60
62
|
# includes the cascaded state.
|
|
61
63
|
#
|
|
@@ -106,14 +108,24 @@ class Goal < ApplicationRecord
|
|
|
106
108
|
errors.add(:parent_goal, "cannot nest deeper than two levels")
|
|
107
109
|
end
|
|
108
110
|
|
|
109
|
-
#
|
|
110
|
-
#
|
|
111
|
+
# Announces a freshly created goal so the active drain pipeline can
|
|
112
|
+
# decide whether Mneme should recall against the updated goal set.
|
|
113
|
+
# Only {MeleteEnrichmentJob}'s scoped listener observes these events;
|
|
114
|
+
# outside of a Melete run they are silently dropped.
|
|
111
115
|
#
|
|
112
116
|
# @return [void]
|
|
113
|
-
def
|
|
114
|
-
|
|
117
|
+
def emit_goal_created
|
|
118
|
+
Events::Bus.emit(Events::GoalCreated.new(session_id: session_id, goal_id: id))
|
|
119
|
+
end
|
|
115
120
|
|
|
116
|
-
|
|
121
|
+
# Announces a description-level change to an existing goal. Status-only
|
|
122
|
+
# updates (finish, cascade, mark_goal_completed) are filtered out by the
|
|
123
|
+
# +saved_change_to_description?+ guard on the callback — a completed goal
|
|
124
|
+
# carries no new search seed for Mneme.
|
|
125
|
+
#
|
|
126
|
+
# @return [void]
|
|
127
|
+
def emit_goal_updated
|
|
128
|
+
Events::Bus.emit(Events::GoalUpdated.new(session_id: session_id, goal_id: id))
|
|
117
129
|
end
|
|
118
130
|
|
|
119
131
|
# Broadcasts goal changes to all clients subscribed to this session.
|
data/app/models/message.rb
CHANGED
|
@@ -7,6 +7,10 @@
|
|
|
7
7
|
# Not to be confused with {Events::Base} (transient bus signals).
|
|
8
8
|
# Messages persist to SQLite; events flow through the bus and are gone.
|
|
9
9
|
#
|
|
10
|
+
# After commit, emits {Events::MessageCreated} and {Events::MessageUpdated}
|
|
11
|
+
# lifecycle events so subscribers ({Events::Subscribers::MessageBroadcaster},
|
|
12
|
+
# {Events::Subscribers::MnemeScheduler}) can react without coupling.
|
|
13
|
+
#
|
|
10
14
|
# @!attribute message_type
|
|
11
15
|
# @return [String] one of {TYPES}: system_message, user_message,
|
|
12
16
|
# agent_message, tool_call, tool_response
|
|
@@ -15,41 +19,30 @@
|
|
|
15
19
|
# @!attribute timestamp
|
|
16
20
|
# @return [Integer] nanoseconds since epoch (Process::CLOCK_REALTIME)
|
|
17
21
|
# @!attribute token_count
|
|
18
|
-
# @return [Integer]
|
|
22
|
+
# @return [Integer] token count for this message's payload. Seeded with
|
|
23
|
+
# a local estimate on create and later refined by {CountTokensJob} using
|
|
24
|
+
# the real Anthropic tokenizer. Always positive — never zero or nil.
|
|
19
25
|
# @!attribute tool_use_id
|
|
20
26
|
# @return [String] ID correlating tool_call and tool_response messages
|
|
21
27
|
# (Anthropic-assigned, or a SecureRandom.uuid fallback when the API returns nil;
|
|
22
28
|
# required for tool_call and tool_response messages)
|
|
23
29
|
class Message < ApplicationRecord
|
|
24
|
-
include
|
|
30
|
+
include TokenEstimation
|
|
25
31
|
|
|
26
32
|
TYPES = %w[system_message user_message agent_message tool_call tool_response].freeze
|
|
27
33
|
LLM_TYPES = %w[user_message agent_message].freeze
|
|
28
|
-
CONTEXT_TYPES = %w[system_message user_message agent_message tool_call tool_response].freeze
|
|
29
34
|
CONVERSATION_TYPES = %w[user_message agent_message system_message].freeze
|
|
30
35
|
THINK_TOOL = "think"
|
|
31
|
-
SPAWN_TOOLS = %w[spawn_subagent spawn_specialist].freeze
|
|
32
|
-
|
|
33
36
|
# Message types that require a tool_use_id to pair call with response.
|
|
34
37
|
TOOL_TYPES = %w[tool_call tool_response].freeze
|
|
35
38
|
|
|
36
39
|
ROLE_MAP = {"user_message" => "user", "agent_message" => "assistant"}.freeze
|
|
37
40
|
|
|
38
|
-
# Heuristic: average bytes per token for English prose.
|
|
39
|
-
BYTES_PER_TOKEN = 4
|
|
40
|
-
|
|
41
41
|
# Synthetic ID for system prompt entries in the TUI message store.
|
|
42
42
|
# Real message IDs are positive integers from the database, so 0
|
|
43
43
|
# is safe for deduplication without collision risk.
|
|
44
44
|
SYSTEM_PROMPT_ID = 0
|
|
45
45
|
|
|
46
|
-
# Estimates token count from a byte size using the {BYTES_PER_TOKEN} heuristic.
|
|
47
|
-
# @param bytesize [Integer] number of bytes
|
|
48
|
-
# @return [Integer] estimated token count (at least 1)
|
|
49
|
-
def self.estimate_token_count(bytesize)
|
|
50
|
-
[(bytesize / BYTES_PER_TOKEN.to_f).ceil, 1].max
|
|
51
|
-
end
|
|
52
|
-
|
|
53
46
|
belongs_to :session
|
|
54
47
|
has_many :pinned_messages, dependent: :destroy
|
|
55
48
|
|
|
@@ -59,27 +52,22 @@ class Message < ApplicationRecord
|
|
|
59
52
|
# Anthropic requires every tool_use to have a matching tool_result with the same ID
|
|
60
53
|
validates :tool_use_id, presence: true, if: -> { message_type.in?(TOOL_TYPES) }
|
|
61
54
|
|
|
62
|
-
|
|
55
|
+
after_create_commit :emit_created_event
|
|
56
|
+
after_update_commit :emit_updated_event
|
|
63
57
|
|
|
64
58
|
# @!method self.llm_messages
|
|
65
59
|
# Messages that represent conversation turns sent to the LLM API.
|
|
66
60
|
# @return [ActiveRecord::Relation]
|
|
67
61
|
scope :llm_messages, -> { where(message_type: LLM_TYPES) }
|
|
68
62
|
|
|
69
|
-
# @!method self.
|
|
70
|
-
#
|
|
63
|
+
# @!method self.conversation_or_think
|
|
64
|
+
# Conversation messages (user/agent/system) and think tool_calls —
|
|
65
|
+
# the messages Mneme treats as boundary-eligible.
|
|
71
66
|
# @return [ActiveRecord::Relation]
|
|
72
|
-
scope :
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
# Used when building parent context for sub-agents — spawn messages cause role
|
|
77
|
-
# confusion because the sub-agent sees sibling spawn results and mistakes
|
|
78
|
-
# itself for the parent.
|
|
79
|
-
# @return [ActiveRecord::Relation]
|
|
80
|
-
scope :excluding_spawn_messages, -> {
|
|
81
|
-
where.not("message_type IN (?) AND json_extract(payload, '$.tool_name') IN (?)",
|
|
82
|
-
TOOL_TYPES, SPAWN_TOOLS)
|
|
67
|
+
scope :conversation_or_think, -> {
|
|
68
|
+
where(message_type: CONVERSATION_TYPES)
|
|
69
|
+
.or(where(message_type: "tool_call")
|
|
70
|
+
.where("json_extract(payload, '$.tool_name') = ?", THINK_TOOL))
|
|
83
71
|
}
|
|
84
72
|
|
|
85
73
|
# Maps message_type to the Anthropic Messages API role.
|
|
@@ -88,16 +76,6 @@ class Message < ApplicationRecord
|
|
|
88
76
|
ROLE_MAP.fetch(message_type)
|
|
89
77
|
end
|
|
90
78
|
|
|
91
|
-
# @return [Boolean] true if this message represents an LLM conversation turn
|
|
92
|
-
def llm_message?
|
|
93
|
-
message_type.in?(LLM_TYPES)
|
|
94
|
-
end
|
|
95
|
-
|
|
96
|
-
# @return [Boolean] true if this message is part of the LLM context window
|
|
97
|
-
def context_message?
|
|
98
|
-
message_type.in?(CONTEXT_TYPES)
|
|
99
|
-
end
|
|
100
|
-
|
|
101
79
|
# @return [Boolean] true if this is a conversation message (user/agent/system)
|
|
102
80
|
# or a think tool_call — the messages Mneme treats as "conversation" for boundary tracking
|
|
103
81
|
def conversation_or_think?
|
|
@@ -105,23 +83,43 @@ class Message < ApplicationRecord
|
|
|
105
83
|
(message_type == "tool_call" && payload["tool_name"] == THINK_TOOL)
|
|
106
84
|
end
|
|
107
85
|
|
|
108
|
-
#
|
|
109
|
-
#
|
|
110
|
-
# and
|
|
86
|
+
# String fed to the token estimator and the remote tokenizer. Tool
|
|
87
|
+
# messages serialize the full payload as JSON so +tool_name+, +tool_input+,
|
|
88
|
+
# and +tool_use_id+ contribute to the count; conversation messages use
|
|
89
|
+
# the content field only.
|
|
111
90
|
#
|
|
112
|
-
# @return [
|
|
113
|
-
def
|
|
114
|
-
|
|
91
|
+
# @return [String]
|
|
92
|
+
def tokenization_text
|
|
93
|
+
if message_type.in?(TOOL_TYPES)
|
|
115
94
|
payload.to_json
|
|
116
95
|
else
|
|
117
96
|
payload["content"].to_s
|
|
118
97
|
end
|
|
119
|
-
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Draper hook: picks the concrete decorator subclass based on
|
|
101
|
+
# {#message_type}. Overrides {Draper::Decoratable#decorator_class},
|
|
102
|
+
# which would otherwise default to the abstract {MessageDecorator}
|
|
103
|
+
# base class. Called implicitly by +message.decorate+.
|
|
104
|
+
#
|
|
105
|
+
# @return [Class] a {MessageDecorator} subclass
|
|
106
|
+
def decorator_class
|
|
107
|
+
case message_type
|
|
108
|
+
when "user_message" then UserMessageDecorator
|
|
109
|
+
when "agent_message" then AgentMessageDecorator
|
|
110
|
+
when "system_message" then SystemMessageDecorator
|
|
111
|
+
when "tool_call" then ToolCallDecorator
|
|
112
|
+
when "tool_response" then ToolResponseDecorator
|
|
113
|
+
end
|
|
120
114
|
end
|
|
121
115
|
|
|
122
116
|
private
|
|
123
117
|
|
|
124
|
-
def
|
|
125
|
-
|
|
118
|
+
def emit_created_event
|
|
119
|
+
Events::Bus.emit(Events::MessageCreated.new(self))
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def emit_updated_event
|
|
123
|
+
Events::Bus.emit(Events::MessageUpdated.new(self))
|
|
126
124
|
end
|
|
127
125
|
end
|