anima-core 1.0.1 → 1.1.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/.gitattributes +1 -0
- data/.reek.yml +61 -0
- data/README.md +202 -116
- data/anima-core.gemspec +4 -1
- data/app/channels/session_channel.rb +44 -10
- data/app/decorators/agent_message_decorator.rb +6 -0
- data/app/decorators/event_decorator.rb +41 -7
- data/app/decorators/tool_call_decorator.rb +66 -5
- data/app/decorators/tool_decorator.rb +57 -0
- data/app/decorators/tool_response_decorator.rb +35 -5
- data/app/decorators/user_message_decorator.rb +6 -0
- data/app/decorators/web_get_tool_decorator.rb +102 -0
- data/app/jobs/agent_request_job.rb +95 -20
- data/app/jobs/mneme_job.rb +51 -0
- data/app/jobs/passive_recall_job.rb +29 -0
- data/app/models/concerns/event/broadcasting.rb +18 -0
- data/app/models/event.rb +10 -0
- data/app/models/goal.rb +27 -0
- data/app/models/goal_pinned_event.rb +11 -0
- data/app/models/pinned_event.rb +41 -0
- data/app/models/session.rb +335 -6
- data/app/models/snapshot.rb +76 -0
- data/config/initializers/event_subscribers.rb +14 -3
- data/config/initializers/fts5_schema_dump.rb +21 -0
- data/db/migrate/20260316094817_add_interrupt_requested_to_sessions.rb +5 -0
- data/db/migrate/20260321080000_create_mneme_schema.rb +32 -0
- data/db/migrate/20260321120000_create_pinned_events.rb +27 -0
- data/db/migrate/20260321140000_create_events_fts_index.rb +77 -0
- data/db/migrate/20260321140100_add_recalled_event_ids_to_sessions.rb +10 -0
- data/lib/agent_loop.rb +67 -18
- data/lib/analytical_brain/runner.rb +159 -84
- data/lib/analytical_brain/tools/assign_nickname.rb +76 -0
- data/lib/analytical_brain/tools/finish_goal.rb +6 -1
- data/lib/anima/cli.rb +34 -1
- data/lib/anima/config_migrator.rb +205 -0
- data/lib/anima/installer.rb +13 -130
- data/lib/anima/settings.rb +42 -1
- data/lib/anima/version.rb +1 -1
- data/lib/events/bounce_back.rb +37 -0
- data/lib/events/subscribers/agent_dispatcher.rb +29 -0
- data/lib/events/subscribers/persister.rb +17 -0
- data/lib/events/subscribers/subagent_message_router.rb +102 -0
- data/lib/events/subscribers/transient_broadcaster.rb +36 -0
- data/lib/llm/client.rb +99 -14
- data/lib/mneme/compressed_viewport.rb +200 -0
- data/lib/mneme/l2_runner.rb +138 -0
- data/lib/mneme/passive_recall.rb +69 -0
- data/lib/mneme/runner.rb +254 -0
- data/lib/mneme/search.rb +150 -0
- data/lib/mneme/tools/attach_events_to_goals.rb +107 -0
- data/lib/mneme/tools/everything_ok.rb +24 -0
- data/lib/mneme/tools/save_snapshot.rb +68 -0
- data/lib/mneme.rb +29 -0
- data/lib/providers/anthropic.rb +57 -13
- data/lib/shell_session.rb +188 -59
- data/lib/tasks/fts5.rake +6 -0
- data/lib/tools/remember.rb +179 -0
- data/lib/tools/spawn_specialist.rb +21 -9
- data/lib/tools/spawn_subagent.rb +22 -11
- data/lib/tools/subagent_prompts.rb +20 -3
- data/lib/tools/think.rb +57 -0
- data/lib/tools/web_get.rb +15 -6
- data/lib/tui/app.rb +230 -127
- data/lib/tui/cable_client.rb +8 -0
- data/lib/tui/decorators/base_decorator.rb +165 -0
- data/lib/tui/decorators/bash_decorator.rb +20 -0
- data/lib/tui/decorators/edit_decorator.rb +19 -0
- data/lib/tui/decorators/read_decorator.rb +24 -0
- data/lib/tui/decorators/think_decorator.rb +36 -0
- data/lib/tui/decorators/web_get_decorator.rb +19 -0
- data/lib/tui/decorators/write_decorator.rb +19 -0
- data/lib/tui/flash.rb +139 -0
- data/lib/tui/formatting.rb +28 -0
- data/lib/tui/height_map.rb +93 -0
- data/lib/tui/message_store.rb +25 -1
- data/lib/tui/performance_logger.rb +90 -0
- data/lib/tui/screens/chat.rb +374 -109
- data/templates/config.toml +156 -0
- metadata +87 -4
- data/CHANGELOG.md +0 -79
- data/Gemfile +0 -17
- data/lib/tools/return_result.rb +0 -81
data/lib/agent_loop.rb
CHANGED
|
@@ -42,7 +42,9 @@ class AgentLoop
|
|
|
42
42
|
|
|
43
43
|
# Runs the agent loop for a single user input.
|
|
44
44
|
#
|
|
45
|
-
#
|
|
45
|
+
# Persists the user event directly (the global Persister skips
|
|
46
|
+
# non-pending user messages because {AgentRequestJob} owns their
|
|
47
|
+
# lifecycle). Then emits a bus notification and delegates to {#run}.
|
|
46
48
|
# On error emits {Events::AgentMessage} with the error text.
|
|
47
49
|
#
|
|
48
50
|
# @param input [String] raw user input
|
|
@@ -51,6 +53,7 @@ class AgentLoop
|
|
|
51
53
|
text = input.to_s.strip
|
|
52
54
|
return if text.empty?
|
|
53
55
|
|
|
56
|
+
persist_user_event(text)
|
|
54
57
|
Events::Bus.emit(Events::UserMessage.new(content: text, session_id: @session.id))
|
|
55
58
|
run
|
|
56
59
|
rescue => error
|
|
@@ -59,13 +62,41 @@ class AgentLoop
|
|
|
59
62
|
error_message
|
|
60
63
|
end
|
|
61
64
|
|
|
65
|
+
# Makes the first LLM API call to verify delivery. Called inside the
|
|
66
|
+
# Bounce Back transaction — if this raises, the user event rolls back.
|
|
67
|
+
#
|
|
68
|
+
# Caches the first response so the subsequent {#run} call can continue
|
|
69
|
+
# from it without duplicating the API call.
|
|
70
|
+
#
|
|
71
|
+
# @return [void]
|
|
72
|
+
# @raise [Providers::Anthropic::Error] on any LLM delivery failure
|
|
73
|
+
def deliver!
|
|
74
|
+
@client ||= LLM::Client.new
|
|
75
|
+
@registry ||= build_tool_registry
|
|
76
|
+
|
|
77
|
+
messages = @session.messages_for_llm
|
|
78
|
+
options = build_llm_options
|
|
79
|
+
|
|
80
|
+
@first_response = @client.provider.create_message(
|
|
81
|
+
model: @client.model,
|
|
82
|
+
messages: messages,
|
|
83
|
+
max_tokens: @client.max_tokens,
|
|
84
|
+
tools: @registry.schemas,
|
|
85
|
+
**options
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
|
|
62
89
|
# Runs the LLM tool-use loop on persisted session messages.
|
|
63
90
|
#
|
|
64
|
-
#
|
|
65
|
-
#
|
|
66
|
-
#
|
|
91
|
+
# When a cached first response exists (from {#deliver!}), continues
|
|
92
|
+
# from that response without a redundant API call. Otherwise makes
|
|
93
|
+
# a fresh call — used for pending message processing and the standard
|
|
94
|
+
# path.
|
|
67
95
|
#
|
|
68
|
-
#
|
|
96
|
+
# Lets errors propagate — designed for callers like {AgentRequestJob}
|
|
97
|
+
# that handle retries and need errors to bubble up.
|
|
98
|
+
#
|
|
99
|
+
# @return [String, nil] the agent's response text, or nil when interrupted
|
|
69
100
|
# @raise [Providers::Anthropic::TransientError] on retryable network/server errors
|
|
70
101
|
# @raise [Providers::Anthropic::AuthenticationError] on auth failures
|
|
71
102
|
def run
|
|
@@ -73,15 +104,17 @@ class AgentLoop
|
|
|
73
104
|
@registry ||= build_tool_registry
|
|
74
105
|
|
|
75
106
|
messages = @session.messages_for_llm
|
|
76
|
-
options =
|
|
107
|
+
options = build_llm_options
|
|
77
108
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
109
|
+
first_resp = @first_response
|
|
110
|
+
@first_response = nil
|
|
111
|
+
|
|
112
|
+
response = @client.chat_with_tools(
|
|
113
|
+
messages, registry: @registry, session_id: @session.id,
|
|
114
|
+
first_response: first_resp, **options
|
|
115
|
+
)
|
|
116
|
+
return unless response
|
|
83
117
|
|
|
84
|
-
response = @client.chat_with_tools(messages, registry: @registry, session_id: @session.id, **options)
|
|
85
118
|
Events::Bus.emit(Events::AgentMessage.new(content: response, session_id: @session.id))
|
|
86
119
|
response
|
|
87
120
|
end
|
|
@@ -94,7 +127,7 @@ class AgentLoop
|
|
|
94
127
|
|
|
95
128
|
# Tool classes available to all sessions by default.
|
|
96
129
|
# @return [Array<Class<Tools::Base>>]
|
|
97
|
-
STANDARD_TOOLS = [Tools::Bash, Tools::Read, Tools::Write, Tools::Edit, Tools::WebGet].freeze
|
|
130
|
+
STANDARD_TOOLS = [Tools::Bash, Tools::Read, Tools::Write, Tools::Edit, Tools::WebGet, Tools::Think, Tools::Remember].freeze
|
|
98
131
|
|
|
99
132
|
# Name-to-class mapping for tool restriction validation and registry building.
|
|
100
133
|
# @return [Hash{String => Class<Tools::Base>}]
|
|
@@ -102,10 +135,28 @@ class AgentLoop
|
|
|
102
135
|
|
|
103
136
|
private
|
|
104
137
|
|
|
138
|
+
# @see Session#create_user_event
|
|
139
|
+
def persist_user_event(content)
|
|
140
|
+
@session.create_user_event(content)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Assembles LLM options (system prompt, environment context).
|
|
144
|
+
# @return [Hash] options for {LLM::Client#chat_with_tools}
|
|
145
|
+
def build_llm_options
|
|
146
|
+
options = {}
|
|
147
|
+
unless @session.sub_agent?
|
|
148
|
+
env_context = EnvironmentProbe.to_prompt(@shell_session.pwd)
|
|
149
|
+
end
|
|
150
|
+
prompt = @session.system_prompt(environment_context: env_context)
|
|
151
|
+
options[:system] = prompt if prompt
|
|
152
|
+
options
|
|
153
|
+
end
|
|
154
|
+
|
|
105
155
|
# Builds the tool registry appropriate for this session type.
|
|
106
156
|
# Main sessions get standard tools + spawn_subagent + spawn_specialist.
|
|
107
|
-
# Sub-
|
|
108
|
-
# Sub-
|
|
157
|
+
# Sub-agents get granted standard tools only (no spawning, no nesting).
|
|
158
|
+
# Sub-agent results are delivered through natural text messages routed
|
|
159
|
+
# by {Events::Subscribers::SubagentMessageRouter}.
|
|
109
160
|
# When {Session#granted_tools} is nil, all standard tools are granted.
|
|
110
161
|
# MCP tools from configured servers are registered for all session types.
|
|
111
162
|
#
|
|
@@ -116,9 +167,7 @@ class AgentLoop
|
|
|
116
167
|
|
|
117
168
|
granted_standard_tools.each { |tool| registry.register(tool) }
|
|
118
169
|
|
|
119
|
-
|
|
120
|
-
registry.register(Tools::ReturnResult)
|
|
121
|
-
else
|
|
170
|
+
unless @session.sub_agent?
|
|
122
171
|
registry.register(Tools::SpawnSubagent)
|
|
123
172
|
registry.register(Tools::SpawnSpecialist)
|
|
124
173
|
registry.register(Tools::RequestFeature)
|
|
@@ -2,67 +2,99 @@
|
|
|
2
2
|
|
|
3
3
|
module AnalyticalBrain
|
|
4
4
|
# Orchestrates the analytical brain — a phantom (non-persisted) LLM loop
|
|
5
|
-
# that observes a
|
|
5
|
+
# that observes a session and performs background maintenance via tools.
|
|
6
6
|
#
|
|
7
|
-
# The
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
7
|
+
# The brain's capabilities are assembled from independent {Responsibility}
|
|
8
|
+
# modules, each contributing a prompt section and tools. Which modules are
|
|
9
|
+
# active depends on the session type:
|
|
10
|
+
#
|
|
11
|
+
# * **Parent sessions** — session naming, skill/workflow/goal management
|
|
12
|
+
# * **Child sessions** — sub-agent nickname assignment, skill/workflow/goal management
|
|
13
|
+
#
|
|
14
|
+
# Tools mutate the observed session directly (e.g. renaming it, activating
|
|
15
|
+
# skills), but no trace of the brain's reasoning is persisted — events are
|
|
16
|
+
# emitted into a phantom session (session_id: nil).
|
|
11
17
|
#
|
|
12
18
|
# @example
|
|
13
19
|
# AnalyticalBrain::Runner.new(session).call
|
|
14
20
|
class Runner
|
|
15
|
-
#
|
|
16
|
-
|
|
17
|
-
TOOLS = [
|
|
18
|
-
Tools::RenameSession,
|
|
19
|
-
Tools::ActivateSkill,
|
|
20
|
-
Tools::DeactivateSkill,
|
|
21
|
-
Tools::ReadWorkflow,
|
|
22
|
-
Tools::DeactivateWorkflow,
|
|
23
|
-
Tools::SetGoal,
|
|
24
|
-
Tools::UpdateGoal,
|
|
25
|
-
Tools::FinishGoal,
|
|
26
|
-
Tools::EverythingIsReady
|
|
27
|
-
].freeze
|
|
28
|
-
|
|
29
|
-
SYSTEM_PROMPT = <<~PROMPT
|
|
30
|
-
You are a background automation that manages session metadata.
|
|
31
|
-
You MUST ONLY communicate through tool calls — NEVER output text.
|
|
32
|
-
Always finish by calling everything_is_ready.
|
|
21
|
+
# A composable unit of brain capability: a prompt section + its tools.
|
|
22
|
+
Responsibility = Data.define(:prompt, :tools)
|
|
33
23
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
24
|
+
RESPONSIBILITIES = {
|
|
25
|
+
session_naming: Responsibility.new(
|
|
26
|
+
prompt: <<~PROMPT,
|
|
27
|
+
──────────────────────────────
|
|
28
|
+
SESSION NAMING
|
|
29
|
+
──────────────────────────────
|
|
30
|
+
Call rename_session when the topic becomes clear or shifts.
|
|
31
|
+
Format: one emoji + 1-3 descriptive words.
|
|
32
|
+
PROMPT
|
|
33
|
+
tools: [Tools::RenameSession]
|
|
34
|
+
),
|
|
39
35
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
36
|
+
sub_agent_naming: Responsibility.new(
|
|
37
|
+
prompt: <<~PROMPT,
|
|
38
|
+
──────────────────────────────
|
|
39
|
+
SUB-AGENT NAMING
|
|
40
|
+
──────────────────────────────
|
|
41
|
+
Call assign_nickname to give this sub-agent a short, memorable nickname.
|
|
42
|
+
Format: 1-3 lowercase words joined by hyphens (e.g. "loop-sleuth", "api-scout").
|
|
43
|
+
Evocative of the task, fun, easy to type after @.
|
|
44
|
+
Generate EXACTLY ONE nickname. If taken, pick another — no numeric suffixes.
|
|
45
|
+
PROMPT
|
|
46
|
+
tools: [Tools::AssignNickname]
|
|
47
|
+
),
|
|
46
48
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
49
|
+
skill_management: Responsibility.new(
|
|
50
|
+
prompt: <<~PROMPT,
|
|
51
|
+
──────────────────────────────
|
|
52
|
+
SKILL MANAGEMENT
|
|
53
|
+
──────────────────────────────
|
|
54
|
+
Call activate_skill when the conversation matches a skill's description.
|
|
55
|
+
Call deactivate_skill when the agent moves to a different domain.
|
|
56
|
+
Multiple skills can be active at once.
|
|
57
|
+
PROMPT
|
|
58
|
+
tools: [Tools::ActivateSkill, Tools::DeactivateSkill]
|
|
59
|
+
),
|
|
55
60
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
61
|
+
workflow_management: Responsibility.new(
|
|
62
|
+
prompt: <<~PROMPT,
|
|
63
|
+
──────────────────────────────
|
|
64
|
+
WORKFLOW MANAGEMENT
|
|
65
|
+
──────────────────────────────
|
|
66
|
+
Call read_workflow when the user starts a multi-step task matching a workflow description.
|
|
67
|
+
Read the returned content and use judgment to create appropriate goals — not a mechanical 1:1 mapping.
|
|
68
|
+
Adapt to context: skip irrelevant steps, add extra steps for unfamiliar areas.
|
|
69
|
+
Call deactivate_workflow when the workflow completes or the user shifts focus.
|
|
70
|
+
Only one workflow can be active at a time — activating a new one replaces the previous.
|
|
71
|
+
PROMPT
|
|
72
|
+
tools: [Tools::ReadWorkflow, Tools::DeactivateWorkflow]
|
|
73
|
+
),
|
|
65
74
|
|
|
75
|
+
goal_tracking: Responsibility.new(
|
|
76
|
+
prompt: <<~PROMPT,
|
|
77
|
+
──────────────────────────────
|
|
78
|
+
GOAL TRACKING
|
|
79
|
+
──────────────────────────────
|
|
80
|
+
Call set_goal to create a root goal when the user starts a multi-step task.
|
|
81
|
+
Call set_goal with parent_goal_id to add sub-goals (TODO items) under it.
|
|
82
|
+
Call update_goal to refine a goal's description as understanding evolves.
|
|
83
|
+
Call finish_goal when the main agent completes work a goal describes.
|
|
84
|
+
Finishing a root goal cascades — all active sub-goals are completed too.
|
|
85
|
+
Never duplicate an existing goal — check the active goals list first.
|
|
86
|
+
PROMPT
|
|
87
|
+
tools: [Tools::SetGoal, Tools::UpdateGoal, Tools::FinishGoal]
|
|
88
|
+
)
|
|
89
|
+
}.freeze
|
|
90
|
+
|
|
91
|
+
BASE_PROMPT = <<~PROMPT
|
|
92
|
+
You are a background automation that manages session metadata.
|
|
93
|
+
You MUST ONLY communicate through tool calls — NEVER output text.
|
|
94
|
+
Always finish by calling everything_is_ready.
|
|
95
|
+
PROMPT
|
|
96
|
+
|
|
97
|
+
COMPLETION_PROMPT = <<~PROMPT
|
|
66
98
|
──────────────────────────────
|
|
67
99
|
COMPLETION
|
|
68
100
|
──────────────────────────────
|
|
@@ -70,7 +102,11 @@ module AnalyticalBrain
|
|
|
70
102
|
If nothing needs changing, call it immediately as your only tool call.
|
|
71
103
|
PROMPT
|
|
72
104
|
|
|
73
|
-
#
|
|
105
|
+
# Which responsibilities activate for each session type.
|
|
106
|
+
PARENT_RESPONSIBILITIES = %i[session_naming skill_management workflow_management goal_tracking].freeze
|
|
107
|
+
CHILD_RESPONSIBILITIES = %i[sub_agent_naming skill_management workflow_management goal_tracking].freeze
|
|
108
|
+
|
|
109
|
+
# @param session [Session] the session to observe and maintain
|
|
74
110
|
# @param client [LLM::Client, nil] injectable LLM client (defaults to fast model)
|
|
75
111
|
def initialize(session, client: nil)
|
|
76
112
|
@session = session
|
|
@@ -81,9 +117,9 @@ module AnalyticalBrain
|
|
|
81
117
|
)
|
|
82
118
|
end
|
|
83
119
|
|
|
84
|
-
# Runs the analytical brain loop. Builds context from the
|
|
85
|
-
# recent events, calls the LLM with the
|
|
86
|
-
# executes any tool calls against the
|
|
120
|
+
# Runs the analytical brain loop. Builds context from the session's
|
|
121
|
+
# recent events, calls the LLM with the session-appropriate tool set,
|
|
122
|
+
# and executes any tool calls against the session.
|
|
87
123
|
#
|
|
88
124
|
# Events emitted during tool execution are not persisted — the phantom
|
|
89
125
|
# session_id (nil) causes the global Persister to skip them.
|
|
@@ -116,20 +152,37 @@ module AnalyticalBrain
|
|
|
116
152
|
|
|
117
153
|
private
|
|
118
154
|
|
|
155
|
+
# @return [Array<Symbol>] responsibility keys for this session type
|
|
156
|
+
def active_responsibility_keys
|
|
157
|
+
@session.sub_agent? ? CHILD_RESPONSIBILITIES : PARENT_RESPONSIBILITIES
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# @return [Array<Responsibility>] active responsibility modules
|
|
161
|
+
def active_responsibilities
|
|
162
|
+
active_responsibility_keys.map { |key| RESPONSIBILITIES.fetch(key) }
|
|
163
|
+
end
|
|
164
|
+
|
|
119
165
|
# Builds a condensed transcript of recent events as a single user message.
|
|
120
|
-
# The
|
|
121
|
-
# just needs to understand "what is the agent doing RIGHT NOW?"
|
|
166
|
+
# The framing differs by session type:
|
|
122
167
|
#
|
|
123
|
-
#
|
|
124
|
-
#
|
|
125
|
-
# confuses the main session's user messages with requests directed at it.
|
|
168
|
+
# * **Parent:** "The main session is working on this: [transcript]"
|
|
169
|
+
# * **Child:** "A sub-agent has been spawned with this task: [transcript]"
|
|
126
170
|
#
|
|
127
171
|
# @return [Array<Hash>] single-element messages array, or empty if no events
|
|
128
172
|
def build_messages
|
|
129
173
|
events = recent_events
|
|
130
174
|
return [] if events.empty?
|
|
131
175
|
|
|
132
|
-
transcript = events.filter_map { |event|
|
|
176
|
+
transcript = events.filter_map { |event| EventDecorator.for(event)&.render("brain") }.join("\n")
|
|
177
|
+
|
|
178
|
+
if @session.sub_agent?
|
|
179
|
+
build_child_message(transcript)
|
|
180
|
+
else
|
|
181
|
+
build_parent_message(transcript)
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def build_parent_message(transcript)
|
|
133
186
|
content = <<~MSG.strip
|
|
134
187
|
The main session is working on this:
|
|
135
188
|
```
|
|
@@ -141,6 +194,18 @@ module AnalyticalBrain
|
|
|
141
194
|
[{role: "user", content: content}]
|
|
142
195
|
end
|
|
143
196
|
|
|
197
|
+
def build_child_message(transcript)
|
|
198
|
+
content = <<~MSG.strip
|
|
199
|
+
A sub-agent has been spawned with this task:
|
|
200
|
+
```
|
|
201
|
+
#{transcript}
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
Assign a memorable nickname based on the task, activate relevant skills, then call everything_is_ready.
|
|
205
|
+
MSG
|
|
206
|
+
[{role: "user", content: content}]
|
|
207
|
+
end
|
|
208
|
+
|
|
144
209
|
# @return [Array<Event>] most recent events in chronological order
|
|
145
210
|
def recent_events
|
|
146
211
|
@session.events
|
|
@@ -151,32 +216,16 @@ module AnalyticalBrain
|
|
|
151
216
|
.reverse
|
|
152
217
|
end
|
|
153
218
|
|
|
154
|
-
#
|
|
155
|
-
# User/agent messages get 500 chars to preserve conversation context;
|
|
156
|
-
# tool responses get 200 chars to reduce noise from verbose outputs.
|
|
157
|
-
#
|
|
158
|
-
# @param event [Event]
|
|
159
|
-
# @return [String, nil] formatted line, or nil for unhandled event types
|
|
160
|
-
def format_event(event)
|
|
161
|
-
payload = event.payload
|
|
162
|
-
summary = payload["content"].to_s.truncate(500)
|
|
163
|
-
|
|
164
|
-
case event.event_type
|
|
165
|
-
when "user_message" then "User: #{summary}"
|
|
166
|
-
when "agent_message" then "Assistant: #{summary}"
|
|
167
|
-
when "tool_call" then "Tool call: #{payload["tool_name"]}"
|
|
168
|
-
when "tool_response" then "Tool result: #{summary.truncate(200)}"
|
|
169
|
-
end
|
|
170
|
-
end
|
|
171
|
-
|
|
172
|
-
# Builds the system prompt with current session state, skills catalog,
|
|
173
|
-
# and currently active skills.
|
|
219
|
+
# Builds the system prompt from active responsibilities + context sections.
|
|
174
220
|
#
|
|
175
221
|
# @return [String]
|
|
176
222
|
def build_system_prompt
|
|
177
223
|
sections = [
|
|
178
|
-
|
|
224
|
+
BASE_PROMPT,
|
|
225
|
+
*active_responsibilities.map(&:prompt),
|
|
226
|
+
COMPLETION_PROMPT,
|
|
179
227
|
session_state_section,
|
|
228
|
+
active_siblings_section,
|
|
180
229
|
skills_catalog_section,
|
|
181
230
|
workflows_catalog_section,
|
|
182
231
|
active_goals_section
|
|
@@ -199,6 +248,27 @@ module AnalyticalBrain
|
|
|
199
248
|
SECTION
|
|
200
249
|
end
|
|
201
250
|
|
|
251
|
+
# Shows sibling nicknames already in use so the brain avoids collisions
|
|
252
|
+
# at prompt level (the tool also validates at execution time).
|
|
253
|
+
#
|
|
254
|
+
# @return [String, nil] sibling names section, or nil for parent sessions
|
|
255
|
+
def active_siblings_section
|
|
256
|
+
return unless @session.sub_agent?
|
|
257
|
+
|
|
258
|
+
siblings = @session.parent_session.child_sessions
|
|
259
|
+
.where.not(id: @session.id)
|
|
260
|
+
.where.not(name: nil)
|
|
261
|
+
.pluck(:name)
|
|
262
|
+
return if siblings.empty?
|
|
263
|
+
|
|
264
|
+
<<~SECTION
|
|
265
|
+
──────────────────────────────
|
|
266
|
+
ACTIVE SIBLINGS
|
|
267
|
+
──────────────────────────────
|
|
268
|
+
These nicknames are already taken: #{siblings.join(", ")}
|
|
269
|
+
SECTION
|
|
270
|
+
end
|
|
271
|
+
|
|
202
272
|
# @return [String] available skills list for the analytical brain
|
|
203
273
|
def skills_catalog_section
|
|
204
274
|
catalog = Skills::Registry.instance.catalog
|
|
@@ -266,11 +336,16 @@ module AnalyticalBrain
|
|
|
266
336
|
# @return [Logger] dev-only analytical brain logger
|
|
267
337
|
def log = AnalyticalBrain.logger
|
|
268
338
|
|
|
269
|
-
# @return [Tools::Registry] registry with
|
|
339
|
+
# @return [Tools::Registry] registry with tools from active responsibilities
|
|
270
340
|
def build_registry
|
|
271
341
|
registry = ::Tools::Registry.new(context: {main_session: @session})
|
|
272
|
-
|
|
342
|
+
active_tools.each { |tool| registry.register(tool) }
|
|
273
343
|
registry
|
|
274
344
|
end
|
|
345
|
+
|
|
346
|
+
# @return [Array<Class<Tools::Base>>] tools from all active responsibilities + completion
|
|
347
|
+
def active_tools
|
|
348
|
+
active_responsibilities.flat_map(&:tools) + [Tools::EverythingIsReady]
|
|
349
|
+
end
|
|
275
350
|
end
|
|
276
351
|
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AnalyticalBrain
|
|
4
|
+
module Tools
|
|
5
|
+
# Assigns a static nickname to a sub-agent session.
|
|
6
|
+
# Operates on the session passed through the registry context.
|
|
7
|
+
#
|
|
8
|
+
# Nicknames must be unique among active siblings — the tool returns
|
|
9
|
+
# an error on collision so the LLM can pick another name naturally,
|
|
10
|
+
# without programmatic suffixes.
|
|
11
|
+
#
|
|
12
|
+
# @see AnalyticalBrain::Runner — invokes this tool for child sessions
|
|
13
|
+
class AssignNickname < ::Tools::Base
|
|
14
|
+
# Lowercase hyphenated words: "loop-sleuth", "api-scout", "test-fixer"
|
|
15
|
+
NICKNAME_PATTERN = /\A[a-z][a-z0-9]*(-[a-z0-9]+)*\z/
|
|
16
|
+
MAX_LENGTH = 30
|
|
17
|
+
|
|
18
|
+
def self.tool_name = "assign_nickname"
|
|
19
|
+
|
|
20
|
+
def self.description = "Assign a short, memorable nickname to this sub-agent. " \
|
|
21
|
+
"The nickname is permanent — it will not change."
|
|
22
|
+
|
|
23
|
+
def self.input_schema
|
|
24
|
+
{
|
|
25
|
+
type: "object",
|
|
26
|
+
properties: {
|
|
27
|
+
nickname: {
|
|
28
|
+
type: "string",
|
|
29
|
+
description: "1-3 lowercase words joined by hyphens (e.g. 'loop-sleuth', 'api-scout'). " \
|
|
30
|
+
"Evocative of the task, fun, easy to type after @."
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
required: %w[nickname]
|
|
34
|
+
}
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @param main_session [Session] the sub-agent session to name
|
|
38
|
+
def initialize(main_session:, **)
|
|
39
|
+
@session = main_session
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @param input [Hash<String, Object>] with "nickname" key
|
|
43
|
+
# @return [String] confirmation message
|
|
44
|
+
# @return [Hash] with :error key on validation failure
|
|
45
|
+
def execute(input)
|
|
46
|
+
nickname = input["nickname"].to_s.strip.downcase
|
|
47
|
+
|
|
48
|
+
error = validate(nickname)
|
|
49
|
+
return error if error
|
|
50
|
+
|
|
51
|
+
@session.update!(name: nickname)
|
|
52
|
+
"Nickname set to @#{nickname}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def validate(nickname)
|
|
58
|
+
return {error: "Nickname cannot be blank"} if nickname.empty?
|
|
59
|
+
return {error: "Invalid format: use 1-3 lowercase words joined by hyphens"} unless nickname.match?(NICKNAME_PATTERN)
|
|
60
|
+
return {error: "Nickname too long (max #{MAX_LENGTH} chars)"} if nickname.length > MAX_LENGTH
|
|
61
|
+
|
|
62
|
+
if sibling_nickname_taken?(nickname)
|
|
63
|
+
{error: "Nickname '#{nickname}' is already taken by a sibling. Choose another."}
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def sibling_nickname_taken?(nickname)
|
|
68
|
+
return false unless @session.parent_session
|
|
69
|
+
|
|
70
|
+
@session.parent_session.child_sessions
|
|
71
|
+
.where.not(id: @session.id)
|
|
72
|
+
.exists?(name: nickname)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -51,11 +51,16 @@ module AnalyticalBrain
|
|
|
51
51
|
id = goal.id
|
|
52
52
|
return {error: "Goal already completed: #{goal.description} (id: #{id})"} if goal.completed?
|
|
53
53
|
|
|
54
|
+
released = 0
|
|
54
55
|
Goal.transaction do
|
|
55
56
|
goal.update!(status: "completed", completed_at: Time.current)
|
|
56
57
|
goal.cascade_completion! if goal.root?
|
|
58
|
+
released = goal.release_orphaned_pins!
|
|
57
59
|
end
|
|
58
|
-
|
|
60
|
+
|
|
61
|
+
msg = "Goal completed: #{goal.description} (id: #{id})"
|
|
62
|
+
msg += " (released #{released} orphaned pins)" if released > 0
|
|
63
|
+
msg
|
|
59
64
|
end
|
|
60
65
|
end
|
|
61
66
|
end
|
data/lib/anima/cli.rb
CHANGED
|
@@ -20,6 +20,38 @@ module Anima
|
|
|
20
20
|
Installer.new.run
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
+
desc "update", "Upgrade gem and migrate config"
|
|
24
|
+
option :migrate_only, type: :boolean, default: false, desc: "Skip gem upgrade, only migrate config"
|
|
25
|
+
def update
|
|
26
|
+
unless options[:migrate_only]
|
|
27
|
+
say "Upgrading anima-core gem..."
|
|
28
|
+
unless system("gem", "update", "anima-core")
|
|
29
|
+
say "Gem update failed.", :red
|
|
30
|
+
exit 1
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Re-exec with the updated gem so migration uses the new template.
|
|
34
|
+
exec(File.join(Gem.bindir, "anima"), "update", "--migrate-only")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
say "Migrating configuration..."
|
|
38
|
+
require_relative "config_migrator"
|
|
39
|
+
result = Anima::ConfigMigrator.new.run
|
|
40
|
+
|
|
41
|
+
case result.status
|
|
42
|
+
when :not_found
|
|
43
|
+
say "Config file not found. Run 'anima install' first.", :red
|
|
44
|
+
exit 1
|
|
45
|
+
when :up_to_date
|
|
46
|
+
say "Config is already up to date."
|
|
47
|
+
when :updated
|
|
48
|
+
result.additions.each do |addition|
|
|
49
|
+
say " added [#{addition.section}] #{addition.key} = #{addition.value.inspect}"
|
|
50
|
+
end
|
|
51
|
+
say "Config updated. Changes take effect immediately — no restart needed."
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
23
55
|
# Start the Anima brain server (Puma + Solid Queue) via Foreman.
|
|
24
56
|
# Environment precedence: -e flag > RAILS_ENV env var > "development".
|
|
25
57
|
# Requires prior installation (~/.anima must exist).
|
|
@@ -46,6 +78,7 @@ module Anima
|
|
|
46
78
|
|
|
47
79
|
desc "tui", "Launch the Anima terminal interface"
|
|
48
80
|
option :host, desc: "Brain server address (default: #{DEFAULT_HOST})"
|
|
81
|
+
option :debug, type: :boolean, default: false, desc: "Enable performance logging to log/tui_performance.log"
|
|
49
82
|
def tui
|
|
50
83
|
require "ratatui_ruby"
|
|
51
84
|
require_relative "../tui/app"
|
|
@@ -57,7 +90,7 @@ module Anima
|
|
|
57
90
|
cable_client = TUI::CableClient.new(host: host)
|
|
58
91
|
cable_client.connect
|
|
59
92
|
|
|
60
|
-
TUI::App.new(cable_client: cable_client).run
|
|
93
|
+
TUI::App.new(cable_client: cable_client, debug: options[:debug]).run
|
|
61
94
|
end
|
|
62
95
|
|
|
63
96
|
desc "version", "Show version"
|