anima-core 1.2.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.reek.yml +14 -8
- data/README.md +96 -23
- data/agents/codebase-analyzer.md +1 -1
- data/agents/codebase-pattern-finder.md +1 -1
- data/agents/documentation-researcher.md +1 -1
- data/agents/thoughts-analyzer.md +1 -1
- data/agents/web-search-researcher.md +2 -2
- data/app/channels/session_channel.rb +53 -35
- data/app/decorators/tool_call_decorator.rb +7 -7
- data/app/decorators/user_message_decorator.rb +3 -17
- data/app/jobs/agent_request_job.rb +15 -6
- data/app/jobs/passive_recall_job.rb +6 -11
- data/app/models/concerns/message/broadcasting.rb +1 -0
- data/app/models/goal.rb +14 -0
- data/app/models/message.rb +13 -31
- data/app/models/pending_message.rb +191 -0
- data/app/models/secret.rb +72 -0
- data/app/models/session.rb +480 -271
- data/bin/inspect-cassette +144 -0
- data/bin/release +212 -0
- data/bin/with-llms +20 -0
- data/config/database.yml +1 -0
- data/config/environments/test.rb +5 -0
- data/config/initializers/time_nanoseconds.rb +11 -0
- data/db/cable_structure.sql +9 -0
- data/db/migrate/20260328100000_create_secrets.rb +15 -0
- data/db/migrate/20260328152142_add_evicted_at_to_goals.rb +6 -0
- data/db/migrate/20260329120000_create_pending_messages.rb +11 -0
- data/db/migrate/20260330120000_add_source_to_pending_messages.rb +8 -0
- data/db/migrate/20260401180000_add_api_metrics_to_messages.rb +7 -0
- data/db/migrate/20260401210935_remove_recalled_message_ids_from_sessions.rb +5 -0
- data/db/migrate/20260403080031_add_initial_cwd_to_sessions.rb +5 -0
- data/db/queue_structure.sql +61 -0
- data/db/structure.sql +120 -0
- data/lib/agent_loop.rb +53 -51
- data/lib/agents/definition.rb +1 -1
- data/lib/analytical_brain/runner.rb +19 -6
- data/lib/analytical_brain/tools/activate_skill.rb +2 -2
- data/lib/analytical_brain/tools/assign_nickname.rb +1 -1
- data/lib/analytical_brain/tools/deactivate_skill.rb +2 -1
- data/lib/analytical_brain/tools/deactivate_workflow.rb +2 -1
- data/lib/analytical_brain/tools/finish_goal.rb +3 -0
- data/lib/analytical_brain/tools/goal_messaging.rb +28 -0
- data/lib/analytical_brain/tools/read_workflow.rb +2 -2
- data/lib/analytical_brain/tools/set_goal.rb +5 -1
- data/lib/analytical_brain/tools/update_goal.rb +5 -1
- data/lib/anima/cli/mcp/secrets.rb +4 -4
- data/lib/anima/cli/mcp.rb +4 -4
- data/lib/anima/cli.rb +41 -13
- data/lib/anima/installer.rb +20 -1
- data/lib/anima/settings.rb +37 -2
- data/lib/anima/version.rb +1 -1
- data/lib/anima.rb +1 -1
- data/lib/credential_store.rb +17 -66
- data/lib/events/agent_message.rb +14 -0
- data/lib/events/base.rb +1 -1
- data/lib/events/subscribers/persister.rb +12 -18
- data/lib/events/subscribers/subagent_message_router.rb +18 -9
- data/lib/events/user_message.rb +2 -13
- data/lib/llm/client.rb +91 -50
- data/lib/mcp/config.rb +2 -2
- data/lib/mcp/secrets.rb +7 -8
- data/lib/mneme/compressed_viewport.rb +9 -5
- data/lib/mneme/passive_recall.rb +85 -16
- data/lib/mneme/runner.rb +15 -4
- data/lib/providers/anthropic.rb +112 -7
- data/lib/shell_session.rb +239 -18
- data/lib/tools/base.rb +22 -0
- data/lib/tools/bash.rb +61 -7
- data/lib/tools/edit.rb +2 -2
- data/lib/tools/mark_goal_completed.rb +85 -0
- data/lib/tools/read.rb +2 -1
- data/lib/tools/recall.rb +98 -0
- data/lib/tools/registry.rb +41 -7
- data/lib/tools/remember.rb +1 -1
- data/lib/tools/response_truncator.rb +70 -0
- data/lib/tools/spawn_specialist.rb +11 -8
- data/lib/tools/spawn_subagent.rb +19 -13
- data/lib/tools/subagent_prompts.rb +41 -5
- data/lib/tools/think.rb +23 -0
- data/lib/tools/write.rb +1 -1
- data/lib/tui/app.rb +545 -137
- data/lib/tui/braille_spinner.rb +152 -0
- data/lib/tui/cable_client.rb +13 -20
- data/lib/tui/decorators/base_decorator.rb +40 -11
- data/lib/tui/decorators/bash_decorator.rb +3 -3
- data/lib/tui/decorators/edit_decorator.rb +7 -4
- data/lib/tui/decorators/read_decorator.rb +6 -8
- data/lib/tui/decorators/think_decorator.rb +4 -6
- data/lib/tui/decorators/web_get_decorator.rb +4 -3
- data/lib/tui/decorators/write_decorator.rb +7 -4
- data/lib/tui/flash.rb +19 -14
- data/lib/tui/formatting.rb +33 -0
- data/lib/tui/input_buffer.rb +6 -6
- data/lib/tui/message_store.rb +159 -27
- data/lib/tui/performance_logger.rb +2 -3
- data/lib/tui/screens/chat.rb +302 -103
- data/lib/tui/settings.rb +86 -0
- data/skills/activerecord/SKILL.md +1 -1
- data/skills/dragonruby/SKILL.md +1 -1
- data/skills/draper-decorators/SKILL.md +1 -1
- data/skills/gh-issue.md +1 -1
- data/skills/mcp-server/SKILL.md +1 -1
- data/skills/ratatui-ruby/SKILL.md +1 -1
- data/skills/rspec/SKILL.md +1 -1
- data/templates/config.toml +30 -1
- data/templates/tui.toml +209 -0
- metadata +24 -3
- data/config/initializers/fts5_schema_dump.rb +0 -21
- data/lib/environment_probe.rb +0 -232
|
@@ -3,22 +3,15 @@
|
|
|
3
3
|
# Decorates user_message records for display in the TUI.
|
|
4
4
|
# Basic mode returns role and content. Verbose mode adds a timestamp.
|
|
5
5
|
# Debug mode adds token count (exact when counted, estimated when not).
|
|
6
|
-
# Pending messages include `status: "pending"` so the TUI renders them
|
|
7
|
-
# with a visual indicator (dimmed, clock icon).
|
|
8
6
|
class UserMessageDecorator < MessageDecorator
|
|
9
|
-
# @return [Hash] structured user message data
|
|
10
|
-
# `{role: :user, content: String}` or with `status: "pending"` when queued
|
|
7
|
+
# @return [Hash] structured user message data `{role: :user, content: String}`
|
|
11
8
|
def render_basic
|
|
12
|
-
|
|
13
|
-
base[:status] = "pending" if pending?
|
|
14
|
-
base
|
|
9
|
+
{role: :user, content: content}
|
|
15
10
|
end
|
|
16
11
|
|
|
17
12
|
# @return [Hash] structured user message with nanosecond timestamp
|
|
18
13
|
def render_verbose
|
|
19
|
-
|
|
20
|
-
base[:status] = "pending" if pending?
|
|
21
|
-
base
|
|
14
|
+
{role: :user, content: content, timestamp: timestamp}
|
|
22
15
|
end
|
|
23
16
|
|
|
24
17
|
# @return [Hash] verbose output plus token count for debugging
|
|
@@ -31,11 +24,4 @@ class UserMessageDecorator < MessageDecorator
|
|
|
31
24
|
def render_brain
|
|
32
25
|
"User: #{truncate_middle(content)}"
|
|
33
26
|
end
|
|
34
|
-
|
|
35
|
-
private
|
|
36
|
-
|
|
37
|
-
# @return [Boolean] true when this message is queued but not yet sent to LLM
|
|
38
|
-
def pending?
|
|
39
|
-
payload["status"] == Message::PENDING_STATUS
|
|
40
|
-
end
|
|
41
27
|
end
|
|
@@ -66,10 +66,10 @@ class AgentRequestJob < ApplicationJob
|
|
|
66
66
|
agent_loop.run
|
|
67
67
|
end
|
|
68
68
|
|
|
69
|
-
# Process any pending messages
|
|
69
|
+
# Process any pending messages that arrived after the last tool round.
|
|
70
70
|
loop do
|
|
71
71
|
promoted = session.promote_pending_messages!
|
|
72
|
-
break if promoted
|
|
72
|
+
break if promoted[:texts].empty? && promoted[:pairs].empty?
|
|
73
73
|
agent_loop.run
|
|
74
74
|
end
|
|
75
75
|
|
|
@@ -155,18 +155,27 @@ class AgentRequestJob < ApplicationJob
|
|
|
155
155
|
|
|
156
156
|
# Sets the session's processing flag atomically. Returns true if this
|
|
157
157
|
# job claimed the lock, false if another job already holds it.
|
|
158
|
-
# Broadcasts the state change to
|
|
158
|
+
# Broadcasts +session_state: llm_generating+ and the state change to
|
|
159
|
+
# the parent session's HUD.
|
|
159
160
|
def claim_processing(session_id)
|
|
160
161
|
claimed = Session.where(id: session_id, processing: false).update_all(processing: true) == 1
|
|
161
|
-
|
|
162
|
+
if claimed
|
|
163
|
+
session = Session.find_by(id: session_id)
|
|
164
|
+
session&.broadcast_session_state("llm_generating")
|
|
165
|
+
session&.broadcast_children_update_to_parent
|
|
166
|
+
end
|
|
162
167
|
claimed
|
|
163
168
|
end
|
|
164
169
|
|
|
165
170
|
# Clears the processing flag so the session can accept new jobs.
|
|
166
|
-
# Broadcasts
|
|
171
|
+
# Broadcasts +session_state: idle+ to the session stream (replaces
|
|
172
|
+
# the old +processing_stopped+ action) and +children_updated+ to the
|
|
173
|
+
# parent session's HUD.
|
|
167
174
|
def release_processing(session_id)
|
|
168
175
|
Session.where(id: session_id).update_all(processing: false)
|
|
169
|
-
Session.find_by(id: session_id)
|
|
176
|
+
session = Session.find_by(id: session_id)
|
|
177
|
+
session&.broadcast_session_state("idle")
|
|
178
|
+
session&.broadcast_children_update_to_parent
|
|
170
179
|
end
|
|
171
180
|
|
|
172
181
|
# Safety-net clearing of the interrupt flag.
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
# Runs passive recall after goal updates — searches message history for
|
|
4
|
-
# context relevant to active goals and
|
|
5
|
-
#
|
|
4
|
+
# context relevant to active goals and injects phantom tool_call/tool_response
|
|
5
|
+
# pairs into the session's message stream.
|
|
6
6
|
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
7
|
+
# Phantom pairs ride the conveyor belt like regular messages, getting
|
|
8
|
+
# cached, evicted, and compressed by Mneme naturally.
|
|
9
9
|
#
|
|
10
10
|
# @example
|
|
11
11
|
# PassiveRecallJob.perform_later(session.id)
|
|
@@ -17,13 +17,8 @@ class PassiveRecallJob < ApplicationJob
|
|
|
17
17
|
# @param session_id [Integer]
|
|
18
18
|
def perform(session_id)
|
|
19
19
|
session = Session.find(session_id)
|
|
20
|
-
|
|
20
|
+
count = Mneme::PassiveRecall.new(session).call
|
|
21
21
|
|
|
22
|
-
if
|
|
23
|
-
session.update_column(:recalled_message_ids, results.map(&:message_id))
|
|
24
|
-
Mneme.logger.info("session=#{session_id} — passive recall found #{results.size} memories")
|
|
25
|
-
elsif session.recalled_message_ids.present?
|
|
26
|
-
session.update_column(:recalled_message_ids, [])
|
|
27
|
-
end
|
|
22
|
+
Mneme.logger.info("session=#{session_id} — passive recall injected #{count} phantom pairs") if count > 0
|
|
28
23
|
end
|
|
29
24
|
end
|
|
@@ -68,6 +68,7 @@ module Message::Broadcasting
|
|
|
68
68
|
mode = session.view_mode
|
|
69
69
|
decorator = MessageDecorator.for(self)
|
|
70
70
|
broadcast_payload = payload.merge("id" => id, "action" => action)
|
|
71
|
+
broadcast_payload["api_metrics"] = api_metrics if api_metrics.present?
|
|
71
72
|
|
|
72
73
|
if decorator
|
|
73
74
|
broadcast_payload["rendered"] = {mode => decorator.render(mode)}
|
data/app/models/goal.rb
CHANGED
|
@@ -25,6 +25,17 @@ class Goal < ApplicationRecord
|
|
|
25
25
|
scope :completed, -> { where(status: "completed") }
|
|
26
26
|
scope :root, -> { where(parent_goal_id: nil) }
|
|
27
27
|
|
|
28
|
+
# @!method self.not_evicted
|
|
29
|
+
# Goals still visible in context (not yet evicted by the analytical brain).
|
|
30
|
+
# @return [ActiveRecord::Relation]
|
|
31
|
+
scope :not_evicted, -> { where(evicted_at: nil) }
|
|
32
|
+
|
|
33
|
+
# @!method self.evictable
|
|
34
|
+
# Completed goals not yet evicted — their phantom pairs remain in the
|
|
35
|
+
# sliding window until Mneme compresses them during the eviction cycle.
|
|
36
|
+
# @return [ActiveRecord::Relation]
|
|
37
|
+
scope :evictable, -> { completed.where(evicted_at: nil) }
|
|
38
|
+
|
|
28
39
|
after_commit :broadcast_goals_update
|
|
29
40
|
after_commit :schedule_passive_recall, on: [:create, :update]
|
|
30
41
|
|
|
@@ -37,6 +48,9 @@ class Goal < ApplicationRecord
|
|
|
37
48
|
# @return [Boolean] true if this is a root goal (no parent)
|
|
38
49
|
def root? = !parent_goal_id
|
|
39
50
|
|
|
51
|
+
# @return [Boolean] true if this goal has been evicted from display
|
|
52
|
+
def evicted? = evicted_at.present?
|
|
53
|
+
|
|
40
54
|
# Cascades completion to all active sub-goals. Called when a root goal
|
|
41
55
|
# is finished — remaining sub-items are implicitly resolved because
|
|
42
56
|
# the semantic episode that spawned them has ended.
|
data/app/models/message.rb
CHANGED
|
@@ -28,9 +28,6 @@ class Message < ApplicationRecord
|
|
|
28
28
|
CONTEXT_TYPES = %w[system_message user_message agent_message tool_call tool_response].freeze
|
|
29
29
|
CONVERSATION_TYPES = %w[user_message agent_message system_message].freeze
|
|
30
30
|
THINK_TOOL = "think"
|
|
31
|
-
SPAWN_TOOLS = %w[spawn_subagent spawn_specialist].freeze
|
|
32
|
-
PENDING_STATUS = "pending"
|
|
33
|
-
|
|
34
31
|
# Message types that require a tool_use_id to pair call with response.
|
|
35
32
|
TOOL_TYPES = %w[tool_call tool_response].freeze
|
|
36
33
|
|
|
@@ -39,6 +36,18 @@ class Message < ApplicationRecord
|
|
|
39
36
|
# Heuristic: average bytes per token for English prose.
|
|
40
37
|
BYTES_PER_TOKEN = 4
|
|
41
38
|
|
|
39
|
+
# Synthetic ID for system prompt entries in the TUI message store.
|
|
40
|
+
# Real message IDs are positive integers from the database, so 0
|
|
41
|
+
# is safe for deduplication without collision risk.
|
|
42
|
+
SYSTEM_PROMPT_ID = 0
|
|
43
|
+
|
|
44
|
+
# Estimates token count from a byte size using the {BYTES_PER_TOKEN} heuristic.
|
|
45
|
+
# @param bytesize [Integer] number of bytes
|
|
46
|
+
# @return [Integer] estimated token count (at least 1)
|
|
47
|
+
def self.estimate_token_count(bytesize)
|
|
48
|
+
[(bytesize / BYTES_PER_TOKEN.to_f).ceil, 1].max
|
|
49
|
+
end
|
|
50
|
+
|
|
42
51
|
belongs_to :session
|
|
43
52
|
has_many :pinned_messages, dependent: :destroy
|
|
44
53
|
|
|
@@ -60,28 +69,6 @@ class Message < ApplicationRecord
|
|
|
60
69
|
# @return [ActiveRecord::Relation]
|
|
61
70
|
scope :context_messages, -> { where(message_type: CONTEXT_TYPES) }
|
|
62
71
|
|
|
63
|
-
# @!method self.pending
|
|
64
|
-
# User messages queued during active agent processing, not yet sent to LLM.
|
|
65
|
-
# @return [ActiveRecord::Relation]
|
|
66
|
-
scope :pending, -> { where(status: PENDING_STATUS) }
|
|
67
|
-
|
|
68
|
-
# @!method self.deliverable
|
|
69
|
-
# Messages eligible for LLM context (excludes pending messages).
|
|
70
|
-
# NULL status means delivered/processed — the only excluded value is "pending".
|
|
71
|
-
# @return [ActiveRecord::Relation]
|
|
72
|
-
scope :deliverable, -> { where(status: nil) }
|
|
73
|
-
|
|
74
|
-
# @!method self.excluding_spawn_messages
|
|
75
|
-
# Excludes spawn_subagent/spawn_specialist tool_call and tool_response messages.
|
|
76
|
-
# Used when building parent context for sub-agents — spawn messages cause role
|
|
77
|
-
# confusion because the sub-agent sees sibling spawn results and mistakes
|
|
78
|
-
# itself for the parent.
|
|
79
|
-
# @return [ActiveRecord::Relation]
|
|
80
|
-
scope :excluding_spawn_messages, -> {
|
|
81
|
-
where.not("message_type IN (?) AND json_extract(payload, '$.tool_name') IN (?)",
|
|
82
|
-
TOOL_TYPES, SPAWN_TOOLS)
|
|
83
|
-
}
|
|
84
|
-
|
|
85
72
|
# Maps message_type to the Anthropic Messages API role.
|
|
86
73
|
# @return [String] "user" or "assistant"
|
|
87
74
|
def api_role
|
|
@@ -98,11 +85,6 @@ class Message < ApplicationRecord
|
|
|
98
85
|
message_type.in?(CONTEXT_TYPES)
|
|
99
86
|
end
|
|
100
87
|
|
|
101
|
-
# @return [Boolean] true if this is a pending message not yet sent to the LLM
|
|
102
|
-
def pending?
|
|
103
|
-
status == PENDING_STATUS
|
|
104
|
-
end
|
|
105
|
-
|
|
106
88
|
# @return [Boolean] true if this is a conversation message (user/agent/system)
|
|
107
89
|
# or a think tool_call — the messages Mneme treats as "conversation" for boundary tracking
|
|
108
90
|
def conversation_or_think?
|
|
@@ -121,7 +103,7 @@ class Message < ApplicationRecord
|
|
|
121
103
|
else
|
|
122
104
|
payload["content"].to_s
|
|
123
105
|
end
|
|
124
|
-
|
|
106
|
+
self.class.estimate_token_count(text.bytesize)
|
|
125
107
|
end
|
|
126
108
|
|
|
127
109
|
private
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# A message waiting to enter a session's conversation history.
|
|
4
|
+
# Pending messages live in their own table — they are NOT part of the
|
|
5
|
+
# message stream and have no database ID that could interleave with
|
|
6
|
+
# tool_call/tool_response pairs.
|
|
7
|
+
#
|
|
8
|
+
# Created when a message arrives while the session is processing.
|
|
9
|
+
# Promoted to a real {Message} (delete + create in transaction) when
|
|
10
|
+
# the current agent loop completes, giving the new message an ID that
|
|
11
|
+
# naturally follows the tool batch.
|
|
12
|
+
#
|
|
13
|
+
# Each pending message knows its source (+source_type+, +source_name+)
|
|
14
|
+
# and how to serialize itself for the LLM conversation via {#to_llm_messages}.
|
|
15
|
+
# Non-user messages (sub-agent results, recalled skills, workflows, recall,
|
|
16
|
+
# goal events) become synthetic tool_use/tool_result pairs so the LLM sees
|
|
17
|
+
# "a tool I invoked returned a result" rather than "a user wrote me."
|
|
18
|
+
#
|
|
19
|
+
# @see Session#enqueue_user_message
|
|
20
|
+
# @see Session#promote_pending_messages!
|
|
21
|
+
class PendingMessage < ApplicationRecord
|
|
22
|
+
# Synthetic tool names used in tool_use/tool_result pairs injected into
|
|
23
|
+
# the parent LLM conversation when non-user messages are promoted.
|
|
24
|
+
# These tools don't exist in the agent's registry — the agent sees
|
|
25
|
+
# them as its own past actions (phantom tool calls).
|
|
26
|
+
SUBAGENT_TOOL = "subagent_message"
|
|
27
|
+
RECALL_SKILL_TOOL = "recall_skill"
|
|
28
|
+
RECALL_WORKFLOW_TOOL = "recall_workflow"
|
|
29
|
+
RECALL_MEMORY_TOOL = "recall_memory"
|
|
30
|
+
RECALL_GOAL_TOOL = "recall_goal"
|
|
31
|
+
|
|
32
|
+
# Source types that produce phantom tool_use/tool_result pairs on promotion.
|
|
33
|
+
# User messages produce plain text blocks instead.
|
|
34
|
+
PHANTOM_PAIR_TYPES = %w[subagent skill workflow recall goal].freeze
|
|
35
|
+
|
|
36
|
+
# Maps each phantom pair source type to its synthetic tool name.
|
|
37
|
+
PHANTOM_TOOL_NAMES = {
|
|
38
|
+
"subagent" => SUBAGENT_TOOL,
|
|
39
|
+
"skill" => RECALL_SKILL_TOOL,
|
|
40
|
+
"workflow" => RECALL_WORKFLOW_TOOL,
|
|
41
|
+
"recall" => RECALL_MEMORY_TOOL,
|
|
42
|
+
"goal" => RECALL_GOAL_TOOL
|
|
43
|
+
}.freeze
|
|
44
|
+
|
|
45
|
+
# Maps each phantom pair source type to a lambda building its tool input.
|
|
46
|
+
PHANTOM_TOOL_INPUTS = {
|
|
47
|
+
"subagent" => ->(name) { {from: name} },
|
|
48
|
+
"skill" => ->(name) { {skill: name} },
|
|
49
|
+
"workflow" => ->(name) { {workflow: name} },
|
|
50
|
+
"recall" => ->(name) { {message_id: name.to_i} },
|
|
51
|
+
"goal" => ->(name) { {goal_id: name.to_i} }
|
|
52
|
+
}.freeze
|
|
53
|
+
|
|
54
|
+
belongs_to :session
|
|
55
|
+
|
|
56
|
+
validates :content, presence: true
|
|
57
|
+
validates :source_type, inclusion: {in: %w[user subagent skill workflow recall goal]}
|
|
58
|
+
validates :source_name, presence: true, unless: :user?
|
|
59
|
+
|
|
60
|
+
after_create_commit :broadcast_created
|
|
61
|
+
after_destroy_commit :broadcast_removed
|
|
62
|
+
|
|
63
|
+
# @return [Boolean] true when this is a plain user message
|
|
64
|
+
def user?
|
|
65
|
+
source_type == "user"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# @return [Boolean] true when this message originated from a sub-agent
|
|
69
|
+
def subagent?
|
|
70
|
+
source_type == "subagent"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# @return [Boolean] true when this message carries recalled skill content
|
|
74
|
+
def skill?
|
|
75
|
+
source_type == "skill"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# @return [Boolean] true when this message carries recalled workflow content
|
|
79
|
+
def workflow?
|
|
80
|
+
source_type == "workflow"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# @return [Boolean] true when this message is an associative recall phantom pair
|
|
84
|
+
def recall?
|
|
85
|
+
source_type == "recall"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# @return [Boolean] true when this message carries a goal event
|
|
89
|
+
def goal?
|
|
90
|
+
source_type == "goal"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# @return [Boolean] true when promotion produces phantom tool_use/tool_result pairs
|
|
94
|
+
def phantom_pair?
|
|
95
|
+
source_type.in?(PHANTOM_PAIR_TYPES)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Phantom tool name for DB persistence and LLM injection.
|
|
99
|
+
# Each phantom pair source type maps to a synthetic tool name.
|
|
100
|
+
#
|
|
101
|
+
# @return [String] phantom tool name
|
|
102
|
+
def phantom_tool_name
|
|
103
|
+
PHANTOM_TOOL_NAMES.fetch(source_type)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Phantom tool input hash for DB persistence and LLM injection.
|
|
107
|
+
#
|
|
108
|
+
# @return [Hash] tool input hash
|
|
109
|
+
def phantom_tool_input
|
|
110
|
+
PHANTOM_TOOL_INPUTS.fetch(source_type).call(source_name)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Content formatted for display and history persistence.
|
|
114
|
+
# Sub-agent messages include an attribution prefix. Skill/workflow
|
|
115
|
+
# messages include a recall label. User messages pass through unchanged.
|
|
116
|
+
#
|
|
117
|
+
# @return [String]
|
|
118
|
+
def display_content
|
|
119
|
+
case source_type
|
|
120
|
+
when "subagent"
|
|
121
|
+
format(Tools::ResponseTruncator::ATTRIBUTION_FORMAT, source_name, content)
|
|
122
|
+
when "skill"
|
|
123
|
+
"[recalled skill: #{source_name}]\n#{content}"
|
|
124
|
+
when "workflow"
|
|
125
|
+
"[recalled workflow: #{source_name}]\n#{content}"
|
|
126
|
+
when "goal"
|
|
127
|
+
"[goal #{source_name}]\n#{content}"
|
|
128
|
+
else
|
|
129
|
+
content
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Builds LLM message hashes for this pending message.
|
|
134
|
+
#
|
|
135
|
+
# Phantom pair types become synthetic tool_use/tool_result pairs so the
|
|
136
|
+
# LLM sees them as its own past invocations. User messages return plain
|
|
137
|
+
# content for injection as text blocks within the current tool_results turn.
|
|
138
|
+
#
|
|
139
|
+
# @return [Array<Hash>] synthetic tool pair for phantom pair types
|
|
140
|
+
# @return [String] raw content for user messages
|
|
141
|
+
def to_llm_messages
|
|
142
|
+
return content unless phantom_pair?
|
|
143
|
+
|
|
144
|
+
build_phantom_pair(phantom_tool_name, phantom_tool_input)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
private
|
|
148
|
+
|
|
149
|
+
# Builds a phantom tool_use/tool_result message pair.
|
|
150
|
+
# Follows the same format for all non-user source types — the only
|
|
151
|
+
# difference is the tool name and input hash.
|
|
152
|
+
#
|
|
153
|
+
# Phantom pairs keep the system prompt stable for prompt caching (#395).
|
|
154
|
+
# Instead of injecting skills/workflows into the system prompt (which
|
|
155
|
+
# busts the cache on every change), they flow through the sliding window
|
|
156
|
+
# as messages the LLM "recalls" via phantom tool invocations.
|
|
157
|
+
#
|
|
158
|
+
# @param tool_name [String] phantom tool name (not in the agent's registry)
|
|
159
|
+
# @param input [Hash] tool input hash
|
|
160
|
+
# @return [Array<Hash>] two-element array: assistant tool_use + user tool_result
|
|
161
|
+
def build_phantom_pair(tool_name, input)
|
|
162
|
+
tool_use_id = "#{tool_name}_#{id}"
|
|
163
|
+
[
|
|
164
|
+
{role: "assistant", content: [
|
|
165
|
+
{type: "tool_use", id: tool_use_id, name: tool_name, input: input}
|
|
166
|
+
]},
|
|
167
|
+
{role: "user", content: [
|
|
168
|
+
{type: "tool_result", tool_use_id: tool_use_id, content: content}
|
|
169
|
+
]}
|
|
170
|
+
]
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Broadcasts a pending message appearance so TUI clients render the
|
|
174
|
+
# dimmed indicator immediately.
|
|
175
|
+
def broadcast_created
|
|
176
|
+
ActionCable.server.broadcast("session_#{session_id}", {
|
|
177
|
+
"action" => "pending_message_created",
|
|
178
|
+
"pending_message_id" => id,
|
|
179
|
+
"content" => content
|
|
180
|
+
})
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Broadcasts pending message removal so TUI clients clear the entry.
|
|
184
|
+
# Fires on both promotion (normal flow) and recall (user edit).
|
|
185
|
+
def broadcast_removed
|
|
186
|
+
ActionCable.server.broadcast("session_#{session_id}", {
|
|
187
|
+
"action" => "pending_message_removed",
|
|
188
|
+
"pending_message_id" => id
|
|
189
|
+
})
|
|
190
|
+
end
|
|
191
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Encrypted key-value storage for runtime secrets (API tokens, credentials).
|
|
4
|
+
# Replaces Rails encrypted credentials for secrets that must be readable
|
|
5
|
+
# across forked Solid Queue workers without cache-busting hacks.
|
|
6
|
+
#
|
|
7
|
+
# Secrets are organized by namespace (e.g. +"anthropic"+, +"mcp"+) and key
|
|
8
|
+
# (e.g. +"subscription_token"+). Values are encrypted at rest using Active
|
|
9
|
+
# Record Encryption — only the +value+ column is encrypted; +namespace+ and
|
|
10
|
+
# +key+ are plain text for queryability.
|
|
11
|
+
#
|
|
12
|
+
# @!attribute namespace
|
|
13
|
+
# @return [String] grouping key (e.g. "anthropic", "mcp")
|
|
14
|
+
# @!attribute key
|
|
15
|
+
# @return [String] credential identifier within the namespace
|
|
16
|
+
# @!attribute value
|
|
17
|
+
# @return [String] the secret value (encrypted at rest)
|
|
18
|
+
class Secret < ApplicationRecord
|
|
19
|
+
encrypts :value
|
|
20
|
+
|
|
21
|
+
validates :namespace, presence: true
|
|
22
|
+
validates :key, presence: true
|
|
23
|
+
validates :value, presence: true
|
|
24
|
+
validates :key, uniqueness: {scope: :namespace}
|
|
25
|
+
|
|
26
|
+
# @!method self.for_namespace(ns)
|
|
27
|
+
# @param ns [String] namespace to filter by
|
|
28
|
+
# @return [ActiveRecord::Relation] secrets in the given namespace
|
|
29
|
+
scope :for_namespace, ->(ns) { where(namespace: ns) }
|
|
30
|
+
|
|
31
|
+
# Reads a single secret value.
|
|
32
|
+
#
|
|
33
|
+
# @param namespace [String] top-level grouping key
|
|
34
|
+
# @param key [String] credential key within the namespace
|
|
35
|
+
# @return [String, nil] decrypted value or nil if not found
|
|
36
|
+
def self.read(namespace, key)
|
|
37
|
+
find_by(namespace: namespace, key: key)&.value
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Writes one or more key-value pairs under a namespace.
|
|
41
|
+
# Each pair is upserted (insert or update). The entire batch is wrapped
|
|
42
|
+
# in a transaction so partial writes cannot occur.
|
|
43
|
+
#
|
|
44
|
+
# @param namespace [String] top-level grouping key
|
|
45
|
+
# @param pairs [Hash<String, String>] key-value pairs to store
|
|
46
|
+
# @return [void]
|
|
47
|
+
def self.write(namespace, pairs)
|
|
48
|
+
transaction do
|
|
49
|
+
pairs.each do |secret_key, secret_value|
|
|
50
|
+
record = find_or_initialize_by(namespace: namespace, key: secret_key)
|
|
51
|
+
record.update!(value: secret_value)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Lists all keys under a namespace (not values).
|
|
57
|
+
#
|
|
58
|
+
# @param namespace [String] top-level grouping key
|
|
59
|
+
# @return [Array<String>] credential keys
|
|
60
|
+
def self.list(namespace)
|
|
61
|
+
for_namespace(namespace).pluck(:key)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Removes a single key from a namespace.
|
|
65
|
+
#
|
|
66
|
+
# @param namespace [String] top-level grouping key
|
|
67
|
+
# @param key [String] credential key to remove
|
|
68
|
+
# @return [void]
|
|
69
|
+
def self.remove(namespace, key)
|
|
70
|
+
find_by(namespace: namespace, key: key)&.destroy!
|
|
71
|
+
end
|
|
72
|
+
end
|