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,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Base decorator for {PendingMessage} records, providing multi-resolution
|
|
4
|
+
# rendering for the TUI ("basic" / "verbose" / "debug") and for the
|
|
5
|
+
# enrichment subsystems Mneme and Melete.
|
|
6
|
+
#
|
|
7
|
+
# Each PM type has a dedicated subclass that mirrors the visual treatment
|
|
8
|
+
# of its promoted-{Message} counterpart, with +status: "pending"+ added
|
|
9
|
+
# so the TUI can render it dimmed.
|
|
10
|
+
#
|
|
11
|
+
# Subclasses must override {#render_basic}. Default delegations form a
|
|
12
|
+
# two-step chain: +render_debug → render_verbose → render_basic+. A
|
|
13
|
+
# subclass that only overrides +render_verbose+ inherits its
|
|
14
|
+
# +render_debug+ for free. Melete and Mneme transcript modes return nil
|
|
15
|
+
# by default — subclasses opt in by overriding {#render_melete} or
|
|
16
|
+
# {#render_mneme}.
|
|
17
|
+
#
|
|
18
|
+
# Instantiate via +pending_message.decorate+ — {PendingMessage#decorator_class}
|
|
19
|
+
# picks the concrete subclass based on +message_type+.
|
|
20
|
+
class PendingMessageDecorator < ApplicationDecorator
|
|
21
|
+
delegate_all
|
|
22
|
+
|
|
23
|
+
RENDER_DISPATCH = {
|
|
24
|
+
"basic" => :render_basic,
|
|
25
|
+
"verbose" => :render_verbose,
|
|
26
|
+
"debug" => :render_debug,
|
|
27
|
+
"melete" => :render_melete,
|
|
28
|
+
"mneme" => :render_mneme
|
|
29
|
+
}.freeze
|
|
30
|
+
private_constant :RENDER_DISPATCH
|
|
31
|
+
|
|
32
|
+
# Dispatches to the render method for the given view mode.
|
|
33
|
+
#
|
|
34
|
+
# @param mode [String] one of "basic", "verbose", "debug", "melete", "mneme"
|
|
35
|
+
# @return [Hash, String, nil] structured TUI payload, transcript line, or nil to hide
|
|
36
|
+
# @raise [ArgumentError] if the mode is not supported
|
|
37
|
+
def render(mode)
|
|
38
|
+
method = RENDER_DISPATCH[mode]
|
|
39
|
+
raise ArgumentError, "Invalid view mode: #{mode.inspect}" unless method
|
|
40
|
+
|
|
41
|
+
public_send(method)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @abstract Subclasses must implement to render the pending message for basic view mode.
|
|
45
|
+
# @return [Hash, nil] structured payload, or nil to hide
|
|
46
|
+
def render_basic
|
|
47
|
+
raise NotImplementedError, "#{self.class} must implement #render_basic"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @return [Hash, nil] verbose payload (defaults to basic)
|
|
51
|
+
def render_verbose
|
|
52
|
+
render_basic
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @return [Hash, nil] debug payload (defaults to verbose)
|
|
56
|
+
def render_debug
|
|
57
|
+
render_verbose
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# @return [String, nil] Melete transcript line, or nil to skip
|
|
61
|
+
def render_melete
|
|
62
|
+
nil
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# @return [String, nil] Mneme transcript line, or nil to skip
|
|
66
|
+
def render_mneme
|
|
67
|
+
nil
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
protected
|
|
71
|
+
|
|
72
|
+
MIDDLE_TRUNCATION_MARKER = MessageDecorator::MIDDLE_TRUNCATION_MARKER
|
|
73
|
+
|
|
74
|
+
# Mirror of {MessageDecorator#truncate_middle} — duplicated here rather than
|
|
75
|
+
# inherited to keep the two decorator families independent.
|
|
76
|
+
def truncate_middle(text, max_chars: 500)
|
|
77
|
+
str = text.to_s
|
|
78
|
+
return str if str.length <= max_chars
|
|
79
|
+
|
|
80
|
+
keep = max_chars - MIDDLE_TRUNCATION_MARKER.length
|
|
81
|
+
head = keep / 2
|
|
82
|
+
tail = keep - head
|
|
83
|
+
"#{str[0, head]}#{MIDDLE_TRUNCATION_MARKER}#{str[-tail, tail]}"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Mirror of {MessageDecorator#truncate_lines}.
|
|
87
|
+
def truncate_lines(text, max_lines:)
|
|
88
|
+
str = text.to_s
|
|
89
|
+
lines = str.split("\n")
|
|
90
|
+
return str unless lines.size > max_lines
|
|
91
|
+
|
|
92
|
+
lines.first(max_lines).push("...").join("\n")
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -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?
|
|
@@ -105,10 +116,10 @@ class ToolCallDecorator < MessageDecorator
|
|
|
105
116
|
# Formats write tool input with file path header and content body.
|
|
106
117
|
# Content newlines are preserved so the TUI can render them as
|
|
107
118
|
# separate lines, matching how read_file tool responses display file content.
|
|
108
|
-
# @param input [Hash] tool input hash with "
|
|
119
|
+
# @param input [Hash] tool input hash with "path" and "content" keys
|
|
109
120
|
# @return [String] path + content with real newlines, or TOON-encoded hash when content is empty
|
|
110
121
|
def format_write_content(input)
|
|
111
|
-
path = input.dig("
|
|
122
|
+
path = input.dig("path").to_s
|
|
112
123
|
content = input.dig("content").to_s
|
|
113
124
|
return Toon.encode(input) if content.empty?
|
|
114
125
|
|
|
@@ -126,7 +137,7 @@ class ToolCallDecorator < MessageDecorator
|
|
|
126
137
|
when "web_get"
|
|
127
138
|
"GET #{input&.dig("url")}"
|
|
128
139
|
when "read_file", "edit_file", "write_file"
|
|
129
|
-
input&.dig("
|
|
140
|
+
input&.dig("path").to_s
|
|
130
141
|
else
|
|
131
142
|
truncate_lines(Toon.encode(input), max_lines: 2)
|
|
132
143
|
end
|
|
@@ -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
|