anima-core 1.1.0 → 1.1.1
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 +4 -0
- data/README.md +3 -3
- data/app/channels/session_channel.rb +8 -8
- data/app/decorators/tool_call_decorator.rb +27 -2
- data/app/jobs/agent_request_job.rb +46 -43
- data/app/models/concerns/event/broadcasting.rb +0 -14
- data/app/models/session.rb +69 -2
- data/bin/jobs +5 -0
- data/config/initializers/event_subscribers.rb +2 -4
- data/config/queue.yml +0 -1
- data/lib/anima/cli.rb +30 -8
- data/lib/anima/installer.rb +0 -12
- data/lib/anima/settings.rb +18 -0
- data/lib/anima/spinner.rb +75 -0
- data/lib/anima/version.rb +1 -1
- data/lib/environment_probe.rb +4 -4
- data/lib/events/subscribers/persister.rb +9 -7
- data/lib/events/tool_call.rb +5 -3
- data/lib/llm/client.rb +3 -1
- data/lib/shell_session.rb +6 -4
- data/lib/tools/base.rb +2 -1
- data/lib/tools/bash.rb +4 -2
- data/lib/tools/registry.rb +22 -3
- data/lib/tools/request_feature.rb +3 -1
- data/lib/tools/web_get.rb +6 -4
- data/lib/tui/message_store.rb +72 -7
- data/templates/config.toml +7 -0
- data/templates/soul.md +1 -1
- metadata +2 -2
- data/lib/events/subscribers/agent_dispatcher.rb +0 -29
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 161b7dfde73fa61f7656427c0af3c5919e1a1cc50d6b1a2d201f5a45ce79c561
|
|
4
|
+
data.tar.gz: 880538d54fbc682b61bbeb6d63b38aa066e86327c778fefce8e60091c6ad816d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f0ee765c1a125e26b9f10b5cc97f41e857ec17562ef4ba116dd53bf97b9b26fb3781d9f292c6296c1c408089d155d9d8a24e4165d58f57ead64e72595f833ae8
|
|
7
|
+
data.tar.gz: 86c95c9f103cb0c6c4b37ef3452662274205da97ef04d3f846f7f2d041ff0c94f375208b60d245f454aa73abb2c144113f9d6691cc99a0124f43971fcfb62ae1
|
data/.reek.yml
CHANGED
|
@@ -39,6 +39,8 @@ detectors:
|
|
|
39
39
|
- "Tools::SubagentPrompts#assign_nickname_via_brain"
|
|
40
40
|
# Validation methods naturally reference the validated value more than self.
|
|
41
41
|
- "AnalyticalBrain::Tools::AssignNickname#validate"
|
|
42
|
+
# Delivery method orchestrates session, event, and agent_loop — inherent.
|
|
43
|
+
- "AgentRequestJob#deliver_persisted_event"
|
|
42
44
|
# Private helpers don't need instance state to be valid.
|
|
43
45
|
# ActiveJob#perform is always a utility function by design.
|
|
44
46
|
# No-op tools (Think, EverythingIsReady) don't need instance state — by design.
|
|
@@ -90,6 +92,8 @@ detectors:
|
|
|
90
92
|
- "Tools::Remember"
|
|
91
93
|
# Nickname validation checks parent_session for existence then queries — two calls, one guard.
|
|
92
94
|
- "AnalyticalBrain::Tools::AssignNickname#sibling_nickname_taken?"
|
|
95
|
+
# Delivery method references session.id for lookup, BounceBack, and auth broadcast.
|
|
96
|
+
- "AgentRequestJob#deliver_persisted_event"
|
|
93
97
|
# Method length is enforced by code review, not arbitrary line counts
|
|
94
98
|
# build_sections passes context through to sub-methods — inherent to assembly.
|
|
95
99
|
LongParameterList:
|
data/README.md
CHANGED
|
@@ -132,8 +132,7 @@ State directory (`~/.anima/`):
|
|
|
132
132
|
├── config.toml # Main settings (hot-reloadable)
|
|
133
133
|
├── mcp.toml # MCP server configuration
|
|
134
134
|
├── config/
|
|
135
|
-
│
|
|
136
|
-
│ └── anima.yml # Placeholder config
|
|
135
|
+
│ └── credentials/ # Rails encrypted credentials per environment
|
|
137
136
|
├── agents/ # User-defined specialist agents (override built-ins)
|
|
138
137
|
├── skills/ # User-defined skills (override built-ins)
|
|
139
138
|
├── workflows/ # User-defined workflows (override built-ins)
|
|
@@ -142,7 +141,7 @@ State directory (`~/.anima/`):
|
|
|
142
141
|
└── tmp/
|
|
143
142
|
```
|
|
144
143
|
|
|
145
|
-
Updates: `anima update` — upgrades the gem
|
|
144
|
+
Updates: `anima update` — upgrades the gem, merges new config settings into your existing `config.toml` without overwriting customized values, and restarts the systemd service if it's running. Use `anima update --migrate-only` to skip the gem upgrade and only add missing config keys.
|
|
146
145
|
|
|
147
146
|
### Authentication Setup
|
|
148
147
|
|
|
@@ -300,6 +299,7 @@ blocking_on_user_message = true
|
|
|
300
299
|
event_window = 20
|
|
301
300
|
|
|
302
301
|
[session]
|
|
302
|
+
default_view_mode = "basic"
|
|
303
303
|
name_generation_interval = 30
|
|
304
304
|
```
|
|
305
305
|
|
|
@@ -42,14 +42,13 @@ class SessionChannel < ApplicationCable::Channel
|
|
|
42
42
|
ActionCable.server.broadcast(stream_name, data)
|
|
43
43
|
end
|
|
44
44
|
|
|
45
|
-
# Processes user input
|
|
45
|
+
# Processes user input. For idle sessions, persists the event immediately
|
|
46
|
+
# so the message appears in the TUI without waiting for the background
|
|
47
|
+
# job, then schedules {AgentRequestJob} for LLM delivery. If delivery
|
|
48
|
+
# fails, the job deletes the event and emits a {Events::BounceBack}.
|
|
46
49
|
#
|
|
47
|
-
#
|
|
48
|
-
#
|
|
49
|
-
# inside a transaction (Bounce Back, #236).
|
|
50
|
-
#
|
|
51
|
-
# When the session is already processing, the message is queued as "pending"
|
|
52
|
-
# and picked up after the current agent loop completes.
|
|
50
|
+
# For busy sessions, emits a pending {Events::UserMessage} that queues
|
|
51
|
+
# until the current agent loop completes.
|
|
53
52
|
#
|
|
54
53
|
# @param data [Hash] must include "content" with the user's message text
|
|
55
54
|
def speak(data)
|
|
@@ -62,7 +61,8 @@ class SessionChannel < ApplicationCable::Channel
|
|
|
62
61
|
if session.processing?
|
|
63
62
|
Events::Bus.emit(Events::UserMessage.new(content: content, session_id: @current_session_id, status: Event::PENDING_STATUS))
|
|
64
63
|
else
|
|
65
|
-
|
|
64
|
+
event = session.create_user_event(content)
|
|
65
|
+
AgentRequestJob.perform_later(session.id, event_id: event.id)
|
|
66
66
|
end
|
|
67
67
|
end
|
|
68
68
|
|
|
@@ -6,7 +6,8 @@ require "toon"
|
|
|
6
6
|
# Hidden in basic mode — tool activity is represented by the
|
|
7
7
|
# aggregated tool counter instead. Verbose mode returns tool name
|
|
8
8
|
# and a formatted preview of the input arguments. Debug mode shows
|
|
9
|
-
# full untruncated input
|
|
9
|
+
# full untruncated input with tool_use_id — TOON format for most
|
|
10
|
+
# tools, but write tool content preserves actual newlines.
|
|
10
11
|
#
|
|
11
12
|
# Think tool calls are special: "aloud" thoughts are shown in all
|
|
12
13
|
# view modes (with a thought bubble), while "inner" thoughts are
|
|
@@ -41,7 +42,7 @@ class ToolCallDecorator < EventDecorator
|
|
|
41
42
|
{
|
|
42
43
|
role: :tool_call,
|
|
43
44
|
tool: payload["tool_name"],
|
|
44
|
-
input:
|
|
45
|
+
input: format_debug_input,
|
|
45
46
|
tool_use_id: payload["tool_use_id"],
|
|
46
47
|
timestamp: timestamp
|
|
47
48
|
}
|
|
@@ -90,6 +91,30 @@ class ToolCallDecorator < EventDecorator
|
|
|
90
91
|
{role: :think, content: thoughts, visibility: visibility, tool_use_id: payload["tool_use_id"], timestamp: timestamp}
|
|
91
92
|
end
|
|
92
93
|
|
|
94
|
+
# Full tool input for debug mode. Write tool content is shown with
|
|
95
|
+
# preserved newlines instead of TOON-escaping them into literal \n.
|
|
96
|
+
# @return [String] formatted debug input
|
|
97
|
+
def format_debug_input
|
|
98
|
+
input = tool_input
|
|
99
|
+
case payload["tool_name"]
|
|
100
|
+
when "write" then format_write_content(input)
|
|
101
|
+
else Toon.encode(input)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Formats write tool input with file path header and content body.
|
|
106
|
+
# Content newlines are preserved so the TUI can render them as
|
|
107
|
+
# separate lines, matching how read tool responses display file content.
|
|
108
|
+
# @param input [Hash] tool input hash with "file_path" and "content" keys
|
|
109
|
+
# @return [String] path + content with real newlines, or TOON-encoded hash when content is empty
|
|
110
|
+
def format_write_content(input)
|
|
111
|
+
path = input.dig("file_path").to_s
|
|
112
|
+
content = input.dig("content").to_s
|
|
113
|
+
return Toon.encode(input) if content.empty?
|
|
114
|
+
|
|
115
|
+
"#{path}\n#{content}"
|
|
116
|
+
end
|
|
117
|
+
|
|
93
118
|
# Formats tool input for display, with tool-specific formatting for
|
|
94
119
|
# known tools and generic JSON fallback for others.
|
|
95
120
|
# @return [String] formatted input preview
|
|
@@ -5,16 +5,18 @@
|
|
|
5
5
|
#
|
|
6
6
|
# Supports two modes:
|
|
7
7
|
#
|
|
8
|
-
# **
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
# is
|
|
8
|
+
# **Immediate Persist (event_id provided):** The user event was already
|
|
9
|
+
# persisted and broadcast by the caller (e.g. {SessionChannel#speak}).
|
|
10
|
+
# The job verifies LLM delivery — if the first API call fails, the
|
|
11
|
+
# event is deleted and a {Events::BounceBack} is emitted so clients
|
|
12
|
+
# can restore the text to the input field.
|
|
12
13
|
#
|
|
13
|
-
# **Standard (no
|
|
14
|
-
# pending message promotion). Uses ActiveJob retry/discard for
|
|
14
|
+
# **Standard (no event_id):** Processes already-persisted events (e.g.
|
|
15
|
+
# after pending message promotion). Uses ActiveJob retry/discard for
|
|
16
|
+
# error handling.
|
|
15
17
|
#
|
|
16
|
-
# @example
|
|
17
|
-
# AgentRequestJob.perform_later(session.id,
|
|
18
|
+
# @example Immediate Persist — event already saved by SessionChannel
|
|
19
|
+
# AgentRequestJob.perform_later(session.id, event_id: 42)
|
|
18
20
|
#
|
|
19
21
|
# @example Standard — pending message processing
|
|
20
22
|
# AgentRequestJob.perform_later(session.id)
|
|
@@ -24,7 +26,7 @@ class AgentRequestJob < ApplicationJob
|
|
|
24
26
|
# ActionCable action signaling clients to prompt for an API token.
|
|
25
27
|
AUTH_REQUIRED_ACTION = "authentication_required"
|
|
26
28
|
|
|
27
|
-
# Standard path only —
|
|
29
|
+
# Standard path only — immediate persist handles its own errors.
|
|
28
30
|
retry_on Providers::Anthropic::TransientError,
|
|
29
31
|
wait: :polynomially_longer, attempts: 5 do |job, error|
|
|
30
32
|
Events::Bus.emit(Events::SystemMessage.new(
|
|
@@ -47,8 +49,8 @@ class AgentRequestJob < ApplicationJob
|
|
|
47
49
|
end
|
|
48
50
|
|
|
49
51
|
# @param session_id [Integer] ID of the session to process
|
|
50
|
-
# @param
|
|
51
|
-
def perform(session_id,
|
|
52
|
+
# @param event_id [Integer, nil] ID of a pre-persisted user event (triggers delivery verification)
|
|
53
|
+
def perform(session_id, event_id: nil)
|
|
52
54
|
session = Session.find(session_id)
|
|
53
55
|
|
|
54
56
|
# Atomic: only one job processes a session at a time.
|
|
@@ -58,8 +60,8 @@ class AgentRequestJob < ApplicationJob
|
|
|
58
60
|
|
|
59
61
|
agent_loop = AgentLoop.new(session: session)
|
|
60
62
|
|
|
61
|
-
if
|
|
62
|
-
|
|
63
|
+
if event_id
|
|
64
|
+
deliver_persisted_event(session, event_id, agent_loop)
|
|
63
65
|
else
|
|
64
66
|
agent_loop.run
|
|
65
67
|
end
|
|
@@ -80,51 +82,52 @@ class AgentRequestJob < ApplicationJob
|
|
|
80
82
|
|
|
81
83
|
private
|
|
82
84
|
|
|
83
|
-
#
|
|
85
|
+
# Verifies LLM delivery for a pre-persisted user event.
|
|
84
86
|
#
|
|
85
|
-
#
|
|
86
|
-
#
|
|
87
|
-
#
|
|
88
|
-
#
|
|
87
|
+
# The event was already created and broadcast by the caller, so
|
|
88
|
+
# the user sees their message immediately. This method makes the
|
|
89
|
+
# first LLM API call — if it fails, the event is deleted and a
|
|
90
|
+
# {Events::BounceBack} notifies clients to remove the phantom
|
|
91
|
+
# message and restore the text to the input field. For
|
|
92
|
+
# {Providers::Anthropic::AuthenticationError}, an additional
|
|
93
|
+
# +authentication_required+ broadcast prompts the client to show
|
|
94
|
+
# the token entry dialog.
|
|
89
95
|
#
|
|
90
|
-
#
|
|
91
|
-
#
|
|
92
|
-
#
|
|
96
|
+
# Unlike the standard path (which uses +retry_on+ / +discard_on+),
|
|
97
|
+
# all errors here are caught and swallowed after emitting a
|
|
98
|
+
# BounceBack — the job completes normally so ActiveJob does not
|
|
99
|
+
# retry a message the user will re-send manually.
|
|
100
|
+
#
|
|
101
|
+
# After successful delivery, continues the agent loop (tool
|
|
102
|
+
# execution, subsequent API calls).
|
|
93
103
|
#
|
|
94
104
|
# @param session [Session] the conversation session
|
|
95
|
-
# @param
|
|
96
|
-
# @param agent_loop [AgentLoop] agent loop instance (reused
|
|
97
|
-
def
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
+
# @param event_id [Integer] database ID of the pre-persisted user event
|
|
106
|
+
# @param agent_loop [AgentLoop] agent loop instance (reused for continuation)
|
|
107
|
+
def deliver_persisted_event(session, event_id, agent_loop)
|
|
108
|
+
event = Event.find_by(id: event_id, session_id: session.id)
|
|
109
|
+
# Event may have been deleted between SessionChannel#speak and job
|
|
110
|
+
# execution (e.g. user recalled the message). Exit silently — there
|
|
111
|
+
# is nothing to deliver or bounce back.
|
|
112
|
+
return unless event
|
|
113
|
+
|
|
114
|
+
content = event.payload["content"]
|
|
115
|
+
|
|
116
|
+
begin
|
|
105
117
|
agent_loop.deliver!
|
|
106
118
|
rescue => error
|
|
119
|
+
event.destroy!
|
|
107
120
|
Events::Bus.emit(Events::BounceBack.new(
|
|
108
121
|
content: content,
|
|
109
122
|
error: error.message,
|
|
110
123
|
session_id: session.id,
|
|
111
124
|
event_id: event_id
|
|
112
125
|
))
|
|
113
|
-
|
|
126
|
+
broadcast_auth_required(session.id, error) if error.is_a?(Providers::Anthropic::AuthenticationError)
|
|
127
|
+
return
|
|
114
128
|
end
|
|
115
129
|
|
|
116
|
-
# Transaction committed — first call succeeded.
|
|
117
|
-
# Continue processing (tool execution, etc.) outside the transaction.
|
|
118
130
|
agent_loop.run
|
|
119
|
-
rescue => error
|
|
120
|
-
# Bounce already emitted inside the transaction rescue.
|
|
121
|
-
# Also trigger auth popup for authentication errors.
|
|
122
|
-
broadcast_auth_required(session.id, error) if error.is_a?(Providers::Anthropic::AuthenticationError)
|
|
123
|
-
end
|
|
124
|
-
|
|
125
|
-
# @see Session#create_user_event
|
|
126
|
-
def persist_user_event(session, content)
|
|
127
|
-
session.create_user_event(content)
|
|
128
131
|
end
|
|
129
132
|
|
|
130
133
|
def broadcast_auth_required(session_id, error)
|
|
@@ -44,23 +44,9 @@ module Event::Broadcasting
|
|
|
44
44
|
after_update_commit :broadcast_update
|
|
45
45
|
end
|
|
46
46
|
|
|
47
|
-
# Broadcasts this event immediately, bypassing +after_create_commit+.
|
|
48
|
-
# Used inside a wrapping transaction where +after_create_commit+ is
|
|
49
|
-
# deferred until the outer transaction commits. Gives clients
|
|
50
|
-
# optimistic UI — the event appears right away and is removed via
|
|
51
|
-
# bounce if the transaction rolls back.
|
|
52
|
-
#
|
|
53
|
-
# Sets a flag so the deferred +after_create_commit+ callback skips
|
|
54
|
-
# the duplicate broadcast after the transaction commits.
|
|
55
|
-
def broadcast_now!
|
|
56
|
-
@already_broadcast = true
|
|
57
|
-
broadcast_event(action: ACTION_CREATE)
|
|
58
|
-
end
|
|
59
|
-
|
|
60
47
|
private
|
|
61
48
|
|
|
62
49
|
def broadcast_create
|
|
63
|
-
return if @already_broadcast
|
|
64
50
|
broadcast_event(action: ACTION_CREATE)
|
|
65
51
|
end
|
|
66
52
|
|
data/app/models/session.rb
CHANGED
|
@@ -11,6 +11,8 @@ class Session < ApplicationRecord
|
|
|
11
11
|
|
|
12
12
|
VIEW_MODES = %w[basic verbose debug].freeze
|
|
13
13
|
|
|
14
|
+
attribute :view_mode, :string, default: -> { Anima::Settings.default_view_mode }
|
|
15
|
+
|
|
14
16
|
serialize :granted_tools, coder: JSON
|
|
15
17
|
|
|
16
18
|
has_many :events, -> { order(:id) }, dependent: :destroy
|
|
@@ -251,6 +253,8 @@ class Session < ApplicationRecord
|
|
|
251
253
|
# @param token_budget [Integer] maximum tokens to include (positive)
|
|
252
254
|
# @return [Array<Hash>] Anthropic Messages API format
|
|
253
255
|
def messages_for_llm(token_budget: Anima::Settings.token_budget)
|
|
256
|
+
heal_orphaned_tool_calls!
|
|
257
|
+
|
|
254
258
|
sliding_budget = token_budget
|
|
255
259
|
snapshot_messages = []
|
|
256
260
|
pinned_messages = []
|
|
@@ -273,11 +277,52 @@ class Session < ApplicationRecord
|
|
|
273
277
|
recall_messages = assemble_recall_messages(budget: recall_budget)
|
|
274
278
|
end
|
|
275
279
|
|
|
276
|
-
snapshot_messages + pinned_messages + recall_messages + assemble_messages(events)
|
|
280
|
+
snapshot_messages + pinned_messages + recall_messages + assemble_messages(ensure_atomic_tool_pairs(events))
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# Detects orphaned tool_call events (those without a matching tool_response
|
|
284
|
+
# and whose timeout has expired) and creates synthetic error responses.
|
|
285
|
+
# An orphaned tool_call permanently breaks the session because the
|
|
286
|
+
# Anthropic API rejects conversations where a tool_use block has no
|
|
287
|
+
# matching tool_result.
|
|
288
|
+
#
|
|
289
|
+
# Respects the per-call timeout stored in the tool_call event payload —
|
|
290
|
+
# a tool_call is only healed after its deadline has passed. This avoids
|
|
291
|
+
# prematurely healing long-running tools that the agent intentionally
|
|
292
|
+
# gave an extended timeout.
|
|
293
|
+
#
|
|
294
|
+
# @return [Integer] number of synthetic responses created
|
|
295
|
+
def heal_orphaned_tool_calls!
|
|
296
|
+
now_ns = Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond)
|
|
297
|
+
responded_ids = events.where(event_type: "tool_response").where.not(tool_use_id: nil).select(:tool_use_id)
|
|
298
|
+
unresponded = events.where(event_type: "tool_call").where.not(tool_use_id: nil)
|
|
299
|
+
.where.not(tool_use_id: responded_ids)
|
|
300
|
+
|
|
301
|
+
healed = 0
|
|
302
|
+
unresponded.find_each do |orphan|
|
|
303
|
+
timeout = orphan.payload["timeout"] || Anima::Settings.tool_timeout
|
|
304
|
+
deadline_ns = orphan.timestamp + (timeout * 1_000_000_000)
|
|
305
|
+
next if now_ns < deadline_ns
|
|
306
|
+
|
|
307
|
+
events.create!(
|
|
308
|
+
event_type: "tool_response",
|
|
309
|
+
payload: {
|
|
310
|
+
"type" => "tool_response",
|
|
311
|
+
"content" => "Tool execution timed out after #{timeout} seconds — no result was returned.",
|
|
312
|
+
"tool_name" => orphan.payload["tool_name"],
|
|
313
|
+
"tool_use_id" => orphan.tool_use_id,
|
|
314
|
+
"success" => false
|
|
315
|
+
},
|
|
316
|
+
tool_use_id: orphan.tool_use_id,
|
|
317
|
+
timestamp: now_ns
|
|
318
|
+
)
|
|
319
|
+
healed += 1
|
|
320
|
+
end
|
|
321
|
+
healed
|
|
277
322
|
end
|
|
278
323
|
|
|
279
324
|
# Creates a user message event record directly (bypasses EventBus+Persister).
|
|
280
|
-
# Used by {
|
|
325
|
+
# Used by {SessionChannel#speak} (immediate display), {AgentLoop#process},
|
|
281
326
|
# and sub-agent spawn tools ({Tools::SpawnSubagent}, {Tools::SpawnSpecialist})
|
|
282
327
|
# because the global {Events::Subscribers::Persister} skips non-pending user
|
|
283
328
|
# messages — these callers own the persistence lifecycle.
|
|
@@ -497,6 +542,28 @@ class Session < ApplicationRecord
|
|
|
497
542
|
event_list
|
|
498
543
|
end
|
|
499
544
|
|
|
545
|
+
# Ensures every tool_call in the event list has a matching tool_response
|
|
546
|
+
# (and vice versa) by removing unpaired events. The Anthropic API requires
|
|
547
|
+
# every tool_use block to have a tool_result — a missing partner causes
|
|
548
|
+
# a permanent API error. Token budget cutoffs can split pairs when the
|
|
549
|
+
# boundary falls between a tool_call and its tool_response.
|
|
550
|
+
#
|
|
551
|
+
# @param event_list [Array<Event>] chronologically ordered events
|
|
552
|
+
# @return [Array<Event>] events with unpaired tool events removed
|
|
553
|
+
def ensure_atomic_tool_pairs(event_list)
|
|
554
|
+
tool_events = event_list.select { |e| e.tool_use_id.present? }
|
|
555
|
+
return event_list if tool_events.empty?
|
|
556
|
+
|
|
557
|
+
paired = tool_events.group_by(&:tool_use_id)
|
|
558
|
+
complete_ids = paired.each_with_object(Set.new) do |(id, evts), set|
|
|
559
|
+
has_call = evts.any? { |e| e.event_type == "tool_call" }
|
|
560
|
+
has_response = evts.any? { |e| e.event_type == "tool_response" }
|
|
561
|
+
set << id if has_call && has_response
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
event_list.reject { |e| e.tool_use_id.present? && !complete_ids.include?(e.tool_use_id) }
|
|
565
|
+
end
|
|
566
|
+
|
|
500
567
|
# Selects visible snapshots and formats them as Anthropic messages.
|
|
501
568
|
# Snapshots are visible when their source events have fully evicted.
|
|
502
569
|
# L1 snapshots are excluded when covered by an L2 snapshot.
|
data/bin/jobs
CHANGED
|
@@ -3,4 +3,9 @@
|
|
|
3
3
|
require_relative "../config/environment"
|
|
4
4
|
require "solid_queue/cli"
|
|
5
5
|
|
|
6
|
+
# Run all Solid Queue components (worker, dispatcher, scheduler) as threads
|
|
7
|
+
# in a single process. Fork mode spawns child processes that can outlive
|
|
8
|
+
# the supervisor and run stale code after gem updates (#275).
|
|
9
|
+
ENV["SOLID_QUEUE_SUPERVISOR_MODE"] = "async"
|
|
10
|
+
|
|
6
11
|
SolidQueue::Cli.start(ARGV)
|
|
@@ -6,12 +6,10 @@
|
|
|
6
6
|
Rails.application.config.after_initialize do
|
|
7
7
|
unless Rails.env.test?
|
|
8
8
|
# Global persister handles events from all sessions (brain server, background jobs).
|
|
9
|
-
# Skips non-pending user messages — those are persisted by
|
|
9
|
+
# Skips non-pending user messages — those are persisted by their callers
|
|
10
|
+
# (SessionChannel#speak for idle sessions, AgentLoop#process for direct usage).
|
|
10
11
|
Events::Bus.subscribe(Events::Subscribers::Persister.new)
|
|
11
12
|
|
|
12
|
-
# Schedules AgentRequestJob when a non-pending user message is emitted.
|
|
13
|
-
Events::Bus.subscribe(Events::Subscribers::AgentDispatcher.new)
|
|
14
|
-
|
|
15
13
|
# Bridges transient events (e.g. BounceBack) to ActionCable for client delivery.
|
|
16
14
|
Events::Bus.subscribe(Events::Subscribers::TransientBroadcaster.new)
|
|
17
15
|
|
data/config/queue.yml
CHANGED
data/lib/anima/cli.rb
CHANGED
|
@@ -20,13 +20,17 @@ module Anima
|
|
|
20
20
|
Installer.new.run
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
-
desc "update", "Upgrade gem and
|
|
23
|
+
desc "update", "Upgrade gem, migrate config, and restart service"
|
|
24
24
|
option :migrate_only, type: :boolean, default: false, desc: "Skip gem upgrade, only migrate config"
|
|
25
25
|
def update
|
|
26
|
+
require_relative "spinner"
|
|
27
|
+
|
|
26
28
|
unless options[:migrate_only]
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
29
|
+
success = Spinner.run("Upgrading anima-core gem...") do
|
|
30
|
+
system("gem", "update", "anima-core", out: File::NULL, err: File::NULL)
|
|
31
|
+
end
|
|
32
|
+
unless success
|
|
33
|
+
say "Run manually for details: gem update anima-core", :red
|
|
30
34
|
exit 1
|
|
31
35
|
end
|
|
32
36
|
|
|
@@ -34,22 +38,24 @@ module Anima
|
|
|
34
38
|
exec(File.join(Gem.bindir, "anima"), "update", "--migrate-only")
|
|
35
39
|
end
|
|
36
40
|
|
|
37
|
-
say "Migrating configuration..."
|
|
38
41
|
require_relative "config_migrator"
|
|
39
|
-
result =
|
|
42
|
+
result = Spinner.run("Migrating configuration...") do
|
|
43
|
+
Anima::ConfigMigrator.new.run
|
|
44
|
+
end
|
|
40
45
|
|
|
41
46
|
case result.status
|
|
42
47
|
when :not_found
|
|
43
48
|
say "Config file not found. Run 'anima install' first.", :red
|
|
44
49
|
exit 1
|
|
45
50
|
when :up_to_date
|
|
46
|
-
say "Config is already up to date."
|
|
51
|
+
say " Config is already up to date."
|
|
47
52
|
when :updated
|
|
48
53
|
result.additions.each do |addition|
|
|
49
54
|
say " added [#{addition.section}] #{addition.key} = #{addition.value.inspect}"
|
|
50
55
|
end
|
|
51
|
-
say "Config updated. Changes take effect immediately — no restart needed."
|
|
52
56
|
end
|
|
57
|
+
|
|
58
|
+
restart_service_if_active
|
|
53
59
|
end
|
|
54
60
|
|
|
55
61
|
# Start the Anima brain server (Puma + Solid Queue) via Foreman.
|
|
@@ -104,5 +110,21 @@ module Anima
|
|
|
104
110
|
subcommand "mcp", Mcp
|
|
105
111
|
|
|
106
112
|
private
|
|
113
|
+
|
|
114
|
+
# Restarts the systemd user service so updated code takes effect.
|
|
115
|
+
# Without this, the service continues running the old gem version
|
|
116
|
+
# until manually restarted (see #269).
|
|
117
|
+
#
|
|
118
|
+
# @return [void]
|
|
119
|
+
def restart_service_if_active
|
|
120
|
+
return unless system("systemctl", "--user", "is-active", "--quiet", "anima.service")
|
|
121
|
+
|
|
122
|
+
success = Spinner.run("Restarting anima service...") do
|
|
123
|
+
system("systemctl", "--user", "restart", "anima.service")
|
|
124
|
+
end
|
|
125
|
+
unless success
|
|
126
|
+
say " Run manually: systemctl --user restart anima.service", :red
|
|
127
|
+
end
|
|
128
|
+
end
|
|
107
129
|
end
|
|
108
130
|
end
|
data/lib/anima/installer.rb
CHANGED
|
@@ -30,7 +30,6 @@ module Anima
|
|
|
30
30
|
say "Installing Anima to #{anima_home}..."
|
|
31
31
|
create_directories
|
|
32
32
|
create_soul_file
|
|
33
|
-
create_config_file
|
|
34
33
|
create_settings_config
|
|
35
34
|
create_mcp_config
|
|
36
35
|
generate_credentials
|
|
@@ -60,17 +59,6 @@ module Anima
|
|
|
60
59
|
say " created #{soul_path}"
|
|
61
60
|
end
|
|
62
61
|
|
|
63
|
-
def create_config_file
|
|
64
|
-
config_path = anima_home.join("config", "anima.yml")
|
|
65
|
-
return if config_path.exist?
|
|
66
|
-
|
|
67
|
-
config_path.write(<<~YAML)
|
|
68
|
-
# Anima configuration
|
|
69
|
-
# See https://github.com/hoblin/anima for documentation
|
|
70
|
-
YAML
|
|
71
|
-
say " created #{config_path}"
|
|
72
|
-
end
|
|
73
|
-
|
|
74
62
|
def create_settings_config
|
|
75
63
|
config_path = anima_home.join("config.toml")
|
|
76
64
|
return if config_path.exist?
|
data/lib/anima/settings.rb
CHANGED
|
@@ -102,6 +102,11 @@ module Anima
|
|
|
102
102
|
# @return [Integer] seconds
|
|
103
103
|
def web_request_timeout = get("timeouts", "web_request")
|
|
104
104
|
|
|
105
|
+
# Per-tool-call timeout. Used as the default deadline for orphan detection
|
|
106
|
+
# and as the default value for the tool's `timeout` input parameter.
|
|
107
|
+
# @return [Integer] seconds
|
|
108
|
+
def tool_timeout = get("timeouts", "tool")
|
|
109
|
+
|
|
105
110
|
# ─── Shell ──────────────────────────────────────────────────────
|
|
106
111
|
|
|
107
112
|
# Maximum bytes of command output before truncation.
|
|
@@ -128,6 +133,19 @@ module Anima
|
|
|
128
133
|
|
|
129
134
|
# ─── Session ────────────────────────────────────────────────────
|
|
130
135
|
|
|
136
|
+
# View mode applied to new sessions: "basic", "verbose", or "debug".
|
|
137
|
+
# Changing this setting only affects sessions created afterwards.
|
|
138
|
+
# @return [String]
|
|
139
|
+
# @raise [MissingSettingError] if the value is not a valid view mode
|
|
140
|
+
def default_view_mode
|
|
141
|
+
value = get("session", "default_view_mode")
|
|
142
|
+
unless Session::VIEW_MODES.include?(value)
|
|
143
|
+
raise MissingSettingError,
|
|
144
|
+
"[session] default_view_mode must be one of: #{Session::VIEW_MODES.join(", ")} (got #{value.inspect})"
|
|
145
|
+
end
|
|
146
|
+
value
|
|
147
|
+
end
|
|
148
|
+
|
|
131
149
|
# Regenerate session name every N messages.
|
|
132
150
|
# @return [Integer]
|
|
133
151
|
def name_generation_interval = get("session", "name_generation_interval")
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Anima
|
|
4
|
+
# Braille spinner for long-running CLI operations.
|
|
5
|
+
# Animates in a background thread while a block executes in the
|
|
6
|
+
# calling thread, then shows a success/failure indicator.
|
|
7
|
+
#
|
|
8
|
+
# @example
|
|
9
|
+
# result = Spinner.run("Installing...") { system("make install") }
|
|
10
|
+
class Spinner
|
|
11
|
+
FRAMES = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].freeze
|
|
12
|
+
FRAME_DELAY = 0.08
|
|
13
|
+
SUCCESS_ICON = "\u2713"
|
|
14
|
+
FAILURE_ICON = "\u2717"
|
|
15
|
+
JOIN_TIMEOUT = 2
|
|
16
|
+
|
|
17
|
+
# Run a block with an animated spinner beside a status message.
|
|
18
|
+
#
|
|
19
|
+
# @param message [String] status text shown beside the spinner
|
|
20
|
+
# @param output [IO] output stream (defaults to $stdout)
|
|
21
|
+
# @yield the operation to run
|
|
22
|
+
# @return [Object] the block's return value
|
|
23
|
+
def self.run(message, output: $stdout, &block)
|
|
24
|
+
new(message, output: output).run(&block)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @param message [String] status text shown beside the spinner
|
|
28
|
+
# @param output [IO] output stream
|
|
29
|
+
def initialize(message, output: $stdout)
|
|
30
|
+
@message = message
|
|
31
|
+
@output = output
|
|
32
|
+
@mutex = Mutex.new
|
|
33
|
+
@running = false
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @yield operation to run while the spinner animates
|
|
37
|
+
# @return [Object] the block's return value
|
|
38
|
+
def run
|
|
39
|
+
thread = start_animation
|
|
40
|
+
result = yield
|
|
41
|
+
stop_animation(thread, success: !!result)
|
|
42
|
+
result
|
|
43
|
+
rescue
|
|
44
|
+
stop_animation(thread, success: false)
|
|
45
|
+
raise
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def running?
|
|
51
|
+
@mutex.synchronize { @running }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def start_animation
|
|
55
|
+
@mutex.synchronize { @running = true }
|
|
56
|
+
Thread.new do
|
|
57
|
+
idx = 0
|
|
58
|
+
while running?
|
|
59
|
+
@output.print "\r#{FRAMES[idx % FRAMES.size]} #{@message}"
|
|
60
|
+
@output.flush
|
|
61
|
+
idx += 1
|
|
62
|
+
sleep FRAME_DELAY
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def stop_animation(thread, success:)
|
|
68
|
+
@mutex.synchronize { @running = false }
|
|
69
|
+
thread.join(JOIN_TIMEOUT)
|
|
70
|
+
icon = success ? SUCCESS_ICON : FAILURE_ICON
|
|
71
|
+
@output.print "\r#{icon} #{@message}\n"
|
|
72
|
+
@output.flush
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
data/lib/anima/version.rb
CHANGED
data/lib/environment_probe.rb
CHANGED
|
@@ -123,7 +123,7 @@ class EnvironmentProbe
|
|
|
123
123
|
# @return [Hash{Symbol => String}, nil] keys: :remote, :repo_name, :branch,
|
|
124
124
|
# and optionally :pr_number (Integer) and :pr_state (String); nil when not in a repo
|
|
125
125
|
def detect_git
|
|
126
|
-
_, status = Open3.capture2("git", "-C", @pwd, "rev-parse", "--is-inside-work-tree")
|
|
126
|
+
_, status = Open3.capture2("git", "-C", @pwd, "rev-parse", "--is-inside-work-tree", err: File::NULL)
|
|
127
127
|
return unless status.success?
|
|
128
128
|
|
|
129
129
|
info = {}
|
|
@@ -136,7 +136,7 @@ class EnvironmentProbe
|
|
|
136
136
|
|
|
137
137
|
# Populates :remote and :repo_name on the info hash.
|
|
138
138
|
def detect_git_remote(info)
|
|
139
|
-
remote, = Open3.capture2("git", "-C", @pwd, "remote", "get-url", "origin")
|
|
139
|
+
remote, = Open3.capture2("git", "-C", @pwd, "remote", "get-url", "origin", err: File::NULL)
|
|
140
140
|
remote = remote.strip
|
|
141
141
|
return unless remote.present?
|
|
142
142
|
|
|
@@ -146,7 +146,7 @@ class EnvironmentProbe
|
|
|
146
146
|
|
|
147
147
|
# Populates :branch, :pr_number, and :pr_state on the info hash.
|
|
148
148
|
def detect_git_branch(info)
|
|
149
|
-
branch, = Open3.capture2("git", "-C", @pwd, "rev-parse", "--abbrev-ref", "HEAD")
|
|
149
|
+
branch, = Open3.capture2("git", "-C", @pwd, "rev-parse", "--abbrev-ref", "HEAD", err: File::NULL)
|
|
150
150
|
branch = branch.strip
|
|
151
151
|
return unless branch.present?
|
|
152
152
|
|
|
@@ -181,7 +181,7 @@ class EnvironmentProbe
|
|
|
181
181
|
output, status = Open3.capture2(
|
|
182
182
|
"gh", "pr", "list", "--head", branch,
|
|
183
183
|
"--json", "number,state", "--limit", "1",
|
|
184
|
-
chdir: @pwd
|
|
184
|
+
chdir: @pwd, err: File::NULL
|
|
185
185
|
)
|
|
186
186
|
return unless status.success?
|
|
187
187
|
|
|
@@ -28,10 +28,10 @@ module Events
|
|
|
28
28
|
|
|
29
29
|
# Receives a Rails.event notification hash and persists it.
|
|
30
30
|
#
|
|
31
|
-
# Skips non-pending user messages — those are persisted by
|
|
32
|
-
# {
|
|
33
|
-
#
|
|
34
|
-
# (transient events like {Events::BounceBack}).
|
|
31
|
+
# Skips non-pending user messages — those are persisted by their
|
|
32
|
+
# callers ({SessionChannel#speak} for idle sessions,
|
|
33
|
+
# {AgentLoop#process} for direct usage). Also skips event types
|
|
34
|
+
# not in {Event::TYPES} (transient events like {Events::BounceBack}).
|
|
35
35
|
#
|
|
36
36
|
# @param event [Hash] with :payload containing event data
|
|
37
37
|
def emit(event)
|
|
@@ -63,9 +63,11 @@ module Events
|
|
|
63
63
|
|
|
64
64
|
private
|
|
65
65
|
|
|
66
|
-
# Non-pending user messages are persisted by
|
|
67
|
-
#
|
|
68
|
-
#
|
|
66
|
+
# Non-pending user messages are persisted by their callers
|
|
67
|
+
# ({SessionChannel#speak}, {AgentLoop#process}) so the event ID
|
|
68
|
+
# is available for bounce-back cleanup if LLM delivery fails.
|
|
69
|
+
# Pending messages are still auto-persisted here because they
|
|
70
|
+
# queue while the session is busy.
|
|
69
71
|
def persisted_by_job?(event_type, payload)
|
|
70
72
|
event_type == "user_message" && payload[:status] != Event::PENDING_STATUS
|
|
71
73
|
end
|
data/lib/events/tool_call.rb
CHANGED
|
@@ -4,18 +4,20 @@ module Events
|
|
|
4
4
|
class ToolCall < Base
|
|
5
5
|
TYPE = "tool_call"
|
|
6
6
|
|
|
7
|
-
attr_reader :tool_name, :tool_input, :tool_use_id
|
|
7
|
+
attr_reader :tool_name, :tool_input, :tool_use_id, :timeout
|
|
8
8
|
|
|
9
9
|
# @param content [String] human-readable description of the tool call
|
|
10
10
|
# @param tool_name [String] registered tool name (e.g. "web_get")
|
|
11
11
|
# @param tool_input [Hash] arguments passed to the tool
|
|
12
12
|
# @param tool_use_id [String] Anthropic-assigned ID for correlating call/result
|
|
13
|
+
# @param timeout [Integer] maximum seconds before the call is considered orphaned
|
|
13
14
|
# @param session_id [String, nil] optional session identifier
|
|
14
|
-
def initialize(content:, tool_name:, tool_input: {}, tool_use_id: nil, session_id: nil)
|
|
15
|
+
def initialize(content:, tool_name:, tool_input: {}, tool_use_id: nil, timeout: nil, session_id: nil)
|
|
15
16
|
super(content: content, session_id: session_id)
|
|
16
17
|
@tool_name = tool_name
|
|
17
18
|
@tool_input = tool_input
|
|
18
19
|
@tool_use_id = tool_use_id
|
|
20
|
+
@timeout = timeout
|
|
19
21
|
end
|
|
20
22
|
|
|
21
23
|
def type
|
|
@@ -23,7 +25,7 @@ module Events
|
|
|
23
25
|
end
|
|
24
26
|
|
|
25
27
|
def to_h
|
|
26
|
-
super.merge(tool_name: tool_name, tool_input: tool_input, tool_use_id: tool_use_id)
|
|
28
|
+
super.merge(tool_name: tool_name, tool_input: tool_input, tool_use_id: tool_use_id, timeout: timeout)
|
|
27
29
|
end
|
|
28
30
|
end
|
|
29
31
|
end
|
data/lib/llm/client.rb
CHANGED
|
@@ -181,12 +181,14 @@ module LLM
|
|
|
181
181
|
name = tool_use["name"]
|
|
182
182
|
id = tool_use["id"]
|
|
183
183
|
input = tool_use["input"] || {}
|
|
184
|
+
timeout = input["timeout"] || Anima::Settings.tool_timeout
|
|
184
185
|
|
|
185
186
|
log(:debug, "tool_call: #{name}(#{input.to_json})")
|
|
186
187
|
|
|
187
188
|
Events::Bus.emit(Events::ToolCall.new(
|
|
188
189
|
content: "Calling #{name}", tool_name: name,
|
|
189
|
-
tool_input: input, tool_use_id: id,
|
|
190
|
+
tool_input: input, tool_use_id: id, timeout: timeout,
|
|
191
|
+
session_id: session_id
|
|
190
192
|
))
|
|
191
193
|
|
|
192
194
|
result = begin
|
data/lib/shell_session.rb
CHANGED
|
@@ -45,13 +45,15 @@ class ShellSession
|
|
|
45
45
|
# automatically if the previous session died (timeout, crash, etc.).
|
|
46
46
|
#
|
|
47
47
|
# @param command [String] bash command to execute
|
|
48
|
+
# @param timeout [Integer, nil] per-call timeout in seconds; overrides
|
|
49
|
+
# Settings.command_timeout when provided
|
|
48
50
|
# @return [Hash] with :stdout, :stderr, :exit_code keys on success
|
|
49
51
|
# @return [Hash] with :error key on failure
|
|
50
|
-
def run(command)
|
|
52
|
+
def run(command, timeout: nil)
|
|
51
53
|
@mutex.synchronize do
|
|
52
54
|
return {error: "Shell session is not running"} if @finalized
|
|
53
55
|
restart unless @alive
|
|
54
|
-
execute_in_pty(command)
|
|
56
|
+
execute_in_pty(command, timeout: timeout)
|
|
55
57
|
end
|
|
56
58
|
rescue => error # rubocop:disable Lint/RescueException -- LLM must always get a result hash, never a stack trace
|
|
57
59
|
{error: "#{error.class}: #{error.message}"}
|
|
@@ -227,10 +229,10 @@ class ShellSession
|
|
|
227
229
|
end
|
|
228
230
|
end
|
|
229
231
|
|
|
230
|
-
def execute_in_pty(command)
|
|
232
|
+
def execute_in_pty(command, timeout: nil)
|
|
231
233
|
clear_stderr
|
|
232
234
|
marker = "__ANIMA_#{SecureRandom.hex(8)}__"
|
|
233
|
-
timeout
|
|
235
|
+
timeout ||= Anima::Settings.command_timeout
|
|
234
236
|
deadline = monotonic_now + timeout
|
|
235
237
|
|
|
236
238
|
@pty_stdin.puts "#{command}; __anima_ec=$?; echo; echo '#{marker}' $__anima_ec"
|
data/lib/tools/base.rb
CHANGED
|
@@ -49,7 +49,8 @@ module Tools
|
|
|
49
49
|
def initialize(**) = nil
|
|
50
50
|
|
|
51
51
|
# Execute the tool with the given input.
|
|
52
|
-
# @param input [Hash] parsed input matching {.input_schema}
|
|
52
|
+
# @param input [Hash] parsed input matching {.input_schema}. May include
|
|
53
|
+
# a "timeout" key (seconds) for tools that support per-call timeout overrides.
|
|
53
54
|
# @return [String, Hash] result content; Hash with :error key signals failure
|
|
54
55
|
def execute(input)
|
|
55
56
|
raise NotImplementedError, "#{self.class} must implement #execute"
|
data/lib/tools/bash.rb
CHANGED
|
@@ -27,14 +27,16 @@ module Tools
|
|
|
27
27
|
@shell_session = shell_session
|
|
28
28
|
end
|
|
29
29
|
|
|
30
|
-
# @param input [Hash<String, Object>] string-keyed hash from the Anthropic API
|
|
30
|
+
# @param input [Hash<String, Object>] string-keyed hash from the Anthropic API.
|
|
31
|
+
# Supports optional "timeout" key (seconds) to override the global
|
|
32
|
+
# command_timeout setting for long-running operations.
|
|
31
33
|
# @return [String] formatted output with stdout, stderr, and exit code
|
|
32
34
|
# @return [Hash] with :error key on failure
|
|
33
35
|
def execute(input)
|
|
34
36
|
command = input["command"].to_s
|
|
35
37
|
return {error: "Command cannot be blank"} if command.strip.empty?
|
|
36
38
|
|
|
37
|
-
result = @shell_session.run(command)
|
|
39
|
+
result = @shell_session.run(command, timeout: input["timeout"])
|
|
38
40
|
return result if result.key?(:error)
|
|
39
41
|
|
|
40
42
|
format_result(result[:stdout], result[:stderr], result[:exit_code])
|
data/lib/tools/registry.rb
CHANGED
|
@@ -30,16 +30,21 @@ module Tools
|
|
|
30
30
|
@tools[tool.tool_name] = tool
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
-
# @return [Array<Hash>] schema array for the Anthropic tools API parameter
|
|
33
|
+
# @return [Array<Hash>] schema array for the Anthropic tools API parameter.
|
|
34
|
+
# Each schema includes an optional `timeout` parameter (seconds) injected
|
|
35
|
+
# by the registry. The agent can override the default per call for
|
|
36
|
+
# long-running operations.
|
|
34
37
|
def schemas
|
|
35
|
-
|
|
38
|
+
default = Anima::Settings.tool_timeout
|
|
39
|
+
@tools.values.map { |tool| inject_timeout(tool.schema, default) }
|
|
36
40
|
end
|
|
37
41
|
|
|
38
42
|
# Execute a tool by name. Classes are instantiated with the registry's
|
|
39
43
|
# context; instances are called directly.
|
|
40
44
|
#
|
|
41
45
|
# @param name [String] registered tool name
|
|
42
|
-
# @param input [Hash] tool input parameters
|
|
46
|
+
# @param input [Hash] tool input parameters (may include "timeout" for
|
|
47
|
+
# tools that support per-call timeout overrides)
|
|
43
48
|
# @return [String, Hash] tool execution result
|
|
44
49
|
# @raise [UnknownToolError] if no tool is registered with the given name
|
|
45
50
|
def execute(name, input)
|
|
@@ -58,5 +63,19 @@ module Tools
|
|
|
58
63
|
def any?
|
|
59
64
|
@tools.any?
|
|
60
65
|
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
# Injects an optional `timeout` parameter into the tool's input schema.
|
|
70
|
+
def inject_timeout(schema, default)
|
|
71
|
+
s = schema.deep_dup
|
|
72
|
+
s[:input_schema] ||= {type: "object", properties: {}}
|
|
73
|
+
s[:input_schema][:properties] ||= {}
|
|
74
|
+
s[:input_schema][:properties]["timeout"] = {
|
|
75
|
+
type: "integer",
|
|
76
|
+
description: "Max execution seconds (default: #{default}). Increase for long-running operations."
|
|
77
|
+
}
|
|
78
|
+
s
|
|
79
|
+
end
|
|
61
80
|
end
|
|
62
81
|
end
|
|
@@ -71,7 +71,9 @@ module Tools
|
|
|
71
71
|
|
|
72
72
|
# @return [String, nil] owner/repo parsed from +git remote get-url origin+
|
|
73
73
|
def git_remote_repo
|
|
74
|
-
url,
|
|
74
|
+
url, status = Open3.capture2("git", "remote", "get-url", "origin", err: File::NULL)
|
|
75
|
+
return unless status.success?
|
|
76
|
+
|
|
75
77
|
parse_owner_repo(url.strip)
|
|
76
78
|
rescue Errno::ENOENT
|
|
77
79
|
nil
|
data/lib/tools/web_get.rb
CHANGED
|
@@ -27,17 +27,19 @@ module Tools
|
|
|
27
27
|
}
|
|
28
28
|
end
|
|
29
29
|
|
|
30
|
-
# @param input [Hash<String, Object>] string-keyed hash from the Anthropic API
|
|
30
|
+
# @param input [Hash<String, Object>] string-keyed hash from the Anthropic API.
|
|
31
|
+
# Supports optional "timeout" key (seconds) to override the global
|
|
32
|
+
# web_request_timeout setting.
|
|
31
33
|
# @return [Hash] `{body: String, content_type: String}` on success
|
|
32
34
|
# @return [Hash] `{error: String}` on failure
|
|
33
35
|
def execute(input)
|
|
34
|
-
validate_and_fetch(input["url"].to_s)
|
|
36
|
+
validate_and_fetch(input["url"].to_s, timeout: input["timeout"])
|
|
35
37
|
end
|
|
36
38
|
|
|
37
39
|
private
|
|
38
40
|
|
|
39
|
-
def validate_and_fetch(url)
|
|
40
|
-
timeout
|
|
41
|
+
def validate_and_fetch(url, timeout: nil)
|
|
42
|
+
timeout ||= Anima::Settings.web_request_timeout
|
|
41
43
|
scheme = URI.parse(url).scheme
|
|
42
44
|
|
|
43
45
|
unless %w[http https].include?(scheme)
|
data/lib/tui/message_store.rb
CHANGED
|
@@ -14,6 +14,11 @@ module TUI
|
|
|
14
14
|
# rendered content fall back to existing behavior: tool events aggregate
|
|
15
15
|
# into counters, messages store role and content.
|
|
16
16
|
#
|
|
17
|
+
# Entries with event IDs are maintained in ID order (ascending)
|
|
18
|
+
# regardless of arrival order, preventing misordering from race
|
|
19
|
+
# conditions between live broadcasts and viewport replays.
|
|
20
|
+
# Duplicate IDs are deduplicated by updating the existing entry.
|
|
21
|
+
#
|
|
17
22
|
# Tool counters aggregate per agent turn: a new counter starts when a
|
|
18
23
|
# tool_call arrives after a message entry. Consecutive tool events
|
|
19
24
|
# increment the same counter until the next message breaks the chain.
|
|
@@ -183,16 +188,79 @@ module TUI
|
|
|
183
188
|
event_data.dig("rendered")&.values&.compact&.first
|
|
184
189
|
end
|
|
185
190
|
|
|
191
|
+
# Inserts a rendered entry at the correct chronological position.
|
|
192
|
+
# System prompt entries (no ID) are always placed at position 0.
|
|
186
193
|
def record_rendered(data, event_type: nil, id: nil)
|
|
187
194
|
@mutex.synchronize do
|
|
188
195
|
entry = {type: :rendered, data: data, event_type: event_type, id: id}
|
|
189
|
-
|
|
190
|
-
@entries_by_id[id] = entry if id
|
|
196
|
+
insert_ordered(entry)
|
|
191
197
|
@version += 1
|
|
192
198
|
end
|
|
193
199
|
true
|
|
194
200
|
end
|
|
195
201
|
|
|
202
|
+
# Inserts an entry in event-ID order. Entries without an ID are
|
|
203
|
+
# appended. If an entry with the same ID already exists, updates
|
|
204
|
+
# it in-place (deduplication for live/viewport replay races).
|
|
205
|
+
# System prompt entries are always placed at position 0.
|
|
206
|
+
#
|
|
207
|
+
# @param entry [Hash] the entry to insert
|
|
208
|
+
# @return [void]
|
|
209
|
+
def insert_ordered(entry)
|
|
210
|
+
if entry[:event_type] == "system_prompt"
|
|
211
|
+
@entries.unshift(entry)
|
|
212
|
+
return
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
id = entry[:id]
|
|
216
|
+
unless id
|
|
217
|
+
@entries << entry
|
|
218
|
+
return
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
existing = @entries_by_id[id]
|
|
222
|
+
if existing
|
|
223
|
+
existing[:data] = entry[:data] if entry.key?(:data)
|
|
224
|
+
existing[:content] = entry[:content] if entry.key?(:content)
|
|
225
|
+
existing[:event_type] = entry[:event_type] if entry.key?(:event_type)
|
|
226
|
+
return
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
insert_sorted_by_id(entry)
|
|
230
|
+
@entries_by_id[id] = entry
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Inserts an entry in sorted order by event ID. Optimized for the
|
|
234
|
+
# common case where events arrive in order (appends without scanning).
|
|
235
|
+
# Entries without IDs (tool counters, etc.) are skipped during the
|
|
236
|
+
# sort scan and don't affect insertion position.
|
|
237
|
+
#
|
|
238
|
+
# @param entry [Hash] entry with a non-nil +:id+
|
|
239
|
+
# @return [void]
|
|
240
|
+
def insert_sorted_by_id(entry)
|
|
241
|
+
id = entry[:id]
|
|
242
|
+
|
|
243
|
+
# Fast path: entry belongs at the end (typical during live streaming)
|
|
244
|
+
last_id = last_entry_id
|
|
245
|
+
if last_id.nil? || last_id < id
|
|
246
|
+
@entries << entry
|
|
247
|
+
return
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Out-of-order arrival: insert before the first entry with a higher ID
|
|
251
|
+
insert_pos = @entries.index { |e| e[:id] && e[:id] > id } || @entries.size
|
|
252
|
+
@entries.insert(insert_pos, entry)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Returns the highest event ID in the entries array, scanning from the
|
|
256
|
+
# end for efficiency (entries with IDs are typically at the tail).
|
|
257
|
+
#
|
|
258
|
+
# @return [Integer, nil] the highest event ID, or nil if no entries have IDs
|
|
259
|
+
def last_entry_id
|
|
260
|
+
@entries.reverse_each { |e| return e[:id] if e[:id] }
|
|
261
|
+
nil
|
|
262
|
+
end
|
|
263
|
+
|
|
196
264
|
def record_tool_call
|
|
197
265
|
@mutex.synchronize do
|
|
198
266
|
current = current_tool_counter
|
|
@@ -221,12 +289,9 @@ module TUI
|
|
|
221
289
|
content = event_data["content"]
|
|
222
290
|
return false if content.nil?
|
|
223
291
|
|
|
224
|
-
event_id = event_data["id"]
|
|
225
|
-
|
|
226
292
|
@mutex.synchronize do
|
|
227
|
-
entry = {type: :message, role: ROLE_MAP.fetch(event_data["type"]), content: content, id:
|
|
228
|
-
|
|
229
|
-
@entries_by_id[event_id] = entry if event_id
|
|
293
|
+
entry = {type: :message, role: ROLE_MAP.fetch(event_data["type"]), content: content, id: event_data["id"]}
|
|
294
|
+
insert_ordered(entry)
|
|
230
295
|
@version += 1
|
|
231
296
|
end
|
|
232
297
|
true
|
data/templates/config.toml
CHANGED
|
@@ -39,6 +39,10 @@ mcp_response = 60
|
|
|
39
39
|
# Web fetch request timeout.
|
|
40
40
|
web_request = 10
|
|
41
41
|
|
|
42
|
+
# Per-tool-call timeout. The agent can override per call via the timeout parameter.
|
|
43
|
+
# Also used as the default deadline for orphan detection in heal_orphaned_tool_calls!.
|
|
44
|
+
tool = 180
|
|
45
|
+
|
|
42
46
|
# ─── Shell ──────────────────────────────────────────────────────
|
|
43
47
|
|
|
44
48
|
[shell]
|
|
@@ -94,6 +98,9 @@ soul = "{{ANIMA_HOME}}/soul.md"
|
|
|
94
98
|
|
|
95
99
|
[session]
|
|
96
100
|
|
|
101
|
+
# View mode for new sessions: "basic", "verbose", or "debug".
|
|
102
|
+
default_view_mode = "basic"
|
|
103
|
+
|
|
97
104
|
# Regenerate session name every N messages.
|
|
98
105
|
name_generation_interval = 30
|
|
99
106
|
|
data/templates/soul.md
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: anima-core
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.1.
|
|
4
|
+
version: 1.1.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Yevhenii Hurin
|
|
@@ -348,6 +348,7 @@ files:
|
|
|
348
348
|
- lib/anima/config_migrator.rb
|
|
349
349
|
- lib/anima/installer.rb
|
|
350
350
|
- lib/anima/settings.rb
|
|
351
|
+
- lib/anima/spinner.rb
|
|
351
352
|
- lib/anima/version.rb
|
|
352
353
|
- lib/credential_store.rb
|
|
353
354
|
- lib/environment_probe.rb
|
|
@@ -356,7 +357,6 @@ files:
|
|
|
356
357
|
- lib/events/bounce_back.rb
|
|
357
358
|
- lib/events/bus.rb
|
|
358
359
|
- lib/events/subscriber.rb
|
|
359
|
-
- lib/events/subscribers/agent_dispatcher.rb
|
|
360
360
|
- lib/events/subscribers/message_collector.rb
|
|
361
361
|
- lib/events/subscribers/persister.rb
|
|
362
362
|
- lib/events/subscribers/subagent_message_router.rb
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Events
|
|
4
|
-
module Subscribers
|
|
5
|
-
# Reacts to non-pending {Events::UserMessage} emissions by scheduling
|
|
6
|
-
# {AgentRequestJob}. This is the event-driven bridge between the
|
|
7
|
-
# channel (which emits the intent) and the job (which persists and
|
|
8
|
-
# delivers the message).
|
|
9
|
-
#
|
|
10
|
-
# Pending messages are skipped — they are picked up by the running
|
|
11
|
-
# agent loop after it finishes the current turn.
|
|
12
|
-
class AgentDispatcher
|
|
13
|
-
include Events::Subscriber
|
|
14
|
-
|
|
15
|
-
# @param event [Hash] Rails.event notification hash
|
|
16
|
-
def emit(event)
|
|
17
|
-
payload = event[:payload]
|
|
18
|
-
return unless payload.is_a?(Hash)
|
|
19
|
-
return unless payload[:type] == "user_message"
|
|
20
|
-
return if payload[:status] == Event::PENDING_STATUS
|
|
21
|
-
|
|
22
|
-
session_id = payload[:session_id]
|
|
23
|
-
return unless session_id
|
|
24
|
-
|
|
25
|
-
AgentRequestJob.perform_later(session_id, content: payload[:content])
|
|
26
|
-
end
|
|
27
|
-
end
|
|
28
|
-
end
|
|
29
|
-
end
|