anima-core 1.1.3 → 1.3.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.
Files changed (127) hide show
  1. checksums.yaml +4 -4
  2. data/.reek.yml +10 -1
  3. data/README.md +36 -11
  4. data/agents/codebase-analyzer.md +2 -2
  5. data/agents/codebase-pattern-finder.md +2 -2
  6. data/agents/documentation-researcher.md +2 -2
  7. data/agents/thoughts-analyzer.md +2 -2
  8. data/agents/web-search-researcher.md +3 -3
  9. data/app/channels/session_channel.rb +83 -64
  10. data/app/decorators/agent_message_decorator.rb +2 -2
  11. data/app/decorators/{event_decorator.rb → message_decorator.rb} +40 -40
  12. data/app/decorators/system_message_decorator.rb +2 -2
  13. data/app/decorators/tool_call_decorator.rb +6 -6
  14. data/app/decorators/tool_decorator.rb +4 -4
  15. data/app/decorators/tool_response_decorator.rb +2 -2
  16. data/app/decorators/user_message_decorator.rb +5 -19
  17. data/app/decorators/web_get_tool_decorator.rb +41 -9
  18. data/app/jobs/agent_request_job.rb +33 -24
  19. data/app/jobs/count_message_tokens_job.rb +39 -0
  20. data/app/jobs/passive_recall_job.rb +4 -4
  21. data/app/models/concerns/{event → message}/broadcasting.rb +16 -16
  22. data/app/models/goal.rb +17 -4
  23. data/app/models/goal_pinned_message.rb +11 -0
  24. data/app/models/message.rb +127 -0
  25. data/app/models/pending_message.rb +43 -0
  26. data/app/models/pinned_message.rb +41 -0
  27. data/app/models/secret.rb +72 -0
  28. data/app/models/session.rb +385 -226
  29. data/app/models/snapshot.rb +25 -25
  30. data/config/environments/test.rb +5 -0
  31. data/config/initializers/time_nanoseconds.rb +11 -0
  32. data/db/migrate/20260326180000_rename_event_to_message.rb +172 -0
  33. data/db/migrate/20260328100000_create_secrets.rb +15 -0
  34. data/db/migrate/20260328152142_add_evicted_at_to_goals.rb +6 -0
  35. data/db/migrate/20260329120000_create_pending_messages.rb +11 -0
  36. data/lib/agent_loop.rb +14 -41
  37. data/lib/agents/definition.rb +1 -1
  38. data/lib/analytical_brain/runner.rb +40 -37
  39. data/lib/analytical_brain/tools/activate_skill.rb +5 -9
  40. data/lib/analytical_brain/tools/assign_nickname.rb +2 -4
  41. data/lib/analytical_brain/tools/deactivate_skill.rb +5 -9
  42. data/lib/analytical_brain/tools/everything_is_ready.rb +1 -2
  43. data/lib/analytical_brain/tools/finish_goal.rb +5 -8
  44. data/lib/analytical_brain/tools/read_workflow.rb +5 -9
  45. data/lib/analytical_brain/tools/rename_session.rb +3 -10
  46. data/lib/analytical_brain/tools/set_goal.rb +3 -7
  47. data/lib/analytical_brain/tools/update_goal.rb +3 -7
  48. data/lib/anima/cli/mcp/secrets.rb +4 -4
  49. data/lib/anima/cli/mcp.rb +4 -4
  50. data/lib/anima/installer.rb +7 -1
  51. data/lib/anima/settings.rb +46 -6
  52. data/lib/anima/version.rb +1 -1
  53. data/lib/anima.rb +1 -1
  54. data/lib/credential_store.rb +17 -66
  55. data/lib/events/base.rb +1 -1
  56. data/lib/events/bounce_back.rb +7 -7
  57. data/lib/events/subscribers/persister.rb +15 -22
  58. data/lib/events/subscribers/subagent_message_router.rb +20 -8
  59. data/lib/events/subscribers/transient_broadcaster.rb +2 -2
  60. data/lib/events/user_message.rb +2 -13
  61. data/lib/llm/client.rb +54 -20
  62. data/lib/mcp/config.rb +2 -2
  63. data/lib/mcp/secrets.rb +7 -8
  64. data/lib/mneme/compressed_viewport.rb +57 -57
  65. data/lib/mneme/l2_runner.rb +4 -4
  66. data/lib/mneme/passive_recall.rb +2 -2
  67. data/lib/mneme/runner.rb +57 -75
  68. data/lib/mneme/search.rb +38 -38
  69. data/lib/mneme/tools/attach_messages_to_goals.rb +103 -0
  70. data/lib/mneme/tools/everything_ok.rb +1 -3
  71. data/lib/mneme/tools/save_snapshot.rb +12 -16
  72. data/lib/shell_session.rb +54 -16
  73. data/lib/tools/base.rb +23 -0
  74. data/lib/tools/bash.rb +60 -16
  75. data/lib/tools/edit.rb +6 -8
  76. data/lib/tools/mark_goal_completed.rb +86 -0
  77. data/lib/tools/{request_feature.rb → open_issue.rb} +10 -13
  78. data/lib/tools/read.rb +6 -5
  79. data/lib/tools/recall.rb +98 -0
  80. data/lib/tools/registry.rb +37 -8
  81. data/lib/tools/remember.rb +46 -55
  82. data/lib/tools/response_truncator.rb +70 -0
  83. data/lib/tools/spawn_specialist.rb +15 -25
  84. data/lib/tools/spawn_subagent.rb +14 -22
  85. data/lib/tools/subagent_prompts.rb +42 -6
  86. data/lib/tools/think.rb +26 -10
  87. data/lib/tools/web_get.rb +23 -4
  88. data/lib/tools/write.rb +4 -4
  89. data/lib/tui/app.rb +178 -13
  90. data/lib/tui/braille_spinner.rb +152 -0
  91. data/lib/tui/cable_client.rb +4 -4
  92. data/lib/tui/decorators/base_decorator.rb +17 -8
  93. data/lib/tui/decorators/bash_decorator.rb +2 -2
  94. data/lib/tui/decorators/edit_decorator.rb +5 -4
  95. data/lib/tui/decorators/read_decorator.rb +4 -8
  96. data/lib/tui/decorators/think_decorator.rb +3 -5
  97. data/lib/tui/decorators/web_get_decorator.rb +4 -3
  98. data/lib/tui/decorators/write_decorator.rb +5 -4
  99. data/lib/tui/flash.rb +1 -1
  100. data/lib/tui/formatting.rb +22 -0
  101. data/lib/tui/message_store.rb +103 -59
  102. data/lib/tui/screens/chat.rb +293 -78
  103. data/skills/activerecord/SKILL.md +1 -1
  104. data/skills/dragonruby/SKILL.md +1 -1
  105. data/skills/draper-decorators/SKILL.md +1 -1
  106. data/skills/gh-issue.md +1 -1
  107. data/skills/mcp-server/SKILL.md +1 -1
  108. data/skills/ratatui-ruby/SKILL.md +1 -1
  109. data/skills/rspec/SKILL.md +1 -1
  110. data/templates/config.toml +42 -5
  111. data/templates/soul.md +7 -19
  112. data/workflows/create_handoff.md +1 -1
  113. data/workflows/create_note.md +1 -1
  114. data/workflows/create_plan.md +1 -1
  115. data/workflows/implement_plan.md +1 -1
  116. data/workflows/iterate_plan.md +1 -1
  117. data/workflows/research_codebase.md +1 -1
  118. data/workflows/resume_handoff.md +1 -1
  119. data/workflows/review_pr.md +78 -16
  120. data/workflows/thoughts_init.md +1 -1
  121. data/workflows/validate_plan.md +1 -1
  122. metadata +20 -9
  123. data/app/jobs/count_event_tokens_job.rb +0 -39
  124. data/app/models/event.rb +0 -129
  125. data/app/models/goal_pinned_event.rb +0 -11
  126. data/app/models/pinned_event.rb +0 -41
  127. data/lib/mneme/tools/attach_events_to_goals.rb +0 -107
@@ -1,22 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # A persisted summary of conversation context created by Mneme before
4
- # events evict from the viewport. Snapshots capture the "gist" of what
4
+ # messages evict from the viewport. Snapshots capture the "gist" of what
5
5
  # happened so the agent retains awareness of past context.
6
6
  #
7
- # Level 1 snapshots are created from raw events (messages + thinks).
7
+ # Level 1 snapshots are created from raw messages (conversation + thinks).
8
8
  # Level 2 snapshots compress multiple Level 1 snapshots (days/weeks scale).
9
- # Both levels use the same event ID range tracking — an L2 snapshot's range
9
+ # Both levels use the same message ID range tracking — an L2 snapshot's range
10
10
  # is the union of its constituent L1 snapshots.
11
11
  #
12
12
  # @!attribute text
13
13
  # @return [String] the summary text generated by Mneme
14
- # @!attribute from_event_id
15
- # @return [Integer] first event ID covered by this snapshot
16
- # @!attribute to_event_id
17
- # @return [Integer] last event ID covered by this snapshot
14
+ # @!attribute from_message_id
15
+ # @return [Integer] first message ID covered by this snapshot
16
+ # @!attribute to_message_id
17
+ # @return [Integer] last message ID covered by this snapshot
18
18
  # @!attribute level
19
- # @return [Integer] compression level (1 = from raw events, 2 = from L1 snapshots)
19
+ # @return [Integer] compression level (1 = from raw messages, 2 = from L1 snapshots)
20
20
  # @!attribute token_count
21
21
  # @return [Integer] cached token count of the summary text
22
22
  class Snapshot < ApplicationRecord
@@ -27,34 +27,34 @@ class Snapshot < ApplicationRecord
27
27
  MAX_TEXT_BYTES = 32_768
28
28
 
29
29
  validates :text, presence: true, length: {maximum: MAX_TEXT_BYTES}
30
- validates :from_event_id, presence: true
31
- validates :to_event_id, presence: true
30
+ validates :from_message_id, presence: true
31
+ validates :to_message_id, presence: true
32
32
  validates :level, presence: true, numericality: {greater_than: 0}
33
33
  validates :token_count, numericality: {greater_than_or_equal_to: 0}, allow_nil: true
34
- validate :from_event_id_not_after_to_event_id
34
+ validate :from_message_id_not_after_to_message_id
35
35
 
36
36
  scope :for_level, ->(level) { where(level: level) }
37
- scope :chronological, -> { order(:from_event_id) }
37
+ scope :chronological, -> { order(:from_message_id) }
38
38
 
39
- # L1 snapshots whose event range is NOT fully contained within any L2 snapshot.
39
+ # L1 snapshots whose message range is NOT fully contained within any L2 snapshot.
40
40
  # Used to determine which L1 snapshots are still "live" in the viewport.
41
41
  scope :not_covered_by_l2, -> {
42
42
  where.not(
43
43
  "EXISTS (SELECT 1 FROM snapshots l2 " \
44
44
  "WHERE l2.session_id = snapshots.session_id " \
45
45
  "AND l2.level = 2 " \
46
- "AND l2.from_event_id <= snapshots.from_event_id " \
47
- "AND l2.to_event_id >= snapshots.to_event_id)"
46
+ "AND l2.from_message_id <= snapshots.from_message_id " \
47
+ "AND l2.to_message_id >= snapshots.to_message_id)"
48
48
  )
49
49
  }
50
50
 
51
- # Snapshots whose source events have fully evicted from the sliding window.
52
- # A snapshot is visible when its entire event range precedes the first
53
- # event currently in the viewport.
51
+ # Snapshots whose source messages have fully evicted from the sliding window.
52
+ # A snapshot is visible when its entire message range precedes the first
53
+ # message currently in the viewport.
54
54
  #
55
- # @param first_event_id [Integer] the first event ID in the sliding window
56
- scope :source_events_evicted, ->(first_event_id) {
57
- where("to_event_id < ?", first_event_id)
55
+ # @param first_message_id [Integer] the first message ID in the sliding window
56
+ scope :source_messages_evicted, ->(first_message_id) {
57
+ where("to_message_id < ?", first_message_id)
58
58
  }
59
59
 
60
60
  # @return [Integer] token cost, using cached count or heuristic estimate
@@ -64,13 +64,13 @@ class Snapshot < ApplicationRecord
64
64
 
65
65
  private
66
66
 
67
- def from_event_id_not_after_to_event_id
68
- return unless from_event_id && to_event_id
69
- errors.add(:from_event_id, "must be <= to_event_id") if from_event_id > to_event_id
67
+ def from_message_id_not_after_to_message_id
68
+ return unless from_message_id && to_message_id
69
+ errors.add(:from_message_id, "must be <= to_message_id") if from_message_id > to_message_id
70
70
  end
71
71
 
72
72
  # @return [Integer] estimated token count (at least 1)
73
73
  def estimate_tokens
74
- [(text.bytesize / Event::BYTES_PER_TOKEN.to_f).ceil, 1].max
74
+ [(text.bytesize / Message::BYTES_PER_TOKEN.to_f).ceil, 1].max
75
75
  end
76
76
  end
@@ -6,4 +6,9 @@ Rails.application.configure do
6
6
  config.consider_all_requests_local = true
7
7
  config.active_support.deprecation = :stderr
8
8
  config.active_job.queue_adapter = :test
9
+
10
+ # Fixed keys for test determinism — production/development load real keys from Rails credentials.
11
+ config.active_record.encryption.primary_key = "test-primary-key-for-ar-encryption"
12
+ config.active_record.encryption.deterministic_key = "test-deterministic-key-for-ar-encryption"
13
+ config.active_record.encryption.key_derivation_salt = "test-key-derivation-salt-for-ar-encryption"
9
14
  end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Nanosecond conversion for Time objects. Replaces raw
4
+ # Process.clock_gettime(CLOCK_REALTIME) calls so that
5
+ # ActiveSupport's freeze_time / travel_to work in tests.
6
+ class Time
7
+ # @return [Integer] nanoseconds since epoch
8
+ def to_ns
9
+ (to_r * 1_000_000_000).to_i
10
+ end
11
+ end
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Renames the persistence layer from Event to Message.
4
+ #
5
+ # Events are ephemeral bus signals; Messages are persisted records of what
6
+ # was said, by whom, in which conversation. The Anthropic API calls them
7
+ # "messages" — this rename aligns the codebase with the agent's vocabulary.
8
+ #
9
+ # See: thoughts/shared/notes/2026-03-24/narrative-design-for-agent-prompts.md
10
+ class RenameEventToMessage < ActiveRecord::Migration[8.1]
11
+ def up
12
+ # --- FTS5: drop triggers and virtual table (references old table name) ---
13
+ execute "DROP TRIGGER IF EXISTS events_fts_insert"
14
+ execute "DROP TRIGGER IF EXISTS events_fts_delete"
15
+ execute "DROP TABLE IF EXISTS events_fts"
16
+
17
+ # --- Main table: events → messages ---
18
+ rename_table :events, :messages
19
+ rename_column :messages, :event_type, :message_type
20
+
21
+ # --- Pinned events → pinned messages ---
22
+ rename_table :pinned_events, :pinned_messages
23
+ rename_column :pinned_messages, :event_id, :message_id
24
+
25
+ # --- Join table: goal_pinned_events → goal_pinned_messages ---
26
+ rename_table :goal_pinned_events, :goal_pinned_messages
27
+ rename_column :goal_pinned_messages, :pinned_event_id, :pinned_message_id
28
+
29
+ # --- Snapshots: event range columns ---
30
+ rename_column :snapshots, :from_event_id, :from_message_id
31
+ rename_column :snapshots, :to_event_id, :to_message_id
32
+
33
+ # --- Sessions: event pointer columns ---
34
+ rename_column :sessions, :mneme_boundary_event_id, :mneme_boundary_message_id
35
+ rename_column :sessions, :mneme_snapshot_first_event_id, :mneme_snapshot_first_message_id
36
+ rename_column :sessions, :mneme_snapshot_last_event_id, :mneme_snapshot_last_message_id
37
+ rename_column :sessions, :recalled_event_ids, :recalled_message_ids
38
+ rename_column :sessions, :viewport_event_ids, :viewport_message_ids
39
+
40
+ # --- Recreate FTS5 virtual table with new names ---
41
+ execute <<~SQL
42
+ CREATE VIRTUAL TABLE messages_fts USING fts5(
43
+ searchable_text,
44
+ content='',
45
+ contentless_delete=1,
46
+ tokenize='porter unicode61'
47
+ );
48
+ SQL
49
+
50
+ execute <<~SQL
51
+ INSERT INTO messages_fts(rowid, searchable_text)
52
+ SELECT m.id,
53
+ CASE
54
+ WHEN m.message_type IN ('user_message', 'agent_message', 'system_message')
55
+ THEN json_extract(m.payload, '$.content')
56
+ WHEN m.message_type = 'tool_call' AND json_extract(m.payload, '$.tool_name') = 'think'
57
+ THEN json_extract(m.payload, '$.tool_input.thoughts')
58
+ END
59
+ FROM messages m
60
+ WHERE (m.message_type IN ('user_message', 'agent_message', 'system_message'))
61
+ OR (m.message_type = 'tool_call' AND json_extract(m.payload, '$.tool_name') = 'think');
62
+ SQL
63
+
64
+ execute <<~SQL
65
+ CREATE TRIGGER messages_fts_insert AFTER INSERT ON messages
66
+ WHEN NEW.message_type IN ('user_message', 'agent_message', 'system_message')
67
+ OR (NEW.message_type = 'tool_call' AND json_extract(NEW.payload, '$.tool_name') = 'think')
68
+ BEGIN
69
+ INSERT INTO messages_fts(rowid, searchable_text)
70
+ VALUES (
71
+ NEW.id,
72
+ CASE
73
+ WHEN NEW.message_type IN ('user_message', 'agent_message', 'system_message')
74
+ THEN json_extract(NEW.payload, '$.content')
75
+ WHEN NEW.message_type = 'tool_call'
76
+ THEN json_extract(NEW.payload, '$.tool_input.thoughts')
77
+ END
78
+ );
79
+ END;
80
+ SQL
81
+
82
+ execute <<~SQL
83
+ CREATE TRIGGER messages_fts_delete AFTER DELETE ON messages
84
+ WHEN OLD.message_type IN ('user_message', 'agent_message', 'system_message')
85
+ OR (OLD.message_type = 'tool_call' AND json_extract(OLD.payload, '$.tool_name') = 'think')
86
+ BEGIN
87
+ DELETE FROM messages_fts WHERE rowid = OLD.id;
88
+ END;
89
+ SQL
90
+ end
91
+
92
+ def down
93
+ # --- FTS5: drop new triggers and table ---
94
+ execute "DROP TRIGGER IF EXISTS messages_fts_insert"
95
+ execute "DROP TRIGGER IF EXISTS messages_fts_delete"
96
+ execute "DROP TABLE IF EXISTS messages_fts"
97
+
98
+ # --- Sessions: restore event pointer columns ---
99
+ rename_column :sessions, :mneme_boundary_message_id, :mneme_boundary_event_id
100
+ rename_column :sessions, :mneme_snapshot_first_message_id, :mneme_snapshot_first_event_id
101
+ rename_column :sessions, :mneme_snapshot_last_message_id, :mneme_snapshot_last_event_id
102
+ rename_column :sessions, :recalled_message_ids, :recalled_event_ids
103
+ rename_column :sessions, :viewport_message_ids, :viewport_event_ids
104
+
105
+ # --- Snapshots: restore event range columns ---
106
+ rename_column :snapshots, :from_message_id, :from_event_id
107
+ rename_column :snapshots, :to_message_id, :to_event_id
108
+
109
+ # --- Join table: goal_pinned_messages → goal_pinned_events ---
110
+ rename_column :goal_pinned_messages, :pinned_message_id, :pinned_event_id
111
+ rename_table :goal_pinned_messages, :goal_pinned_events
112
+
113
+ # --- Pinned messages → pinned events ---
114
+ rename_column :pinned_messages, :message_id, :event_id
115
+ rename_table :pinned_messages, :pinned_events
116
+
117
+ # --- Main table: messages → events ---
118
+ rename_column :messages, :message_type, :event_type
119
+ rename_table :messages, :events
120
+
121
+ # --- Recreate original FTS5 ---
122
+ execute <<~SQL
123
+ CREATE VIRTUAL TABLE events_fts USING fts5(
124
+ searchable_text,
125
+ content='',
126
+ contentless_delete=1,
127
+ tokenize='porter unicode61'
128
+ );
129
+ SQL
130
+
131
+ execute <<~SQL
132
+ INSERT INTO events_fts(rowid, searchable_text)
133
+ SELECT e.id,
134
+ CASE
135
+ WHEN e.event_type IN ('user_message', 'agent_message', 'system_message')
136
+ THEN json_extract(e.payload, '$.content')
137
+ WHEN e.event_type = 'tool_call' AND json_extract(e.payload, '$.tool_name') = 'think'
138
+ THEN json_extract(e.payload, '$.tool_input.thoughts')
139
+ END
140
+ FROM events e
141
+ WHERE (e.event_type IN ('user_message', 'agent_message', 'system_message'))
142
+ OR (e.event_type = 'tool_call' AND json_extract(e.payload, '$.tool_name') = 'think');
143
+ SQL
144
+
145
+ execute <<~SQL
146
+ CREATE TRIGGER events_fts_insert AFTER INSERT ON events
147
+ WHEN NEW.event_type IN ('user_message', 'agent_message', 'system_message')
148
+ OR (NEW.event_type = 'tool_call' AND json_extract(NEW.payload, '$.tool_name') = 'think')
149
+ BEGIN
150
+ INSERT INTO events_fts(rowid, searchable_text)
151
+ VALUES (
152
+ NEW.id,
153
+ CASE
154
+ WHEN NEW.event_type IN ('user_message', 'agent_message', 'system_message')
155
+ THEN json_extract(NEW.payload, '$.content')
156
+ WHEN NEW.event_type = 'tool_call'
157
+ THEN json_extract(NEW.payload, '$.tool_input.thoughts')
158
+ END
159
+ );
160
+ END;
161
+ SQL
162
+
163
+ execute <<~SQL
164
+ CREATE TRIGGER events_fts_delete AFTER DELETE ON events
165
+ WHEN OLD.event_type IN ('user_message', 'agent_message', 'system_message')
166
+ OR (OLD.event_type = 'tool_call' AND json_extract(OLD.payload, '$.tool_name') = 'think')
167
+ BEGIN
168
+ DELETE FROM events_fts WHERE rowid = OLD.id;
169
+ END;
170
+ SQL
171
+ end
172
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateSecrets < ActiveRecord::Migration[8.1]
4
+ def change
5
+ create_table :secrets do |t|
6
+ t.string :namespace, null: false
7
+ t.string :key, null: false
8
+ t.text :value, null: false
9
+
10
+ t.timestamps
11
+ end
12
+
13
+ add_index :secrets, [:namespace, :key], unique: true
14
+ end
15
+ end
@@ -0,0 +1,6 @@
1
+ class AddEvictedAtToGoals < ActiveRecord::Migration[8.1]
2
+ def change
3
+ add_column :goals, :evicted_at, :datetime
4
+ add_index :goals, :evicted_at
5
+ end
6
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreatePendingMessages < ActiveRecord::Migration[8.1]
4
+ def change
5
+ create_table :pending_messages do |t|
6
+ t.references :session, null: false, foreign_key: true
7
+ t.text :content, null: false
8
+ t.timestamps
9
+ end
10
+ end
11
+ end
data/lib/agent_loop.rb CHANGED
@@ -6,22 +6,17 @@
6
6
  # Extracted from {TUI::Screens::Chat} so the same agent logic can run from
7
7
  # the TUI, a background job, or an Action Cable channel.
8
8
  #
9
- # @note Not thread-safe. Callers must serialize concurrent calls to {#process}
10
- # (e.g. TUI uses a loading flag, future callers should use session-level locks).
9
+ # @note Not thread-safe. Callers must serialize concurrent access
10
+ # (e.g. {AgentRequestJob} uses session-level processing locks).
11
11
  #
12
12
  # @example Basic usage
13
13
  # loop = AgentLoop.new(session: session)
14
- # loop.process("What files are in the current directory?")
14
+ # loop.run
15
15
  # loop.finalize
16
16
  #
17
17
  # @example With dependency injection (testing)
18
18
  # loop = AgentLoop.new(session: session, client: mock_client, registry: mock_registry)
19
- # loop.process("hello")
20
- #
21
- # @example Background job usage (retry-safe)
22
- # loop = AgentLoop.new(session: session)
23
- # loop.run # processes persisted session messages without emitting UserMessage
24
- # loop.finalize
19
+ # loop.run
25
20
  class AgentLoop
26
21
  # @return [Session] the conversation session this loop operates on
27
22
  attr_reader :session
@@ -30,9 +25,9 @@ class AgentLoop
30
25
  # @param shell_session [ShellSession, nil] injectable persistent shell;
31
26
  # created automatically if not provided
32
27
  # @param client [LLM::Client, nil] injectable LLM client;
33
- # created lazily on first {#process} call if not provided
28
+ # created lazily on first {#run} call if not provided
34
29
  # @param registry [Tools::Registry, nil] injectable tool registry;
35
- # built lazily on first {#process} call if not provided
30
+ # built lazily on first {#run} call if not provided
36
31
  def initialize(session:, shell_session: nil, client: nil, registry: nil)
37
32
  @session = session
38
33
  @shell_session = shell_session || ShellSession.new(session_id: session.id)
@@ -40,28 +35,6 @@ class AgentLoop
40
35
  @registry = registry
41
36
  end
42
37
 
43
- # Runs the agent loop for a single user input.
44
- #
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}.
48
- # On error emits {Events::AgentMessage} with the error text.
49
- #
50
- # @param input [String] raw user input
51
- # @return [String, nil] the agent's response text, or nil for blank input
52
- def process(input)
53
- text = input.to_s.strip
54
- return if text.empty?
55
-
56
- persist_user_event(text)
57
- Events::Bus.emit(Events::UserMessage.new(content: text, session_id: @session.id))
58
- run
59
- rescue => error
60
- error_message = "#{error.class}: #{error.message}"
61
- Events::Bus.emit(Events::AgentMessage.new(content: error_message, session_id: @session.id))
62
- error_message
63
- end
64
-
65
38
  # Makes the first LLM API call to verify delivery. Called inside the
66
39
  # Bounce Back transaction — if this raises, the user event rolls back.
67
40
  #
@@ -127,7 +100,7 @@ class AgentLoop
127
100
 
128
101
  # Tool classes available to all sessions by default.
129
102
  # @return [Array<Class<Tools::Base>>]
130
- STANDARD_TOOLS = [Tools::Bash, Tools::Read, Tools::Write, Tools::Edit, Tools::WebGet, Tools::Think, Tools::Remember].freeze
103
+ STANDARD_TOOLS = [Tools::Bash, Tools::Read, Tools::Write, Tools::Edit, Tools::WebGet, Tools::Think, Tools::Remember, Tools::Recall].freeze
131
104
 
132
105
  # Tools that bypass {Session#granted_tools} filtering.
133
106
  # The agent's reasoning depends on these regardless of task scope.
@@ -140,12 +113,9 @@ class AgentLoop
140
113
 
141
114
  private
142
115
 
143
- # @see Session#create_user_event
144
- def persist_user_event(content)
145
- @session.create_user_event(content)
146
- end
147
-
148
116
  # Assembles LLM options (system prompt, environment context).
117
+ # Broadcasts the full debug context (system prompt + tool schemas)
118
+ # to debug-mode TUI clients on every LLM request.
149
119
  # @return [Hash] options for {LLM::Client#chat_with_tools}
150
120
  def build_llm_options
151
121
  options = {}
@@ -154,6 +124,7 @@ class AgentLoop
154
124
  end
155
125
  prompt = @session.system_prompt(environment_context: env_context)
156
126
  options[:system] = prompt if prompt
127
+ @session.broadcast_debug_context(system: prompt, tools: @registry&.schemas)
157
128
  options
158
129
  end
159
130
 
@@ -172,10 +143,12 @@ class AgentLoop
172
143
 
173
144
  granted_standard_tools.each { |tool| registry.register(tool) }
174
145
 
175
- unless @session.sub_agent?
146
+ if @session.sub_agent?
147
+ registry.register(Tools::MarkGoalCompleted)
148
+ else
176
149
  registry.register(Tools::SpawnSubagent)
177
150
  registry.register(Tools::SpawnSpecialist)
178
- registry.register(Tools::RequestFeature)
151
+ registry.register(Tools::OpenIssue)
179
152
  end
180
153
 
181
154
  register_mcp_tools(registry)
@@ -12,7 +12,7 @@ module Agents
12
12
  # ---
13
13
  # name: codebase-analyzer
14
14
  # description: Analyzes codebase implementation details.
15
- # tools: read, bash
15
+ # tools: read_file, bash
16
16
  # model: claude-sonnet-4-5
17
17
  # ---
18
18
  #
@@ -9,7 +9,9 @@ module AnalyticalBrain
9
9
  # active depends on the session type:
10
10
  #
11
11
  # * **Parent sessions** — session naming, skill/workflow/goal management
12
- # * **Child sessions** — sub-agent nickname assignment, skill/workflow/goal management
12
+ # * **Child sessions** — sub-agent nickname assignment, skill management
13
+ # (goal tracking and workflows disabled — sub-agents manage their sole goal
14
+ # via mark_goal_completed)
13
15
  #
14
16
  # Tools mutate the observed session directly (e.g. renaming it, activating
15
17
  # skills), but no trace of the brain's reasoning is persisted — events are
@@ -27,7 +29,7 @@ module AnalyticalBrain
27
29
  ──────────────────────────────
28
30
  SESSION NAMING
29
31
  ──────────────────────────────
30
- Call rename_session when the topic becomes clear or shifts.
32
+ Name the session when the topic becomes clear. Rename if it shifts.
31
33
  Format: one emoji + 1-3 descriptive words.
32
34
  PROMPT
33
35
  tools: [Tools::RenameSession]
@@ -38,10 +40,10 @@ module AnalyticalBrain
38
40
  ──────────────────────────────
39
41
  SUB-AGENT NAMING
40
42
  ──────────────────────────────
41
- Call assign_nickname to give this sub-agent a short, memorable nickname.
43
+ Give this sub-agent a memorable nickname based on its task.
42
44
  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
+ Evocative, fun, easy to type after @.
46
+ One nickname per call. If taken, pick another — no numeric suffixes.
45
47
  PROMPT
46
48
  tools: [Tools::AssignNickname]
47
49
  ),
@@ -51,8 +53,9 @@ module AnalyticalBrain
51
53
  ──────────────────────────────
52
54
  SKILL MANAGEMENT
53
55
  ──────────────────────────────
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
+ Activate skills when the conversation signals intent before the agent acts on it.
57
+ Late activation means the agent works without domain knowledge.
58
+ Deactivate when the agent moves to a different domain.
56
59
  Multiple skills can be active at once.
57
60
  PROMPT
58
61
  tools: [Tools::ActivateSkill, Tools::DeactivateSkill]
@@ -63,11 +66,11 @@ module AnalyticalBrain
63
66
  ──────────────────────────────
64
67
  WORKFLOW MANAGEMENT
65
68
  ──────────────────────────────
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.
69
+ Activate a workflow when the user starts a multi-step task that matches one.
70
+ Read the returned content and use judgment to create goals — not a mechanical 1:1 mapping.
68
71
  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.
72
+ Deactivate the workflow when it completes or the user shifts focus.
73
+ Only one workflow active at a time — activating a new one replaces the previous.
71
74
  PROMPT
72
75
  tools: [Tools::ReadWorkflow, Tools::DeactivateWorkflow]
73
76
  ),
@@ -77,11 +80,11 @@ module AnalyticalBrain
77
80
  ──────────────────────────────
78
81
  GOAL TRACKING
79
82
  ──────────────────────────────
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.
83
+ Create a root goal when the user starts a multi-step task.
84
+ Break it into sub-goals as the plan becomes clear.
85
+ Refine goal wording as understanding evolves.
86
+ Mark goals complete when the agent finishes the work they describe.
87
+ Completing a root goal cascades — all sub-goals are finished too.
85
88
  Never duplicate an existing goal — check the active goals list first.
86
89
  PROMPT
87
90
  tools: [Tools::SetGoal, Tools::UpdateGoal, Tools::FinishGoal]
@@ -89,22 +92,22 @@ module AnalyticalBrain
89
92
  }.freeze
90
93
 
91
94
  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
+ You manage context for the main agent — skills, goals, workflows, and session names.
96
+ Watch the conversation and act when context needs updating.
97
+ Communicate only through tool calls — never output text.
95
98
  PROMPT
96
99
 
97
100
  COMPLETION_PROMPT = <<~PROMPT
98
101
  ──────────────────────────────
99
102
  COMPLETION
100
103
  ──────────────────────────────
101
- Call everything_is_ready as your LAST tool call, every time.
102
- If nothing needs changing, call it immediately as your only tool call.
104
+ Always finish with everything_is_ready.
105
+ If nothing needs attention, call it immediately.
103
106
  PROMPT
104
107
 
105
108
  # Which responsibilities activate for each session type.
106
109
  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
110
+ CHILD_RESPONSIBILITIES = %i[sub_agent_naming skill_management].freeze
108
111
 
109
112
  # @param session [Session] the session to observe and maintain
110
113
  # @param client [LLM::Client, nil] injectable LLM client (defaults to fast model)
@@ -118,7 +121,7 @@ module AnalyticalBrain
118
121
  end
119
122
 
120
123
  # Runs the analytical brain loop. Builds context from the session's
121
- # recent events, calls the LLM with the session-appropriate tool set,
124
+ # recent messages, calls the LLM with the session-appropriate tool set,
122
125
  # and executes any tool calls against the session.
123
126
  #
124
127
  # Events emitted during tool execution are not persisted — the phantom
@@ -130,12 +133,12 @@ module AnalyticalBrain
130
133
  messages = build_messages
131
134
  sid = @session.id
132
135
  if messages.empty?
133
- log.debug("session=#{sid} — no events, skipping")
136
+ log.debug("session=#{sid} — no messages, skipping")
134
137
  return
135
138
  end
136
139
 
137
140
  system = build_system_prompt
138
- log.info("session=#{sid} — running (#{recent_events.size} events)")
141
+ log.info("session=#{sid} — running (#{recent_messages.size} messages)")
139
142
  log.debug("system prompt:\n#{system}")
140
143
  log.debug("user message:\n#{messages.first[:content]}")
141
144
 
@@ -162,18 +165,18 @@ module AnalyticalBrain
162
165
  active_responsibility_keys.map { |key| RESPONSIBILITIES.fetch(key) }
163
166
  end
164
167
 
165
- # Builds a condensed transcript of recent events as a single user message.
168
+ # Builds a condensed transcript of recent messages as a single user message.
166
169
  # The framing differs by session type:
167
170
  #
168
171
  # * **Parent:** "The main session is working on this: [transcript]"
169
172
  # * **Child:** "A sub-agent has been spawned with this task: [transcript]"
170
173
  #
171
- # @return [Array<Hash>] single-element messages array, or empty if no events
174
+ # @return [Array<Hash>] single-element messages array, or empty if no messages
172
175
  def build_messages
173
- events = recent_events
174
- return [] if events.empty?
176
+ messages = recent_messages
177
+ return [] if messages.empty?
175
178
 
176
- transcript = events.filter_map { |event| EventDecorator.for(event)&.render("brain") }.join("\n")
179
+ transcript = messages.filter_map { |msg| MessageDecorator.for(msg)&.render("brain") }.join("\n")
177
180
 
178
181
  if @session.sub_agent?
179
182
  build_child_message(transcript)
@@ -189,7 +192,7 @@ module AnalyticalBrain
189
192
  #{transcript}
190
193
  ```
191
194
 
192
- Observe the conversation and take action: manage goals, activate or deactivate relevant skills, read workflows when a multi-step task matches, rename the session if needed, then call everything_is_ready.
195
+ Review and take any needed actions, then call everything_is_ready.
193
196
  MSG
194
197
  [{role: "user", content: content}]
195
198
  end
@@ -201,17 +204,17 @@ module AnalyticalBrain
201
204
  #{transcript}
202
205
  ```
203
206
 
204
- Assign a memorable nickname based on the task, activate relevant skills, then call everything_is_ready.
207
+ Assign a nickname and activate relevant skills, then call everything_is_ready.
205
208
  MSG
206
209
  [{role: "user", content: content}]
207
210
  end
208
211
 
209
- # @return [Array<Event>] most recent events in chronological order
210
- def recent_events
211
- @session.events
212
- .context_events
212
+ # @return [Array<Message>] most recent messages in chronological order
213
+ def recent_messages
214
+ @session.messages
215
+ .context_messages
213
216
  .reorder(id: :desc)
214
- .limit(Anima::Settings.analytical_brain_event_window)
217
+ .limit(Anima::Settings.analytical_brain_message_window)
215
218
  .to_a
216
219
  .reverse
217
220
  end