little_ghost 0.1.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.
- checksums.yaml +7 -0
- data/LICENSE.txt +22 -0
- data/README.md +122 -0
- data/docs/guides/Core Concepts.md +203 -0
- data/docs/guides/Getting Started.md +187 -0
- data/lib/little_ghost/ag_ui/adapter.rb +194 -0
- data/lib/little_ghost/ag_ui.rb +5 -0
- data/lib/little_ghost/agent/context_management.rb +285 -0
- data/lib/little_ghost/agent/delegation.rb +128 -0
- data/lib/little_ghost/agent/skills.rb +96 -0
- data/lib/little_ghost/agent/tool_loop.rb +239 -0
- data/lib/little_ghost/agent.rb +2111 -0
- data/lib/little_ghost/agent_builder.rb +191 -0
- data/lib/little_ghost/agent_interruptions.rb +197 -0
- data/lib/little_ghost/configuration.rb +337 -0
- data/lib/little_ghost/content.rb +324 -0
- data/lib/little_ghost/default_model_registry.rb +71 -0
- data/lib/little_ghost/errors.rb +48 -0
- data/lib/little_ghost/events.rb +264 -0
- data/lib/little_ghost/execution_state.rb +58 -0
- data/lib/little_ghost/instrumentation.rb +475 -0
- data/lib/little_ghost/invocation.rb +285 -0
- data/lib/little_ghost/lookup.rb +37 -0
- data/lib/little_ghost/mcp/client.rb +396 -0
- data/lib/little_ghost/mcp.rb +5 -0
- data/lib/little_ghost/message.rb +75 -0
- data/lib/little_ghost/model.rb +88 -0
- data/lib/little_ghost/model_capabilities.rb +126 -0
- data/lib/little_ghost/model_registry.rb +173 -0
- data/lib/little_ghost/model_request.rb +107 -0
- data/lib/little_ghost/model_response.rb +48 -0
- data/lib/little_ghost/path_set.rb +32 -0
- data/lib/little_ghost/prompt_resolver.rb +251 -0
- data/lib/little_ghost/providers/bedrock.rb +506 -0
- data/lib/little_ghost/providers/http_transport.rb +149 -0
- data/lib/little_ghost/providers/open_router.rb +171 -0
- data/lib/little_ghost/providers/openai.rb +27 -0
- data/lib/little_ghost/providers/openai_compatible.rb +745 -0
- data/lib/little_ghost/providers/sse_parser.rb +35 -0
- data/lib/little_ghost/run.rb +607 -0
- data/lib/little_ghost/run_context.rb +129 -0
- data/lib/little_ghost/run_result.rb +111 -0
- data/lib/little_ghost/runtime/hook.rb +31 -0
- data/lib/little_ghost/runtime.rb +392 -0
- data/lib/little_ghost/sandbox.rb +138 -0
- data/lib/little_ghost/session.rb +229 -0
- data/lib/little_ghost/session_store.rb +96 -0
- data/lib/little_ghost/session_stores/agent_core_memory.rb +1086 -0
- data/lib/little_ghost/session_stores/memory.rb +86 -0
- data/lib/little_ghost/skills/catalog.rb +283 -0
- data/lib/little_ghost/skills/skill.rb +60 -0
- data/lib/little_ghost/skills.rb +4 -0
- data/lib/little_ghost/stream_event.rb +49 -0
- data/lib/little_ghost/structured_output.rb +126 -0
- data/lib/little_ghost/subagents/agent_path.rb +63 -0
- data/lib/little_ghost/subagents/definition.rb +42 -0
- data/lib/little_ghost/subagents/manager.rb +1615 -0
- data/lib/little_ghost/support/callbacks.rb +151 -0
- data/lib/little_ghost/support/cancellation_token.rb +86 -0
- data/lib/little_ghost/support/class_attributes.rb +40 -0
- data/lib/little_ghost/support/content_capture.rb +150 -0
- data/lib/little_ghost/support/executor.rb +75 -0
- data/lib/little_ghost/support/interruptible_stream.rb +103 -0
- data/lib/little_ghost/support/loader.rb +263 -0
- data/lib/little_ghost/support/output_truncation.rb +71 -0
- data/lib/little_ghost/support/redactor.rb +66 -0
- data/lib/little_ghost/support.rb +34 -0
- data/lib/little_ghost/tool.rb +448 -0
- data/lib/little_ghost/tool_execution.rb +59 -0
- data/lib/little_ghost/tool_registry.rb +156 -0
- data/lib/little_ghost/tools/filesystem.rb +119 -0
- data/lib/little_ghost/tools/shell.rb +45 -0
- data/lib/little_ghost/tools/write_todos.rb +91 -0
- data/lib/little_ghost/tools.rb +6 -0
- data/lib/little_ghost/tracing/open_telemetry.rb +517 -0
- data/lib/little_ghost/unrestricted_sandbox.rb +306 -0
- data/lib/little_ghost/usage.rb +47 -0
- data/lib/little_ghost/version.rb +6 -0
- data/lib/little_ghost/workflow.rb +351 -0
- data/lib/little_ghost/workspace.rb +31 -0
- data/lib/little_ghost.rb +120 -0
- metadata +225 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LittleGhost
|
|
4
|
+
# A Sandbox decides what an agent may do with files and processes. Applications
|
|
5
|
+
# can place that work on the host, in a container or VM, or behind a remote
|
|
6
|
+
# execution service without changing their tools.
|
|
7
|
+
#
|
|
8
|
+
# class ContainerSandbox < LittleGhost::Sandbox
|
|
9
|
+
# def initialize(workspace:, container:)
|
|
10
|
+
# super(workspace:)
|
|
11
|
+
# @container = container
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# def read(path, context: nil)
|
|
15
|
+
# context&.check!
|
|
16
|
+
# @container.read(path)
|
|
17
|
+
# end
|
|
18
|
+
#
|
|
19
|
+
# def execute_program(command, timeout:, context: nil, **)
|
|
20
|
+
# result = @container.run(
|
|
21
|
+
# command, timeout:, cancellation: context&.cancellation_token
|
|
22
|
+
# )
|
|
23
|
+
# Execution.new(**result)
|
|
24
|
+
# end
|
|
25
|
+
# end
|
|
26
|
+
#
|
|
27
|
+
# Implementations confine paths to #workspace, honor RunContext cancellation,
|
|
28
|
+
# enforce time and output limits, and return
|
|
29
|
+
# {Execution}[rdoc-ref:LittleGhost::Sandbox::Execution] from process
|
|
30
|
+
# operations.
|
|
31
|
+
class Sandbox
|
|
32
|
+
# Contains captured process output, exit status, and an optional execution
|
|
33
|
+
# error.
|
|
34
|
+
Execution = Data.define(:stdout, :stderr, :exit_code, :error) do # :nodoc:
|
|
35
|
+
def initialize(stdout:, stderr:, exit_code:, error: nil)
|
|
36
|
+
super
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def success?
|
|
40
|
+
error.nil? && exit_code&.zero?
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Carries captured process output, exit status, and an optional execution
|
|
45
|
+
# error from Sandbox#execute or Sandbox#execute_program.
|
|
46
|
+
class Execution < Data # :doc:
|
|
47
|
+
##
|
|
48
|
+
# :singleton-method: new
|
|
49
|
+
# :call-seq:
|
|
50
|
+
# new(stdout:, stderr:, exit_code:, error: nil) -> Execution
|
|
51
|
+
#
|
|
52
|
+
# Collects the observable result of one sandbox process.
|
|
53
|
+
|
|
54
|
+
##
|
|
55
|
+
# :attr_reader: stdout
|
|
56
|
+
# Captured standard output, subject to the sandbox's output limit.
|
|
57
|
+
|
|
58
|
+
##
|
|
59
|
+
# :attr_reader: stderr
|
|
60
|
+
# Captured standard error, subject to the sandbox's output limit.
|
|
61
|
+
|
|
62
|
+
##
|
|
63
|
+
# :attr_reader: exit_code
|
|
64
|
+
# The child process exit status, when one is available.
|
|
65
|
+
|
|
66
|
+
##
|
|
67
|
+
# :attr_reader: error
|
|
68
|
+
# The execution error, or +nil+ when the process completed normally.
|
|
69
|
+
|
|
70
|
+
##
|
|
71
|
+
# :method: success?
|
|
72
|
+
# Indicates that no execution error occurred and the exit code is zero.
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Binds the sandbox to +workspace+.
|
|
76
|
+
def initialize(workspace:)
|
|
77
|
+
@workspace = workspace
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Workspace whose files and processes this sandbox governs.
|
|
81
|
+
attr_reader :workspace
|
|
82
|
+
|
|
83
|
+
# Opens any run-scoped resources and makes the sandbox ready for tools.
|
|
84
|
+
def open(run: nil)
|
|
85
|
+
self
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Indicates whether filesystem mutation is allowed.
|
|
89
|
+
def writable? = false
|
|
90
|
+
|
|
91
|
+
# Reads UTF-8 text at a workspace-relative +path+.
|
|
92
|
+
def read(path, context: nil)
|
|
93
|
+
raise NotImplementedError, "#{self.class} does not support filesystem reads"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Lists entries at a workspace-relative directory +path+.
|
|
97
|
+
def list(path = ".", context: nil)
|
|
98
|
+
raise NotImplementedError, "#{self.class} does not support filesystem listings"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Writes +content+ to a workspace-relative +path+.
|
|
102
|
+
def write(path, content, context: nil)
|
|
103
|
+
raise NotImplementedError, "#{self.class} does not support filesystem writes"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Replaces one exact +old_text+ occurrence with +new_text+.
|
|
107
|
+
def replace(path, old_text, new_text, context: nil)
|
|
108
|
+
raise NotImplementedError, "#{self.class} does not support filesystem edits"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Executes +command+ through +/bin/sh+.
|
|
112
|
+
#
|
|
113
|
+
# Prefer #execute_program for model-controlled arguments so shell syntax is
|
|
114
|
+
# not interpreted.
|
|
115
|
+
def execute(command, timeout:, context: nil, max_output_bytes: 1_000_000, **options)
|
|
116
|
+
execute_program(
|
|
117
|
+
["/bin/sh", "-c", String(command)],
|
|
118
|
+
timeout:,
|
|
119
|
+
context:,
|
|
120
|
+
max_output_bytes:,
|
|
121
|
+
**options
|
|
122
|
+
)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Executes an argument vector without shell interpretation.
|
|
126
|
+
#
|
|
127
|
+
# Implementations must enforce +timeout+ and +max_output_bytes+. Environment
|
|
128
|
+
# inheritance is disabled by default to avoid leaking process credentials.
|
|
129
|
+
def execute_program(command, timeout:, context: nil, max_output_bytes: 1_000_000, environment: {}, inherit_environment: false)
|
|
130
|
+
raise NotImplementedError, "#{self.class} does not support program execution"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Releases sandbox resources. Runs close the sandbox before its workspace.
|
|
134
|
+
def close
|
|
135
|
+
nil
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LittleGhost
|
|
4
|
+
# Sessions let an agent continue a conversation without tying it to one Ruby
|
|
5
|
+
# process. Each session keeps messages, application state, and metadata
|
|
6
|
+
# together behind a SessionStore.
|
|
7
|
+
#
|
|
8
|
+
# session = LittleGhost::Session.new(
|
|
9
|
+
# id: "conversation-42",
|
|
10
|
+
# actor_id: "user-7",
|
|
11
|
+
# store: LittleGhost::SessionStores::Memory.new
|
|
12
|
+
# )
|
|
13
|
+
# session.append(
|
|
14
|
+
# messages: [LittleGhost::Message.new(role: :user, content: "Hello")],
|
|
15
|
+
# state: {language: "en"}
|
|
16
|
+
# )
|
|
17
|
+
#
|
|
18
|
+
# reopened = LittleGhost::Session.new(
|
|
19
|
+
# id: "conversation-42",
|
|
20
|
+
# actor_id: "user-7",
|
|
21
|
+
# store: session.store
|
|
22
|
+
# )
|
|
23
|
+
# reopened.history.last.text # => "Hello"
|
|
24
|
+
# reopened.state # => {language: "en"}
|
|
25
|
+
#
|
|
26
|
+
# === Persistence and trust
|
|
27
|
+
#
|
|
28
|
+
# System messages, transient messages, and private model reasoning are removed
|
|
29
|
+
# before persistence. Store failures reach the caller; a successful write is
|
|
30
|
+
# the checkpoint boundary.
|
|
31
|
+
#
|
|
32
|
+
# Multi-tenant applications must derive +actor_id+ from stable, authenticated
|
|
33
|
+
# identity. A nil actor provides no tenant isolation and is appropriate only
|
|
34
|
+
# for a store that serves one actor.
|
|
35
|
+
class Session
|
|
36
|
+
# The store key, explicit actor identity, backing store, and telemetry
|
|
37
|
+
# operation used by this session.
|
|
38
|
+
attr_reader :id, :actor_id, :store, :operation_id
|
|
39
|
+
|
|
40
|
+
# No store access occurs until the session is read or written.
|
|
41
|
+
def initialize(id:, store:, actor_id: nil, metadata: {}, operation_id: nil)
|
|
42
|
+
@id = String(id)
|
|
43
|
+
@actor_id = actor_id&.to_s
|
|
44
|
+
@store = store
|
|
45
|
+
@operation_id = operation_id
|
|
46
|
+
@metadata = metadata.to_h.freeze
|
|
47
|
+
@loaded = false
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Loads and normalizes the snapshot once. A new session has no snapshot.
|
|
51
|
+
def load
|
|
52
|
+
return @snapshot if @loaded
|
|
53
|
+
|
|
54
|
+
value = with_store_operation_context { store.load(id, actor_id:) }
|
|
55
|
+
@snapshot = normalize(value)
|
|
56
|
+
@loaded = true
|
|
57
|
+
@snapshot
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Uses persisted conversation messages when present and +fallback+ for a new
|
|
61
|
+
# session.
|
|
62
|
+
def history(fallback: [])
|
|
63
|
+
load&.fetch(:messages) || fallback
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Exposes a mutable copy of the persisted application state.
|
|
67
|
+
def state
|
|
68
|
+
snapshot = load
|
|
69
|
+
snapshot ? mutable_copy(snapshot.fetch(:state)) : {}
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Uses persisted metadata when present and otherwise keeps the metadata from
|
|
73
|
+
# construction.
|
|
74
|
+
def metadata
|
|
75
|
+
load&.fetch(:metadata) || @metadata
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Atomically appends +messages+ when the store still has the expected
|
|
79
|
+
# history length. Prefer #checkpoint when replacing earlier messages is
|
|
80
|
+
# also valid.
|
|
81
|
+
def append(messages:, state: self.state, metadata: self.metadata)
|
|
82
|
+
current = current_snapshot
|
|
83
|
+
added = persistable_messages(messages)
|
|
84
|
+
snapshot = build_snapshot(
|
|
85
|
+
messages: [*current.fetch(:messages), *added],
|
|
86
|
+
state:,
|
|
87
|
+
metadata:
|
|
88
|
+
)
|
|
89
|
+
with_store_operation_context do
|
|
90
|
+
store.append(
|
|
91
|
+
id,
|
|
92
|
+
messages: added,
|
|
93
|
+
state: snapshot.fetch(:state),
|
|
94
|
+
metadata: snapshot.fetch(:metadata),
|
|
95
|
+
expected_count: current.fetch(:messages).length,
|
|
96
|
+
actor_id:
|
|
97
|
+
)
|
|
98
|
+
end
|
|
99
|
+
remember(snapshot)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Replaces the complete persisted snapshot.
|
|
103
|
+
def replace(messages:, state: self.state, metadata: self.metadata)
|
|
104
|
+
snapshot = build_snapshot(messages:, state:, metadata:)
|
|
105
|
+
with_store_operation_context { store.replace(id, actor_id:, **snapshot) }
|
|
106
|
+
remember(snapshot)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Persists one conversation checkpoint. History is appended when the stored
|
|
110
|
+
# messages are an unchanged prefix and replaced otherwise.
|
|
111
|
+
def checkpoint(messages:, state: self.state, metadata: self.metadata, parent_operation_id: @operation_id)
|
|
112
|
+
with_store_operation_context(parent_operation_id) do
|
|
113
|
+
snapshot = build_snapshot(messages:, state:, metadata:)
|
|
114
|
+
current = current_snapshot
|
|
115
|
+
if message_prefix?(current.fetch(:messages), snapshot.fetch(:messages))
|
|
116
|
+
added = snapshot.fetch(:messages).drop(current.fetch(:messages).length)
|
|
117
|
+
unless added.empty? && same_session_data?(current, snapshot)
|
|
118
|
+
store.append(
|
|
119
|
+
id,
|
|
120
|
+
messages: added,
|
|
121
|
+
state: snapshot.fetch(:state),
|
|
122
|
+
metadata: snapshot.fetch(:metadata),
|
|
123
|
+
expected_count: current.fetch(:messages).length,
|
|
124
|
+
actor_id:
|
|
125
|
+
)
|
|
126
|
+
end
|
|
127
|
+
else
|
|
128
|
+
store.replace(id, actor_id:, **snapshot)
|
|
129
|
+
end
|
|
130
|
+
remember(snapshot)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Checkpoints the messages and state from a completed run result.
|
|
135
|
+
def checkpoint_result(result)
|
|
136
|
+
checkpoint(messages: result.messages, state: result.state)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Serializes work for this session and actor through the backing store.
|
|
140
|
+
def synchronize(&block)
|
|
141
|
+
store.synchronize(id, actor_id:, &block)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Publishes a conversational view without changing the session's stored
|
|
145
|
+
# transcript. Unlike session persistence, projection does not automatically
|
|
146
|
+
# remove system or transient messages; callers must omit any message whose
|
|
147
|
+
# visible text should stay local. Stores that do not support projections
|
|
148
|
+
# return nil.
|
|
149
|
+
def project_conversation(messages:, metadata: self.metadata)
|
|
150
|
+
with_store_operation_context do
|
|
151
|
+
store.project_conversation(id, messages:, metadata:, actor_id:)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
private
|
|
156
|
+
|
|
157
|
+
def current_snapshot
|
|
158
|
+
load || build_snapshot(messages: [], state: {}, metadata: @metadata)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def with_store_operation_context(operation_id = @operation_id)
|
|
162
|
+
store.with_operation_context(operation_id) { yield }
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def build_snapshot(messages:, state:, metadata:)
|
|
166
|
+
{
|
|
167
|
+
messages: persistable_messages(messages),
|
|
168
|
+
state: Support.deep_dup(state.to_h),
|
|
169
|
+
metadata: Support.deep_dup(metadata.to_h)
|
|
170
|
+
}.freeze
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def remember(snapshot)
|
|
174
|
+
@snapshot = snapshot
|
|
175
|
+
@loaded = true
|
|
176
|
+
snapshot
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def message_prefix?(current, candidate)
|
|
180
|
+
return false if current.length > candidate.length
|
|
181
|
+
|
|
182
|
+
current.each_with_index.all? do |message, index|
|
|
183
|
+
message.to_h == candidate.fetch(index).to_h
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def same_session_data?(left, right)
|
|
188
|
+
left.fetch(:state) == right.fetch(:state) && left.fetch(:metadata) == right.fetch(:metadata)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def normalize(value)
|
|
192
|
+
return unless value
|
|
193
|
+
|
|
194
|
+
{
|
|
195
|
+
messages: persistable_messages(Array(value.fetch(:messages))),
|
|
196
|
+
state: Support.deep_dup(value.fetch(:state, {}).to_h),
|
|
197
|
+
metadata: Support.deep_dup(value.fetch(:metadata, {}).to_h)
|
|
198
|
+
}.freeze
|
|
199
|
+
rescue KeyError, NoMethodError, TypeError => error
|
|
200
|
+
raise ProtocolError, "Session store returned an invalid value: #{error.class}"
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def persistable_messages(messages)
|
|
204
|
+
messages.filter_map do |value|
|
|
205
|
+
message = Message.coerce(value)
|
|
206
|
+
next if message.role == :system
|
|
207
|
+
next if message.metadata[:transient] || message.metadata["transient"]
|
|
208
|
+
|
|
209
|
+
sanitized = message.without_reasoning
|
|
210
|
+
next if sanitized.content.empty? && !message.content.empty?
|
|
211
|
+
|
|
212
|
+
sanitized
|
|
213
|
+
end.freeze
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def mutable_copy(value)
|
|
217
|
+
case value
|
|
218
|
+
when Hash
|
|
219
|
+
value.to_h { |key, child| [mutable_copy(key), mutable_copy(child)] }
|
|
220
|
+
when Array
|
|
221
|
+
value.map { |child| mutable_copy(child) }
|
|
222
|
+
when String
|
|
223
|
+
value.dup
|
|
224
|
+
else
|
|
225
|
+
value
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LittleGhost
|
|
4
|
+
# SessionStore connects LittleGhost conversations to application
|
|
5
|
+
# persistence. Subclass it to keep sessions in a database, remote service, or
|
|
6
|
+
# other durable store.
|
|
7
|
+
#
|
|
8
|
+
# class DatabaseSessionStore < LittleGhost::SessionStore
|
|
9
|
+
# def load(id, actor_id: nil)
|
|
10
|
+
# Conversation.find_by(external_id: id, actor_id:)&.snapshot
|
|
11
|
+
# end
|
|
12
|
+
#
|
|
13
|
+
# def append(id, messages:, state:, metadata:, expected_count:, actor_id: nil)
|
|
14
|
+
# Conversation.append!(
|
|
15
|
+
# id, messages:, state:, metadata:, expected_count:, actor_id:
|
|
16
|
+
# )
|
|
17
|
+
# end
|
|
18
|
+
#
|
|
19
|
+
# def replace(id, messages:, state:, metadata:, actor_id: nil)
|
|
20
|
+
# Conversation.replace!(id, messages:, state:, metadata:, actor_id:)
|
|
21
|
+
# end
|
|
22
|
+
# end
|
|
23
|
+
#
|
|
24
|
+
# A snapshot contains +:messages+, +:state+, and +:metadata+. Implementations
|
|
25
|
+
# provide #load, #append, and #replace; #append must check +expected_count+
|
|
26
|
+
# atomically so two writers cannot silently lose a turn.
|
|
27
|
+
#
|
|
28
|
+
# Actor identity always comes from the caller. A store must not infer it from
|
|
29
|
+
# ambient process state.
|
|
30
|
+
class SessionStore
|
|
31
|
+
# Prepares the per-session synchronization used by #synchronize.
|
|
32
|
+
def initialize
|
|
33
|
+
@session_locks = {}
|
|
34
|
+
@session_locks_mutex = Mutex.new
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Finds the snapshot for +id+, or returns nil when it does not exist.
|
|
38
|
+
def load(_id, actor_id: nil)
|
|
39
|
+
raise NotImplementedError
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Atomically appends sanitized messages and stores state and metadata.
|
|
43
|
+
# Implementations raise ProtocolError if the persisted message count differs
|
|
44
|
+
# from +expected_count+.
|
|
45
|
+
def append(_id, messages:, state:, metadata:, expected_count:, actor_id: nil)
|
|
46
|
+
raise NotImplementedError
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Replaces the complete snapshot for +id+ atomically.
|
|
50
|
+
def replace(_id, messages:, state:, metadata:, actor_id: nil)
|
|
51
|
+
raise NotImplementedError
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Stores may expose a clean conversational view without changing the stored
|
|
55
|
+
# session transcript. The default implementation is a no-op.
|
|
56
|
+
def project_conversation(_id, messages:, metadata:, actor_id: nil)
|
|
57
|
+
nil
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Wraps a store operation with an optional telemetry parent operation.
|
|
61
|
+
# Custom stores may override this while preserving the block's return value.
|
|
62
|
+
def with_operation_context(_operation_id)
|
|
63
|
+
yield
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Serializes work for one actor/session key within this store instance.
|
|
67
|
+
def synchronize(id, actor_id: nil)
|
|
68
|
+
key = [actor_id&.to_s, id.to_s].freeze
|
|
69
|
+
entry = @session_locks_mutex.synchronize do
|
|
70
|
+
current = (@session_locks[key] ||= [Mutex.new, 0])
|
|
71
|
+
current[1] += 1
|
|
72
|
+
current
|
|
73
|
+
end
|
|
74
|
+
entry.first.synchronize { yield }
|
|
75
|
+
ensure
|
|
76
|
+
if entry
|
|
77
|
+
@session_locks_mutex.synchronize do
|
|
78
|
+
entry[1] -= 1
|
|
79
|
+
@session_locks.delete(key) if entry[1].zero?
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
protected
|
|
85
|
+
|
|
86
|
+
def persistable_messages(messages)
|
|
87
|
+
messages.filter_map do |value|
|
|
88
|
+
message = Message.coerce(value)
|
|
89
|
+
sanitized = message.without_reasoning
|
|
90
|
+
next if sanitized.content.empty? && !message.content.empty?
|
|
91
|
+
|
|
92
|
+
sanitized
|
|
93
|
+
end.freeze
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|