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,23 +1,23 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tools
4
- # Fractal-resolution zoom into event history. Returns a window centered
5
- # on a target event with full detail at the center and compressed context
4
+ # Fractal-resolution zoom into message history. Returns a window centered
5
+ # on a target message with full detail at the center and compressed context
6
6
  # at the edges — sharp fovea, blurry periphery.
7
7
  #
8
8
  # Output structure:
9
9
  # [Previous snapshots — compressed context before]
10
- # [Events N-M — full detail, tool_responses compressed to checkmarks]
10
+ # [Messages N-M — full detail, tool_responses compressed to checkmarks]
11
11
  # [Following snapshots — compressed context after]
12
12
  #
13
- # The agent discovers target events via FTS5 search results embedded in
13
+ # The agent discovers target messages via FTS5 search results embedded in
14
14
  # viewport recall snippets. This tool drills down into the full context.
15
15
  #
16
16
  # @example
17
- # remember(event_id: 42)
17
+ # remember(message_id: 42)
18
18
  class Remember < Base
19
- # Events around the target to include at full resolution.
20
- # ±10 events provides sharp foveal detail while keeping output readable.
19
+ # Messages around the target to include at full resolution.
20
+ # ±10 messages provides sharp foveal detail while keeping output readable.
21
21
  CONTEXT_WINDOW = 20
22
22
 
23
23
  ROLE_LABELS = {
@@ -28,24 +28,15 @@ module Tools
28
28
 
29
29
  def self.tool_name = "remember"
30
30
 
31
- def self.description
32
- "Recall the full context around a past event. " \
33
- "Returns a fractal-resolution window: high detail at the center " \
34
- "(the target event and its neighbors), compressed context at the edges " \
35
- "(snapshots before and after). Use this when search results surface " \
36
- "a relevant event and you need the surrounding conversation."
37
- end
31
+ def self.description = "Recall the full conversation around a past message."
38
32
 
39
33
  def self.input_schema
40
34
  {
41
35
  type: "object",
42
36
  properties: {
43
- event_id: {
44
- type: "integer",
45
- description: "The event ID to zoom into (from search results or recall snippets)"
46
- }
37
+ message_id: {type: "integer"}
47
38
  },
48
- required: ["event_id"]
39
+ required: ["message_id"]
49
40
  }
50
41
  end
51
42
 
@@ -53,12 +44,12 @@ module Tools
53
44
  @session = session
54
45
  end
55
46
 
56
- # @param input [Hash] with "event_id"
57
- # @return [String] fractal-resolution window around the target event
47
+ # @param input [Hash] with "message_id"
48
+ # @return [String] fractal-resolution window around the target message
58
49
  def execute(input)
59
- event_id = input["event_id"].to_i
60
- target = Event.find_by(id: event_id)
61
- return {error: "Event #{event_id} not found"} unless target
50
+ message_id = input["message_id"].to_i
51
+ target = Message.find_by(id: message_id)
52
+ return {error: "Message #{message_id} not found"} unless target
62
53
 
63
54
  build_fractal_window(target)
64
55
  end
@@ -67,17 +58,17 @@ module Tools
67
58
 
68
59
  # Assembles the three-zone fractal window.
69
60
  #
70
- # @param target [Event] the center event
61
+ # @param target [Message] the center message
71
62
  # @return [String] formatted fractal window
72
63
  def build_fractal_window(target)
73
64
  target_session = target.session
74
- center_events = fetch_center_events(target, target_session)
75
- first_center_id = center_events.first&.id
76
- last_center_id = center_events.last&.id
65
+ center_messages = fetch_center_messages(target, target_session)
66
+ first_center_id = center_messages.first&.id
67
+ last_center_id = center_messages.last&.id
77
68
 
78
69
  sections = build_sections(
79
70
  target_session: target_session,
80
- center_events: center_events,
71
+ center_messages: center_messages,
81
72
  target_id: target.id,
82
73
  first_center_id: first_center_id,
83
74
  last_center_id: last_center_id
@@ -86,18 +77,18 @@ module Tools
86
77
  end
87
78
 
88
79
  # Builds ordered sections: header, before snapshots, center, after snapshots.
89
- def build_sections(target_session:, center_events:, target_id:, first_center_id:, last_center_id:)
80
+ def build_sections(target_session:, center_messages:, target_id:, first_center_id:, last_center_id:)
90
81
  sections = [session_header(target_session)]
91
82
 
92
83
  append_snapshot_sections(sections, target_session.snapshots
93
- .where("to_event_id < ?", first_center_id)
84
+ .where("to_message_id < ?", first_center_id)
94
85
  .chronological.last(3), label: "PREVIOUS CONTEXT")
95
86
 
96
- sections << "── FULL CONTEXT (events #{first_center_id}..#{last_center_id}) ──"
97
- center_events.each { |event| sections << render_center_event(event, target_id) }
87
+ sections << "── FULL CONTEXT (messages #{first_center_id}..#{last_center_id}) ──"
88
+ center_messages.each { |msg| sections << render_center_message(msg, target_id) }
98
89
 
99
90
  append_snapshot_sections(sections, target_session.snapshots
100
- .where("from_event_id > ?", last_center_id)
91
+ .where("from_message_id > ?", last_center_id)
101
92
  .chronological.first(3), label: "FOLLOWING CONTEXT")
102
93
 
103
94
  sections
@@ -116,12 +107,12 @@ module Tools
116
107
  snapshots.each { |snapshot| sections << format_snapshot(snapshot) }
117
108
  end
118
109
 
119
- # Fetches conversation events around the target within a fixed window.
110
+ # Fetches conversation messages around the target within a fixed window.
120
111
  #
121
- # @return [Array<Event>] chronologically ordered
122
- def fetch_center_events(target, target_session)
112
+ # @return [Array<Message>] chronologically ordered
113
+ def fetch_center_messages(target, target_session)
123
114
  half = CONTEXT_WINDOW / 2
124
- scope = target_session.events.context_events.deliverable
115
+ scope = target_session.messages.context_messages
125
116
  target_id = target.id
126
117
 
127
118
  before = scope.where("id <= ?", target_id).reorder(id: :desc).limit(half + 1).to_a.reverse
@@ -130,37 +121,37 @@ module Tools
130
121
  before + after
131
122
  end
132
123
 
133
- # Renders a center event at full resolution.
134
- # Conversation events show full content. Tool calls show name + input.
124
+ # Renders a center message at full resolution.
125
+ # Conversation messages show full content. Tool calls show name + input.
135
126
  # Tool responses compressed to status indicator.
136
127
  #
137
- # @param event [Event]
138
- # @param target_id [Integer] the event being zoomed into (marked with arrow)
128
+ # @param message [Message]
129
+ # @param target_id [Integer] the message being zoomed into (marked with arrow)
139
130
  # @return [String]
140
- def render_center_event(event, target_id)
141
- marker = (event.id == target_id) ? "→" : " "
142
- prefix = "#{marker} event #{event.id}"
131
+ def render_center_message(message, target_id)
132
+ marker = (message.id == target_id) ? "→" : " "
133
+ prefix = "#{marker} message #{message.id}"
143
134
 
144
- "#{prefix} #{format_event_content(event)}"
135
+ "#{prefix} #{format_message_content(message)}"
145
136
  end
146
137
 
147
- # Formats event content based on type.
148
- def format_event_content(event)
149
- data = event.payload
138
+ # Formats message content based on type.
139
+ def format_message_content(message)
140
+ data = message.payload
150
141
  content = data["content"]
151
142
 
152
- if ROLE_LABELS.key?(event.event_type)
153
- "#{ROLE_LABELS[event.event_type]}: #{content}"
154
- elsif event.event_type == "tool_call"
143
+ if ROLE_LABELS.key?(message.message_type)
144
+ "#{ROLE_LABELS[message.message_type]}: #{content}"
145
+ elsif message.message_type == "tool_call"
155
146
  format_tool_call(data)
156
- elsif event.event_type == "tool_response"
147
+ elsif message.message_type == "tool_response"
157
148
  status = content.to_s.start_with?("Error") ? "error" : "ok"
158
149
  "ToolResult: [#{status}] #{data["tool_use_id"]}"
159
150
  end
160
151
  end
161
152
 
162
153
  def format_tool_call(data)
163
- if data["tool_name"] == Event::THINK_TOOL
154
+ if data["tool_name"] == Message::THINK_TOOL
164
155
  "Think: #{data.dig("tool_input", "thoughts")}"
165
156
  else
166
157
  "Tool: #{data["tool_name"]}(#{data["tool_input"].to_json.truncate(200)})"
@@ -173,7 +164,7 @@ module Tools
173
164
  # @return [String]
174
165
  def format_snapshot(snapshot)
175
166
  level = (snapshot.level == 2) ? "L2" : "L1"
176
- "[#{level} snapshot, events #{snapshot.from_event_id}..#{snapshot.to_event_id}]\n#{snapshot.text}"
167
+ "[#{level} snapshot, messages #{snapshot.from_message_id}..#{snapshot.to_message_id}]\n#{snapshot.text}"
177
168
  end
178
169
  end
179
170
  end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tempfile"
4
+
5
+ module Tools
6
+ # Truncates oversized tool results to protect the agent's context window.
7
+ #
8
+ # When a tool returns more characters than the configured threshold,
9
+ # saves the full output to a temp file and returns a truncated version:
10
+ # first 10 lines + notice + last 10 lines. The agent can use the
11
+ # +read_file+ tool with offset/limit to inspect the full output.
12
+ #
13
+ # Two thresholds exist:
14
+ # - **Tool threshold** (~3000 chars) — for raw tool output (bash, web, etc.)
15
+ # - **Sub-agent threshold** (~24000 chars) — for curated sub-agent results
16
+ #
17
+ # @example Truncating a tool result
18
+ # ResponseTruncator.truncate(huge_string, threshold: 3000)
19
+ # # => "line 1\nline 2\n...\n---\n⚠️ Response truncated..."
20
+ module ResponseTruncator
21
+ HEAD_LINES = 10
22
+ TAIL_LINES = 10
23
+
24
+ # Attribution prefix for messages routed from sub-agent to parent.
25
+ # Shared by {Events::Subscribers::SubagentMessageRouter} and
26
+ # {Tools::MarkGoalCompleted} to keep formatting consistent.
27
+ ATTRIBUTION_FORMAT = "[sub-agent @%s]: %s"
28
+
29
+ NOTICE = <<~NOTICE.strip
30
+ ---
31
+ ⚠️ Response truncated (%<total>d lines total%<reason>s). Full output saved to: %<path>s
32
+ Use `read_file` tool with offset/limit to inspect specific sections.
33
+ ---
34
+ NOTICE
35
+
36
+ # Truncates content that exceeds the character threshold.
37
+ #
38
+ # @param content [Object] the tool result to (maybe) truncate; non-strings pass through unchanged
39
+ # @param threshold [Integer] character limit before truncation kicks in
40
+ # @param reason [String, nil] why truncation occurred (e.g. "bash output displays first/last 10 lines")
41
+ # @return [Object] original value if non-String/under threshold/few lines, truncated String otherwise
42
+ def self.truncate(content, threshold:, reason: nil)
43
+ return content unless content.is_a?(String)
44
+ return content if content.length <= threshold
45
+
46
+ lines = content.lines
47
+ total = lines.size
48
+ return content if total <= HEAD_LINES + TAIL_LINES
49
+
50
+ path = save_full_output(content)
51
+ head = lines.first(HEAD_LINES).join
52
+ tail = lines.last(TAIL_LINES).join
53
+ reason_text = reason ? " — #{reason}" : ""
54
+ notice = format(NOTICE, total: total, path: path, reason: reason_text)
55
+
56
+ "#{head}\n#{notice}\n\n#{tail}"
57
+ end
58
+
59
+ # Saves full content to a temp file that persists until system cleanup.
60
+ #
61
+ # @param content [String] the full tool result
62
+ # @return [String] absolute path to the saved file
63
+ def self.save_full_output(content)
64
+ file = Tempfile.create(["tool_result_", ".txt"])
65
+ file.write(content)
66
+ file.close
67
+ file.path
68
+ end
69
+ end
70
+ end
@@ -21,10 +21,9 @@ module Tools
21
21
 
22
22
  # Builds description dynamically to include available specialists.
23
23
  def self.description
24
- base = "Spawn a named specialist sub-agent to work on a task autonomously. " \
25
- "The specialist has a predefined role, system prompt, and tool set. " \
26
- "Its text messages are forwarded to you automatically. " \
27
- "Address it via @name to send follow-up instructions."
24
+ base = "Need a specific skill set for the job? Bring in a specialist. " \
25
+ "Its messages appear in yours; any message containing " \
26
+ "@nickname is forwarded even casual mentions will wake it."
28
27
 
29
28
  registry = Agents::Registry.instance
30
29
  return base unless registry.any?
@@ -39,16 +38,9 @@ module Tools
39
38
  type: "object",
40
39
  properties: {
41
40
  name: name_property,
42
- task: {
43
- type: "string",
44
- description: "What the specialist should do (persisted as its first user message)"
45
- },
46
- expected_output: {
47
- type: "string",
48
- description: "Description of the expected deliverable"
49
- }
41
+ task: {type: "string", description: "State the goal — the specialist knows its method."}
50
42
  },
51
- required: %w[name task expected_output]
43
+ required: %w[name task]
52
44
  }
53
45
  end
54
46
 
@@ -57,7 +49,7 @@ module Tools
57
49
  registry = Agents::Registry.instance
58
50
  prop = {
59
51
  type: "string",
60
- description: "Named specialist agent to spawn from the registry."
52
+ description: "Specialist to spawn."
61
53
  }
62
54
  prop[:enum] = registry.names if registry.any?
63
55
  prop
@@ -76,46 +68,44 @@ module Tools
76
68
  # persists the task as a user message, and queues background processing.
77
69
  # Returns immediately (non-blocking).
78
70
  #
79
- # @param input [Hash<String, Object>] with "name", "task", and "expected_output"
71
+ # @param input [Hash<String, Object>] with "name" and "task"
80
72
  # @return [String] confirmation with child session ID
81
73
  # @return [Hash{Symbol => String}] with :error key on validation failure
82
74
  def execute(input)
83
75
  task = input["task"].to_s.strip
84
- expected_output = input["expected_output"].to_s.strip
85
76
  name = input["name"].to_s.strip
86
77
 
87
78
  return {error: "Name cannot be blank"} if name.empty?
88
79
  return {error: "Task cannot be blank"} if task.empty?
89
- return {error: "Expected output cannot be blank"} if expected_output.empty?
90
80
 
91
81
  definition = @agent_registry.get(name)
92
82
  return {error: "Unknown agent: #{name}"} unless definition
93
83
 
94
- child = spawn_child(definition, task, expected_output)
84
+ child = spawn_child(definition, task)
95
85
  nickname = child.name
96
86
  "Specialist @#{nickname} spawned (session #{child.id}). " \
97
87
  "Its messages will appear in your conversation. " \
98
- "Reply with @#{nickname} to send it instructions."
88
+ "Reply with @#{nickname} to send it instructions" \
89
+ "any message mentioning @#{nickname} is forwarded, even in narration."
99
90
  end
100
91
 
101
92
  private
102
93
 
103
- def spawn_child(definition, task, expected_output)
104
- prompt = build_prompt(definition, expected_output)
94
+ def spawn_child(definition, task)
105
95
  child = Session.create!(
106
96
  parent_session_id: @session.id,
107
- prompt: prompt,
97
+ prompt: build_prompt(definition),
108
98
  granted_tools: definition.tools
109
99
  )
110
- child.create_user_event(task)
100
+ pin_goal_and_frame(child, task)
111
101
  assign_nickname_via_brain(child)
112
102
  child.broadcast_children_update_to_parent
113
103
  AgentRequestJob.perform_later(child.id)
114
104
  child
115
105
  end
116
106
 
117
- def build_prompt(definition, expected_output)
118
- "#{definition.prompt}\n\n#{COMMUNICATION_INSTRUCTION}\n\n#{EXPECTED_DELIVERABLE_PREFIX}#{expected_output}"
107
+ def build_prompt(definition)
108
+ "#{definition.prompt}\n\n#{COMMUNICATION_INSTRUCTION}"
119
109
  end
120
110
  end
121
111
  end
@@ -14,29 +14,22 @@ module Tools
14
14
  class SpawnSubagent < Base
15
15
  include SubagentPrompts
16
16
 
17
- GENERIC_PROMPT = "You are a focused sub-agent. #{COMMUNICATION_INSTRUCTION}\n"
17
+ GENERIC_PROMPT = "#{COMMUNICATION_INSTRUCTION}\n"
18
18
 
19
19
  def self.tool_name = "spawn_subagent"
20
20
 
21
21
  def self.description
22
- "Spawn a generic sub-agent to work on a task autonomously. " \
23
- "The sub-agent inherits your conversation context, works independently, " \
24
- "and its text messages are forwarded to you automatically. " \
25
- "Address it via @nickname to send follow-up instructions."
22
+ "Task feels like a sidequest or a context-switch? Hand it off. " \
23
+ "Inherits your context; its messages appear in yours. " \
24
+ "Any message containing @nickname is forwarded " \
25
+ "even casual mentions will wake the sub-agent."
26
26
  end
27
27
 
28
28
  def self.input_schema
29
29
  {
30
30
  type: "object",
31
31
  properties: {
32
- task: {
33
- type: "string",
34
- description: "What the sub-agent should do (persisted as its first user message)"
35
- },
36
- expected_output: {
37
- type: "string",
38
- description: "Description of the expected deliverable"
39
- },
32
+ task: {type: "string"},
40
33
  tools: {
41
34
  type: "array",
42
35
  items: {type: "string"},
@@ -45,7 +38,7 @@ module Tools
45
38
  "Valid tools: #{AgentLoop::STANDARD_TOOLS_BY_NAME.keys.join(", ")}"
46
39
  }
47
40
  },
48
- required: %w[task expected_output]
41
+ required: %w[task]
49
42
  }
50
43
  end
51
44
 
@@ -58,37 +51,36 @@ module Tools
58
51
  # persists the task as a user message, and queues background processing.
59
52
  # Returns immediately after brain completes (blocking for ~200ms).
60
53
  #
61
- # @param input [Hash<String, Object>] with "task", "expected_output", and optional "tools"
54
+ # @param input [Hash<String, Object>] with "task" and optional "tools"
62
55
  # @return [String] confirmation with child session ID and @nickname
63
56
  # @return [Hash{Symbol => String}] with :error key on validation failure
64
57
  def execute(input)
65
58
  task = input["task"].to_s.strip
66
- expected_output = input["expected_output"].to_s.strip
67
59
 
68
60
  return {error: "Task cannot be blank"} if task.empty?
69
- return {error: "Expected output cannot be blank"} if expected_output.empty?
70
61
 
71
62
  tools = normalize_tools(input["tools"])
72
63
 
73
64
  error = validate_tools(tools)
74
65
  return error if error
75
66
 
76
- child = spawn_child(task, expected_output, tools)
67
+ child = spawn_child(task, tools)
77
68
  nickname = child.name
78
69
  "Sub-agent @#{nickname} spawned (session #{child.id}). " \
79
70
  "Its messages will appear in your conversation. " \
80
- "Reply with @#{nickname} to send it instructions."
71
+ "Reply with @#{nickname} to send it instructions" \
72
+ "any message mentioning @#{nickname} is forwarded, even in narration."
81
73
  end
82
74
 
83
75
  private
84
76
 
85
- def spawn_child(task, expected_output, granted_tools)
77
+ def spawn_child(task, granted_tools)
86
78
  child = Session.create!(
87
79
  parent_session_id: @session.id,
88
- prompt: "#{GENERIC_PROMPT}\n#{EXPECTED_DELIVERABLE_PREFIX}#{expected_output}",
80
+ prompt: GENERIC_PROMPT,
89
81
  granted_tools: granted_tools
90
82
  )
91
- child.create_user_event(task)
83
+ pin_goal_and_frame(child, task)
92
84
  assign_nickname_via_brain(child)
93
85
  child.broadcast_children_update_to_parent
94
86
  AgentRequestJob.perform_later(child.id)
@@ -1,25 +1,61 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tools
4
- # Shared prompt fragments and nickname logic for tools that spawn sub-agent sessions.
4
+ # Shared prompt fragments and spawn logic for tools that create sub-agent sessions.
5
5
  # Included by {SpawnSubagent} and {SpawnSpecialist} to avoid duplication.
6
6
  module SubagentPrompts
7
- COMMUNICATION_INSTRUCTION = "Your text messages are automatically forwarded to the parent agent. " \
8
- "When you finish, write your final summary and stop — no special tool needed. " \
9
- "If you need clarification, just ask the parent can reply."
7
+ # Prepended to every sub-agent's stored prompt after nickname assignment.
8
+ # Establishes identity before any other instruction.
9
+ IDENTITY_TEMPLATE = "You are @%s, a sub-agent of the primary agent."
10
10
 
11
- EXPECTED_DELIVERABLE_PREFIX = "Expected deliverable: "
11
+ COMMUNICATION_INSTRUCTION = "Your messages reach the parent automatically. " \
12
+ "Ask if you need clarification — the parent can reply."
13
+
14
+ # Framing message inserted as the sub-agent's first user message.
15
+ # This is the "brake" between inherited parent context and the sub-agent's
16
+ # own task — without it, the model continues the parent's trajectory.
17
+ FORK_FRAMING_MESSAGE = "You were spawned to help with a single task. " \
18
+ "The messages above are the parent agent's context — background for your work, " \
19
+ "but the parent's goals are not yours. " \
20
+ "Your sole task is described in your Goal."
12
21
 
13
22
  private
14
23
 
15
- # Runs the analytical brain synchronously to assign a nickname.
24
+ # Creates the sub-agent's Goal from the task description and inserts
25
+ # the framing message as the first user message.
26
+ #
27
+ # @param child [Session] the newly created child session
28
+ # @param task [String] the task description to pin as the sole Goal
29
+ # @return [void]
30
+ def pin_goal_and_frame(child, task)
31
+ child.goals.create!(description: task)
32
+ child.create_user_message(FORK_FRAMING_MESSAGE)
33
+ end
34
+
35
+ # Runs the analytical brain synchronously to assign a nickname,
36
+ # then prepends identity context to the stored prompt.
16
37
  # Falls back to a sequential "agent-N" name on any failure.
38
+ # Identity injection runs in +ensure+ so it applies to both
39
+ # brain-assigned and fallback nicknames.
17
40
  def assign_nickname_via_brain(child)
18
41
  AnalyticalBrain::Runner.new(child).call
19
42
  child.reload
20
43
  rescue => error
21
44
  Rails.logger.warn("Sub-agent nickname assignment failed: #{error.message}")
22
45
  child.update!(name: fallback_nickname)
46
+ ensure
47
+ inject_identity_context(child)
48
+ end
49
+
50
+ # Prepends identity context (nickname + sub-agent status) to the child's
51
+ # stored prompt. Called after nickname assignment so the sub-agent knows
52
+ # who it is from first token.
53
+ #
54
+ # @param child [Session] the child session with a nickname already set
55
+ # @return [void]
56
+ def inject_identity_context(child)
57
+ identity = format(IDENTITY_TEMPLATE, child.name)
58
+ child.update!(prompt: "#{identity}\n#{child.prompt}")
23
59
  end
24
60
 
25
61
  def fallback_nickname
data/lib/tools/think.rb CHANGED
@@ -13,6 +13,10 @@ module Tools
13
13
  # - **inner** (default) — silent reasoning, visible only in verbose/debug
14
14
  # - **aloud** — narration shown in all view modes with a thought bubble
15
15
  #
16
+ # The +maxLength+ on thoughts is controlled by +thinking_budget+ in settings.
17
+ # Sub-agents receive half the main agent's budget — their tasks are scoped
18
+ # and less complex, so runaway reasoning is a stronger signal of confusion.
19
+ #
16
20
  # @example Silent planning between tool calls
17
21
  # think(thoughts: "Three auth failures — likely a config issue, not individual tests.")
18
22
  #
@@ -21,30 +25,42 @@ module Tools
21
25
  class Think < Base
22
26
  def self.tool_name = "think"
23
27
 
24
- def self.description
25
- "Express your internal reasoning between tool calls. " \
26
- "Use this to analyze intermediate results, plan next steps, or make decisions before continuing. " \
27
- "Set visibility to \"aloud\" when you want the user to see your thought process."
28
- end
28
+ def self.description = "Think out loud or silently."
29
29
 
30
+ # Schema is static — maxLength is injected at runtime by the registry
31
+ # via {#dynamic_schema} when session context is available.
30
32
  def self.input_schema
31
33
  {
32
34
  type: "object",
33
35
  properties: {
34
- thoughts: {
35
- type: "string",
36
- description: "Your reasoning, analysis, or internal monologue"
37
- },
36
+ thoughts: {type: "string"},
38
37
  visibility: {
39
38
  type: "string",
40
39
  enum: ["inner", "aloud"],
41
- description: "\"inner\" (default) for silent reasoning; \"aloud\" to narrate for the user"
40
+ description: "inner (default) is silent. aloud is shown to the user."
42
41
  }
43
42
  },
44
43
  required: ["thoughts"]
45
44
  }
46
45
  end
47
46
 
47
+ # @param session [Session, nil] current session for budget calculation
48
+ def initialize(session: nil, **)
49
+ @session = session
50
+ end
51
+
52
+ # Returns the tool schema with a thinking budget applied as maxLength
53
+ # on the thoughts property. Sub-agents get half the budget.
54
+ #
55
+ # @return [Hash] Anthropic tool schema with maxLength constraint
56
+ def dynamic_schema
57
+ schema = self.class.schema.deep_dup
58
+ budget = Anima::Settings.thinking_budget
59
+ budget /= 2 if @session&.sub_agent?
60
+ schema[:input_schema][:properties][:thoughts][:maxLength] = budget
61
+ schema
62
+ end
63
+
48
64
  # @param input [Hash] with "thoughts" and optional "visibility"
49
65
  # @return [String] acknowledgement — the value is in the call, not the result
50
66
  def execute(input)
data/lib/tools/web_get.rb CHANGED
@@ -15,13 +15,13 @@ module Tools
15
15
  class WebGet < Base
16
16
  def self.tool_name = "web_get"
17
17
 
18
- def self.description = "Fetch content from a URL via HTTP GET and return the response body"
18
+ def self.description = "Fetch a URL."
19
19
 
20
20
  def self.input_schema
21
21
  {
22
22
  type: "object",
23
23
  properties: {
24
- url: {type: "string", description: "The URL to fetch (http or https)"}
24
+ url: {type: "string"}
25
25
  },
26
26
  required: ["url"]
27
27
  }
@@ -47,10 +47,11 @@ module Tools
47
47
  end
48
48
 
49
49
  response = HTTParty.get(url, timeout: timeout, follow_redirects: false, ssl_ca_file: Certifi.where)
50
- body = truncate_body(response.body.to_s)
51
50
  content_type = response.content_type || "text/plain"
51
+ body = response.body.to_s
52
+ body = strip_html_noise(body) if content_type.include?("text/html")
52
53
 
53
- {body: body, content_type: content_type}
54
+ {body: truncate_body(body), content_type: content_type}
54
55
  rescue URI::InvalidURIError => error
55
56
  {error: "Invalid URL: #{error.message}"}
56
57
  rescue Net::OpenTimeout, Net::ReadTimeout
@@ -68,5 +69,23 @@ module Tools
68
69
  body.byteslice(0, max_bytes).scrub +
69
70
  "\n\n[Truncated: response exceeded #{max_bytes} bytes]"
70
71
  end
72
+
73
+ # First-stage noise stripping — runs before truncation so that the
74
+ # byte budget is spent on content, not on scripts/SVGs/metadata.
75
+ # Each pattern targets one tag type for easy maintenance.
76
+ # The decorator applies a second, structure-aware pass via Nokogiri.
77
+ HTML_NOISE_PATTERNS = [
78
+ %r{<head\b[^>]*>.*?</head>}mi, # metadata, link/meta tags
79
+ %r{<script\b[^>]*>.*?</script>}mi, # JavaScript
80
+ %r{<style\b[^>]*>.*?</style>}mi, # CSS
81
+ %r{<svg\b[^>]*>.*?</svg>}mi, # inline graphics
82
+ %r{<template\b[^>]*>.*?</template>}mi, # deferred markup
83
+ %r{<noscript\b[^>]*>.*?</noscript>}mi # JS-disabled fallbacks
84
+ ].freeze
85
+ private_constant :HTML_NOISE_PATTERNS
86
+
87
+ def strip_html_noise(html)
88
+ HTML_NOISE_PATTERNS.reduce(html) { |text, pattern| text.gsub(pattern, "") }
89
+ end
71
90
  end
72
91
  end