solid_agent 0.2.0 → 0.2.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2b204dd708358a41a24ed81cb3e27a76c83b97bd6b69fb098eeaf9a3c54d4ddd
4
- data.tar.gz: 3ee800cb2936615ee7ac57072da8a26c6152bf73e92088c8853a8cbcdd3b4a6b
3
+ metadata.gz: ea68dd1f79ae5dc2a0e18874a7a6b0f9ae031b30fbc0111a4d965a0cbf7d2229
4
+ data.tar.gz: 2c8bb5f5f965daeb1d7d5dcded961208d356231c8c31f6189fbb4c0827f7d356
5
5
  SHA512:
6
- metadata.gz: 4a9d1c5133162f5ebd32fe1cf90ac999cd9b0f0f9c64396eb4e59ef9b60ca3d871e79c5d6e83fe50a830bfbbea6677837ff5ad0e0ca8e32277b885c577d63e9c
7
- data.tar.gz: 6e3d799d6cb5321f10455d2a954cb24d2c41a0768c88489a86eb24f6bfe84f5b4ae25fffc614e60f0edfd05c517dc6dfef66c79c078e125d9ed275fa581e27a9
6
+ metadata.gz: 0d193d194377e1c04bbae506d33c6645f573d5c37917a9564a45fb118f8db58c8a81165b445017b2794e78372340a4de6a0e420df0b49390c8cd4bad8380cef2
7
+ data.tar.gz: 2a27747e8a939338604d94c45113f667a040f1b53615a1c42fef881206c34e12ae56709bf8984219f2188b42a99ed7467daf3011fe91fed079bd228d6ae95ca0
data/CHANGELOG.md CHANGED
@@ -7,6 +7,53 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## [0.2.2] - 2026-10-31
11
+
12
+ A patch release so the install generators work on SQLite and MySQL.
13
+
14
+ ### Fixed
15
+
16
+ - **The generated migrations run on SQLite and MySQL.** Every migration
17
+ template declared its JSON columns as `jsonb`, a PostgreSQL type, so
18
+ `rails generate solid_agent:install` (and `solid_agent:context`,
19
+ `solid_agent:reasons`) wrote migrations that aborted on SQLite with
20
+ `undefined method 'jsonb'`, including on a fresh Rails app. The templates
21
+ now choose the type when the migration runs: `jsonb` on PostgreSQL, `json`
22
+ elsewhere, and no column default on MySQL, which rejects one on JSON
23
+ columns. Migrations already generated are unaffected; an app that edited
24
+ them by hand to `json` needs no change.
25
+
26
+ ## [0.2.1] - 2026-10-07
27
+
28
+ A patch release for activeagent 1.9, whose generations can pause to ask the
29
+ user and resume with the answer.
30
+
31
+ ### Added
32
+
33
+ - **`HasContext#resuming_generation?`** answers whether a generation resumes
34
+ one that paused for user input: true when the agent defines a public
35
+ `resuming?` that returns true, or after `self.resuming_generation = true`.
36
+ The writer is for a host that replays a stored conversation itself. Both
37
+ are private, so neither becomes one of the agent's actions.
38
+
39
+ ### Fixed
40
+
41
+ - **`HasContext` skips persisting the response of a generation paused for
42
+ user input, and the prompt of the generation that resumes it.** A response
43
+ answering `awaiting_input?` with true is returned untouched: no generation
44
+ record, no assistant row and no tool rows, because its last message is an
45
+ unfinished turn. The paused generation's prompt, the user turn, is still
46
+ persisted. The resumed generation skips prompt persistence, because its
47
+ prompt replays that turn, and persists its response as usual. Both checks
48
+ are duck-typed, so framework releases without a pause behave as before.
49
+
50
+ - Tool messages are now deduped by `tool_call_id` within one response's
51
+ stack as well as against rows already on the context, so a stack that
52
+ repeats a call persists it once even when the context's `messages` scope
53
+ cannot see rows written earlier in the same pass.
54
+
55
+ ## [0.2.0] - 2026-08-18
56
+
10
57
  ### Added
11
58
 
12
59
  - **`SolidAgent::Records::*` — behavior for the agent-configuration records.**
data/README.md CHANGED
@@ -94,6 +94,28 @@ the user turn and the response as the assistant turn, both after the
94
94
  provider call — so reach for `add_conversation_user_message` only with
95
95
  `auto_save: false`, or the turn is stored twice.
96
96
 
97
+ A generation that pauses for user input (its response answers
98
+ `awaiting_input?` with true) persists only its prompt, the user turn. The
99
+ paused response writes no generation record, assistant row or tool rows. The
100
+ generation that resumes it persists the tool results and the final answer,
101
+ and skips its own prompt, which replays the user turn already stored. Tool
102
+ results are deduped by `tool_call_id`, so each is stored once.
103
+
104
+ The resumed generation writes to whichever context it holds, so it must load
105
+ the paused generation's context. Give `has_context` a `contextual:` param
106
+ that names the same record on both runs, or load the context by id in the
107
+ action (`load_conversation(context_id: ...)`).
108
+ Without either, each agent instance creates its own anonymous context, and
109
+ the user turn and the answer land in two different contexts.
110
+
111
+ The agent counts as resuming when the framework's `resuming?` returns true.
112
+ A host that replays a stored conversation itself sets the flag from inside
113
+ the agent:
114
+
115
+ ```ruby
116
+ before_generation { self.resuming_generation = params[:checkpoint].present? }
117
+ ```
118
+
97
119
  > **Naming a context also names its models.** `has_context :conversation`
98
120
  > infers `Conversation`, `ConversationMessage` and `ConversationGeneration`,
99
121
  > not the `AgentContext` family the installer wrote — hence `class_name:`
@@ -14,7 +14,7 @@ class Create<%= class_name.pluralize %> < ActiveRecord::Migration<%= migration_v
14
14
  t.text :instructions
15
15
 
16
16
  # Flexible options storage (input_params, model settings, etc.)
17
- t.jsonb :options, default: {}
17
+ t.column :options, json_type, **json_default({})
18
18
 
19
19
  # Tracing/debugging
20
20
  t.string :trace_id, index: true
@@ -29,4 +29,33 @@ class Create<%= class_name.pluralize %> < ActiveRecord::Migration<%= migration_v
29
29
  add_index :<%= table_name %>, [:agent_name, :action_name]
30
30
  add_index :<%= table_name %>, :created_at
31
31
  end
32
+
33
+ private
34
+
35
+ # jsonb on PostgreSQL, json elsewhere: SQLite and MySQL have no jsonb type,
36
+ # and SQLite aborts the migration on it.
37
+ def json_type
38
+ @json_type ||= postgres? ? :jsonb : :json
39
+ end
40
+
41
+ # MySQL rejects a default on a JSON column outright ("BLOB, TEXT, GEOMETRY or
42
+ # JSON column can't have a default value"), which aborts the migration, so
43
+ # the column is created without one there. The gem assigns every JSON column
44
+ # it writes and reads them nil-safely, so a NULL reads as the empty default
45
+ # would.
46
+ def json_default(value)
47
+ mysql? ? {} : { default: value }
48
+ end
49
+
50
+ def postgres?
51
+ adapter_name.include?("postgres")
52
+ end
53
+
54
+ def mysql?
55
+ adapter_name.include?("mysql") || adapter_name.include?("trilogy")
56
+ end
57
+
58
+ def adapter_name
59
+ connection.adapter_name.to_s.downcase
60
+ end
32
61
  end
@@ -23,17 +23,17 @@ class Create<%= generation_class_name.pluralize %> < ActiveRecord::Migration<%=
23
23
  t.integer :reasoning_tokens, default: 0
24
24
 
25
25
  # Tool calls made in this generation
26
- t.jsonb :tool_calls, default: []
26
+ t.column :tool_calls, json_type, **json_default([])
27
27
 
28
28
  # Raw response from the provider
29
- t.jsonb :raw_response
29
+ t.column :raw_response, json_type
30
30
 
31
31
  # Timing
32
32
  t.float :duration_seconds
33
33
 
34
34
  # Telemetry correlation + provenance snapshot
35
35
  t.string :trace_id
36
- t.jsonb :provenance, default: {}
36
+ t.column :provenance, json_type, **json_default({})
37
37
 
38
38
  t.timestamps
39
39
  end
@@ -43,4 +43,33 @@ class Create<%= generation_class_name.pluralize %> < ActiveRecord::Migration<%=
43
43
  add_index :<%= generation_table_name %>, :trace_id
44
44
  add_index :<%= generation_table_name %>, [:<%= file_name %>_id, :created_at]
45
45
  end
46
+
47
+ private
48
+
49
+ # jsonb on PostgreSQL, json elsewhere: SQLite and MySQL have no jsonb type,
50
+ # and SQLite aborts the migration on it.
51
+ def json_type
52
+ @json_type ||= postgres? ? :jsonb : :json
53
+ end
54
+
55
+ # MySQL rejects a default on a JSON column outright ("BLOB, TEXT, GEOMETRY or
56
+ # JSON column can't have a default value"), which aborts the migration, so
57
+ # the column is created without one there. The gem assigns every JSON column
58
+ # it writes and reads them nil-safely, so a NULL reads as the empty default
59
+ # would.
60
+ def json_default(value)
61
+ mysql? ? {} : { default: value }
62
+ end
63
+
64
+ def postgres?
65
+ adapter_name.include?("postgres")
66
+ end
67
+
68
+ def mysql?
69
+ adapter_name.include?("mysql") || adapter_name.include?("trilogy")
70
+ end
71
+
72
+ def adapter_name
73
+ connection.adapter_name.to_s.downcase
74
+ end
46
75
  end
@@ -14,17 +14,17 @@ class Create<%= message_class_name.pluralize %> < ActiveRecord::Migration<%= mig
14
14
  # For tool calls and results
15
15
  t.string :tool_call_id
16
16
  t.string :tool_name
17
- t.jsonb :tool_arguments, default: {}
18
- t.jsonb :tool_result
17
+ t.column :tool_arguments, json_type, **json_default({})
18
+ t.column :tool_result, json_type
19
19
 
20
20
  # For multimodal content
21
- t.jsonb :attachments, default: []
21
+ t.column :attachments, json_type, **json_default([])
22
22
 
23
23
  # Metadata
24
- t.jsonb :metadata, default: {}
24
+ t.column :metadata, json_type, **json_default({})
25
25
 
26
26
  # Provenance snapshot + content checksum (populated by HasContext)
27
- t.jsonb :provenance, default: {}
27
+ t.column :provenance, json_type, **json_default({})
28
28
  t.string :content_checksum
29
29
 
30
30
  t.timestamps
@@ -34,4 +34,33 @@ class Create<%= message_class_name.pluralize %> < ActiveRecord::Migration<%= mig
34
34
  add_index :<%= message_table_name %>, :tool_call_id
35
35
  add_index :<%= message_table_name %>, [:<%= file_name %>_id, :created_at]
36
36
  end
37
+
38
+ private
39
+
40
+ # jsonb on PostgreSQL, json elsewhere: SQLite and MySQL have no jsonb type,
41
+ # and SQLite aborts the migration on it.
42
+ def json_type
43
+ @json_type ||= postgres? ? :jsonb : :json
44
+ end
45
+
46
+ # MySQL rejects a default on a JSON column outright ("BLOB, TEXT, GEOMETRY or
47
+ # JSON column can't have a default value"), which aborts the migration, so
48
+ # the column is created without one there. The gem assigns every JSON column
49
+ # it writes and reads them nil-safely, so a NULL reads as the empty default
50
+ # would.
51
+ def json_default(value)
52
+ mysql? ? {} : { default: value }
53
+ end
54
+
55
+ def postgres?
56
+ adapter_name.include?("postgres")
57
+ end
58
+
59
+ def mysql?
60
+ adapter_name.include?("mysql") || adapter_name.include?("trilogy")
61
+ end
62
+
63
+ def adapter_name
64
+ connection.adapter_name.to_s.downcase
65
+ end
37
66
  end
@@ -14,7 +14,7 @@ class CreateAgentContexts < ActiveRecord::Migration<%= migration_version %>
14
14
  t.text :instructions
15
15
 
16
16
  # Flexible options storage (input_params, model settings, etc.)
17
- t.jsonb :options, default: {}
17
+ t.column :options, json_type, **json_default({})
18
18
 
19
19
  # Tracing/debugging
20
20
  t.string :trace_id, index: true
@@ -29,4 +29,33 @@ class CreateAgentContexts < ActiveRecord::Migration<%= migration_version %>
29
29
  add_index :agent_contexts, [:agent_name, :action_name]
30
30
  add_index :agent_contexts, :created_at
31
31
  end
32
+
33
+ private
34
+
35
+ # jsonb on PostgreSQL, json elsewhere: SQLite and MySQL have no jsonb type,
36
+ # and SQLite aborts the migration on it.
37
+ def json_type
38
+ @json_type ||= postgres? ? :jsonb : :json
39
+ end
40
+
41
+ # MySQL rejects a default on a JSON column outright ("BLOB, TEXT, GEOMETRY or
42
+ # JSON column can't have a default value"), which aborts the migration, so
43
+ # the column is created without one there. The gem assigns every JSON column
44
+ # it writes and reads them nil-safely, so a NULL reads as the empty default
45
+ # would.
46
+ def json_default(value)
47
+ mysql? ? {} : { default: value }
48
+ end
49
+
50
+ def postgres?
51
+ adapter_name.include?("postgres")
52
+ end
53
+
54
+ def mysql?
55
+ adapter_name.include?("mysql") || adapter_name.include?("trilogy")
56
+ end
57
+
58
+ def adapter_name
59
+ connection.adapter_name.to_s.downcase
60
+ end
32
61
  end
@@ -23,10 +23,10 @@ class CreateAgentGenerations < ActiveRecord::Migration<%= migration_version %>
23
23
  t.integer :reasoning_tokens, default: 0
24
24
 
25
25
  # Tool calls made in this generation
26
- t.jsonb :tool_calls, default: []
26
+ t.column :tool_calls, json_type, **json_default([])
27
27
 
28
28
  # Raw response from the provider (for debugging)
29
- t.jsonb :raw_response
29
+ t.column :raw_response, json_type
30
30
 
31
31
  # Timing
32
32
  t.float :duration_seconds
@@ -38,7 +38,7 @@ class CreateAgentGenerations < ActiveRecord::Migration<%= migration_version %>
38
38
 
39
39
  # Provenance snapshot (agent/prompt/context checksums) captured at
40
40
  # generation time — see SolidAgent::HasContext#current_provenance
41
- t.jsonb :provenance, default: {}
41
+ t.column :provenance, json_type, **json_default({})
42
42
 
43
43
  t.timestamps
44
44
  end
@@ -48,4 +48,33 @@ class CreateAgentGenerations < ActiveRecord::Migration<%= migration_version %>
48
48
  add_index :agent_generations, :trace_id
49
49
  add_index :agent_generations, [:agent_context_id, :created_at]
50
50
  end
51
+
52
+ private
53
+
54
+ # jsonb on PostgreSQL, json elsewhere: SQLite and MySQL have no jsonb type,
55
+ # and SQLite aborts the migration on it.
56
+ def json_type
57
+ @json_type ||= postgres? ? :jsonb : :json
58
+ end
59
+
60
+ # MySQL rejects a default on a JSON column outright ("BLOB, TEXT, GEOMETRY or
61
+ # JSON column can't have a default value"), which aborts the migration, so
62
+ # the column is created without one there. The gem assigns every JSON column
63
+ # it writes and reads them nil-safely, so a NULL reads as the empty default
64
+ # would.
65
+ def json_default(value)
66
+ mysql? ? {} : { default: value }
67
+ end
68
+
69
+ def postgres?
70
+ adapter_name.include?("postgres")
71
+ end
72
+
73
+ def mysql?
74
+ adapter_name.include?("mysql") || adapter_name.include?("trilogy")
75
+ end
76
+
77
+ def adapter_name
78
+ connection.adapter_name.to_s.downcase
79
+ end
51
80
  end
@@ -14,18 +14,18 @@ class CreateAgentMessages < ActiveRecord::Migration<%= migration_version %>
14
14
  # For tool calls and results
15
15
  t.string :tool_call_id
16
16
  t.string :tool_name
17
- t.jsonb :tool_arguments, default: {}
18
- t.jsonb :tool_result
17
+ t.column :tool_arguments, json_type, **json_default({})
18
+ t.column :tool_result, json_type
19
19
 
20
20
  # For multimodal content (images, files)
21
- t.jsonb :attachments, default: []
21
+ t.column :attachments, json_type, **json_default([])
22
22
 
23
23
  # Metadata
24
- t.jsonb :metadata, default: {}
24
+ t.column :metadata, json_type, **json_default({})
25
25
 
26
26
  # Provenance snapshot + content checksum, populated automatically by
27
27
  # SolidAgent::HasContext when present
28
- t.jsonb :provenance, default: {}
28
+ t.column :provenance, json_type, **json_default({})
29
29
  t.string :content_checksum
30
30
 
31
31
  t.timestamps
@@ -35,4 +35,33 @@ class CreateAgentMessages < ActiveRecord::Migration<%= migration_version %>
35
35
  add_index :agent_messages, :tool_call_id
36
36
  add_index :agent_messages, [:agent_context_id, :created_at]
37
37
  end
38
+
39
+ private
40
+
41
+ # jsonb on PostgreSQL, json elsewhere: SQLite and MySQL have no jsonb type,
42
+ # and SQLite aborts the migration on it.
43
+ def json_type
44
+ @json_type ||= postgres? ? :jsonb : :json
45
+ end
46
+
47
+ # MySQL rejects a default on a JSON column outright ("BLOB, TEXT, GEOMETRY or
48
+ # JSON column can't have a default value"), which aborts the migration, so
49
+ # the column is created without one there. The gem assigns every JSON column
50
+ # it writes and reads them nil-safely, so a NULL reads as the empty default
51
+ # would.
52
+ def json_default(value)
53
+ mysql? ? {} : { default: value }
54
+ end
55
+
56
+ def postgres?
57
+ adapter_name.include?("postgres")
58
+ end
59
+
60
+ def mysql?
61
+ adapter_name.include?("mysql") || adapter_name.include?("trilogy")
62
+ end
63
+
64
+ def adapter_name
65
+ connection.adapter_name.to_s.downcase
66
+ end
38
67
  end
@@ -16,14 +16,14 @@ class CreateAgentRuns < ActiveRecord::Migration<%= migration_version %>
16
16
  t.string :status, null: false, default: "pending"
17
17
 
18
18
  t.text :input_prompt
19
- t.jsonb :input_params, default: {}
19
+ t.column :input_params, json_type, **json_default({})
20
20
  t.text :output
21
- t.jsonb :output_metadata, default: {}
21
+ t.column :output_metadata, json_type, **json_default({})
22
22
  t.text :error_message
23
23
 
24
24
  # Append-only progress events ({at, eid, kind, label, status,
25
25
  # detail, duration_ms}) streamed to pollers mid-run.
26
- t.jsonb :events, default: []
26
+ t.column :events, json_type, **json_default([])
27
27
 
28
28
  # 8-char fingerprint of the instructions the run executed under —
29
29
  # the cohort grouping key for instruction/model comparisons.
@@ -43,4 +43,33 @@ class CreateAgentRuns < ActiveRecord::Migration<%= migration_version %>
43
43
  add_index :agent_runs, :instructions_digest
44
44
  add_index :agent_runs, :created_at
45
45
  end
46
+
47
+ private
48
+
49
+ # jsonb on PostgreSQL, json elsewhere: SQLite and MySQL have no jsonb type,
50
+ # and SQLite aborts the migration on it.
51
+ def json_type
52
+ @json_type ||= postgres? ? :jsonb : :json
53
+ end
54
+
55
+ # MySQL rejects a default on a JSON column outright ("BLOB, TEXT, GEOMETRY or
56
+ # JSON column can't have a default value"), which aborts the migration, so
57
+ # the column is created without one there. The gem assigns every JSON column
58
+ # it writes and reads them nil-safely, so a NULL reads as the empty default
59
+ # would.
60
+ def json_default(value)
61
+ mysql? ? {} : { default: value }
62
+ end
63
+
64
+ def postgres?
65
+ adapter_name.include?("postgres")
66
+ end
67
+
68
+ def mysql?
69
+ adapter_name.include?("mysql") || adapter_name.include?("trilogy")
70
+ end
71
+
72
+ def adapter_name
73
+ connection.adapter_name.to_s.downcase
74
+ end
46
75
  end
@@ -4,9 +4,38 @@ class AddReasoningTo<%= @table_name.camelize %> < ActiveRecord::Migration[7.0]
4
4
  def change
5
5
  add_column :<%= @table_name %>, :<%= @content_column %>, :text
6
6
  add_column :<%= @table_name %>, :<%= @tokens_column %>, :integer, default: 0
7
- add_column :<%= @table_name %>, :<%= @metadata_column %>, :jsonb, default: {}
7
+ add_column :<%= @table_name %>, :<%= @metadata_column %>, json_type, **json_default({})
8
8
 
9
9
  add_index :<%= @table_name %>, :<%= @tokens_column %>,
10
10
  name: "index_<%= @table_name %>_on_<%= @tokens_column %>"
11
11
  end
12
+
13
+ private
14
+
15
+ # jsonb on PostgreSQL, json elsewhere: SQLite and MySQL have no jsonb type,
16
+ # and SQLite aborts the migration on it.
17
+ def json_type
18
+ @json_type ||= postgres? ? :jsonb : :json
19
+ end
20
+
21
+ # MySQL rejects a default on a JSON column outright ("BLOB, TEXT, GEOMETRY or
22
+ # JSON column can't have a default value"), which aborts the migration, so
23
+ # the column is created without one there. The gem assigns every JSON column
24
+ # it writes and reads them nil-safely, so a NULL reads as the empty default
25
+ # would.
26
+ def json_default(value)
27
+ mysql? ? {} : { default: value }
28
+ end
29
+
30
+ def postgres?
31
+ adapter_name.include?("postgres")
32
+ end
33
+
34
+ def mysql?
35
+ adapter_name.include?("mysql") || adapter_name.include?("trilogy")
36
+ end
37
+
38
+ def adapter_name
39
+ connection.adapter_name.to_s.downcase
40
+ end
12
41
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "digest"
4
+ require "set"
4
5
 
5
6
  # HasContext provides database-backed prompt context management for agents.
6
7
  #
@@ -542,9 +543,34 @@ module SolidAgent
542
543
  self.class.public_send(reader)&.except(:access_token, :api_key)
543
544
  end
544
545
 
545
- # After prompt callback - persists the rendered prompt message to context
546
+ # Marks this generation as resuming one that paused for user input, for a
547
+ # host that replays a stored conversation itself. Private, like
548
+ # #resuming_generation?, because every public method on an agent is one
549
+ # of its actions.
550
+ #
551
+ # @example
552
+ # before_generation { self.resuming_generation = params[:checkpoint].present? }
553
+ attr_writer :resuming_generation
554
+
555
+ # Returns whether this generation resumes one that paused for user input:
556
+ # true when `self.resuming_generation = true` was set, or when the agent
557
+ # defines a public `resuming?` (the framework's resume flag) that returns
558
+ # true. A resuming generation does not persist its prompt.
559
+ #
560
+ # @return [Boolean]
561
+ def resuming_generation?
562
+ return true if @resuming_generation
563
+ return false unless respond_to?(:resuming?)
564
+
565
+ resuming? ? true : false
566
+ end
567
+
568
+ # After prompt callback - persists the rendered prompt message to context.
569
+ # Skipped for a resumed generation, because the generation that paused
570
+ # already persisted the user turn and the resumed prompt replays it.
546
571
  def persist_prompt_to_context
547
572
  return unless context
573
+ return if resuming_generation?
548
574
 
549
575
  if prompt_options[:messages].present?
550
576
  rendered_message = prompt_options[:messages].last
@@ -560,10 +586,18 @@ module SolidAgent
560
586
  generation_response
561
587
  end
562
588
 
563
- # Persists the generation response to context
589
+ # Persists the generation response to context. A response paused for user
590
+ # input is skipped, because its last message is an unfinished turn. The
591
+ # response of the generation that resumes it repeats the restored tool
592
+ # results, so they are persisted with the final answer.
564
593
  def persist_generation_to_context
565
594
  return unless context && generation_response
566
595
 
596
+ if generation_paused?
597
+ Rails.logger.info "[SolidAgent] Skipping persistence - generation is awaiting user input"
598
+ return
599
+ end
600
+
567
601
  persist_tool_messages_to_context
568
602
 
569
603
  begin
@@ -584,6 +618,12 @@ module SolidAgent
584
618
  end
585
619
  end
586
620
 
621
+ def generation_paused?
622
+ return false unless generation_response.respond_to?(:awaiting_input?)
623
+
624
+ generation_response.awaiting_input? ? true : false
625
+ end
626
+
587
627
  # Overridable enrichment hook for tool persistence. Executors that run
588
628
  # tools server-side (a platform's execution service, a job) can
589
629
  # override this to return their own invocation records — an array of
@@ -602,19 +642,25 @@ module SolidAgent
602
642
  #
603
643
  # Requires the context model to expose add_tool_message (the install
604
644
  # generator's AgentContext does); contexts without it are skipped.
605
- # Messages are deduped by tool_call_id so re-persisting a shared
606
- # message stack (multi-turn conversations) doesn't duplicate rows.
645
+ # Messages are deduped by tool_call_id. A response's stack repeats
646
+ # earlier turns (a multi-turn conversation's history, a resumed
647
+ # generation's restored conversation), so a call already on the context
648
+ # or earlier in the same stack is skipped.
607
649
  def persist_tool_messages_to_context
608
650
  return unless context.respond_to?(:add_tool_message)
609
651
  return unless generation_response.respond_to?(:messages)
610
652
 
611
653
  tool_index = -1
654
+ seen_tool_call_ids = Set.new
612
655
  Array(generation_response.messages).each do |message|
613
656
  next unless message.respond_to?(:role) && message.role.to_s == "tool"
614
657
 
615
658
  tool_index += 1
616
659
  tool_call_id = message.respond_to?(:tool_call_id) ? message.tool_call_id : nil
617
- next if tool_call_id.present? && tool_message_persisted?(tool_call_id)
660
+ if tool_call_id.present?
661
+ next unless seen_tool_call_ids.add?(tool_call_id.to_s)
662
+ next if tool_message_persisted?(tool_call_id)
663
+ end
618
664
 
619
665
  invocation = tool_invocation_for(tool_call_id, tool_index)
620
666
  # Provider tool messages often carry no name (Ollama's don't); the
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SolidAgent
4
- VERSION = "0.2.0"
4
+ VERSION = "0.2.2"
5
5
  end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/solid_agent/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "solid_agent"
7
+ spec.version = SolidAgent::VERSION
8
+ spec.authors = ["Justin Bowen"]
9
+ spec.email = ["JusBowen@gmail.com"]
10
+
11
+ spec.summary = "Database-backed context, tools, and streaming for ActiveAgent"
12
+ spec.description = "SolidAgent extends ActiveAgent with database-backed prompt context management, declarative tool schemas, and real-time streaming updates. Provides has_context, has_tools, and streams_tool_updates concerns for building robust AI agents."
13
+ spec.homepage = "https://docs.activeagents.ai/solid_agent"
14
+ spec.license = "MIT"
15
+ spec.required_ruby_version = ">= 3.0.0"
16
+
17
+ spec.metadata["homepage_uri"] = spec.homepage
18
+ spec.metadata["source_code_uri"] = "https://github.com/activeagents/solid_agent"
19
+ spec.metadata["changelog_uri"] = "https://github.com/activeagents/solid_agent/blob/main/CHANGELOG.md"
20
+ spec.metadata["documentation_uri"] = "https://docs.activeagents.ai/solid_agent"
21
+
22
+ # Specify which files should be added to the gem when it is released.
23
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
24
+ spec.files = Dir.chdir(__dir__) do
25
+ `git ls-files -z`.split("\x0").reject do |f|
26
+ (File.expand_path(f) == __FILE__) ||
27
+ f.start_with?(*%w[bin/ test/ spec/ features/ .git assets/ appveyor Gemfile])
28
+ end
29
+ end
30
+ spec.bindir = "exe"
31
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
32
+ spec.require_paths = ["lib"]
33
+
34
+ spec.add_dependency "activeagent", ">= 1.0.0"
35
+ spec.add_dependency "activesupport", ">= 7.0"
36
+ spec.add_dependency "activerecord", ">= 7.0"
37
+ # AgentManifest is an ActiveModel, and lib/solid_agent.rb now requires it by
38
+ # name so the gem loads outside Rails. It arrived transitively through
39
+ # activerecord before; a require deserves a declaration.
40
+ spec.add_dependency "activemodel", ">= 7.0"
41
+ end
metadata CHANGED
@@ -1,13 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: solid_agent
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.2.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Justin Bowen
8
+ autorequire:
8
9
  bindir: exe
9
10
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
11
+ date: 2026-10-31 00:00:00.000000000 Z
11
12
  dependencies:
12
13
  - !ruby/object:Gem::Dependency
13
14
  name: activeagent
@@ -175,6 +176,7 @@ files:
175
176
  - lib/solid_agent/tool_cache.rb
176
177
  - lib/solid_agent/version.rb
177
178
  - sig/solid_agent.rbs
179
+ - solid_agent.gemspec
178
180
  homepage: https://docs.activeagents.ai/solid_agent
179
181
  licenses:
180
182
  - MIT
@@ -183,6 +185,7 @@ metadata:
183
185
  source_code_uri: https://github.com/activeagents/solid_agent
184
186
  changelog_uri: https://github.com/activeagents/solid_agent/blob/main/CHANGELOG.md
185
187
  documentation_uri: https://docs.activeagents.ai/solid_agent
188
+ post_install_message:
186
189
  rdoc_options: []
187
190
  require_paths:
188
191
  - lib
@@ -197,7 +200,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
197
200
  - !ruby/object:Gem::Version
198
201
  version: '0'
199
202
  requirements: []
200
- rubygems_version: 3.7.2
203
+ rubygems_version: 3.5.22
204
+ signing_key:
201
205
  specification_version: 4
202
206
  summary: Database-backed context, tools, and streaming for ActiveAgent
203
207
  test_files: []