mistri 0.0.3 → 0.2.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 +4 -4
- data/CHANGELOG.md +215 -0
- data/README.md +367 -3
- data/lib/generators/mistri/install/install_generator.rb +54 -0
- data/lib/generators/mistri/install/templates/migration.rb.tt +14 -0
- data/lib/generators/mistri/install/templates/model.rb.tt +4 -0
- data/lib/generators/mistri/mcp/mcp_generator.rb +57 -0
- data/lib/generators/mistri/mcp/templates/migration.rb.tt +27 -0
- data/lib/generators/mistri/mcp/templates/model.rb.tt +63 -0
- data/lib/mistri/abort_signal.rb +63 -0
- data/lib/mistri/agent.rb +389 -0
- data/lib/mistri/budget.rb +29 -0
- data/lib/mistri/compaction.rb +78 -0
- data/lib/mistri/compactor.rb +182 -0
- data/lib/mistri/content.rb +89 -0
- data/lib/mistri/edit.rb +238 -0
- data/lib/mistri/errors.rb +94 -0
- data/lib/mistri/event.rb +54 -0
- data/lib/mistri/mcp/client.rb +156 -0
- data/lib/mistri/mcp/oauth.rb +286 -0
- data/lib/mistri/mcp/wires.rb +164 -0
- data/lib/mistri/mcp.rb +96 -0
- data/lib/mistri/memory.rb +26 -0
- data/lib/mistri/message.rb +90 -0
- data/lib/mistri/models.rb +43 -0
- data/lib/mistri/partial_json.rb +210 -0
- data/lib/mistri/providers/anthropic/assembler.rb +205 -0
- data/lib/mistri/providers/anthropic/serializer.rb +106 -0
- data/lib/mistri/providers/anthropic.rb +106 -0
- data/lib/mistri/providers/fake.rb +109 -0
- data/lib/mistri/providers/gemini/assembler.rb +163 -0
- data/lib/mistri/providers/gemini/serializer.rb +109 -0
- data/lib/mistri/providers/gemini.rb +73 -0
- data/lib/mistri/providers/openai/assembler.rb +205 -0
- data/lib/mistri/providers/openai/serializer.rb +104 -0
- data/lib/mistri/providers/openai.rb +72 -0
- data/lib/mistri/reminder.rb +36 -0
- data/lib/mistri/result.rb +32 -0
- data/lib/mistri/retry_policy.rb +47 -0
- data/lib/mistri/schema.rb +162 -0
- data/lib/mistri/session.rb +124 -0
- data/lib/mistri/sinks/action_cable.rb +30 -0
- data/lib/mistri/sinks/coalesced.rb +61 -0
- data/lib/mistri/sinks/sse.rb +26 -0
- data/lib/mistri/skill.rb +15 -0
- data/lib/mistri/skills.rb +81 -0
- data/lib/mistri/sse.rb +50 -0
- data/lib/mistri/stop_reason.rb +25 -0
- data/lib/mistri/stores/active_record.rb +47 -0
- data/lib/mistri/stores/jsonl.rb +37 -0
- data/lib/mistri/stores/memory.rb +22 -0
- data/lib/mistri/sub_agent.rb +211 -0
- data/lib/mistri/tool.rb +95 -0
- data/lib/mistri/tool_call.rb +18 -0
- data/lib/mistri/tool_context.rb +15 -0
- data/lib/mistri/tool_executor.rb +87 -0
- data/lib/mistri/tool_result.rb +23 -0
- data/lib/mistri/tools/edit_file.rb +37 -0
- data/lib/mistri/tools/find_in_file.rb +36 -0
- data/lib/mistri/tools/list_files.rb +16 -0
- data/lib/mistri/tools/read_file.rb +38 -0
- data/lib/mistri/tools/read_memory.rb +16 -0
- data/lib/mistri/tools/update_memory.rb +22 -0
- data/lib/mistri/tools/write_file.rb +20 -0
- data/lib/mistri/tools.rb +50 -0
- data/lib/mistri/transport.rb +228 -0
- data/lib/mistri/usage.rb +79 -0
- data/lib/mistri/version.rb +1 -1
- data/lib/mistri/workspace/active_record.rb +47 -0
- data/lib/mistri/workspace/directory.rb +52 -0
- data/lib/mistri/workspace/memory.rb +40 -0
- data/lib/mistri/workspace/single.rb +48 -0
- data/lib/mistri.rb +89 -0
- metadata +79 -10
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Mistri
|
|
6
|
+
module Providers
|
|
7
|
+
class OpenAI
|
|
8
|
+
# Folds the Responses API stream into the event union. Items arrive
|
|
9
|
+
# sequentially: output_item.added opens a block, typed deltas fill it,
|
|
10
|
+
# output_item.done closes it with the complete item, whose ids and
|
|
11
|
+
# encrypted reasoning land in the signature slots for replay.
|
|
12
|
+
#
|
|
13
|
+
# Unknown event and item types are skipped by contract.
|
|
14
|
+
class Assembler
|
|
15
|
+
def initialize(model:)
|
|
16
|
+
@model = model
|
|
17
|
+
@blocks = []
|
|
18
|
+
@current = nil
|
|
19
|
+
@usage = Usage.zero
|
|
20
|
+
@status = nil
|
|
21
|
+
@incomplete_reason = nil
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def feed(record, &)
|
|
25
|
+
case record["type"]
|
|
26
|
+
when "response.output_item.added" then start_item(record["item"], &)
|
|
27
|
+
when "response.output_text.delta" then text_delta(record["delta"], &)
|
|
28
|
+
when "response.reasoning_summary_text.delta" then thinking_delta(record["delta"], &)
|
|
29
|
+
when "response.function_call_arguments.delta" then arguments_delta(record["delta"], &)
|
|
30
|
+
when "response.output_item.done" then finish_item(record["item"], &)
|
|
31
|
+
when "response.completed", "response.incomplete", "response.failed"
|
|
32
|
+
finish_response(record["response"] || {})
|
|
33
|
+
when "error" then @error = wire_error(record)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# A stream that ended without a terminal response event was truncated,
|
|
38
|
+
# not cancelled: fail it so the loop can treat it as retryable.
|
|
39
|
+
def finish(&emit)
|
|
40
|
+
return fail_stream(@error, &emit) if @error
|
|
41
|
+
return fail_stream("stream ended without a terminal event", &emit) unless @status
|
|
42
|
+
|
|
43
|
+
@message = assemble(stop_reason: stop_reason)
|
|
44
|
+
emit&.call(Event.new(type: :done, reason: @message.stop_reason, message: @message))
|
|
45
|
+
@message
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def abort(&)
|
|
49
|
+
terminal(StopReason::ABORTED, "aborted", &)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# In-stream failures carry a code; rate limits and server errors must
|
|
53
|
+
# classify as retryable, not fold into prose.
|
|
54
|
+
def wire_error(record)
|
|
55
|
+
message = record["message"] || "provider error"
|
|
56
|
+
code = record["code"].to_s
|
|
57
|
+
klass = if code.include?("rate_limit") then RateLimitError
|
|
58
|
+
elsif code.include?("server") then ServerError
|
|
59
|
+
else ProviderError
|
|
60
|
+
end
|
|
61
|
+
klass.new(message)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def fail_stream(reason, &)
|
|
65
|
+
text = case reason
|
|
66
|
+
when ProviderError then "#{reason.class}: #{reason.describe}"
|
|
67
|
+
when Exception then "#{reason.class}: #{reason.message}"
|
|
68
|
+
else reason.to_s
|
|
69
|
+
end
|
|
70
|
+
terminal(StopReason::ERROR, text, error: ErrorData.for(reason), &)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def message = @message ||= finish
|
|
74
|
+
|
|
75
|
+
Builder = Struct.new(:kind, :index, :text, :json)
|
|
76
|
+
KINDS = { "message" => :text, "reasoning" => :thinking,
|
|
77
|
+
"function_call" => :toolcall }.freeze
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def start_item(item, &)
|
|
82
|
+
kind = KINDS[item&.fetch("type", nil)]
|
|
83
|
+
return unless kind
|
|
84
|
+
|
|
85
|
+
@current = Builder.new(kind, @blocks.size, +"", +"")
|
|
86
|
+
emit_event(:"#{kind}_start", content_index: @current.index, &)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def text_delta(delta, &)
|
|
90
|
+
return unless @current
|
|
91
|
+
|
|
92
|
+
@current.text << delta.to_s
|
|
93
|
+
emit_event(:text_delta, content_index: @current.index, delta: delta, &)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def thinking_delta(delta, &)
|
|
97
|
+
return unless @current
|
|
98
|
+
|
|
99
|
+
@current.text << delta.to_s
|
|
100
|
+
emit_event(:thinking_delta, content_index: @current.index, delta: delta, &)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def arguments_delta(delta, &)
|
|
104
|
+
return unless @current
|
|
105
|
+
|
|
106
|
+
@current.json << delta.to_s
|
|
107
|
+
emit_event(:toolcall_delta, content_index: @current.index, delta: delta, &)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# The done item is authoritative: its text, arguments, ids, and
|
|
111
|
+
# encrypted content replace whatever the deltas accumulated.
|
|
112
|
+
def finish_item(item, &)
|
|
113
|
+
kind = KINDS[item&.fetch("type", nil)]
|
|
114
|
+
return unless kind
|
|
115
|
+
|
|
116
|
+
index = @current&.index || @blocks.size
|
|
117
|
+
block = build_block(kind, item)
|
|
118
|
+
@blocks << block
|
|
119
|
+
@current = nil
|
|
120
|
+
fields = { content_index: index }
|
|
121
|
+
fields[:tool_call] = block if block.is_a?(ToolCall)
|
|
122
|
+
unless block.is_a?(ToolCall)
|
|
123
|
+
fields[:content] = block.respond_to?(:text) ? block.text : block.thinking
|
|
124
|
+
end
|
|
125
|
+
emit_event(:"#{kind}_end", **fields.compact, &)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def build_block(kind, item)
|
|
129
|
+
case kind
|
|
130
|
+
when :text
|
|
131
|
+
text = Array(item["content"]).filter_map { |part| part["text"] }.join
|
|
132
|
+
Content::Text.new(text: text, signature: item["id"])
|
|
133
|
+
when :thinking
|
|
134
|
+
summary = Array(item["summary"]).filter_map { |part| part["text"] }.join
|
|
135
|
+
Content::Thinking.new(thinking: summary, signature: JSON.generate(item))
|
|
136
|
+
when :toolcall
|
|
137
|
+
ToolCall.new(id: item["call_id"], name: item["name"],
|
|
138
|
+
arguments: parse_arguments(item["arguments"]), signature: item["id"])
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def parse_arguments(raw)
|
|
143
|
+
parsed = raw.to_s.strip.empty? ? {} : JSON.parse(raw)
|
|
144
|
+
parsed.is_a?(Hash) ? parsed : {}
|
|
145
|
+
rescue JSON::ParserError
|
|
146
|
+
fallback = PartialJson.parse(raw)
|
|
147
|
+
fallback.is_a?(Hash) ? fallback : {}
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def finish_response(response)
|
|
151
|
+
@status = response["status"] || "completed"
|
|
152
|
+
@incomplete_reason = response.dig("incomplete_details", "reason")
|
|
153
|
+
@error = response.dig("error", "message") if @status == "failed"
|
|
154
|
+
usage = response["usage"]
|
|
155
|
+
@usage = parse_usage(usage) if usage
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def stop_reason
|
|
159
|
+
return StopReason::LENGTH if @incomplete_reason == "max_output_tokens"
|
|
160
|
+
return StopReason::TOOL_USE if @blocks.any?(ToolCall)
|
|
161
|
+
|
|
162
|
+
StopReason::STOP
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def terminal(reason, text, error: nil, &emit)
|
|
166
|
+
@message = assemble(stop_reason: reason, error_message: text, error: error)
|
|
167
|
+
emit&.call(Event.new(type: :error, reason: reason, message: @message,
|
|
168
|
+
error_message: text))
|
|
169
|
+
@message
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def emit_event(type, **fields, &emit)
|
|
173
|
+
emit&.call(Event.new(type:, partial: assemble, **fields))
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def assemble(**meta)
|
|
177
|
+
blocks = @blocks.dup
|
|
178
|
+
blocks << partial_block(@current) if @current
|
|
179
|
+
Message.assistant(content: blocks, model: @model, provider: :openai,
|
|
180
|
+
usage: @usage, **meta)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def partial_block(builder)
|
|
184
|
+
case builder.kind
|
|
185
|
+
when :text then Content::Text.new(text: builder.text)
|
|
186
|
+
when :thinking then Content::Thinking.new(thinking: builder.text)
|
|
187
|
+
when :toolcall
|
|
188
|
+
args = PartialJson.parse(builder.json)
|
|
189
|
+
ToolCall.new(id: "pending", name: "pending",
|
|
190
|
+
arguments: args.is_a?(Hash) ? args : {})
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def parse_usage(raw)
|
|
195
|
+
details = raw["input_tokens_details"] || {}
|
|
196
|
+
output_details = raw["output_tokens_details"] || {}
|
|
197
|
+
cache_read = details["cached_tokens"].to_i
|
|
198
|
+
Usage.new(input: [raw["input_tokens"].to_i - cache_read, 0].max,
|
|
199
|
+
output: raw["output_tokens"].to_i, cache_read: cache_read,
|
|
200
|
+
reasoning: output_details["reasoning_tokens"].to_i)
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Mistri
|
|
6
|
+
module Providers
|
|
7
|
+
class OpenAI
|
|
8
|
+
# Serializes protocol messages into Responses API input items.
|
|
9
|
+
#
|
|
10
|
+
# Replay pairing is the rule that matters: with store: false the full
|
|
11
|
+
# history resends every turn, and a reasoning item must return VERBATIM
|
|
12
|
+
# (its encrypted_content is the model's chain of thought) followed by
|
|
13
|
+
# the same items it originally preceded. The signature slots carry what
|
|
14
|
+
# pairing needs: Thinking.signature holds the whole reasoning item,
|
|
15
|
+
# Text.signature the message item id, ToolCall.signature the
|
|
16
|
+
# function_call item id.
|
|
17
|
+
module Serializer
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
def input_items(history)
|
|
21
|
+
history.reject(&:system?).flat_map { |msg| items_for(msg) }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def tools(definitions)
|
|
25
|
+
definitions.map do |tool|
|
|
26
|
+
spec = tool.transform_keys(&:to_sym)
|
|
27
|
+
{ type: "function", name: spec[:name], description: spec[:description],
|
|
28
|
+
parameters: spec[:input_schema] }
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def items_for(msg)
|
|
33
|
+
case msg.role
|
|
34
|
+
when :user then [user_item(msg)]
|
|
35
|
+
when :tool then [tool_result_item(msg)]
|
|
36
|
+
when :assistant then assistant_items(msg)
|
|
37
|
+
else []
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def user_item(msg)
|
|
42
|
+
{ role: "user", content: msg.content.map { |block| user_block(block) } }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def user_block(block)
|
|
46
|
+
case block
|
|
47
|
+
when Content::Text then { type: "input_text", text: block.text }
|
|
48
|
+
when Content::Image
|
|
49
|
+
{ type: "input_image", image_url: "data:#{block.mime_type};base64,#{block.data}" }
|
|
50
|
+
else
|
|
51
|
+
raise SchemaError, "cannot serialize #{block.class} for OpenAI user input"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Non-text blocks in a tool result have no function_call_output
|
|
56
|
+
# encoding; note the omission rather than dropping it silently.
|
|
57
|
+
def tool_result_item(msg)
|
|
58
|
+
omitted = msg.content.count { |block| !block.is_a?(Content::Text) }
|
|
59
|
+
text = msg.text.to_s
|
|
60
|
+
text = "#{text}\n[#{omitted} non-text block(s) omitted]".strip if omitted.positive?
|
|
61
|
+
{ type: "function_call_output", call_id: msg.tool_call_id, output: text }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def assistant_items(msg)
|
|
65
|
+
msg.content.filter_map { |block| assistant_item(block) }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def assistant_item(block)
|
|
69
|
+
case block
|
|
70
|
+
when Content::Thinking then reasoning_item(block)
|
|
71
|
+
when Content::Text then message_item(block)
|
|
72
|
+
when ToolCall then function_call_item(block)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# The reasoning item replays exactly as it arrived or not at all. An
|
|
77
|
+
# item without encrypted_content triggers a server-side id lookup that
|
|
78
|
+
# cannot succeed under store: false, so it drops rather than 404s.
|
|
79
|
+
def reasoning_item(block)
|
|
80
|
+
return nil unless block.signature
|
|
81
|
+
|
|
82
|
+
item = JSON.parse(block.signature)
|
|
83
|
+
item.is_a?(Hash) && item["encrypted_content"] ? item : nil
|
|
84
|
+
rescue JSON::ParserError
|
|
85
|
+
nil
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def message_item(block)
|
|
89
|
+
item = { type: "message", role: "assistant",
|
|
90
|
+
content: [{ type: "output_text", text: block.text }] }
|
|
91
|
+
item[:id] = block.signature if block.signature
|
|
92
|
+
item
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def function_call_item(block)
|
|
96
|
+
item = { type: "function_call", call_id: block.id, name: block.name,
|
|
97
|
+
arguments: JSON.generate(block.arguments) }
|
|
98
|
+
item[:id] = block.signature if block.signature
|
|
99
|
+
item
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mistri
|
|
4
|
+
module Providers
|
|
5
|
+
# The OpenAI Responses API, streamed and stateless: store is always false,
|
|
6
|
+
# the full history replays every turn, and encrypted reasoning items round
|
|
7
|
+
# trip through the signature slots so nothing depends on server-side
|
|
8
|
+
# state. Reasoning summaries stream as thinking. max_output_tokens is
|
|
9
|
+
# deliberately omitted: the API defaults to the model's own ceiling.
|
|
10
|
+
#
|
|
11
|
+
# Provider failures fold into the stream as an error turn rather than
|
|
12
|
+
# raising, matching the Anthropic provider's contract.
|
|
13
|
+
class OpenAI
|
|
14
|
+
DEFAULT_REASONING = { summary: "auto" }.freeze
|
|
15
|
+
|
|
16
|
+
def initialize(api_key:, model: "gpt-5.5", origin: "https://api.openai.com",
|
|
17
|
+
reasoning: DEFAULT_REASONING, **transport_options)
|
|
18
|
+
@api_key = api_key
|
|
19
|
+
@model = model
|
|
20
|
+
@reasoning = reasoning
|
|
21
|
+
@transport = Transport.new(origin: origin, **transport_options)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
attr_reader :model
|
|
25
|
+
|
|
26
|
+
def stream(messages:, system: nil, tools: [], signal: nil, **overrides, &emit)
|
|
27
|
+
model = overrides.fetch(:model, @model)
|
|
28
|
+
assembler = OpenAI::Assembler.new(model: model)
|
|
29
|
+
body = build_body(model, messages, system, tools, overrides)
|
|
30
|
+
outcome = @transport.stream_post("/v1/responses", body: body, headers: headers,
|
|
31
|
+
signal: signal) do |record|
|
|
32
|
+
assembler.feed(
|
|
33
|
+
record, &emit
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
outcome == :aborted ? assembler.abort(&emit) : assembler.finish(&emit)
|
|
37
|
+
rescue Error => e
|
|
38
|
+
assembler.fail_stream(e, &emit)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def close = @transport.close
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def build_body(model, messages, system, tools, overrides)
|
|
46
|
+
body = {
|
|
47
|
+
model: model,
|
|
48
|
+
input: Serializer.input_items(messages),
|
|
49
|
+
stream: true,
|
|
50
|
+
store: false,
|
|
51
|
+
include: ["reasoning.encrypted_content"]
|
|
52
|
+
}
|
|
53
|
+
body[:instructions] = system if system && !system.empty?
|
|
54
|
+
body[:tools] = Serializer.tools(tools) if tools.any?
|
|
55
|
+
reasoning = overrides.fetch(:reasoning, @reasoning)
|
|
56
|
+
body[:reasoning] = reasoning if reasoning
|
|
57
|
+
if (schema = overrides[:output_schema])
|
|
58
|
+
body[:text] = { format: { type: "json_schema", name: "output", strict: true,
|
|
59
|
+
schema: Schema.strict(schema, all_required: true) } }
|
|
60
|
+
end
|
|
61
|
+
body
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def headers
|
|
65
|
+
{ "Authorization" => "Bearer #{@api_key}" }
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
require_relative "openai/serializer"
|
|
72
|
+
require_relative "openai/assembler"
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mistri
|
|
4
|
+
# A periodic reminder for long agentic runs: models drift from their
|
|
5
|
+
# instructions as turns accumulate, and a short reminder at the tail of
|
|
6
|
+
# the context, where attention is strongest, pulls them back. It rides
|
|
7
|
+
# transform_context, so it appears fresh on the wire each time it is due
|
|
8
|
+
# and never persists to the session.
|
|
9
|
+
#
|
|
10
|
+
# agent = Mistri.agent("claude-opus-4-8", tools: tools,
|
|
11
|
+
# transform_context: Mistri::Reminder.every(
|
|
12
|
+
# 3, "Stay on gifting. Verify with tools before claiming.",
|
|
13
|
+
# ))
|
|
14
|
+
#
|
|
15
|
+
# Due is counted in completed assistant turns: the first reminder lands
|
|
16
|
+
# once `after` turns have finished (default: one full interval), then
|
|
17
|
+
# every `interval` turns.
|
|
18
|
+
class Reminder
|
|
19
|
+
def self.every(interval, text, after: nil)
|
|
20
|
+
new(interval: interval, text: text, after: after)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def initialize(interval:, text:, after: nil)
|
|
24
|
+
@interval = [interval.to_i, 1].max
|
|
25
|
+
@after = (after || @interval).to_i
|
|
26
|
+
@body = "<system-reminder>\n#{text}\n</system-reminder>"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def call(messages)
|
|
30
|
+
turns = messages.count(&:assistant?)
|
|
31
|
+
return messages unless turns >= @after && ((turns - @after) % @interval).zero?
|
|
32
|
+
|
|
33
|
+
messages + [Message.user(@body)]
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mistri
|
|
4
|
+
# The outcome of a run. A run either finishes, or suspends because a tool
|
|
5
|
+
# needs a human's approval, or stops on abort or budget. Suspension is a
|
|
6
|
+
# first-class outcome, not an error: the run returns immediately with
|
|
7
|
+
# awaiting_approval? true and the pending calls, so nothing blocks waiting
|
|
8
|
+
# for a decision that may come days later.
|
|
9
|
+
#
|
|
10
|
+
# Reads delegate to the final message, so result.text works whether the run
|
|
11
|
+
# completed or suspended.
|
|
12
|
+
# output is a task's validated value, nil on plain runs. usage is the
|
|
13
|
+
# run's own accounting: every persisted turn plus compaction calls, summed
|
|
14
|
+
# (a resumed run counts from the resume; task sums across its fix passes).
|
|
15
|
+
Result = Data.define(:message, :status, :pending, :output, :usage) do
|
|
16
|
+
def initialize(message:, status:, pending: [], output: nil, usage: Usage.zero)
|
|
17
|
+
super
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def completed? = status == :completed
|
|
21
|
+
def awaiting_approval? = status == :awaiting_approval
|
|
22
|
+
def aborted? = status == :aborted
|
|
23
|
+
def stopped_by_budget? = status == :budget
|
|
24
|
+
def errored? = status == :error
|
|
25
|
+
|
|
26
|
+
def text = message&.text
|
|
27
|
+
def stop_reason = message&.stop_reason
|
|
28
|
+
def error_message = message&.error_message
|
|
29
|
+
def tool_calls = message ? message.tool_calls : []
|
|
30
|
+
def to_s = text.to_s
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mistri
|
|
4
|
+
# When a failed turn is worth retrying, and how long to wait. Transient
|
|
5
|
+
# failures (rate limits, overload, server errors, timeouts, dropped or
|
|
6
|
+
# truncated streams) retry with jittered exponential backoff, honoring the
|
|
7
|
+
# provider's retry-after when it sent one. Everything else — auth, invalid
|
|
8
|
+
# requests, our own bugs — fails fast.
|
|
9
|
+
#
|
|
10
|
+
# attempts counts retries, not calls: attempts 3 means up to four requests.
|
|
11
|
+
class RetryPolicy
|
|
12
|
+
RETRYABLE_STATUSES = [408, 429, 500, 502, 503, 504, 529].freeze
|
|
13
|
+
RETRYABLE_TYPES = %w[ProviderError RateLimitError OverloadedError ServerError
|
|
14
|
+
TruncatedStream].freeze
|
|
15
|
+
|
|
16
|
+
attr_reader :attempts, :base, :max_delay
|
|
17
|
+
|
|
18
|
+
def initialize(attempts: 3, base: 1.0, max_delay: 30.0)
|
|
19
|
+
@attempts = attempts
|
|
20
|
+
@base = base
|
|
21
|
+
@max_delay = max_delay
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def retry?(error, attempt)
|
|
25
|
+
attempt <= attempts && retryable?(error)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# error is the ErrorData hash from an errored message. A status decides
|
|
29
|
+
# when present; otherwise only known-transient types retry, so schema
|
|
30
|
+
# violations and host bugs never loop.
|
|
31
|
+
def retryable?(error)
|
|
32
|
+
return false unless error
|
|
33
|
+
|
|
34
|
+
status = error["status"]
|
|
35
|
+
return RETRYABLE_STATUSES.include?(status) if status
|
|
36
|
+
|
|
37
|
+
RETRYABLE_TYPES.include?(error["type"])
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def delay(attempt, retry_after = nil)
|
|
41
|
+
return retry_after.clamp(0.0, max_delay) if retry_after
|
|
42
|
+
|
|
43
|
+
exponential = base * (2**(attempt - 1))
|
|
44
|
+
(exponential * rand(0.5..1.0)).clamp(0.0, max_delay)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mistri
|
|
4
|
+
# A small builder for tool argument schemas, so a tool declares its inputs
|
|
5
|
+
# in Ruby instead of hand-writing JSON Schema. It emits the object schema
|
|
6
|
+
# every provider accepts:
|
|
7
|
+
#
|
|
8
|
+
# Schema.build do
|
|
9
|
+
# string :city, "City name", required: true
|
|
10
|
+
# string :units, "Temperature units", enum: %w[celsius fahrenheit]
|
|
11
|
+
# integer :days, "Forecast length"
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# A raw JSON Schema hash is always accepted directly for anything the
|
|
15
|
+
# builder does not cover, so the DSL is a convenience, never a ceiling.
|
|
16
|
+
class Schema
|
|
17
|
+
# instance_exec, not instance_eval: it binds self to the builder without
|
|
18
|
+
# passing an argument, so a zero-arity lambda works as naturally as a proc.
|
|
19
|
+
def self.build(&)
|
|
20
|
+
builder = new
|
|
21
|
+
builder.instance_exec(&)
|
|
22
|
+
builder.to_h
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def initialize
|
|
26
|
+
@properties = {}
|
|
27
|
+
@required = []
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
%i[string integer number boolean].each do |type|
|
|
31
|
+
define_method(type) do |name, description = nil, required: false, enum: nil, **extra|
|
|
32
|
+
prop = { type: type.to_s }
|
|
33
|
+
prop[:description] = description if description
|
|
34
|
+
prop[:enum] = enum if enum
|
|
35
|
+
@properties[name.to_s] = prop.merge(extra)
|
|
36
|
+
@required << name.to_s if required
|
|
37
|
+
nil
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def array(name, description = nil, items: { type: "string" }, required: false, **extra)
|
|
42
|
+
prop = { type: "array", items: items }
|
|
43
|
+
prop[:description] = description if description
|
|
44
|
+
@properties[name.to_s] = prop.merge(extra)
|
|
45
|
+
@required << name.to_s if required
|
|
46
|
+
nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def object(name, description = nil, required: false, &)
|
|
50
|
+
prop = self.class.build(&)
|
|
51
|
+
prop[:description] = description if description
|
|
52
|
+
@properties[name.to_s] = prop
|
|
53
|
+
@required << name.to_s if required
|
|
54
|
+
nil
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def to_h
|
|
58
|
+
schema = { type: "object", properties: @properties }
|
|
59
|
+
schema[:required] = @required unless @required.empty?
|
|
60
|
+
schema
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
class << self
|
|
64
|
+
# Violations of a value against the schema subset the harness emits and
|
|
65
|
+
# providers constrain: types (including type arrays), object properties
|
|
66
|
+
# with required and additionalProperties: false, array items, enum.
|
|
67
|
+
# Empty means valid; entries are human-readable, written to be fed back
|
|
68
|
+
# to a model for one-shot correction.
|
|
69
|
+
def violations(value, schema, path = "$")
|
|
70
|
+
spec = schema.transform_keys(&:to_s)
|
|
71
|
+
mismatch = type_violation(value, spec, path)
|
|
72
|
+
return [mismatch] if mismatch
|
|
73
|
+
|
|
74
|
+
errors = []
|
|
75
|
+
if (enum = spec["enum"]) && !enum.include?(value)
|
|
76
|
+
errors << "#{path} must be one of: #{enum.join(", ")}"
|
|
77
|
+
end
|
|
78
|
+
case value
|
|
79
|
+
when Hash then errors.concat(object_violations(value, spec, path))
|
|
80
|
+
when Array then errors.concat(array_violations(value, spec, path))
|
|
81
|
+
end
|
|
82
|
+
errors
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Prepares a schema for constrained decoding: every object gains
|
|
86
|
+
# additionalProperties: false (Anthropic and OpenAI both demand it),
|
|
87
|
+
# and all_required marks every property required (OpenAI strict mode's
|
|
88
|
+
# rule). String keys throughout, ready for the wire.
|
|
89
|
+
def strict(schema, all_required: false)
|
|
90
|
+
spec = schema.transform_keys(&:to_s)
|
|
91
|
+
out = spec.dup
|
|
92
|
+
if spec["type"] == "object" || spec.key?("properties")
|
|
93
|
+
props = (spec["properties"] || {}).to_h do |key, member|
|
|
94
|
+
[key.to_s, strict(member, all_required: all_required)]
|
|
95
|
+
end
|
|
96
|
+
out["properties"] = props
|
|
97
|
+
out["additionalProperties"] = false
|
|
98
|
+
out["required"] = all_required ? props.keys : Array(spec["required"]).map(&:to_s)
|
|
99
|
+
end
|
|
100
|
+
if spec["items"].is_a?(Hash)
|
|
101
|
+
out["items"] =
|
|
102
|
+
strict(spec["items"], all_required: all_required)
|
|
103
|
+
end
|
|
104
|
+
out
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
TYPES = {
|
|
108
|
+
"object" => ->(v) { v.is_a?(Hash) }, "array" => ->(v) { v.is_a?(Array) },
|
|
109
|
+
"string" => ->(v) { v.is_a?(String) }, "integer" => ->(v) { v.is_a?(Integer) },
|
|
110
|
+
"number" => ->(v) { v.is_a?(Numeric) }, "boolean" => ->(v) { [true, false].include?(v) },
|
|
111
|
+
"null" => lambda(&:nil?)
|
|
112
|
+
}.freeze
|
|
113
|
+
|
|
114
|
+
private
|
|
115
|
+
|
|
116
|
+
def type_violation(value, spec, path)
|
|
117
|
+
names = Array(spec["type"]).map(&:to_s)
|
|
118
|
+
return nil if names.empty?
|
|
119
|
+
return nil if names.any? { |name| TYPES.fetch(name, ->(_) { true }).call(value) }
|
|
120
|
+
|
|
121
|
+
"#{path} must be #{names.join(" or ")}, got #{json_type(value)}"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def object_violations(value, spec, path)
|
|
125
|
+
props = (spec["properties"] || {}).transform_keys(&:to_s)
|
|
126
|
+
errors = Array(spec["required"]).map(&:to_s).filter_map do |key|
|
|
127
|
+
"#{path}.#{key} is required" unless value.key?(key)
|
|
128
|
+
end
|
|
129
|
+
value.each do |key, member|
|
|
130
|
+
if (member_schema = props[key.to_s])
|
|
131
|
+
errors.concat(violations(member, member_schema, "#{path}.#{key}"))
|
|
132
|
+
elsif spec["additionalProperties"] == false
|
|
133
|
+
errors << "#{path}.#{key} is not allowed"
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
errors
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def array_violations(value, spec, path)
|
|
140
|
+
items = spec["items"]
|
|
141
|
+
return [] unless items.is_a?(Hash)
|
|
142
|
+
|
|
143
|
+
value.each_with_index.flat_map do |member, index|
|
|
144
|
+
violations(member, items, "#{path}[#{index}]")
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def json_type(value)
|
|
149
|
+
case value
|
|
150
|
+
when Hash then "object"
|
|
151
|
+
when Array then "array"
|
|
152
|
+
when String then "string"
|
|
153
|
+
when Integer then "integer"
|
|
154
|
+
when Numeric then "number"
|
|
155
|
+
when true, false then "boolean"
|
|
156
|
+
when nil then "null"
|
|
157
|
+
else value.class.name
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|