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
|
@@ -2,33 +2,31 @@
|
|
|
2
2
|
|
|
3
3
|
module Events
|
|
4
4
|
module Subscribers
|
|
5
|
-
# Routes text messages between parent and child sessions,
|
|
6
|
-
# bidirectional @mention communication.
|
|
5
|
+
# Routes agent text messages between parent and child sessions,
|
|
6
|
+
# enabling bidirectional @mention communication.
|
|
7
7
|
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
# emits a pending message that is promoted after the current loop
|
|
13
|
-
# completes — same mechanism as {SessionChannel#speak}.
|
|
8
|
+
# Subscribes to {Events::MessageCreated} and filters on
|
|
9
|
+
# +message_type == "agent_message"+ — the Message record is the single
|
|
10
|
+
# source of truth for LLM-produced text, so routing hangs off the
|
|
11
|
+
# persistence lifecycle rather than a parallel domain-event emission.
|
|
14
12
|
#
|
|
15
|
-
# **
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
13
|
+
# **Child → Parent:** When a sub-agent persists an +agent_message+,
|
|
14
|
+
# the router enqueues a {PendingMessage} on the parent with sub-agent
|
|
15
|
+
# attribution. The PM's +after_create_commit+ kicks off the drain
|
|
16
|
+
# pipeline when the parent is idle; otherwise the message queues
|
|
17
|
+
# silently and the idle-wake rule picks it up.
|
|
19
18
|
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
19
|
+
# **Parent → Child:** When a parent agent persists an +agent_message+
|
|
20
|
+
# containing +@name+ mentions, the router enqueues a PendingMessage
|
|
21
|
+
# in each matching child session with a +[from parent]:+ origin label.
|
|
22
|
+
#
|
|
23
|
+
# Both directions delegate to {Session#enqueue_user_message}.
|
|
23
24
|
#
|
|
24
25
|
# This replaces the +return_result+ tool — sub-agents communicate
|
|
25
26
|
# through natural text messages instead of structured tool calls.
|
|
26
27
|
class SubagentMessageRouter
|
|
27
28
|
include Events::Subscriber
|
|
28
29
|
|
|
29
|
-
# @see Tools::ResponseTruncator::ATTRIBUTION_FORMAT
|
|
30
|
-
ATTRIBUTION_FORMAT = Tools::ResponseTruncator::ATTRIBUTION_FORMAT
|
|
31
|
-
|
|
32
30
|
# Origin label for messages routed from parent agent to sub-agent.
|
|
33
31
|
# Lets the sub-agent distinguish delegated work from direct user input.
|
|
34
32
|
PARENT_ATTRIBUTION_FORMAT = "[from parent]: %s"
|
|
@@ -38,25 +36,21 @@ module Events
|
|
|
38
36
|
|
|
39
37
|
# Routes agent text messages between parent and child sessions.
|
|
40
38
|
#
|
|
41
|
-
# For sub-agent sessions: forwards to parent with attribution
|
|
39
|
+
# For sub-agent sessions: forwards to parent with attribution.
|
|
42
40
|
# For parent sessions: scans for @mentions and routes to matching children.
|
|
43
41
|
#
|
|
44
|
-
# @param event [Hash] Rails.event notification hash with +:payload+
|
|
45
|
-
#
|
|
42
|
+
# @param event [Hash] Rails.event notification hash with +:payload+
|
|
43
|
+
# carrying the persisted {Message} record under +:message+
|
|
46
44
|
# @return [void]
|
|
47
45
|
def emit(event)
|
|
48
|
-
|
|
49
|
-
return unless
|
|
50
|
-
return unless
|
|
51
|
-
|
|
52
|
-
session_id = payload[:session_id]
|
|
53
|
-
return unless session_id
|
|
46
|
+
message = event.dig(:payload, :message)
|
|
47
|
+
return unless message.is_a?(Message)
|
|
48
|
+
return unless message.message_type == "agent_message"
|
|
54
49
|
|
|
55
|
-
content = payload[
|
|
50
|
+
content = message.payload["content"].to_s
|
|
56
51
|
return if content.empty?
|
|
57
52
|
|
|
58
|
-
session =
|
|
59
|
-
return unless session
|
|
53
|
+
session = message.session
|
|
60
54
|
|
|
61
55
|
if session.sub_agent?
|
|
62
56
|
route_to_parent(session, content)
|
|
@@ -68,8 +62,9 @@ module Events
|
|
|
68
62
|
private
|
|
69
63
|
|
|
70
64
|
# Forwards a sub-agent's text message to its parent session
|
|
71
|
-
# via {Session#enqueue_user_message}
|
|
72
|
-
#
|
|
65
|
+
# via {Session#enqueue_user_message} with source metadata.
|
|
66
|
+
# The parent's {PendingMessage} owns the attribution formatting —
|
|
67
|
+
# the router passes raw content.
|
|
73
68
|
#
|
|
74
69
|
# @param child [Session] the sub-agent session
|
|
75
70
|
# @param content [String] the sub-agent's message text
|
|
@@ -83,9 +78,8 @@ module Events
|
|
|
83
78
|
threshold: Anima::Settings.max_subagent_response_chars,
|
|
84
79
|
reason: "sub-agent output displays first/last #{Tools::ResponseTruncator::HEAD_LINES} lines"
|
|
85
80
|
)
|
|
86
|
-
attributed = format(ATTRIBUTION_FORMAT, name, truncated)
|
|
87
81
|
|
|
88
|
-
parent.enqueue_user_message(
|
|
82
|
+
parent.enqueue_user_message(truncated, source_type: "subagent", source_name: name)
|
|
89
83
|
end
|
|
90
84
|
|
|
91
85
|
# Scans a parent agent's message for @mentions and routes the message
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Events
|
|
4
|
+
module Subscribers
|
|
5
|
+
# Broadcasts sub-agent eviction to the parent session's stream so the
|
|
6
|
+
# TUI HUD panel removes the entry. Fires in response to
|
|
7
|
+
# {Events::SubagentEvicted}, which {Mneme::Runner} emits after a
|
|
8
|
+
# boundary advance leaves a sub-agent with no remaining traces in the
|
|
9
|
+
# parent viewport.
|
|
10
|
+
#
|
|
11
|
+
# @example Registering at boot
|
|
12
|
+
# Events::Bus.subscribe(Events::Subscribers::SubagentVisibilityBroadcaster.new) { |event|
|
|
13
|
+
# event[:name] == "anima.subagent.evicted"
|
|
14
|
+
# }
|
|
15
|
+
class SubagentVisibilityBroadcaster
|
|
16
|
+
include Events::Subscriber
|
|
17
|
+
|
|
18
|
+
# @param event [Hash] Rails.event notification hash
|
|
19
|
+
def emit(event)
|
|
20
|
+
payload = event[:payload]
|
|
21
|
+
session_id = payload[:session_id]
|
|
22
|
+
ActionCable.server.broadcast(
|
|
23
|
+
"session_#{session_id}",
|
|
24
|
+
{
|
|
25
|
+
"action" => "subagent_evicted",
|
|
26
|
+
"session_id" => session_id,
|
|
27
|
+
"child_id" => payload[:child_id]
|
|
28
|
+
}
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Events
|
|
4
|
+
module Subscribers
|
|
5
|
+
# Records a tool's outcome as a +tool_response+ PendingMessage on
|
|
6
|
+
# {Events::ToolExecuted}. One ToolExecuted → one PM. The subscriber
|
|
7
|
+
# owns no state transitions: the session stays in +:executing+ until
|
|
8
|
+
# {DrainJob} claims it via the +executing → awaiting+ branch of
|
|
9
|
+
# +start_processing+ (gated by +Session#tool_round_complete?+).
|
|
10
|
+
#
|
|
11
|
+
# The PM's +after_create_commit+ emits {Events::StartProcessing}
|
|
12
|
+
# whenever the AASM guard says drain may now claim — typically when
|
|
13
|
+
# the last sibling tool_response of the round lands.
|
|
14
|
+
class ToolResponseCreator
|
|
15
|
+
include Events::Subscriber
|
|
16
|
+
|
|
17
|
+
# @param event [Hash] Rails.event notification hash
|
|
18
|
+
def emit(event)
|
|
19
|
+
payload = event[:payload]
|
|
20
|
+
session = Session.find(payload[:session_id])
|
|
21
|
+
|
|
22
|
+
session.pending_messages.create!(
|
|
23
|
+
content: payload[:content].to_s,
|
|
24
|
+
source_type: "tool",
|
|
25
|
+
source_name: payload[:tool_name],
|
|
26
|
+
message_type: "tool_response",
|
|
27
|
+
tool_use_id: payload[:tool_use_id],
|
|
28
|
+
success: payload[:success]
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -4,7 +4,7 @@ module Events
|
|
|
4
4
|
module Subscribers
|
|
5
5
|
# Bridges transient (non-persisted) events to ActionCable so clients
|
|
6
6
|
# receive them over WebSocket. Persisted messages reach clients via
|
|
7
|
-
# {
|
|
7
|
+
# {Events::Subscribers::MessageBroadcaster}; this subscriber handles events
|
|
8
8
|
# that never touch the database.
|
|
9
9
|
#
|
|
10
10
|
# @example Registering at boot
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Events
|
|
4
|
+
# Emitted by {ToolExecutionJob} after a tool finishes running.
|
|
5
|
+
# Carries the tool result so the response subscriber can create a
|
|
6
|
+
# +tool_response+ PendingMessage and release the session back to idle
|
|
7
|
+
# — which in turn wakes the drain loop for the next LLM round.
|
|
8
|
+
class ToolExecuted
|
|
9
|
+
TYPE = "session.tool_executed"
|
|
10
|
+
|
|
11
|
+
attr_reader :session_id, :tool_use_id, :tool_name, :content, :success
|
|
12
|
+
|
|
13
|
+
# @param session_id [Integer] session the tool ran on behalf of
|
|
14
|
+
# @param tool_use_id [String] pairing ID for the originating +tool_use+ block
|
|
15
|
+
# @param tool_name [String] name of the tool that executed
|
|
16
|
+
# @param content [String] tool output (already formatted and truncated)
|
|
17
|
+
# @param success [Boolean] +true+ on normal completion, +false+ on error or interrupt
|
|
18
|
+
def initialize(session_id:, tool_use_id:, tool_name:, content:, success:)
|
|
19
|
+
@session_id = session_id
|
|
20
|
+
@tool_use_id = tool_use_id
|
|
21
|
+
@tool_name = tool_name
|
|
22
|
+
@content = content
|
|
23
|
+
@success = success
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def event_name
|
|
27
|
+
"#{Bus::NAMESPACE}.#{TYPE}"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def to_h
|
|
31
|
+
{type: TYPE, session_id:, tool_use_id:, tool_name:, content:, success:}
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Events
|
|
4
|
+
# Emitted after {Session#activate_workflow} enqueues a workflow's
|
|
5
|
+
# phantom pair. Subscribers rebroadcast the session's active
|
|
6
|
+
# skills/workflow so the HUD reflects the new activation.
|
|
7
|
+
class WorkflowActivated
|
|
8
|
+
TYPE = "workflow.activated"
|
|
9
|
+
|
|
10
|
+
attr_reader :session_id, :workflow_name
|
|
11
|
+
|
|
12
|
+
# @param session_id [Integer] the session the workflow was activated on
|
|
13
|
+
# @param workflow_name [String] canonical workflow name
|
|
14
|
+
def initialize(session_id:, workflow_name:)
|
|
15
|
+
@session_id = session_id
|
|
16
|
+
@workflow_name = workflow_name
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def event_name
|
|
20
|
+
"#{Bus::NAMESPACE}.#{TYPE}"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def to_h
|
|
24
|
+
{type: TYPE, session_id:, workflow_name:}
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
data/lib/llm/client.rb
CHANGED
|
@@ -1,21 +1,26 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module LLM
|
|
4
|
-
# Convenience layer over {Providers::Anthropic} for
|
|
5
|
-
#
|
|
6
|
-
#
|
|
4
|
+
# Convenience layer over {Providers::Anthropic} for phantom sessions
|
|
5
|
+
# (Mneme, Melete, Mneme::L2Runner) that need a multi-round tool-use
|
|
6
|
+
# loop driven from plain Ruby objects rather than the main drain
|
|
7
|
+
# pipeline.
|
|
7
8
|
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
9
|
+
# The main agent loop does NOT use this class — {DrainJob} talks to
|
|
10
|
+
# the provider directly and emits {Events::LLMResponded} for
|
|
11
|
+
# {Events::Subscribers::LLMResponseHandler} to process. The tool loop
|
|
12
|
+
# here is deliberately minimal: no events, no AASM transitions, no
|
|
13
|
+
# interrupt handling — phantom sessions don't interact with those
|
|
14
|
+
# machineries.
|
|
12
15
|
#
|
|
13
|
-
# @example
|
|
16
|
+
# @example
|
|
14
17
|
# registry = Tools::Registry.new
|
|
15
|
-
# registry.register(Tools::
|
|
16
|
-
# client.chat_with_tools(messages, registry: registry
|
|
18
|
+
# registry.register(Tools::SaveSnapshot)
|
|
19
|
+
# client.chat_with_tools(messages, registry: registry)
|
|
17
20
|
class Client
|
|
18
|
-
# Synthetic tool_result when a tool is
|
|
21
|
+
# Synthetic tool_result text shown when a tool run is aborted by the
|
|
22
|
+
# user's Escape press. Mirrored into the interrupt subsystem so both
|
|
23
|
+
# the bash tool and any future interrupt handler share the phrasing.
|
|
19
24
|
INTERRUPT_MESSAGE = "Your human wants your attention"
|
|
20
25
|
|
|
21
26
|
# @return [Providers::Anthropic] the underlying API provider
|
|
@@ -39,86 +44,52 @@ module LLM
|
|
|
39
44
|
@logger = logger
|
|
40
45
|
end
|
|
41
46
|
|
|
42
|
-
#
|
|
47
|
+
# Runs a minimal multi-round tool-use cycle: call the LLM, execute
|
|
48
|
+
# any requested tools, feed results back, repeat until the LLM
|
|
49
|
+
# produces a final text response.
|
|
43
50
|
#
|
|
44
|
-
#
|
|
45
|
-
#
|
|
46
|
-
#
|
|
47
|
-
# @raise [Providers::Anthropic::Error] on API errors
|
|
48
|
-
# @raise [Providers::Anthropic::AuthenticationError] on auth failures
|
|
49
|
-
def chat(messages, **options)
|
|
50
|
-
response = provider.create_message(
|
|
51
|
-
model: model,
|
|
52
|
-
messages: messages,
|
|
53
|
-
max_tokens: max_tokens,
|
|
54
|
-
**options
|
|
55
|
-
)
|
|
56
|
-
|
|
57
|
-
extract_text(response)
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
# Send messages with tool support. Runs the full tool execution loop:
|
|
61
|
-
# call LLM, execute any requested tools, feed results back, repeat
|
|
62
|
-
# until the LLM produces a final text response.
|
|
63
|
-
#
|
|
64
|
-
# Emits {Events::ToolCall} and {Events::ToolResponse} events for each
|
|
65
|
-
# tool interaction so they're persisted and visible in the event stream.
|
|
66
|
-
#
|
|
67
|
-
# When the user interrupts via Escape, remaining tools receive synthetic
|
|
68
|
-
# "Your human wants your attention" results and the loop exits without another LLM call.
|
|
51
|
+
# Intended for phantom sessions (Mneme, Melete). No events are
|
|
52
|
+
# emitted and no persistence happens — the caller is responsible
|
|
53
|
+
# for capturing whatever state the tool runs produce.
|
|
69
54
|
#
|
|
70
55
|
# @param messages [Array<Hash>] conversation messages in Anthropic format
|
|
71
56
|
# @param registry [Tools::Registry] registered tools to make available
|
|
72
|
-
# @param session_id [Integer, String] session ID for emitted events
|
|
73
|
-
# @param first_response [Hash, nil] pre-fetched first API response from
|
|
74
|
-
# {AgentLoop#deliver!}. Skips the first API call when provided so
|
|
75
|
-
# the Bounce Back transaction doesn't duplicate work.
|
|
76
57
|
# @param options [Hash] additional API parameters (e.g. +system:+)
|
|
77
|
-
# @return [
|
|
58
|
+
# @return [Hash] +:text+ (String) and +:api_metrics+ (Hash)
|
|
78
59
|
# @raise [Providers::Anthropic::Error] on API errors
|
|
79
|
-
def chat_with_tools(messages, registry:,
|
|
60
|
+
def chat_with_tools(messages, registry:, **options)
|
|
80
61
|
messages = messages.dup
|
|
81
62
|
rounds = 0
|
|
63
|
+
last_api_metrics = nil
|
|
82
64
|
|
|
83
65
|
loop do
|
|
84
66
|
rounds += 1
|
|
85
67
|
max_rounds = Anima::Settings.max_tool_rounds
|
|
86
68
|
if rounds > max_rounds
|
|
87
|
-
return "[Tool loop exceeded #{max_rounds} rounds — halting]"
|
|
69
|
+
return {text: "[Tool loop exceeded #{max_rounds} rounds — halting]", api_metrics: last_api_metrics}
|
|
88
70
|
end
|
|
89
71
|
|
|
90
|
-
response =
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
)
|
|
101
|
-
end
|
|
72
|
+
response = provider.create_message(
|
|
73
|
+
model: model,
|
|
74
|
+
messages: messages,
|
|
75
|
+
max_tokens: max_tokens,
|
|
76
|
+
tools: registry.schemas,
|
|
77
|
+
include_metrics: true,
|
|
78
|
+
**options
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
last_api_metrics = response.api_metrics if response.respond_to?(:api_metrics)
|
|
102
82
|
|
|
103
83
|
log(:debug, "stop_reason=#{response["stop_reason"]} content_types=#{(response["content"] || []).map { |b| b["type"] }.join(",")}")
|
|
104
84
|
|
|
105
85
|
if response["stop_reason"] == "tool_use"
|
|
106
|
-
tool_results = execute_tools(response, registry
|
|
107
|
-
|
|
86
|
+
tool_results = execute_tools(response, registry)
|
|
108
87
|
messages += [
|
|
109
88
|
{role: "assistant", content: response["content"]},
|
|
110
89
|
{role: "user", content: tool_results}
|
|
111
90
|
]
|
|
112
|
-
|
|
113
|
-
return nil if handle_interrupt!(session_id)
|
|
114
91
|
else
|
|
115
|
-
|
|
116
|
-
# the API was generating it. Without this check the interrupt
|
|
117
|
-
# flag set during the blocking API call would be silently
|
|
118
|
-
# cleared by the ensure block in AgentRequestJob.
|
|
119
|
-
return nil if handle_interrupt!(session_id)
|
|
120
|
-
|
|
121
|
-
return extract_text(response)
|
|
92
|
+
return {text: extract_text(response), api_metrics: last_api_metrics}
|
|
122
93
|
end
|
|
123
94
|
end
|
|
124
95
|
end
|
|
@@ -131,7 +102,6 @@ module LLM
|
|
|
131
102
|
|
|
132
103
|
def extract_text(response)
|
|
133
104
|
content = response["content"] || []
|
|
134
|
-
|
|
135
105
|
content
|
|
136
106
|
.select { |block| block["type"] == "text" }
|
|
137
107
|
.map { |block| block["text"] }
|
|
@@ -143,157 +113,36 @@ module LLM
|
|
|
143
113
|
content.select { |block| block["type"] == "tool_use" }
|
|
144
114
|
end
|
|
145
115
|
|
|
146
|
-
# Executes
|
|
147
|
-
#
|
|
148
|
-
#
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
# @param response [Hash] Anthropic API response with tool_use content blocks
|
|
152
|
-
# @param registry [Tools::Registry] tool registry for dispatch
|
|
153
|
-
# @param session_id [Integer, String] session ID for events
|
|
154
|
-
# @return [Array<Hash>] tool_result content blocks for the next API call
|
|
155
|
-
def execute_tools(response, registry, session_id)
|
|
156
|
-
tool_uses = extract_tool_uses(response)
|
|
157
|
-
results = []
|
|
158
|
-
interrupted = false
|
|
159
|
-
|
|
160
|
-
tool_uses.each_with_index do |tool_use, index|
|
|
161
|
-
# Check-only here; clearing happens in handle_interrupt! after the loop
|
|
162
|
-
interrupted ||= interrupt_requested?(session_id)
|
|
163
|
-
if interrupted
|
|
164
|
-
remaining = tool_uses[index..]
|
|
165
|
-
results.concat(interrupt_remaining_tools(remaining, session_id)) if remaining&.any?
|
|
166
|
-
break
|
|
167
|
-
end
|
|
168
|
-
results << execute_single_tool(tool_use, registry, session_id)
|
|
169
|
-
end
|
|
170
|
-
|
|
171
|
-
results
|
|
172
|
-
end
|
|
173
|
-
|
|
174
|
-
# Creates synthetic "Your human wants your attention" results for all tools in the list.
|
|
175
|
-
#
|
|
176
|
-
# @param tool_uses [Array<Hash>] remaining tool_use content blocks
|
|
177
|
-
# @param session_id [Integer, String] session ID for events
|
|
178
|
-
# @return [Array<Hash>] tool_result content blocks
|
|
179
|
-
def interrupt_remaining_tools(tool_uses, session_id)
|
|
180
|
-
tool_uses.map { |tool_use| interrupt_tool(tool_use, session_id) }
|
|
116
|
+
# Executes every +tool_use+ block from the response and returns
|
|
117
|
+
# matching +tool_result+ blocks. Always emits a result — a missing
|
|
118
|
+
# result permanently corrupts the Anthropic conversation history.
|
|
119
|
+
def execute_tools(response, registry)
|
|
120
|
+
extract_tool_uses(response).map { |tool_use| execute_single_tool(tool_use, registry) }
|
|
181
121
|
end
|
|
182
122
|
|
|
183
|
-
|
|
184
|
-
# tool raises. Per the Anthropic tool-use protocol, every tool_use must
|
|
185
|
-
# have a matching tool_result; a missing result permanently corrupts the
|
|
186
|
-
# conversation history and breaks the session.
|
|
187
|
-
#
|
|
188
|
-
# Falls back to SecureRandom.uuid when Anthropic omits the tool_use id,
|
|
189
|
-
# ensuring the ToolCall/ToolResponse pair always shares a valid identifier.
|
|
190
|
-
def execute_single_tool(tool_use, registry, session_id)
|
|
123
|
+
def execute_single_tool(tool_use, registry)
|
|
191
124
|
name = tool_use["name"]
|
|
192
125
|
id = tool_use["id"] || SecureRandom.uuid
|
|
193
126
|
input = tool_use["input"] || {}
|
|
194
|
-
timeout = input["timeout"] || Anima::Settings.tool_timeout
|
|
195
127
|
|
|
196
128
|
log(:debug, "tool_call: #{name}(#{input.to_json})")
|
|
197
129
|
|
|
198
|
-
broadcast_session_state(session_id, "tool_executing", tool: name)
|
|
199
|
-
|
|
200
|
-
Events::Bus.emit(Events::ToolCall.new(
|
|
201
|
-
content: "Calling #{name}", tool_name: name,
|
|
202
|
-
tool_input: input, tool_use_id: id, timeout: timeout,
|
|
203
|
-
session_id: session_id
|
|
204
|
-
))
|
|
205
|
-
|
|
206
130
|
result = registry.execute(name, input)
|
|
207
131
|
result = ToolDecorator.call(name, result)
|
|
208
132
|
result_content = format_tool_result(result)
|
|
209
133
|
result_content = truncate_tool_result(result_content, registry, name)
|
|
210
|
-
log(:debug, "tool_result: #{name} → #{result_content.to_s.truncate(200)}")
|
|
211
134
|
|
|
212
|
-
|
|
213
|
-
content: result_content, tool_name: name, tool_use_id: id,
|
|
214
|
-
success: !result.is_a?(Hash) || !result.key?(:error),
|
|
215
|
-
session_id: session_id
|
|
216
|
-
))
|
|
135
|
+
log(:debug, "tool_result: #{name} → #{result_content.to_s.truncate(200)}")
|
|
217
136
|
|
|
218
137
|
{type: "tool_result", tool_use_id: id, content: result_content}
|
|
219
138
|
rescue => error
|
|
220
139
|
error_detail = "#{error.class}: #{error.message}"
|
|
221
140
|
Rails.logger.error("Tool #{name} raised #{error_detail}")
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
# Emission can fail (e.g. encoding errors in ActionCable/SQLite),
|
|
225
|
-
# but losing the tool_result would permanently corrupt the session.
|
|
226
|
-
begin
|
|
227
|
-
Events::Bus.emit(Events::ToolResponse.new(
|
|
228
|
-
content: error_content, tool_name: name, tool_use_id: id,
|
|
229
|
-
success: false, session_id: session_id
|
|
230
|
-
))
|
|
231
|
-
rescue => emit_error
|
|
232
|
-
Rails.logger.error("ToolResponse emission failed: #{emit_error.class}: #{emit_error.message}")
|
|
233
|
-
end
|
|
234
|
-
|
|
235
|
-
{type: "tool_result", tool_use_id: id, content: error_content}
|
|
236
|
-
end
|
|
237
|
-
|
|
238
|
-
# Creates a synthetic "Your human wants your attention" result for a tool that was not
|
|
239
|
-
# executed due to user interrupt. Emits both ToolCall and ToolResponse
|
|
240
|
-
# events so the TUI shows the interrupted tool in the event stream.
|
|
241
|
-
#
|
|
242
|
-
# @param tool_use [Hash] Anthropic tool_use content block
|
|
243
|
-
# @param session_id [Integer, String] session ID for events
|
|
244
|
-
# @return [Hash] tool_result content block
|
|
245
|
-
def interrupt_tool(tool_use, session_id)
|
|
246
|
-
name = tool_use["name"]
|
|
247
|
-
id = tool_use["id"] || SecureRandom.uuid
|
|
248
|
-
input = tool_use["input"] || {}
|
|
249
|
-
|
|
250
|
-
Events::Bus.emit(Events::ToolCall.new(
|
|
251
|
-
content: "Skipped #{name} — your human wants your attention", tool_name: name,
|
|
252
|
-
tool_input: input, tool_use_id: id, session_id: session_id
|
|
253
|
-
))
|
|
254
|
-
|
|
255
|
-
Events::Bus.emit(Events::ToolResponse.new(
|
|
256
|
-
content: INTERRUPT_MESSAGE, tool_name: name, tool_use_id: id,
|
|
257
|
-
success: false, session_id: session_id
|
|
258
|
-
))
|
|
259
|
-
|
|
260
|
-
{type: "tool_result", tool_use_id: id, content: INTERRUPT_MESSAGE}
|
|
261
|
-
end
|
|
262
|
-
|
|
263
|
-
# Checks whether the session has a pending interrupt flag.
|
|
264
|
-
#
|
|
265
|
-
# @param session_id [Integer, String] session to check
|
|
266
|
-
# @return [Boolean] true when interrupt is pending
|
|
267
|
-
def interrupt_requested?(session_id)
|
|
268
|
-
Session.where(id: session_id, interrupt_requested: true).exists?
|
|
269
|
-
end
|
|
270
|
-
|
|
271
|
-
# Atomically checks for a pending interrupt and clears it in one query.
|
|
272
|
-
# Used at loop boundaries (after tools, before LLM text return) to
|
|
273
|
-
# short-circuit the agent loop when the user presses Escape.
|
|
274
|
-
#
|
|
275
|
-
# @param session_id [Integer, String] session to check
|
|
276
|
-
# @return [Boolean] true when interrupt was detected and cleared
|
|
277
|
-
def handle_interrupt!(session_id)
|
|
278
|
-
Session.where(id: session_id, interrupt_requested: true)
|
|
279
|
-
.update_all(interrupt_requested: false) > 0
|
|
280
|
-
end
|
|
281
|
-
|
|
282
|
-
# Broadcasts a session state transition to all subscribed clients.
|
|
283
|
-
# Delegates to {Session#broadcast_session_state} which handles both
|
|
284
|
-
# the session's own stream and the parent's stream for HUD updates.
|
|
285
|
-
#
|
|
286
|
-
# @param session_id [Integer, String] session to broadcast for
|
|
287
|
-
# @param state [String] one of "idle", "llm_generating", "tool_executing", "interrupting"
|
|
288
|
-
# @param tool [String, nil] tool name when state is "tool_executing"
|
|
289
|
-
# @return [void]
|
|
290
|
-
def broadcast_session_state(session_id, state, tool: nil)
|
|
291
|
-
Session.find_by(id: session_id)&.broadcast_session_state(state, tool: tool)
|
|
141
|
+
{type: "tool_result", tool_use_id: id, content: format_tool_result(error: error_detail)}
|
|
292
142
|
end
|
|
293
143
|
|
|
294
144
|
def log(level, message)
|
|
295
145
|
return unless @logger
|
|
296
|
-
|
|
297
146
|
@logger.public_send(level, message)
|
|
298
147
|
end
|
|
299
148
|
|
|
@@ -301,8 +150,6 @@ module LLM
|
|
|
301
150
|
result.is_a?(Hash) ? result.to_json : result.to_s
|
|
302
151
|
end
|
|
303
152
|
|
|
304
|
-
# Applies head+tail truncation when a tool result exceeds the tool's
|
|
305
|
-
# configured character threshold. Skips tools that opt out (e.g. read).
|
|
306
153
|
def truncate_tool_result(content, registry, tool_name)
|
|
307
154
|
threshold = registry.truncation_threshold(tool_name)
|
|
308
155
|
return content unless threshold
|