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.
Files changed (111) hide show
  1. checksums.yaml +4 -4
  2. data/.reek.yml +14 -8
  3. data/README.md +96 -23
  4. data/agents/codebase-analyzer.md +1 -1
  5. data/agents/codebase-pattern-finder.md +1 -1
  6. data/agents/documentation-researcher.md +1 -1
  7. data/agents/thoughts-analyzer.md +1 -1
  8. data/agents/web-search-researcher.md +2 -2
  9. data/app/channels/session_channel.rb +53 -35
  10. data/app/decorators/tool_call_decorator.rb +7 -7
  11. data/app/decorators/user_message_decorator.rb +3 -17
  12. data/app/jobs/agent_request_job.rb +15 -6
  13. data/app/jobs/passive_recall_job.rb +6 -11
  14. data/app/models/concerns/message/broadcasting.rb +1 -0
  15. data/app/models/goal.rb +14 -0
  16. data/app/models/message.rb +13 -31
  17. data/app/models/pending_message.rb +191 -0
  18. data/app/models/secret.rb +72 -0
  19. data/app/models/session.rb +480 -271
  20. data/bin/inspect-cassette +144 -0
  21. data/bin/release +212 -0
  22. data/bin/with-llms +20 -0
  23. data/config/database.yml +1 -0
  24. data/config/environments/test.rb +5 -0
  25. data/config/initializers/time_nanoseconds.rb +11 -0
  26. data/db/cable_structure.sql +9 -0
  27. data/db/migrate/20260328100000_create_secrets.rb +15 -0
  28. data/db/migrate/20260328152142_add_evicted_at_to_goals.rb +6 -0
  29. data/db/migrate/20260329120000_create_pending_messages.rb +11 -0
  30. data/db/migrate/20260330120000_add_source_to_pending_messages.rb +8 -0
  31. data/db/migrate/20260401180000_add_api_metrics_to_messages.rb +7 -0
  32. data/db/migrate/20260401210935_remove_recalled_message_ids_from_sessions.rb +5 -0
  33. data/db/migrate/20260403080031_add_initial_cwd_to_sessions.rb +5 -0
  34. data/db/queue_structure.sql +61 -0
  35. data/db/structure.sql +120 -0
  36. data/lib/agent_loop.rb +53 -51
  37. data/lib/agents/definition.rb +1 -1
  38. data/lib/analytical_brain/runner.rb +19 -6
  39. data/lib/analytical_brain/tools/activate_skill.rb +2 -2
  40. data/lib/analytical_brain/tools/assign_nickname.rb +1 -1
  41. data/lib/analytical_brain/tools/deactivate_skill.rb +2 -1
  42. data/lib/analytical_brain/tools/deactivate_workflow.rb +2 -1
  43. data/lib/analytical_brain/tools/finish_goal.rb +3 -0
  44. data/lib/analytical_brain/tools/goal_messaging.rb +28 -0
  45. data/lib/analytical_brain/tools/read_workflow.rb +2 -2
  46. data/lib/analytical_brain/tools/set_goal.rb +5 -1
  47. data/lib/analytical_brain/tools/update_goal.rb +5 -1
  48. data/lib/anima/cli/mcp/secrets.rb +4 -4
  49. data/lib/anima/cli/mcp.rb +4 -4
  50. data/lib/anima/cli.rb +41 -13
  51. data/lib/anima/installer.rb +20 -1
  52. data/lib/anima/settings.rb +37 -2
  53. data/lib/anima/version.rb +1 -1
  54. data/lib/anima.rb +1 -1
  55. data/lib/credential_store.rb +17 -66
  56. data/lib/events/agent_message.rb +14 -0
  57. data/lib/events/base.rb +1 -1
  58. data/lib/events/subscribers/persister.rb +12 -18
  59. data/lib/events/subscribers/subagent_message_router.rb +18 -9
  60. data/lib/events/user_message.rb +2 -13
  61. data/lib/llm/client.rb +91 -50
  62. data/lib/mcp/config.rb +2 -2
  63. data/lib/mcp/secrets.rb +7 -8
  64. data/lib/mneme/compressed_viewport.rb +9 -5
  65. data/lib/mneme/passive_recall.rb +85 -16
  66. data/lib/mneme/runner.rb +15 -4
  67. data/lib/providers/anthropic.rb +112 -7
  68. data/lib/shell_session.rb +239 -18
  69. data/lib/tools/base.rb +22 -0
  70. data/lib/tools/bash.rb +61 -7
  71. data/lib/tools/edit.rb +2 -2
  72. data/lib/tools/mark_goal_completed.rb +85 -0
  73. data/lib/tools/read.rb +2 -1
  74. data/lib/tools/recall.rb +98 -0
  75. data/lib/tools/registry.rb +41 -7
  76. data/lib/tools/remember.rb +1 -1
  77. data/lib/tools/response_truncator.rb +70 -0
  78. data/lib/tools/spawn_specialist.rb +11 -8
  79. data/lib/tools/spawn_subagent.rb +19 -13
  80. data/lib/tools/subagent_prompts.rb +41 -5
  81. data/lib/tools/think.rb +23 -0
  82. data/lib/tools/write.rb +1 -1
  83. data/lib/tui/app.rb +545 -137
  84. data/lib/tui/braille_spinner.rb +152 -0
  85. data/lib/tui/cable_client.rb +13 -20
  86. data/lib/tui/decorators/base_decorator.rb +40 -11
  87. data/lib/tui/decorators/bash_decorator.rb +3 -3
  88. data/lib/tui/decorators/edit_decorator.rb +7 -4
  89. data/lib/tui/decorators/read_decorator.rb +6 -8
  90. data/lib/tui/decorators/think_decorator.rb +4 -6
  91. data/lib/tui/decorators/web_get_decorator.rb +4 -3
  92. data/lib/tui/decorators/write_decorator.rb +7 -4
  93. data/lib/tui/flash.rb +19 -14
  94. data/lib/tui/formatting.rb +33 -0
  95. data/lib/tui/input_buffer.rb +6 -6
  96. data/lib/tui/message_store.rb +159 -27
  97. data/lib/tui/performance_logger.rb +2 -3
  98. data/lib/tui/screens/chat.rb +302 -103
  99. data/lib/tui/settings.rb +86 -0
  100. data/skills/activerecord/SKILL.md +1 -1
  101. data/skills/dragonruby/SKILL.md +1 -1
  102. data/skills/draper-decorators/SKILL.md +1 -1
  103. data/skills/gh-issue.md +1 -1
  104. data/skills/mcp-server/SKILL.md +1 -1
  105. data/skills/ratatui-ruby/SKILL.md +1 -1
  106. data/skills/rspec/SKILL.md +1 -1
  107. data/templates/config.toml +30 -1
  108. data/templates/tui.toml +209 -0
  109. metadata +24 -3
  110. data/config/initializers/fts5_schema_dump.rb +0 -21
  111. data/lib/environment_probe.rb +0 -232
data/lib/tools/bash.rb CHANGED
@@ -38,8 +38,10 @@ module Tools
38
38
  end
39
39
 
40
40
  # @param shell_session [ShellSession] persistent shell backing this tool
41
- def initialize(shell_session:, **)
41
+ # @param session [Session] conversation session for interrupt checking
42
+ def initialize(shell_session:, session:, **)
42
43
  @shell_session = shell_session
44
+ @session = session
43
45
  end
44
46
 
45
47
  # @param input [Hash<String, Object>] string-keyed hash from the Anthropic API.
@@ -65,18 +67,24 @@ module Tools
65
67
 
66
68
  private
67
69
 
68
- # Executes a single command — the original code path, preserved for backward compatibility.
70
+ # Executes a single command — the original code path.
69
71
  def execute_single(command, timeout: nil)
70
72
  command = command.to_s
71
73
  return {error: "Command cannot be blank"} if command.strip.empty?
72
74
 
73
- result = @shell_session.run(command, timeout: timeout)
75
+ result = @shell_session.run(command, timeout: timeout, interrupt_check: interrupt_checker)
76
+
77
+ return format_interrupted(result) if result[:interrupted]
74
78
  return result if result.key?(:error)
75
79
 
76
- format_result(result[:stdout], result[:stderr], result[:exit_code])
80
+ output = format_result(result[:stdout], result[:stderr], result[:exit_code])
81
+ append_env_summary(output, result[:env_summary])
77
82
  end
78
83
 
79
84
  # Executes an array of commands, returning a combined result string.
85
+ # Checks for user interrupt between commands and during each command
86
+ # via the {ShellSession} interrupt_check callback.
87
+ #
80
88
  # @param commands [Array<String>] commands to execute
81
89
  # @param mode [String] "sequential" (stop on first failure) or "parallel" (run all)
82
90
  # @param timeout [Integer, nil] per-command timeout override
@@ -85,13 +93,22 @@ module Tools
85
93
  def execute_batch(commands, mode:, timeout: nil)
86
94
  return {error: "Commands array cannot be empty"} unless commands.is_a?(Array) && commands.any?
87
95
 
96
+ checker = interrupt_checker
88
97
  total = commands.size
89
98
  results = []
90
99
  failed = false
100
+ interrupted = false
101
+
102
+ last_env_summary = nil
91
103
 
92
104
  commands.each_with_index do |command, index|
93
105
  position = "[#{index + 1}/#{total}]"
94
106
 
107
+ if interrupted
108
+ results << "#{position} $ #{command}\n(skipped — interrupted by user)"
109
+ next
110
+ end
111
+
95
112
  if failed && mode == "sequential"
96
113
  results << "#{position} $ #{command}\n(skipped)"
97
114
  next
@@ -103,20 +120,33 @@ module Tools
103
120
  next
104
121
  end
105
122
 
106
- result = @shell_session.run(command, timeout: timeout)
123
+ result = @shell_session.run(command, timeout: timeout, interrupt_check: checker)
107
124
 
108
- if result.key?(:error)
125
+ if result[:interrupted]
126
+ results << "#{position} $ #{command}\n#{format_interrupted(result)}"
127
+ interrupted = true
128
+ elsif result.key?(:error)
109
129
  results << "#{position} $ #{command}\n#{result[:error]}"
110
130
  failed = true
111
131
  else
112
132
  exit_code = result[:exit_code]
113
133
  output = format_result(result[:stdout], result[:stderr], exit_code)
114
134
  results << "#{position} $ #{command}\n#{output}"
135
+ last_env_summary = result[:env_summary]
115
136
  failed = true if exit_code != 0
116
137
  end
117
138
  end
118
139
 
119
- results.join("\n\n")
140
+ append_env_summary(results.join("\n\n"), last_env_summary)
141
+ end
142
+
143
+ # Appends environment summary to tool output when present.
144
+ #
145
+ # @param output [String] formatted tool response
146
+ # @param env_summary [String, nil] natural-language environment change summary
147
+ # @return [String] output with env summary appended
148
+ def append_env_summary(output, env_summary)
149
+ env_summary ? "#{output}\n\n#{env_summary}" : output
120
150
  end
121
151
 
122
152
  def format_result(stdout, stderr, exit_code)
@@ -126,5 +156,29 @@ module Tools
126
156
  parts << "exit_code: #{exit_code}"
127
157
  parts.join("\n\n")
128
158
  end
159
+
160
+ # Formats the result of an interrupted command for the LLM.
161
+ # Includes partial output captured before the interrupt.
162
+ #
163
+ # @param result [Hash] ShellSession result with :stdout, :stderr keys
164
+ # @return [String] formatted message for the LLM
165
+ def format_interrupted(result)
166
+ stdout = result[:stdout].to_s
167
+ stderr = result[:stderr].to_s
168
+ parts = [LLM::Client::INTERRUPT_MESSAGE]
169
+ parts << "Partial stdout:\n#{stdout}" unless stdout.empty?
170
+ parts << "stderr:\n#{stderr}" unless stderr.empty?
171
+ parts.join("\n\n")
172
+ end
173
+
174
+ # Builds a lambda that checks the database for a pending interrupt flag.
175
+ # Called every {Anima::Settings.interrupt_check_interval} seconds during
176
+ # command execution inside {ShellSession}.
177
+ #
178
+ # @return [Proc] lambda returning truthy when interrupt is pending
179
+ def interrupt_checker
180
+ session_id = @session.id
181
+ -> { Session.where(id: session_id, interrupt_requested: true).exists? }
182
+ end
129
183
  end
130
184
  end
data/lib/tools/edit.rb CHANGED
@@ -16,7 +16,7 @@ module Tools
16
16
  # "new_text" => "def greet\n 'hello'\nend")
17
17
  # # => "--- app.rb\n+++ app.rb\n@@ -1,3 +1,3 @@\n ..."
18
18
  class Edit < Base
19
- def self.tool_name = "edit"
19
+ def self.tool_name = "edit_file"
20
20
 
21
21
  def self.description = "Replace text in a file."
22
22
 
@@ -132,7 +132,7 @@ module Tools
132
132
 
133
133
  {error: "Could not find old_text in #{path}. " \
134
134
  "Verify the text exists and matches exactly (including whitespace). " \
135
- "Use the read tool to check current file contents."}
135
+ "Use the read_file tool to check current file contents."}
136
136
  end
137
137
 
138
138
  def ambiguity_error(positions, content, path, fuzzy: false)
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tools
4
+ # Signals sub-agent task completion by marking its assigned Goal as
5
+ # completed and routing the result back to the parent session.
6
+ #
7
+ # Only available to sub-agent sessions (those with a +parent_session+).
8
+ # This is the explicit "finish line" that prevents runaway sub-agents
9
+ # from continuing past their assigned task.
10
+ #
11
+ # The result text is delivered to the parent session as a user message
12
+ # attributed to the sub-agent, identical to how regular sub-agent
13
+ # messages are routed by {Events::Subscribers::SubagentMessageRouter}.
14
+ #
15
+ # @example Sub-agent completing its task
16
+ # mark_goal_completed(result: "Found 3 N+1 queries in the orders controller.")
17
+ class MarkGoalCompleted < Base
18
+ def self.tool_name = "mark_goal_completed"
19
+
20
+ def self.description = "Deliver result to parent. Stop working after this call."
21
+
22
+ def self.input_schema
23
+ {
24
+ type: "object",
25
+ properties: {
26
+ result: {type: "string"}
27
+ },
28
+ required: %w[result]
29
+ }
30
+ end
31
+
32
+ # @param session [Session] the sub-agent session
33
+ def initialize(session:, **)
34
+ @session = session
35
+ end
36
+
37
+ # Completes the sub-agent's assigned goal and routes the result
38
+ # to the parent session.
39
+ #
40
+ # @param input [Hash<String, Object>] with "result"
41
+ # @return [String] confirmation message
42
+ # @return [Hash{Symbol => String}] with :error key on failure
43
+ def execute(input)
44
+ result = input["result"].to_s.strip
45
+ return {error: "Result cannot be blank"} if result.empty?
46
+
47
+ goal = @session.goals.active.root.first
48
+ return {error: "No active goal found"} unless goal
49
+
50
+ complete_goal(goal)
51
+ route_result_to_parent(result)
52
+
53
+ "Done. Result delivered to parent."
54
+ end
55
+
56
+ private
57
+
58
+ def complete_goal(goal)
59
+ Goal.transaction do
60
+ goal.update!(status: "completed", completed_at: Time.current)
61
+ goal.cascade_completion!
62
+ goal.release_orphaned_pins!
63
+ end
64
+ end
65
+
66
+ # Delivers the sub-agent's result to the parent session with source
67
+ # metadata. Truncates oversized results to protect the parent's
68
+ # context window. No-op when the parent session is absent.
69
+ #
70
+ # @param result [String] the sub-agent's findings to forward
71
+ # @return [void]
72
+ def route_result_to_parent(result)
73
+ parent = @session.parent_session
74
+ return unless parent
75
+
76
+ name = @session.name || "agent-#{@session.id}"
77
+ truncated = Tools::ResponseTruncator.truncate(
78
+ result,
79
+ threshold: Anima::Settings.max_subagent_response_chars,
80
+ reason: "sub-agent output displays first/last #{Tools::ResponseTruncator::HEAD_LINES} lines"
81
+ )
82
+ parent.enqueue_user_message(truncated, source_type: "subagent", source_name: name)
83
+ end
84
+ end
85
+ end
data/lib/tools/read.rb CHANGED
@@ -16,7 +16,8 @@ module Tools
16
16
  # tool.execute("path" => "large.log", "offset" => 2001, "limit" => 500)
17
17
  # # => "line 2001 content\n..."
18
18
  class Read < Base
19
- def self.tool_name = "read"
19
+ def self.tool_name = "read_file"
20
+ def self.truncation_threshold = nil
20
21
 
21
22
  def self.description = "Read file. Relative paths resolve against working directory."
22
23
 
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tools
4
+ # Active memory search — keyword lookup across conversation history.
5
+ # Returns ranked snippets with message IDs for drill-down via {Remember}.
6
+ #
7
+ # Two-step memory workflow:
8
+ # 1. `recall(query: "auth flow")` → discovers relevant messages
9
+ # 2. `remember(message_id: 42)` → fractal zoom into full context
10
+ #
11
+ # Wraps {Mneme::Search} — same FTS5 engine used by passive recall,
12
+ # but triggered on demand by the agent instead of automatically by goals.
13
+ #
14
+ # @example Search all sessions
15
+ # recall(query: "authentication flow")
16
+ #
17
+ # @example Search current session only
18
+ # recall(query: "OAuth config", session_only: true)
19
+ class Recall < Base
20
+ def self.tool_name = "recall"
21
+
22
+ def self.description = "Find messages across past conversations by keywords."
23
+
24
+ def self.input_schema
25
+ {
26
+ type: "object",
27
+ properties: {
28
+ query: {type: "string"},
29
+ session_only: {type: "boolean", description: "Default: all sessions"}
30
+ },
31
+ required: ["query"]
32
+ }
33
+ end
34
+
35
+ # @param session [Session] the current session (used for session_only scoping)
36
+ def initialize(session:, **)
37
+ @session = session
38
+ end
39
+
40
+ # @param input [Hash] with "query" and optional "session_only"
41
+ # @return [String] formatted search results with message IDs
42
+ # @return [Hash] with :error key when query is blank
43
+ def execute(input)
44
+ query = input["query"].to_s.strip
45
+ return {error: "Query cannot be blank"} if query.empty?
46
+
47
+ session_id = (input["session_only"] == true) ? @session.id : nil
48
+ results = Mneme::Search.query(query, session_id: session_id)
49
+
50
+ return "No results found for \"#{query}\"." if results.empty?
51
+
52
+ format_results(query, results)
53
+ end
54
+
55
+ private
56
+
57
+ # Formats results as token-efficient, LLM-readable output.
58
+ # Each result includes message_id for drill-down via remember tool.
59
+ #
60
+ # @param query [String] the original search query
61
+ # @param results [Array<Mneme::Search::Result>] ranked search results
62
+ # @return [String] formatted output
63
+ def format_results(query, results)
64
+ session_names = load_session_names(results)
65
+
66
+ result_word = (results.size == 1) ? "result" : "results"
67
+ lines = ["Found #{results.size} #{result_word} for \"#{query}\":", ""]
68
+ results.each { |result| lines.concat(format_single_result(result, session_names)) }
69
+ lines.join("\n")
70
+ end
71
+
72
+ # Formats a single search result as display lines.
73
+ #
74
+ # @param result [Mneme::Search::Result]
75
+ # @param session_names [Hash{Integer => String}]
76
+ # @return [Array<String>]
77
+ def format_single_result(result, session_names)
78
+ sid = result.session_id
79
+ session_name = session_names[sid] || "Session ##{sid}"
80
+ snippet = result.snippet.to_s.gsub(/\s+/, " ").strip
81
+
82
+ [
83
+ "[message #{result.message_id}, session \"#{session_name}\", #{result.message_type}]",
84
+ " ...#{snippet}...",
85
+ ""
86
+ ]
87
+ end
88
+
89
+ # Batch-loads session names to avoid N+1 queries.
90
+ #
91
+ # @param results [Array<Mneme::Search::Result>]
92
+ # @return [Hash{Integer => String}] session_id => name
93
+ def load_session_names(results)
94
+ session_ids = results.map(&:session_id).uniq
95
+ Session.where(id: session_ids).pluck(:id, :name).to_h
96
+ end
97
+ end
98
+ end
@@ -33,10 +33,19 @@ module Tools
33
33
  # @return [Array<Hash>] schema array for the Anthropic tools API parameter.
34
34
  # Each schema includes an optional `timeout` parameter (seconds) injected
35
35
  # by the registry. The agent can override the default per call for
36
- # long-running operations.
36
+ # long-running operations. Tools with session-dependent schemas (e.g.
37
+ # {Think} with budget-based maxLength, {Bash} with CWD in description)
38
+ # are instantiated with context to generate their schema:
39
+ # - {Think}: budget-based maxLength
40
+ # - {Bash}: CWD embedded in description
41
+ # Returns tool schemas for the Anthropic API. The last schema is
42
+ # annotated with +cache_control+ so the API caches the entire tools
43
+ # prefix (tools are evaluated first in cache prefix order).
37
44
  def schemas
38
45
  default = Anima::Settings.tool_timeout
39
- @tools.values.map { |tool| inject_timeout(tool.schema, default) }
46
+ result = @tools.values.map { |tool| inject_timeout(resolve_schema(tool), default) }
47
+ result.last[:cache_control] = {type: "ephemeral"}
48
+ result
40
49
  end
41
50
 
42
51
  # Execute a tool by name. Classes are instantiated with the registry's
@@ -53,6 +62,19 @@ module Tools
53
62
  instance.execute(input)
54
63
  end
55
64
 
65
+ # Returns the truncation threshold for a tool, or +nil+ if the tool
66
+ # opts out of truncation (e.g. read_file tool has its own pagination).
67
+ # MCP tools and other duck-typed instances use the default threshold.
68
+ #
69
+ # @param name [String] registered tool name
70
+ # @return [Integer, nil] character threshold, or nil to skip truncation
71
+ def truncation_threshold(name)
72
+ tool = @tools[name]
73
+ return tool.truncation_threshold if tool&.respond_to?(:truncation_threshold)
74
+
75
+ Anima::Settings.max_tool_response_chars
76
+ end
77
+
56
78
  # @param name [String] tool name to check
57
79
  # @return [Boolean] whether a tool with the given name is registered
58
80
  def registered?(name)
@@ -66,16 +88,28 @@ module Tools
66
88
 
67
89
  private
68
90
 
91
+ # Returns a tool's schema, preferring the instance-level dynamic
92
+ # variant when available. Only instantiates the tool when needed.
93
+ def resolve_schema(tool)
94
+ return tool.schema unless dynamic_schema?(tool)
95
+
96
+ tool.new(**@context).dynamic_schema
97
+ end
98
+
99
+ def dynamic_schema?(tool)
100
+ tool.is_a?(Class) && tool.method_defined?(:dynamic_schema)
101
+ end
102
+
69
103
  # Injects an optional `timeout` parameter into the tool's input schema.
70
104
  def inject_timeout(schema, default)
71
- s = schema.deep_dup
72
- s[:input_schema] ||= {type: "object", properties: {}}
73
- s[:input_schema][:properties] ||= {}
74
- s[:input_schema][:properties]["timeout"] = {
105
+ result = schema.deep_dup
106
+ input = result[:input_schema] ||= {type: "object", properties: {}}
107
+ props = input[:properties] ||= {}
108
+ props["timeout"] = {
75
109
  type: "integer",
76
110
  description: "Seconds (default: #{default})."
77
111
  }
78
- s
112
+ result
79
113
  end
80
114
  end
81
115
  end
@@ -112,7 +112,7 @@ module Tools
112
112
  # @return [Array<Message>] chronologically ordered
113
113
  def fetch_center_messages(target, target_session)
114
114
  half = CONTEXT_WINDOW / 2
115
- scope = target_session.messages.context_messages.deliverable
115
+ scope = target_session.messages.context_messages
116
116
  target_id = target.id
117
117
 
118
118
  before = scope.where("id <= ?", target_id).reorder(id: :desc).limit(half + 1).to_a.reverse
@@ -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,9 +21,9 @@ module Tools
21
21
 
22
22
  # Builds description dynamically to include available specialists.
23
23
  def self.description
24
- base = "Spawn a specialist to work on a task. " \
25
- "Its messages are forwarded to you. " \
26
- "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 as tool responses in your conversation. " \
26
+ "Prefix its nickname with @ to send instructions."
27
27
 
28
28
  registry = Agents::Registry.instance
29
29
  return base unless registry.any?
@@ -58,9 +58,11 @@ module Tools
58
58
  private_class_method :name_property
59
59
 
60
60
  # @param session [Session] the parent session spawning the specialist
61
+ # @param shell_session [ShellSession] the parent's persistent shell (for CWD inheritance)
61
62
  # @param agent_registry [Agents::Registry, nil] injectable for testing
62
- def initialize(session:, agent_registry: nil, **)
63
+ def initialize(session:, shell_session:, agent_registry: nil, **)
63
64
  @session = session
65
+ @shell_session = shell_session
64
66
  @agent_registry = agent_registry || Agents::Registry.instance
65
67
  end
66
68
 
@@ -83,9 +85,9 @@ module Tools
83
85
 
84
86
  child = spawn_child(definition, task)
85
87
  nickname = child.name
86
- "Specialist @#{nickname} spawned (session #{child.id}). " \
88
+ "Specialist #{nickname} spawned (session #{child.id}). " \
87
89
  "Its messages will appear in your conversation. " \
88
- "Reply with @#{nickname} to send it instructions."
90
+ "To address it, prefix its name with @ in your message."
89
91
  end
90
92
 
91
93
  private
@@ -94,9 +96,10 @@ module Tools
94
96
  child = Session.create!(
95
97
  parent_session_id: @session.id,
96
98
  prompt: build_prompt(definition),
97
- granted_tools: definition.tools
99
+ granted_tools: definition.tools,
100
+ initial_cwd: @shell_session.pwd
98
101
  )
99
- child.create_user_message(task)
102
+ create_goal_with_pinned_task(child, task)
100
103
  assign_nickname_via_brain(child)
101
104
  child.broadcast_children_update_to_parent
102
105
  AgentRequestJob.perform_later(child.id)
@@ -2,8 +2,9 @@
2
2
 
3
3
  module Tools
4
4
  # Spawns a generic child session that works on a task autonomously.
5
- # The sub-agent inherits the parent's viewport context at fork time,
6
- # runs via {AgentRequestJob}, and communicates with the parent through
5
+ # The sub-agent starts clean — no parent conversation history with
6
+ # only a system prompt, a Goal, and the task as its first user message.
7
+ # Runs via {AgentRequestJob} and communicates with the parent through
7
8
  # natural text messages routed by {Events::Subscribers::SubagentMessageRouter}.
8
9
  #
9
10
  # Nickname assignment is handled by the {AnalyticalBrain::Runner} which
@@ -14,14 +15,15 @@ module Tools
14
15
  class SpawnSubagent < Base
15
16
  include SubagentPrompts
16
17
 
17
- GENERIC_PROMPT = "You are a focused sub-agent. #{COMMUNICATION_INSTRUCTION}\n"
18
+ GENERIC_PROMPT = "#{COMMUNICATION_INSTRUCTION}\n"
18
19
 
19
20
  def self.tool_name = "spawn_subagent"
20
21
 
21
22
  def self.description
22
- "Spawn a sub-agent to work on a task. " \
23
- "It inherits your conversation context and its messages are forwarded to you. " \
24
- "Address it via @nickname to send follow-up instructions."
23
+ "Task feels like a sidequest or a context-switch? Hand it off. " \
24
+ "Starts clean with just the task include all relevant context in the task description. " \
25
+ "Its messages appear as tool responses in your conversation. " \
26
+ "Prefix its nickname with @ to send instructions."
25
27
  end
26
28
 
27
29
  def self.input_schema
@@ -42,12 +44,15 @@ module Tools
42
44
  end
43
45
 
44
46
  # @param session [Session] the parent session spawning the sub-agent
45
- def initialize(session:, **)
47
+ # @param shell_session [ShellSession] the parent's persistent shell (for CWD inheritance)
48
+ def initialize(session:, shell_session:, **)
46
49
  @session = session
50
+ @shell_session = shell_session
47
51
  end
48
52
 
49
- # Creates a child session, runs the analytical brain to assign a nickname,
50
- # persists the task as a user message, and queues background processing.
53
+ # Creates a child session with a clean context (no parent history),
54
+ # runs the analytical brain to assign a nickname, persists the task
55
+ # as a pinned user message, and queues background processing.
51
56
  # Returns immediately after brain completes (blocking for ~200ms).
52
57
  #
53
58
  # @param input [Hash<String, Object>] with "task" and optional "tools"
@@ -65,9 +70,9 @@ module Tools
65
70
 
66
71
  child = spawn_child(task, tools)
67
72
  nickname = child.name
68
- "Sub-agent @#{nickname} spawned (session #{child.id}). " \
73
+ "Sub-agent #{nickname} spawned (session #{child.id}). " \
69
74
  "Its messages will appear in your conversation. " \
70
- "Reply with @#{nickname} to send it instructions."
75
+ "To address it, prefix its name with @ in your message."
71
76
  end
72
77
 
73
78
  private
@@ -76,9 +81,10 @@ module Tools
76
81
  child = Session.create!(
77
82
  parent_session_id: @session.id,
78
83
  prompt: GENERIC_PROMPT,
79
- granted_tools: granted_tools
84
+ granted_tools: granted_tools,
85
+ initial_cwd: @shell_session.pwd
80
86
  )
81
- child.create_user_message(task)
87
+ create_goal_with_pinned_task(child, task)
82
88
  assign_nickname_via_brain(child)
83
89
  child.broadcast_children_update_to_parent
84
90
  AgentRequestJob.perform_later(child.id)