Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- **`CHANGELOG.md`**, which the gemspec already advertised.

- **`HasContext#resuming_generation?`** answers whether a generation resumes
one that paused for user input: true when the agent defines a public
`resuming?` that returns true, or after `self.resuming_generation = true`.
The writer is for a host that replays a stored conversation itself. Both
are private, so neither becomes one of the agent's actions.

### Fixed

- **`HasContext` skips persisting the response of a generation paused for
user input, and the prompt of the generation that resumes it.** A response
answering `awaiting_input?` with true is returned untouched: no generation
record, no assistant row and no tool rows, because its last message is an
unfinished turn. The paused generation's prompt, the user turn, is still
persisted. The resumed generation skips prompt persistence, because its
prompt replays that turn, and persists its response as usual. Both checks
are duck-typed, so framework releases without a pause behave as before.

- Tool messages are now deduped by `tool_call_id` within one response's
stack as well as against rows already on the context, so a stack that
repeats a call persists it once even when the context's `messages` scope
cannot see rows written earlier in the same pass.

- `SolidAgent.context_class`, `message_class` and `generation_class` had no
consumers while the shipped initializer template told hosts to set them, so
uncommenting it did nothing. `HasContext#infer_class_names` now reads them.
Expand Down
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,28 @@ the user turn and the response as the assistant turn, both after the
provider call — so reach for `add_conversation_user_message` only with
`auto_save: false`, or the turn is stored twice.

A generation that pauses for user input (its response answers
`awaiting_input?` with true) persists only its prompt, the user turn. The
paused response writes no generation record, assistant row or tool rows. The
generation that resumes it persists the tool results and the final answer,
and skips its own prompt, which replays the user turn already stored. Tool
results are deduped by `tool_call_id`, so each is stored once.

The resumed generation writes to whichever context it holds, so it must load
the paused generation's context. Give `has_context` a `contextual:` param
that names the same record on both runs, or load the context by id in the
action (`load_conversation(context_id: ...)`).
Without either, each agent instance creates its own anonymous context, and
the user turn and the answer land in two different contexts.

The agent counts as resuming when the framework's `resuming?` returns true.
A host that replays a stored conversation itself sets the flag from inside
the agent:

```ruby
before_generation { self.resuming_generation = params[:checkpoint].present? }
```

> **Naming a context also names its models.** `has_context :conversation`
> infers `Conversation`, `ConversationMessage` and `ConversationGeneration`,
> not the `AgentContext` family the installer wrote — hence `class_name:`
Expand Down
56 changes: 51 additions & 5 deletions lib/solid_agent/has_context.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# frozen_string_literal: true

require "digest"
require "set"

# HasContext provides database-backed prompt context management for agents.
#
Expand Down Expand Up @@ -542,9 +543,34 @@ def class_options(reader)
self.class.public_send(reader)&.except(:access_token, :api_key)
end

# After prompt callback - persists the rendered prompt message to context
# Marks this generation as resuming one that paused for user input, for a
# host that replays a stored conversation itself. Private, like
# #resuming_generation?, because every public method on an agent is one
# of its actions.
#
# @example
# before_generation { self.resuming_generation = params[:checkpoint].present? }
attr_writer :resuming_generation

# Returns whether this generation resumes one that paused for user input:
# true when `self.resuming_generation = true` was set, or when the agent
# defines a public `resuming?` (the framework's resume flag) that returns
# true. A resuming generation does not persist its prompt.
#
# @return [Boolean]
def resuming_generation?
return true if @resuming_generation
return false unless respond_to?(:resuming?)

resuming? ? true : false
end

# After prompt callback - persists the rendered prompt message to context.
# Skipped for a resumed generation, because the generation that paused
# already persisted the user turn and the resumed prompt replays it.
def persist_prompt_to_context
return unless context
return if resuming_generation?

if prompt_options[:messages].present?
rendered_message = prompt_options[:messages].last
Expand All @@ -560,10 +586,18 @@ def capture_and_persist_generation
generation_response
end

# Persists the generation response to context
# Persists the generation response to context. A response paused for user
# input is skipped, because its last message is an unfinished turn. The
# response of the generation that resumes it repeats the restored tool
# results, so they are persisted with the final answer.
def persist_generation_to_context
return unless context && generation_response

if generation_paused?
Rails.logger.info "[SolidAgent] Skipping persistence - generation is awaiting user input"
return
end

persist_tool_messages_to_context

begin
Expand All @@ -584,6 +618,12 @@ def persist_generation_to_context
end
end

def generation_paused?
return false unless generation_response.respond_to?(:awaiting_input?)

generation_response.awaiting_input? ? true : false
end

# Overridable enrichment hook for tool persistence. Executors that run
# tools server-side (a platform's execution service, a job) can
# override this to return their own invocation records — an array of
Expand All @@ -602,19 +642,25 @@ def tool_invocations
#
# Requires the context model to expose add_tool_message (the install
# generator's AgentContext does); contexts without it are skipped.
# Messages are deduped by tool_call_id so re-persisting a shared
# message stack (multi-turn conversations) doesn't duplicate rows.
# Messages are deduped by tool_call_id. A response's stack repeats
# earlier turns (a multi-turn conversation's history, a resumed
# generation's restored conversation), so a call already on the context
# or earlier in the same stack is skipped.
def persist_tool_messages_to_context
return unless context.respond_to?(:add_tool_message)
return unless generation_response.respond_to?(:messages)

tool_index = -1
seen_tool_call_ids = Set.new
Array(generation_response.messages).each do |message|
next unless message.respond_to?(:role) && message.role.to_s == "tool"

tool_index += 1
tool_call_id = message.respond_to?(:tool_call_id) ? message.tool_call_id : nil
next if tool_call_id.present? && tool_message_persisted?(tool_call_id)
if tool_call_id.present?
next unless seen_tool_call_ids.add?(tool_call_id.to_s)
next if tool_message_persisted?(tool_call_id)
end

invocation = tool_invocation_for(tool_call_id, tool_index)
# Provider tool messages often carry no name (Ollama's don't); the
Expand Down
Loading
Loading