little_ghost 0.10.0 → 0.11.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 50e597abbf025721200897c852c8ea72e244c0b5bb2f1b50aa58084e55d5d0df
4
- data.tar.gz: be4d63e81003723c9a916a61359ae508a8d35a16a02b8c7e11f14dc5a3bcd12b
3
+ metadata.gz: a7758c8ce1e1ee404d86fe7dd76065dbd983a0abb660cedd82f7a6ffac0a3509
4
+ data.tar.gz: 485a32b90c723ed4a3c8b064151495e92d8679527f408d39e91145c2f3aaea09
5
5
  SHA512:
6
- metadata.gz: f546cd196e43eb53f6e3ab812c362a9bfb5d81e5408b0608c14ba20ca865a77196c6a801ed3b8980f38e098b6e468555dc2520ee32d9ee72373b4307686b9768
7
- data.tar.gz: 18294764bb5ada766b993993172cccef4e077bf7d2cb886e7f76f1a3bda7ce60a8c448547b056f55eadd2775761457f8a725d3ab895d268f9785cdc2eebac22b
6
+ metadata.gz: bb412e0ec1ff688067bdbb14d34e62a72713f617a4d487188ca40ef2d05c2b3c18262b6b9da1e286a977f8251fb65b4818078d4a044c2d9566b1836876634b6e
7
+ data.tar.gz: c79396a4608f4643415a0ac50ca30f23e7a26cc5688a8ac593bf6674e7d8005185068c6e6107f205d53c834289a4aac390350f3e4e5deffea72d57ed76bddb19
data/README.md CHANGED
@@ -73,7 +73,9 @@ The source checkout also includes a complete
73
73
  [single-file Agent](https://github.com/littleghostai/little_ghost/tree/main/examples/basic_agent)
74
74
  and
75
75
  [coding harness](https://github.com/littleghostai/little_ghost/tree/main/examples/coding_harness),
76
- both configured for local Ollama.
76
+ both configured for local Ollama. The
77
+ [decision example](https://github.com/littleghostai/little_ghost/tree/main/examples/decision)
78
+ shows typed questions with Jev through OpenRouter.
77
79
 
78
80
  ## Give an agent real capabilities
79
81
 
@@ -160,6 +162,8 @@ upgrading, because interfaces may change between releases.
160
162
  - [Structured Results and Content](docs/guides/structured_outputs_and_content.md) covers checked result shapes, images, and documents.
161
163
  - [Compose Agents](docs/guides/assemblies.md) walks through workflows, swarms, graphs, nesting, and builders.
162
164
  - [Skills](docs/guides/skills.md) organizes reusable instructions and supporting resources.
165
+ - [Decisions](docs/guides/decisions.md) explains typed questions and structured answers.
166
+ - [Embeddings](docs/guides/embeddings.md) explains text representations for similarity-based features.
163
167
  - [Workspaces and Sandboxes](docs/guides/sandboxing.md) gives files and child processes a deliberate place to run.
164
168
  - [Code Mode](docs/guides/code_mode.md) lets a model compose Tools in sandboxed Ruby or optional JavaScript.
165
169
  - [Integrations](docs/guides/integrations.md) connects Run streams to AG-UI and OpenTelemetry.
@@ -0,0 +1,69 @@
1
+ # Decisions
2
+
3
+ Use a decision model when an application needs typed answers to a small set of
4
+ questions. `LittleGhost.decide` returns the answers and token usage without
5
+ starting an Agent or creating a Run. Jev supports Choice, Score, and Noul
6
+ (yes/no probability) questions:
7
+
8
+ ```ruby
9
+ result = LittleGhost.decide(
10
+ model: "primary:~typesafe/jev-latest",
11
+ state: {payout_status: "failed", failed_days: 3},
12
+ questions: [
13
+ {
14
+ id: "route",
15
+ type: :choice,
16
+ instructions: "Choose a route",
17
+ criteria: {
18
+ review: "Needs more investigation",
19
+ approve: "Meets the criteria",
20
+ decline: "Does not meet the criteria"
21
+ }
22
+ },
23
+ {id: "urgent", type: :noul, instructions: "Does this need same-day attention?"}
24
+ ]
25
+ )
26
+
27
+ result.answers.fetch("route").choice
28
+ result.answers.fetch("urgent").noul
29
+ ```
30
+
31
+ Choice answers contain the selected label. Noul answers contain the
32
+ probability of yes from 0 to 1. Score answers contain a probability-weighted
33
+ value across the declared levels. Choice and Score answers also include the
34
+ probability distribution and confidence; Score answers include the level
35
+ legend.
36
+
37
+ For reusable decisions, declare questions once on a `Decision` class. A class
38
+ can mix question types:
39
+
40
+ ```ruby
41
+ class ApplicationTriage < LittleGhost::Decision
42
+ model "primary:~typesafe/jev-latest"
43
+ choice :route, instructions: "Choose a route", criteria: {
44
+ review: "Needs more investigation",
45
+ approve: "Meets the criteria",
46
+ decline: "Does not meet the criteria"
47
+ }
48
+ noul :urgent, instructions: "Does this need same-day attention?"
49
+ score :quality, instructions: "Rate the quality", criteria: ["accuracy", "completeness"]
50
+ end
51
+
52
+ application_state = {payout_status: "failed", failed_days: 3}
53
+ result = ApplicationTriage.ask(application_state)
54
+ result.answers.fetch("quality").score
55
+ ```
56
+
57
+ The class-level `.ask` and an instance's `#ask` return
58
+ `LittleGhost::DecisionResult`. Read a typed value with
59
+ `LittleGhost::DecisionAnswer#value` or its type-specific reader (`choice`,
60
+ `noul`, or `score`).
61
+
62
+ The selected provider receives the state and question text. Choose a provider
63
+ that is appropriate for that data.
64
+
65
+ Decisions require a TypeSafe connection or an OpenRouter connection configured
66
+ for a Jev decision endpoint. Other providers raise
67
+ `UnsupportedModelOperationError`. See [Provider Support](providers.md) for
68
+ connection setup and [Models and Providers](models_and_providers.md) for model
69
+ targets, shared roles, and capabilities.
@@ -0,0 +1,23 @@
1
+ # Embeddings
2
+
3
+ Use `LittleGhost.embed` to turn text into numeric representations for search,
4
+ clustering, or another similarity-based feature:
5
+
6
+ ```ruby
7
+ response = LittleGhost.embed(
8
+ model: "primary:openai/text-embedding-3-small",
9
+ inputs: ["Reset a password", "Track a transfer"]
10
+ )
11
+
12
+ response.vectors.length # => 2
13
+ response.dimensions
14
+ response.usage.input_tokens
15
+ ```
16
+
17
+ The response keeps vectors in the same order as the inputs. LittleGhost rejects
18
+ an incomplete or malformed response instead of returning a partial batch.
19
+ Embedding text is sent to the selected provider, so choose one appropriate for
20
+ that data. [Provider Support](providers.md) identifies adapters with embedding
21
+ support; `LittleGhost::Embeddings::Request` documents settings and request
22
+ bounds. See [Models and Providers](models_and_providers.md) for model targets,
23
+ shared roles, and capabilities.
@@ -74,9 +74,10 @@ application obtains short-lived credentials at runtime, configure a credential
74
74
  resolver that returns them for the selected connection.
75
75
 
76
76
  > **Safety note:** The selected provider may receive system instructions,
77
- > caller input, conversation history, Tool results, schemas, and attachments.
78
- > Choose a provider that is appropriate for that data, and keep credentials and
79
- > provider endpoints under application control.
77
+ > caller input, application state, question text, conversation
78
+ > history, Tool results, schemas, and attachments. Choose a provider that is
79
+ > appropriate for that data, and keep credentials and provider endpoints under
80
+ > application control.
80
81
 
81
82
  ## Choose a role for each request
82
83
 
@@ -155,29 +156,9 @@ Results and Content](structured_outputs_and_content.md) covers strict schemas,
155
156
  provider strategies, repair behavior, and the application checks that still
156
157
  belong outside the schema.
157
158
 
158
- ## Create embeddings
159
-
160
- Use `LittleGhost.embed` when your application needs numeric representations for
161
- search, clustering, or another similarity-based feature:
162
-
163
- ```ruby
164
- response = LittleGhost.embed(
165
- model: "primary:openai/text-embedding-3-small",
166
- inputs: ["Reset a password", "Track a transfer"]
167
- )
168
-
169
- response.vectors.length # => 2
170
- response.dimensions
171
- response.usage.input_tokens
172
- ```
173
-
174
- The response keeps vectors in the same order as the inputs. LittleGhost rejects
175
- an incomplete or malformed response instead of returning a partial batch.
176
- Embedding text is sent to the selected provider, so choose one appropriate for
177
- that data. [Provider Support](providers.md) identifies adapters with embedding
178
- support; `LittleGhost::Embeddings::Request` documents settings and request
179
- bounds.
180
-
181
- Continue with [Prompts as Views](prompt_views.md) when an Agent's instructions
182
- outgrow one string. See [Structured Results and Content](structured_outputs_and_content.md)
183
- when you need checked result shapes, images, or documents.
159
+ Continue with [Decisions](decisions.md) for typed, structured answers or
160
+ [Embeddings](embeddings.md) for text representations. [Prompts as
161
+ Views](prompt_views.md) moves growing Agent instructions into conventional ERB
162
+ files. See [Structured Results and
163
+ Content](structured_outputs_and_content.md) for checked result shapes, images,
164
+ or documents.
@@ -23,17 +23,24 @@ end
23
23
  protocol. An Agent can now use a target such as
24
24
  `primary:openai/gpt-5.6-luna`.
25
25
 
26
- | Provider or endpoint | Adapter | Generation | Embeddings |
27
- | --- | --- | --- | --- |
28
- | OpenRouter | `:openrouter` | Chat Completions | When the model supports it |
29
- | OpenAI | `:openai` | Responses or Chat Completions | Yes |
30
- | Compatible API | `:openai_compatible` | Responses or Chat Completions | When the endpoint implements it |
31
- | Ollama | `:openai_compatible` | Responses or Chat Completions | When the model supports it |
32
- | Anthropic | `:anthropic` | Messages | No |
33
- | Gemini | `:gemini` | Gemini API | No |
34
- | Vertex AI | `:vertex_ai` | Gemini on Vertex AI | No |
35
- | Bedrock | `:bedrock` | Converse | Titan Text Embeddings V2 |
36
- | LM Studio | `:lm_studio` | Responses or Chat Completions | Yes |
26
+ | Provider or endpoint | Adapter | Generation | Decisions | Embeddings |
27
+ | --- | --- | --- | --- | --- |
28
+ | OpenRouter | `:openrouter` | Chat Completions | Typed decision endpoints | When the model supports it |
29
+ | TypeSafe | `:typesafe` | No | System One decisions | No |
30
+ | OpenAI | `:openai` | Responses or Chat Completions | No | Yes |
31
+ | Compatible API | `:openai_compatible` | Responses or Chat Completions | No | When the endpoint implements it |
32
+ | Ollama | `:openai_compatible` | Responses or Chat Completions | No | When the model supports it |
33
+ | Anthropic | `:anthropic` | Messages | No | No |
34
+ | Gemini | `:gemini` | Gemini API | No | No |
35
+ | Vertex AI | `:vertex_ai` | Gemini on Vertex AI | No | No |
36
+ | Bedrock | `:bedrock` | Converse | No | Titan Text Embeddings V2 |
37
+ | LM Studio | `:lm_studio` | Responses or Chat Completions | No | Yes |
38
+
39
+ TypeSafe Jev is a decision model that returns structured answers to typed
40
+ questions. Its API and OpenRouter's Jev routes support the same question
41
+ types.
42
+ These operations return structured answers directly and do not create an Agent
43
+ Run. See [Decisions](decisions.md) for the question and answer model.
37
44
 
38
45
  Capabilities can vary by model, account, and server version. Handle provider
39
46
  failures even when a model is expected to support a feature.
@@ -60,6 +67,46 @@ Use OpenRouter's `publisher/model` form, such as
60
67
  The selected model must support embeddings before you call
61
68
  `LittleGhost.embed`.
62
69
 
70
+ For Jev decisions, OpenRouter supports both decision endpoints. The default
71
+ uses the Decisions API and namespaced model identifiers such as
72
+ `openrouter:~typesafe/jev-latest`:
73
+
74
+ ```ruby
75
+ LittleGhost.configure do |config|
76
+ config.providers = {
77
+ openrouter: {
78
+ adapter: :openrouter,
79
+ api_key: ENV.fetch("OPENROUTER_API_KEY"),
80
+ decision_api: :decisions
81
+ }
82
+ }
83
+ end
84
+ ```
85
+
86
+ Set `decision_api: :system_one` to use `/api/v1/systemone`; that route
87
+ accepts a bare TypeSafe model identifier such as `jev-latest`, used as
88
+ `openrouter:jev-latest`.
89
+ OpenRouter describes these routes in its [Jev guide](https://openrouter.ai/blog/insights/what-is-jev/).
90
+
91
+ ### TypeSafe
92
+
93
+ Connect directly to TypeSafe's System One endpoint with a TypeSafe API key:
94
+
95
+ ```ruby
96
+ LittleGhost.configure do |config|
97
+ config.providers = {
98
+ typesafe: {
99
+ adapter: :typesafe,
100
+ api_key: ENV.fetch("TYPESAFE_API_KEY")
101
+ }
102
+ }
103
+ end
104
+ ```
105
+
106
+ Use a Jev identifier such as `typesafe:jev-latest`. This adapter supports
107
+ typed decisions only. See the [TypeSafe API reference](https://docs.typesafe.ai/api)
108
+ for question and answer fields.
109
+
63
110
  ### OpenAI
64
111
 
65
112
  ```ruby
@@ -176,7 +176,7 @@ module LittleGhost
176
176
  value = implementation.instance_variable_get(name)
177
177
  implementation.instance_variable_set(name, deep_freeze_snapshot_value(value))
178
178
  end
179
- implementation.methods.grep(/_value\z/).each do |reader|
179
+ implementation.methods.grep(/_values?\z/).each do |reader|
180
180
  writer = :"#{reader}="
181
181
  next unless implementation.respond_to?(writer)
182
182
 
@@ -232,7 +232,7 @@ module LittleGhost
232
232
  value.instance_variables.each do |name|
233
233
  snapshot.instance_variable_set(name, Support.deep_dup(value.instance_variable_get(name)))
234
234
  end
235
- value.methods.grep(/_value\z/).each do |reader|
235
+ value.methods.grep(/_values?\z/).each do |reader|
236
236
  writer = :"#{reader}="
237
237
  next unless snapshot.respond_to?(writer)
238
238
 
@@ -222,7 +222,7 @@ module LittleGhost
222
222
  def observe(owner:, program_id:, timeout:, max_tokens:, context: nil)
223
223
  program = owned_program(owner, program_id)
224
224
  result = program.observe(timeout:, max_tokens:, context:)
225
- release_program(program) if program.terminal?
225
+ release_program(program) unless result.fetch(:status) == "still_working"
226
226
  result
227
227
  end
228
228
 
@@ -16,6 +16,9 @@ module LittleGhost
16
16
  # +:still_working+ when an observation interval elapsed, +:terminated+ after
17
17
  # requested termination, and +:error+ for a model-program error. +wait+
18
18
  # observes an active program without pausing, resuming, or restarting it.
19
+ # A +:still_working+ result keeps the program available for another observation,
20
+ # even if it finishes immediately afterward. Collect output from every result;
21
+ # the terminal result contains only output not returned by earlier observations.
19
22
  # Lifecycle, host, and cleanup failures raise rather than becoming a result.
20
23
  #
21
24
  # ProgramResult.new(
@@ -0,0 +1,230 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LittleGhost
4
+ # Provider-neutral inputs for one typed decision operation.
5
+ #
6
+ # State and questions are trusted application inputs. Question ids are
7
+ # normalized to strings and must be unique.
8
+ DecisionRequest = Data.define(:state, :questions, :cancellation_token, :deadline) do # :nodoc:
9
+ def initialize(state:, questions:, cancellation_token: Support::CancellationToken.new, deadline: nil)
10
+ unless state.is_a?(String) || state.is_a?(Hash) || state.is_a?(Array)
11
+ raise ArgumentError, "decision state must be a string, mapping, or array"
12
+ end
13
+ entries = questions.is_a?(Hash) ? questions.map { |id, value| value.to_h.merge(id:) } : Array(questions)
14
+ normalized = entries.map do |question|
15
+ raise ArgumentError, "decision questions must be mappings" unless question.is_a?(Hash)
16
+
17
+ item = question.to_h.transform_keys(&:to_sym)
18
+ type = item[:type]&.to_sym
19
+ raise ArgumentError, "decision question type must be :choice, :noul, or :score" unless %i[choice noul score].include?(type)
20
+ item[:type] = type
21
+ item[:id] = item[:id].to_s
22
+ raise ArgumentError, "decision question id is required" if item[:id].empty?
23
+ unless item[:instructions].is_a?(String) || item[:instructions].is_a?(Hash) || item[:instructions].is_a?(Array)
24
+ raise ArgumentError, "decision question instructions are required"
25
+ end
26
+ if type == :choice
27
+ criteria = item[:criteria]
28
+ raise ArgumentError, "choice criteria must be a mapping or array" unless criteria.is_a?(Hash) || criteria.is_a?(Array)
29
+ raise ArgumentError, "choice criteria must contain between one and 255 options" unless (1..255).cover?(criteria.length)
30
+ if criteria.is_a?(Array)
31
+ labels = criteria.map(&:to_s)
32
+ raise ArgumentError, "choice criteria labels must be unique" unless labels.uniq.length == labels.length
33
+
34
+ item[:criteria] = labels.to_h { |label| [label, nil] }
35
+ else
36
+ labels = criteria.keys.map(&:to_s)
37
+ raise ArgumentError, "choice criteria labels must be unique" unless labels.uniq.length == labels.length
38
+
39
+ valid_criteria = criteria.all? do |label, description|
40
+ valid_label = label.is_a?(String) || label.is_a?(Symbol)
41
+ valid_description = description.nil? || description.is_a?(String) ||
42
+ description.is_a?(Hash) || description.is_a?(Array)
43
+ valid_label && valid_description
44
+ end
45
+ raise ArgumentError, "choice criteria must map labels to descriptions" unless valid_criteria
46
+ end
47
+ elsif type == :score
48
+ criteria = item[:criteria]
49
+ raise ArgumentError, "score criteria must contain between two and ten levels" unless criteria.is_a?(Array) && (2..10).cover?(criteria.length)
50
+ valid_levels = criteria.all? { |level| level.is_a?(String) || level.is_a?(Hash) || level.is_a?(Array) }
51
+ raise ArgumentError, "score criteria must be descriptions" unless valid_levels
52
+ elsif !item[:criteria].nil?
53
+ criteria = item[:criteria]
54
+ valid_criteria = criteria.is_a?(Hash) && criteria.all? do |key, description|
55
+ %w[true false].include?(key.to_s) &&
56
+ (description.is_a?(String) || description.is_a?(Hash) || description.is_a?(Array))
57
+ end
58
+ raise ArgumentError, "noul criteria must map true or false to descriptions" unless valid_criteria
59
+ end
60
+ item.freeze
61
+ end
62
+ raise ArgumentError, "at least one decision question is required" if normalized.empty?
63
+ ids = normalized.map { |item| item[:id] }
64
+ raise ArgumentError, "decision question ids must be unique" unless ids.uniq.length == ids.length
65
+
66
+ super(state:, questions: normalized.freeze, cancellation_token:, deadline:)
67
+ end
68
+ end
69
+
70
+ # Carries the state, questions, and request controls passed to a decision
71
+ # provider.
72
+ class DecisionRequest < Data # :doc:
73
+ ##
74
+ # :attr_reader: state
75
+ # Application state evaluated by the provider.
76
+
77
+ ##
78
+ # :attr_reader: questions
79
+ # Frozen question declarations, each with a string +id+.
80
+
81
+ ##
82
+ # :attr_reader: cancellation_token
83
+ # Token the provider checks while the request runs.
84
+
85
+ ##
86
+ # :attr_reader: deadline
87
+ # Optional request deadline passed to the provider.
88
+
89
+ ##
90
+ # :singleton-method: new
91
+ # :call-seq:
92
+ # new(state:, questions:, cancellation_token: Support::CancellationToken.new, deadline: nil) -> DecisionRequest
93
+ #
94
+ # Validates and freezes question declarations before a provider request.
95
+ end
96
+
97
+ # A validated, provider-neutral answer to a typed decision question.
98
+ #
99
+ # +value+ contains the choice label, a numeric noul probability from 0 to 1,
100
+ # or a numeric score according to +type+. The type-specific readers return
101
+ # +nil+ for other answer types.
102
+ DecisionAnswer = Data.define(:type, :value, :probabilities, :confidence, :legend) do # :nodoc:
103
+ def choice
104
+ value if type == :choice
105
+ end
106
+
107
+ def noul
108
+ value if type == :noul
109
+ end
110
+
111
+ def score
112
+ value if type == :score
113
+ end
114
+ end
115
+
116
+ # One typed answer returned by a decision provider.
117
+ class DecisionAnswer < Data # :doc:
118
+ ##
119
+ # :attr_reader: type
120
+ # Answer type: +:choice+, +:noul+, or +:score+.
121
+
122
+ ##
123
+ # :attr_reader: value
124
+ # Selected label, yes probability, or probability-weighted score.
125
+
126
+ ##
127
+ # :attr_reader: probabilities
128
+ # Option or level probabilities for Choice and Score answers.
129
+
130
+ ##
131
+ # :attr_reader: confidence
132
+ # Confidence value for Choice and Score answers.
133
+
134
+ ##
135
+ # :attr_reader: legend
136
+ # Level descriptions for Score answers.
137
+
138
+ ##
139
+ # :method: choice
140
+ # Returns the selected label for a Choice answer, or +nil+ for another type.
141
+
142
+ ##
143
+ # :method: noul
144
+ # Returns the yes probability from 0 to 1 for a Noul answer, or +nil+ for another type.
145
+
146
+ ##
147
+ # :method: score
148
+ # Returns the probability-weighted value for a Score answer, or +nil+ for another type.
149
+ end
150
+
151
+ # Results returned by a decision model, keyed by question id.
152
+ #
153
+ # Provider metadata contains bounded response identifiers, model details,
154
+ # and reported cost when available; it excludes request state and question text.
155
+ DecisionResult = Data.define(:answers, :usage, :metadata) do # :nodoc:
156
+ def initialize(answers:, usage: Usage.new, metadata: {})
157
+ super(answers: answers.to_h.freeze, usage:, metadata: metadata.to_h.freeze)
158
+ end
159
+ end
160
+
161
+ # Carries validated answers and token usage from one decision call.
162
+ class DecisionResult < Data # :doc:
163
+ ##
164
+ # :attr_reader: answers
165
+ # Frozen question-id to DecisionAnswer mapping.
166
+
167
+ ##
168
+ # :attr_reader: usage
169
+ # Normalized token usage for the provider request.
170
+
171
+ ##
172
+ # :attr_reader: metadata
173
+ # Frozen provider metadata such as model, request id, provider, and cost.
174
+ end
175
+
176
+ # Declarative collection of typed questions for one decision request.
177
+ #
178
+ # Subclass and declare a model plus one or more typed questions, then call
179
+ # +.ask(state)+ or instantiate the class and call +#ask(state)+.
180
+ class Decision
181
+ class << self
182
+ def inherited(child) # :nodoc:
183
+ child.instance_variable_set(:@questions, questions.map(&:dup))
184
+ child.instance_variable_set(:@model, model)
185
+ end
186
+
187
+ # Selects a direct target or configured model role for this decision.
188
+ def model(value = :__read__)
189
+ return @model if value == :__read__
190
+
191
+ @model = value
192
+ end
193
+
194
+ # Declares a choice question. Criteria may be an array of labels or a
195
+ # mapping from labels to their descriptions.
196
+ def choice(id, instructions:, criteria:, **options) = add_question(:choice, id, instructions:, criteria:, **options)
197
+ # Declares a yes/no (noul) question, optionally with decision criteria.
198
+ def noul(id, instructions:, criteria: nil, **options) = add_question(:noul, id, instructions:, criteria:, **options)
199
+ # Declares a score question with ordered scoring criteria.
200
+ def score(id, instructions:, criteria:, **options) = add_question(:score, id, instructions:, criteria:, **options)
201
+
202
+ # Runs all declared questions against +state+.
203
+ def ask(state, **options)
204
+ LittleGhost.decide(model: model || raise(ConfigurationError, "Decision model is required"), state:, questions:, **options)
205
+ end
206
+
207
+ # Returns the immutable question declarations for this class.
208
+ def questions = (@questions ||= []).map(&:dup).freeze
209
+
210
+ private
211
+
212
+ def add_question(type, id, **attributes)
213
+ raise ArgumentError, "question id is required" if id.to_s.empty?
214
+ raise ArgumentError, "duplicate decision question id: #{id}" if questions.any? { |question| question[:id] == id.to_s }
215
+
216
+ (@questions ||= []) << attributes.merge(type:, id: id.to_s).freeze
217
+ end
218
+ end
219
+
220
+ # Binds this decision instance to a Runtime.
221
+ def initialize(runtime: LittleGhost.runtime)
222
+ @runtime = runtime
223
+ end
224
+
225
+ # Runs this decision's questions against +state+.
226
+ def ask(state, **options)
227
+ @runtime.decide(model: self.class.model || raise(ConfigurationError, "Decision model is required"), state:, questions: self.class.questions, **options)
228
+ end
229
+ end
230
+ end
@@ -96,6 +96,11 @@ module LittleGhost
96
96
  response
97
97
  end
98
98
 
99
+ # Executes a typed decision through this model's provider.
100
+ def decide(request)
101
+ provider.decide(request)
102
+ end
103
+
99
104
  # Uses advertised provider capabilities.
100
105
  def capabilities
101
106
  @capabilities ||= provider.capabilities(metadata: details.attributes)
@@ -89,6 +89,20 @@ module LittleGhost
89
89
  raise
90
90
  end
91
91
 
92
+ def decide(model:, state:, questions:, cancellation_token: Support::CancellationToken.new, deadline: nil)
93
+ request = DecisionRequest.new(state:, questions:, cancellation_token:, deadline:)
94
+ resolved = @model_resolver.resolve(model)
95
+ handle = Instrumentation.start(:decision, model_provider: resolved.target.provider, model_id: resolved.model_id, question_count: request.questions.length)
96
+ result = resolved.decide(request)
97
+ raise ProtocolError, "Decision provider returned an unexpected result" unless result.is_a?(DecisionResult)
98
+
99
+ handle.finish(outcome: :success, **usage_attributes(result.usage))
100
+ result
101
+ rescue => error
102
+ handle&.finish(outcome: :error, error_type: error.class.name) if handle&.active?
103
+ raise
104
+ end
105
+
92
106
  private
93
107
 
94
108
  def complete(model, messages:, settings:, schema:, strategy:, repair:, invocation_paths:, cancellation_token:, deadline:)
@@ -14,6 +14,7 @@ module LittleGhost
14
14
  "openai" => Providers::OpenAI,
15
15
  "lm_studio" => Providers::LMStudio,
16
16
  "openrouter" => Providers::OpenRouter,
17
+ "typesafe" => Providers::Typesafe,
17
18
  "anthropic" => Providers::Anthropic,
18
19
  "gemini" => Providers::Gemini,
19
20
  "vertex_ai" => Providers::VertexAI,
@@ -47,6 +47,11 @@ module LittleGhost
47
47
  raise UnsupportedModelOperationError, "#{self.class} does not support embeddings"
48
48
  end
49
49
 
50
+ # Executes a typed decision request when the adapter supports it.
51
+ def decide(_request)
52
+ raise UnsupportedModelOperationError, "#{self.class} does not support decisions"
53
+ end
54
+
50
55
  # Applies provider-specific capability constraints before streaming.
51
56
  def prepare_request(request, capabilities:)
52
57
  request
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "uri"
5
+
6
+ module LittleGhost
7
+ module Providers
8
+ module DecisionHTTP # :nodoc:
9
+ private
10
+
11
+ def send_decision(request, endpoint:, model:, api_key:)
12
+ request.cancellation_token&.raise_if_cancelled!
13
+ base_url = @decision_base_url.to_s
14
+ base_url += "/" unless base_url.end_with?("/")
15
+ payload = {
16
+ model:,
17
+ state: request.state,
18
+ questions: request.questions.to_h do |question|
19
+ id = question.fetch(:id)
20
+ fields = question.except(:id).reject { |name, value| name == :criteria && value.nil? }
21
+ [id, fields]
22
+ end
23
+ }
24
+ body = (@decision_transport || @transport).request(
25
+ uri: URI.join(base_url, endpoint),
26
+ method: :post,
27
+ headers: {"Authorization" => "Bearer #{api_key}", "Content-Type" => "application/json"},
28
+ body: JSON.generate(payload),
29
+ cancellation_token: request.cancellation_token,
30
+ deadline: request.deadline,
31
+ label: "Decision request"
32
+ )
33
+ data = JSON.parse(body)
34
+ answers = data.fetch("answers")
35
+ expected_ids = request.questions.map { |question| question.fetch(:id) }.sort
36
+ unless answers.is_a?(Hash) && answers.keys.sort == expected_ids
37
+ raise ProtocolError, "Decision provider returned unexpected or incomplete answers"
38
+ end
39
+ normalized = answers.to_h do |id, answer|
40
+ question = request.questions.find { |item| item.fetch(:id) == id }
41
+ [id, normalize_answer(question, answer)]
42
+ end
43
+ usage_data = data["usage"] || {}
44
+ cost = usage_data["cost"] || data["cost"]
45
+ DecisionResult.new(
46
+ answers: normalized,
47
+ usage: Usage.new(input_tokens: usage_data["input_tokens"], output_tokens: usage_data["output_tokens"]),
48
+ metadata: {model: data["model"], id: data["id"], provider: data["provider"], cost:}
49
+ )
50
+ rescue JSON::ParserError, KeyError, TypeError => error
51
+ raise ProtocolError, "Decision provider returned an invalid response (#{error.class})"
52
+ end
53
+
54
+ def normalize_answer(question, raw)
55
+ raise ProtocolError, "Decision provider returned an invalid answer" unless raw.is_a?(Hash)
56
+
57
+ field = question.fetch(:type).to_s
58
+ raise ProtocolError, "Decision provider returned an answer with the wrong type" unless raw["type"] == field
59
+
60
+ value = raw[field]
61
+ valid = case field
62
+ when "choice"
63
+ criteria = question[:criteria]
64
+ options = criteria.is_a?(Hash) ? criteria.keys : Array(criteria)
65
+ value.is_a?(String) && options.any? { |item| item.to_s == value }
66
+ when "noul" then value.is_a?(Numeric) && value.finite? && value.between?(0, 1)
67
+ when "score" then value.is_a?(Numeric) && value.finite?
68
+ end
69
+ raise ProtocolError, "Decision provider returned an invalid #{field} answer" unless valid
70
+
71
+ if %w[choice score].include?(field)
72
+ confidence = raw["confidence"]
73
+ probabilities = raw["probabilities"]
74
+ unless confidence.is_a?(Numeric) && confidence.finite? && confidence.between?(0, 1) &&
75
+ probabilities.is_a?(Hash) && probabilities.values.all? { |probability| probability.is_a?(Numeric) && probability.finite? && probability.between?(0, 1) }
76
+ raise ProtocolError, "Decision provider returned invalid #{field} probabilities"
77
+ end
78
+ raise ProtocolError, "Decision provider returned an invalid score legend" if field == "score" && !raw["legend"].is_a?(Hash)
79
+ end
80
+
81
+ DecisionAnswer.new(
82
+ type: question.fetch(:type),
83
+ value:,
84
+ probabilities: raw["probabilities"] || {},
85
+ confidence: raw["confidence"],
86
+ legend: raw["legend"]
87
+ )
88
+ end
89
+ end
90
+ end
91
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "base64"
4
4
  require "json"
5
+ require "securerandom"
5
6
  require "uri"
6
7
  require_relative "../support/http_client"
7
8
  require_relative "../support/sse_parser"
@@ -17,6 +18,18 @@ module LittleGhost
17
18
  # LittleGhost StreamEvents. HTTP and response-shape failures become
18
19
  # ProviderError subclasses. It uses the built-in HTTP client and does not
19
20
  # require Google's SDK.
21
+ #
22
+ # Gemini attaches an opaque +thoughtSignature+ to a function-call response
23
+ # part and expects it echoed back on the next request's matching
24
+ # +functionCall+. The adapter preserves these opaque values in the response
25
+ # Message metadata under +gemini/thought_signatures+, keyed by tool-call ID.
26
+ # Preserve the full assistant Message, including this provider replay metadata,
27
+ # when storing or rebuilding conversation history. Signatures survive provider
28
+ # recreation and Message#without_reasoning and replay with their original calls.
29
+ #
30
+ # Tool results use the function name from their preceding ToolUse in the
31
+ # request history. Keep each tool call before its result when restoring history.
32
+ # Results without a preceding call retain their tool-use ID as the name.
20
33
  class Gemini < Base
21
34
  # Request policy supported by Gemini and Vertex AI HTTP clients.
22
35
  def self.request_options = %i[max_response_bytes open_timeout read_timeout].freeze
@@ -75,7 +88,8 @@ module LittleGhost
75
88
 
76
89
  def request_body(request)
77
90
  system, messages = request.messages.partition { |message| message.role == :system }
78
- body = {contents: messages.map { |message| google_message(message) }}
91
+ tool_names = {}
92
+ body = {contents: messages.map { |message| google_message(message, tool_names) }}
79
93
  body[:systemInstruction] = {parts: system.flat_map { |message| message.content.grep(Content::Text).map { |block| {text: block.text} } }} unless system.empty?
80
94
  body[:tools] = [{functionDeclarations: request.tools.map { |tool| google_tool(tool) }}] unless request.tools.empty?
81
95
  body[:toolConfig] = google_tool_choice(request.tool_choice) if request.tool_choice
@@ -92,23 +106,32 @@ module LittleGhost
92
106
  {max_tokens: :maxOutputTokens, top_p: :topP, top_k: :topK, stop_sequences: :stopSequences}[key.to_sym] || key.to_sym
93
107
  end
94
108
 
95
- def google_message(message)
96
- {role: (message.role == :assistant) ? "model" : "user", parts: message.content.map { |block| google_content(block) }}
109
+ def google_message(message, tool_names)
110
+ signatures = message.metadata.dig(:gemini, :thought_signatures) || {}
111
+ {role: (message.role == :assistant) ? "model" : "user", parts: message.content.map { |block| google_content(block, signatures, tool_names) }}
97
112
  end
98
113
 
99
- def google_content(block)
114
+ def google_content(block, thought_signatures, tool_names)
100
115
  case block
101
116
  when Content::Text then {text: block.text}
102
117
  when Content::Image, Content::Document
103
118
  {inlineData: {mimeType: block.media_type, data: Base64.strict_encode64(block.data)}}
104
- when Content::ToolUse then {functionCall: {id: block.id, name: block.name, args: block.input}}
119
+ when Content::ToolUse
120
+ tool_names[block.id] = block.name
121
+ google_tool_use(block, thought_signatures)
105
122
  when Content::ToolResult
106
- {functionResponse: {id: block.tool_use_id, name: block.tool_use_id, response: {output: Array(block.content).join("\n")}}}
123
+ {functionResponse: {id: block.tool_use_id, name: tool_names.fetch(block.tool_use_id, block.tool_use_id), response: {output: Array(block.content).join("\n")}}}
107
124
  when Content::Reasoning then {text: block.text, thought: true}
108
125
  else raise ConfigurationError, "Unsupported Google content block: #{block.class}"
109
126
  end
110
127
  end
111
128
 
129
+ def google_tool_use(block, thought_signatures)
130
+ call = {id: block.id, name: block.name, args: block.input}
131
+ signature = thought_signatures[block.id]
132
+ signature ? {functionCall: call, thoughtSignature: signature} : {functionCall: call}
133
+ end
134
+
112
135
  def google_tool(tool)
113
136
  value = tool.is_a?(Hash) ? tool.transform_keys(&:to_sym) : {name: tool.name, description: tool.description, input_schema: tool.input_schema}
114
137
  {name: value.fetch(:name), description: value[:description], parametersJsonSchema: value[:input_schema] || {}}
@@ -123,6 +146,7 @@ module LittleGhost
123
146
  class Normalizer # :nodoc:
124
147
  def initialize(model:)
125
148
  @model = model
149
+ @thought_signatures = {}
126
150
  @text = +""
127
151
  @reasoning = +""
128
152
  @tools = []
@@ -161,7 +185,8 @@ module LittleGhost
161
185
  blocks << Content::Reasoning.new(text: @reasoning) unless @reasoning.empty?
162
186
  blocks << Content::Text.new(text: @text) unless @text.empty?
163
187
  blocks.concat(@tools)
164
- response = ModelResponse.new(message: Message.new(role: :assistant, content: blocks),
188
+ metadata = @thought_signatures.empty? ? {} : {gemini: {thought_signatures: @thought_signatures}}
189
+ response = ModelResponse.new(message: Message.new(role: :assistant, content: blocks, metadata:),
165
190
  stop_reason: @stop_reason, usage: @usage, metadata: {model: @model})
166
191
  [StreamEvent.build(:message_stop, response:)]
167
192
  end
@@ -172,8 +197,9 @@ module LittleGhost
172
197
  if part["functionCall"]
173
198
  call = part.fetch("functionCall")
174
199
  index = @tools.length
175
- tool = Content::ToolUse.new(id: call["id"] || "call-#{index}", name: call.fetch("name"), input: call["args"] || {})
200
+ tool = Content::ToolUse.new(id: call["id"] || SecureRandom.uuid, name: call.fetch("name"), input: call["args"] || {})
176
201
  @tools << tool
202
+ @thought_signatures[tool.id] = part["thoughtSignature"] if part["thoughtSignature"]
177
203
  [
178
204
  StreamEvent.build(:tool_call_start, index:, id: tool.id, name: tool.name),
179
205
  StreamEvent.build(:tool_call_stop, index:, tool_use: tool)
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "openai_compatible"
4
+ require_relative "decision_http"
4
5
  require_relative "open_router/catalog_source"
5
6
 
6
7
  module LittleGhost
@@ -20,6 +21,8 @@ module LittleGhost
20
21
  # Requests that need a capability ask OpenRouter to route only to providers
21
22
  # that advertise it.
22
23
  class OpenRouter < OpenAICompatible
24
+ include DecisionHTTP
25
+
23
26
  # Adds OpenRouter attribution to the shared OpenAI-compatible policy.
24
27
  def self.request_options = (super + [:app_name]).freeze
25
28
 
@@ -38,12 +41,26 @@ module LittleGhost
38
41
 
39
42
  # Configures an OpenRouter client. All OpenAICompatible options, including
40
43
  # +api_key+, +model+, retry limits, timeouts, and custom transport, apply.
41
- def initialize(site_url: nil, app_name: nil, base_url: DEFAULT_BASE_URL, **arguments)
44
+ def initialize(site_url: nil, app_name: nil, base_url: DEFAULT_BASE_URL, decision_api: :decisions, **arguments)
42
45
  @site_url = site_url
43
46
  @app_name = app_name
47
+ @decision_api = decision_api.to_sym
48
+ raise ConfigurationError, "decision_api must be :decisions or :system_one" unless %i[decisions system_one].include?(@decision_api)
49
+ @decision_base_url = base_url
44
50
  super(base_url:, api: :chat_completions, **arguments)
45
51
  end
46
52
 
53
+ # Executes typed decisions through OpenRouter's selected Jev endpoint.
54
+ def decide(request)
55
+ base_url = @decision_base_url.end_with?("/") ? @decision_base_url : "#{@decision_base_url}/"
56
+ endpoint = if @decision_api == :decisions
57
+ URI.join(base_url, "../alpha/decisions").to_s
58
+ else
59
+ URI.join(base_url, "systemone").to_s
60
+ end
61
+ send_decision(request, endpoint:, model:, api_key: @api_key)
62
+ end
63
+
47
64
  # Reads model capabilities from OpenRouter +supported_parameters+ metadata.
48
65
  # Missing metadata produces ModelCapabilities.unknown.
49
66
  def capabilities(metadata: {})
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "decision_http"
4
+
5
+ module LittleGhost
6
+ module Providers
7
+ # Direct TypeSafe Jev decision API connection.
8
+ class Typesafe < Base
9
+ include DecisionHTTP
10
+
11
+ # The TypeSafe API base URL.
12
+ DEFAULT_BASE_URL = "https://api.typesafe.ai/v1/"
13
+
14
+ # Configures a direct Jev connection. Pass +transport+ to supply a custom
15
+ # bounded HTTP client.
16
+ def initialize(api_key:, model:, base_url: DEFAULT_BASE_URL, transport: nil,
17
+ open_timeout: 10, read_timeout: 120, max_response_bytes: Support::HTTPClient::DEFAULT_MAX_RESPONSE_BYTES)
18
+ @model = model
19
+ @decision_api_key = api_key
20
+ @decision_base_url = base_url
21
+ @decision_transport = transport || Support::HTTPClient.new(
22
+ base_url:, open_timeout:, read_timeout:, max_response_bytes:
23
+ )
24
+ end
25
+
26
+ def decide(request)
27
+ send_decision(request, endpoint: "systemone", model: @model, api_key: @decision_api_key)
28
+ end
29
+
30
+ def stream(_request)
31
+ raise UnsupportedModelOperationError, "TypeSafe supports decisions only"
32
+ end
33
+ end
34
+ end
35
+ end
@@ -208,6 +208,11 @@ module LittleGhost
208
208
  @model_operations.embed(**arguments)
209
209
  end
210
210
 
211
+ # Runs typed decision questions without creating a Run.
212
+ def decide(**arguments)
213
+ @model_operations.decide(**arguments)
214
+ end
215
+
211
216
  # Creates a Run that owns any workspace and sandbox built for the request.
212
217
  #
213
218
  # +include_agent_events_by_default+ defaults to +true+, exposing source-tagged
@@ -2,5 +2,5 @@
2
2
 
3
3
  module LittleGhost
4
4
  # Current LittleGhost gem version.
5
- VERSION = "0.10.0"
5
+ VERSION = "0.11.0"
6
6
  end
data/lib/little_ghost.rb CHANGED
@@ -23,6 +23,7 @@ require_relative "little_ghost/model_response"
23
23
  require_relative "little_ghost/embeddings/request"
24
24
  require_relative "little_ghost/embeddings/response"
25
25
  require_relative "little_ghost/run_result"
26
+ require_relative "little_ghost/decision"
26
27
  require_relative "little_ghost/model_capabilities"
27
28
  require_relative "little_ghost/models/target"
28
29
  require_relative "little_ghost/models/details"
@@ -37,6 +38,7 @@ require_relative "little_ghost/providers/openai_compatible"
37
38
  require_relative "little_ghost/providers/openai"
38
39
  require_relative "little_ghost/providers/lm_studio"
39
40
  require_relative "little_ghost/providers/open_router"
41
+ require_relative "little_ghost/providers/typesafe"
40
42
  require_relative "little_ghost/providers/bedrock"
41
43
  require_relative "little_ghost/providers/anthropic"
42
44
  require_relative "little_ghost/providers/gemini"
@@ -180,6 +182,9 @@ module LittleGhost
180
182
  # controls. The operation raises rather than returning a partial batch.
181
183
  def embed(...) = runtime.embed(...)
182
184
 
185
+ # Runs typed choice, noul, or score questions against a decision model.
186
+ def decide(...) = runtime.decide(...)
187
+
183
188
  # Makes +configuration+ and its independent shared Runtime current only
184
189
  # while the block runs.
185
190
  #
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: little_ghost
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.10.0
4
+ version: 0.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Matt Robinson
@@ -191,6 +191,8 @@ files:
191
191
  - docs/guides/assemblies.md
192
192
  - docs/guides/code_mode.md
193
193
  - docs/guides/core_concepts.md
194
+ - docs/guides/decisions.md
195
+ - docs/guides/embeddings.md
194
196
  - docs/guides/getting_started.md
195
197
  - docs/guides/integrations.md
196
198
  - docs/guides/mcp.md
@@ -245,6 +247,7 @@ files:
245
247
  - lib/little_ghost/content.rb
246
248
  - lib/little_ghost/data/model_catalog.json
247
249
  - lib/little_ghost/data_map.rb
250
+ - lib/little_ghost/decision.rb
248
251
  - lib/little_ghost/embeddings/request.rb
249
252
  - lib/little_ghost/embeddings/response.rb
250
253
  - lib/little_ghost/errors.rb
@@ -484,6 +487,7 @@ files:
484
487
  - lib/little_ghost/providers/bedrock/credential_resolver.rb
485
488
  - lib/little_ghost/providers/bedrock/http_client.rb
486
489
  - lib/little_ghost/providers/configuration.rb
490
+ - lib/little_ghost/providers/decision_http.rb
487
491
  - lib/little_ghost/providers/gemini.rb
488
492
  - lib/little_ghost/providers/gemini/catalog_source.rb
489
493
  - lib/little_ghost/providers/lm_studio.rb
@@ -492,6 +496,7 @@ files:
492
496
  - lib/little_ghost/providers/open_router/catalog_source.rb
493
497
  - lib/little_ghost/providers/openai.rb
494
498
  - lib/little_ghost/providers/openai_compatible.rb
499
+ - lib/little_ghost/providers/typesafe.rb
495
500
  - lib/little_ghost/providers/vertex_ai.rb
496
501
  - lib/little_ghost/providers/vertex_ai/credential_resolver.rb
497
502
  - lib/little_ghost/run.rb