anima-core 1.0.1 → 1.0.2

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.
data/lib/llm/client.rb CHANGED
@@ -15,6 +15,9 @@ module LLM
15
15
  # registry.register(Tools::WebGet)
16
16
  # client.chat_with_tools(messages, registry: registry, session_id: session.id)
17
17
  class Client
18
+ # Synthetic tool_result message when a tool is skipped due to user interrupt.
19
+ INTERRUPT_MESSAGE = "Stopped by user"
20
+
18
21
  # @return [Providers::Anthropic] the underlying API provider
19
22
  attr_reader :provider
20
23
 
@@ -61,11 +64,14 @@ module LLM
61
64
  # Emits {Events::ToolCall} and {Events::ToolResponse} events for each
62
65
  # tool interaction so they're persisted and visible in the event stream.
63
66
  #
67
+ # When the user interrupts via Escape, remaining tools receive synthetic
68
+ # "Stopped by user" results and the loop exits without another LLM call.
69
+ #
64
70
  # @param messages [Array<Hash>] conversation messages in Anthropic format
65
71
  # @param registry [Tools::Registry] registered tools to make available
66
72
  # @param session_id [Integer, String] session ID for emitted events
67
73
  # @param options [Hash] additional API parameters (e.g. +system:+)
68
- # @return [String] the assistant's final text response
74
+ # @return [String, nil] the assistant's final text response, or nil when interrupted
69
75
  # @raise [Providers::Anthropic::Error] on API errors
70
76
  def chat_with_tools(messages, registry:, session_id:, **options)
71
77
  messages = messages.dup
@@ -95,6 +101,11 @@ module LLM
95
101
  {role: "assistant", content: response["content"]},
96
102
  {role: "user", content: tool_results}
97
103
  ]
104
+
105
+ if interrupted?(session_id)
106
+ clear_interrupt!(session_id)
107
+ return nil
108
+ end
98
109
  else
99
110
  return extract_text(response)
100
111
  end
@@ -122,20 +133,43 @@ module LLM
122
133
  end
123
134
 
124
135
  # Executes all tool_use blocks from a response, emitting events for each.
136
+ # Checks for user interrupt between tools — remaining tools receive
137
+ # synthetic results to satisfy the Anthropic API's tool_use/tool_result
138
+ # pairing requirement (a missing result permanently breaks the conversation).
125
139
  #
126
140
  # @param response [Hash] Anthropic API response with tool_use content blocks
127
141
  # @param registry [Tools::Registry] tool registry for dispatch
128
142
  # @param session_id [Integer, String] session ID for events
129
143
  # @return [Array<Hash>] tool_result content blocks for the next API call
130
144
  def execute_tools(response, registry, session_id)
131
- extract_tool_uses(response).map do |tool_use|
132
- execute_single_tool(tool_use, registry, session_id)
145
+ tool_uses = extract_tool_uses(response)
146
+ results = []
147
+
148
+ tool_uses.each_with_index do |tool_use, index|
149
+ if interrupted?(session_id)
150
+ remaining = tool_uses[index..]
151
+ results.concat(interrupt_remaining_tools(remaining, session_id)) if remaining&.any?
152
+ break
153
+ end
154
+ results << execute_single_tool(tool_use, registry, session_id)
133
155
  end
156
+
157
+ results
158
+ end
159
+
160
+ # Creates synthetic "Stopped by user" results for all tools in the list.
161
+ #
162
+ # @param tool_uses [Array<Hash>] remaining tool_use content blocks
163
+ # @param session_id [Integer, String] session ID for events
164
+ # @return [Array<Hash>] tool_result content blocks
165
+ def interrupt_remaining_tools(tool_uses, session_id)
166
+ tool_uses.map { |tool_use| interrupt_tool(tool_use, session_id) }
134
167
  end
135
168
 
136
- # Executes a single tool and always returns a tool_result — even if
137
- # the tool raises. The LLM requires every tool_use to have a matching
138
- # tool_result; a missing result breaks the conversation permanently.
169
+ # Executes a single tool and always returns a tool_result — even if the
170
+ # tool raises. Per the Anthropic tool-use protocol, every tool_use must
171
+ # have a matching tool_result; a missing result permanently corrupts the
172
+ # conversation history and breaks the session.
139
173
  def execute_single_tool(tool_use, registry, session_id)
140
174
  name = tool_use["name"]
141
175
  id = tool_use["id"]
@@ -167,6 +201,49 @@ module LLM
167
201
  {type: "tool_result", tool_use_id: id, content: result_content}
168
202
  end
169
203
 
204
+ # Creates a synthetic "Stopped by user" result for a tool that was not
205
+ # executed due to user interrupt. Emits both ToolCall and ToolResponse
206
+ # events so the TUI shows the interrupted tool in the event stream.
207
+ #
208
+ # @param tool_use [Hash] Anthropic tool_use content block
209
+ # @param session_id [Integer, String] session ID for events
210
+ # @return [Hash] tool_result content block
211
+ def interrupt_tool(tool_use, session_id)
212
+ name = tool_use["name"]
213
+ id = tool_use["id"]
214
+ input = tool_use["input"] || {}
215
+
216
+ Events::Bus.emit(Events::ToolCall.new(
217
+ content: "Skipped #{name} (interrupted)", tool_name: name,
218
+ tool_input: input, tool_use_id: id, session_id: session_id
219
+ ))
220
+
221
+ Events::Bus.emit(Events::ToolResponse.new(
222
+ content: INTERRUPT_MESSAGE, tool_name: name, tool_use_id: id,
223
+ success: false, session_id: session_id
224
+ ))
225
+
226
+ {type: "tool_result", tool_use_id: id, content: INTERRUPT_MESSAGE}
227
+ end
228
+
229
+ # Checks the database for a pending interrupt flag on the session.
230
+ #
231
+ # @param session_id [Integer, String] session to check
232
+ # @return [Boolean] whether the session has a pending interrupt request
233
+ def interrupted?(session_id)
234
+ Session.where(id: session_id, interrupt_requested: true).exists?
235
+ end
236
+
237
+ # Clears the interrupt flag so the agent loop can continue with pending
238
+ # messages. Also cleared by {AgentRequestJob#clear_interrupt} as a safety
239
+ # net for unexpected exits.
240
+ #
241
+ # @param session_id [Integer, String] session to clear
242
+ # @return [void]
243
+ def clear_interrupt!(session_id)
244
+ Session.where(id: session_id).update_all(interrupt_requested: false)
245
+ end
246
+
170
247
  def log(level, message)
171
248
  return unless @logger
172
249
 
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tools
4
+ # A deliberate reasoning space for the agent's inner voice. Creates a
5
+ # pause between tool calls where the agent can organize thoughts, plan
6
+ # next steps, or make decisions without interrupting the user.
7
+ #
8
+ # Think events bridge the gap between the analytical brain (subconscious
9
+ # background processing) and speech (user-facing messages). Without this
10
+ # tool, reasoning leaks into tool arguments as comments.
11
+ #
12
+ # Two visibility modes control how thoughts appear in the TUI:
13
+ # - **inner** (default) — silent reasoning, visible only in verbose/debug
14
+ # - **aloud** — narration shown in all view modes with a thought bubble
15
+ #
16
+ # @example Silent planning between tool calls
17
+ # think(thoughts: "Three auth failures — likely a config issue, not individual tests.")
18
+ #
19
+ # @example Narrating approach for the user
20
+ # think(thoughts: "Checking the OAuth config first.", visibility: "aloud")
21
+ class Think < Base
22
+ def self.tool_name = "think"
23
+
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
29
+
30
+ def self.input_schema
31
+ {
32
+ type: "object",
33
+ properties: {
34
+ thoughts: {
35
+ type: "string",
36
+ description: "Your reasoning, analysis, or internal monologue"
37
+ },
38
+ visibility: {
39
+ type: "string",
40
+ enum: ["inner", "aloud"],
41
+ description: "\"inner\" (default) for silent reasoning; \"aloud\" to narrate for the user"
42
+ }
43
+ },
44
+ required: ["thoughts"]
45
+ }
46
+ end
47
+
48
+ # @param input [Hash] with "thoughts" and optional "visibility"
49
+ # @return [String] acknowledgement — the value is in the call, not the result
50
+ def execute(input)
51
+ thoughts = input["thoughts"].to_s
52
+ return {error: "Thoughts cannot be blank"} if thoughts.strip.empty?
53
+
54
+ "OK"
55
+ end
56
+ end
57
+ end
data/lib/tui/app.rb CHANGED
@@ -391,9 +391,15 @@ module TUI
391
391
  return nil
392
392
  end
393
393
 
394
+ # Escape key priority: unfocus chat > interrupt tools > clear input > parent session
394
395
  if event.esc?
395
- if @screens[:chat].chat_focused
396
- @screens[:chat].unfocus_chat
396
+ chat = @screens[:chat]
397
+ if chat.chat_focused
398
+ chat.unfocus_chat
399
+ elsif chat.loading? && chat.input.empty?
400
+ chat.interrupt_execution
401
+ elsif !chat.input.empty?
402
+ chat.clear_input
397
403
  else
398
404
  return_to_parent_session
399
405
  end
@@ -134,6 +134,14 @@ module TUI
134
134
  send_action("recall_pending", {"event_id" => event_id})
135
135
  end
136
136
 
137
+ # Requests interruption of the current tool execution. The server sets
138
+ # an interrupt flag that the LLM client checks between tool calls.
139
+ #
140
+ # @return [void]
141
+ def interrupt
142
+ send_action("interrupt_execution", {})
143
+ end
144
+
137
145
  # Sends an Anthropic subscription token to the brain for validation and storage.
138
146
  # The token flows directly from TUI input to encrypted credentials — never
139
147
  # enters the LLM context window.
@@ -21,6 +21,8 @@ module TUI
21
21
  RETURN_ARROW = "\u21A9"
22
22
  ERROR_ICON = "\u274C"
23
23
 
24
+ THOUGHT_BUBBLE = "\u{1F4AD}"
25
+
24
26
  ROLE_COLORS = {"user" => "green", "assistant" => "cyan"}.freeze
25
27
 
26
28
  # Intentionally duplicated from Session::VIEW_MODES to keep the TUI
@@ -165,6 +167,21 @@ module TUI
165
167
  @cable_client.change_view_mode(mode)
166
168
  end
167
169
 
170
+ # Sends an interrupt request to the server to stop the current tool chain.
171
+ # Called when Escape is pressed with empty input during active processing.
172
+ #
173
+ # @return [void]
174
+ def interrupt_execution
175
+ @cable_client.interrupt
176
+ end
177
+
178
+ # Clears the input buffer. Used when Escape is pressed with non-empty input.
179
+ #
180
+ # @return [void]
181
+ def clear_input
182
+ @input_buffer.clear
183
+ end
184
+
168
185
  # Clears the authentication_required flag after the App has consumed it.
169
186
  # @return [void]
170
187
  def clear_authentication_required
@@ -449,6 +466,8 @@ module TUI
449
466
  render_tool_call_entry(tui, data)
450
467
  when "tool_response"
451
468
  render_tool_response_entry(tui, data)
469
+ when "think"
470
+ render_think_entry(tui, data)
452
471
  when "system"
453
472
  render_system_entry(tui, data)
454
473
  when "system_prompt"
@@ -557,6 +576,27 @@ module TUI
557
576
  lines
558
577
  end
559
578
 
579
+ # Renders a think event — the agent's inner reasoning between tool calls.
580
+ # "aloud" thoughts use yellow (narration for the user), "inner" thoughts
581
+ # use dark_gray (visible only in verbose/debug, dimmed to signal internality).
582
+ # @param tui [RatatuiRuby] TUI rendering API
583
+ # @param data [Hash] structured data with "content", "visibility", optional "timestamp", "tool_use_id"
584
+ # @return [Array<RatatuiRuby::Widgets::Line>]
585
+ def render_think_entry(tui, data)
586
+ aloud = data["visibility"] == "aloud"
587
+ color = aloud ? "yellow" : "dark_gray"
588
+ style = tui.style(fg: color)
589
+
590
+ meta = []
591
+ meta << "[#{format_ns_timestamp(data["timestamp"])}]" if data["timestamp"]
592
+ header = meta.empty? ? THOUGHT_BUBBLE : "#{meta.join(" ")} #{THOUGHT_BUBBLE}"
593
+
594
+ content_lines = data["content"].to_s.split("\n", -1)
595
+ lines = [tui.line(spans: [tui.span(content: "#{header} #{content_lines.first}", style: style)])]
596
+ content_lines.drop(1).each { |line| lines << tui.line(spans: [tui.span(content: " #{line}", style: style)]) }
597
+ lines
598
+ end
599
+
560
600
  # Formats a token count for display, with tilde prefix for estimates.
561
601
  # @param tokens [Integer, nil] token count
562
602
  # @param estimated [Boolean] whether the count is an estimate
@@ -0,0 +1,116 @@
1
+ # Anima Configuration
2
+ #
3
+ # Edit settings below to customize Anima's behavior.
4
+ # Changes take effect immediately — no restart needed.
5
+
6
+ # ─── LLM ───────────────────────────────────────────────────────
7
+
8
+ [llm]
9
+
10
+ # Primary model for conversations.
11
+ model = "claude-opus-4-6"
12
+
13
+ # Lightweight model for fast tasks (e.g. session naming).
14
+ fast_model = "claude-haiku-4-5"
15
+
16
+ # Maximum tokens per LLM response.
17
+ max_tokens = 8192
18
+
19
+ # Maximum consecutive tool execution rounds per request.
20
+ max_tool_rounds = 500
21
+
22
+ # Context window budget — tokens reserved for conversation history.
23
+ # Set this based on your model's context window minus system prompt.
24
+ token_budget = 190_000
25
+
26
+ # ─── Timeouts (seconds) ─────────────────────────────────────────
27
+
28
+ [timeouts]
29
+
30
+ # LLM API request timeout.
31
+ api = 300
32
+
33
+ # Shell command execution timeout.
34
+ command = 30
35
+
36
+ # MCP server response timeout.
37
+ mcp_response = 60
38
+
39
+ # Web fetch request timeout.
40
+ web_request = 10
41
+
42
+ # ─── Shell ──────────────────────────────────────────────────────
43
+
44
+ [shell]
45
+
46
+ # Maximum bytes of command output before truncation.
47
+ max_output_bytes = 100_000
48
+
49
+ # ─── Tools ──────────────────────────────────────────────────────
50
+
51
+ [tools]
52
+
53
+ # Maximum file size for read/edit operations (bytes).
54
+ max_file_size = 10_485_760
55
+
56
+ # Maximum lines returned by the read tool.
57
+ max_read_lines = 2_000
58
+
59
+ # Maximum bytes returned by the read tool.
60
+ max_read_bytes = 50_000
61
+
62
+ # Maximum bytes from web GET responses.
63
+ max_web_response_bytes = 100_000
64
+
65
+ # ─── Environment ──────────────────────────────────────────────
66
+
67
+ [environment]
68
+
69
+ # Files to scan for in the working directory (at root and up to project_files_max_depth subdirectories deep).
70
+ project_files = ["CLAUDE.md", "AGENTS.md", "README.md", "CONTRIBUTING.md"]
71
+
72
+ # Maximum directory depth for project file scanning.
73
+ project_files_max_depth = 3
74
+
75
+ # ─── GitHub ─────────────────────────────────────────────────────
76
+
77
+ [github]
78
+
79
+ # Repository for agent feature requests (owner/repo format).
80
+ # Falls back to parsing git remote origin when unset.
81
+ repo = "hoblin/anima"
82
+
83
+ # Label applied to agent-created feature request issues.
84
+ label = "anima-wants"
85
+
86
+ # ─── Paths ─────────────────────────────────────────────────────
87
+
88
+ [paths]
89
+
90
+ # The agent's self-authored identity file.
91
+ soul = "{{ANIMA_HOME}}/soul.md"
92
+
93
+ # ─── Session ────────────────────────────────────────────────────
94
+
95
+ [session]
96
+
97
+ # Regenerate session name every N messages.
98
+ name_generation_interval = 30
99
+
100
+ # ─── Analytical Brain ─────────────────────────────────────────
101
+
102
+ [analytical_brain]
103
+
104
+ # Maximum tokens per analytical brain response.
105
+ # Must accommodate multiple tool calls (rename + goals + skills + ready).
106
+ max_tokens = 4096
107
+
108
+ # Run the analytical brain synchronously before the main agent on user messages.
109
+ # Ensures activated skills are available for the current response.
110
+ blocking_on_user_message = true
111
+
112
+ # Run the analytical brain asynchronously after the main agent completes.
113
+ blocking_on_agent_message = false
114
+
115
+ # Number of recent events to include in the analytical brain's context window.
116
+ event_window = 20
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: anima-core
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.1
4
+ version: 1.0.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yevhenii Hurin
@@ -269,6 +269,7 @@ files:
269
269
  - db/migrate/20260315140843_create_goals.rb
270
270
  - db/migrate/20260315144837_add_completed_at_to_goals.rb
271
271
  - db/migrate/20260315191105_add_active_workflow_to_sessions.rb
272
+ - db/migrate/20260316094817_add_interrupt_requested_to_sessions.rb
272
273
  - db/queue_schema.rb
273
274
  - db/seeds.rb
274
275
  - exe/anima
@@ -290,6 +291,7 @@ files:
290
291
  - lib/anima/cli.rb
291
292
  - lib/anima/cli/mcp.rb
292
293
  - lib/anima/cli/mcp/secrets.rb
294
+ - lib/anima/config_migrator.rb
293
295
  - lib/anima/installer.rb
294
296
  - lib/anima/settings.rb
295
297
  - lib/anima/version.rb
@@ -326,6 +328,7 @@ files:
326
328
  - lib/tools/spawn_specialist.rb
327
329
  - lib/tools/spawn_subagent.rb
328
330
  - lib/tools/subagent_prompts.rb
331
+ - lib/tools/think.rb
329
332
  - lib/tools/web_get.rb
330
333
  - lib/tools/write.rb
331
334
  - lib/tui/app.rb
@@ -514,6 +517,7 @@ files:
514
517
  - skills/rspec/references/matchers.md
515
518
  - skills/rspec/references/mocks.md
516
519
  - skills/rspec/references/rails.md
520
+ - templates/config.toml
517
521
  - templates/soul.md
518
522
  - workflows/commit.md
519
523
  - workflows/create_handoff.md