mistri 0.0.3 → 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 +4 -4
- data/CHANGELOG.md +162 -0
- data/README.md +314 -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/mistri/abort_signal.rb +63 -0
- data/lib/mistri/agent.rb +340 -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 +50 -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/result.rb +30 -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 +94 -0
- data/lib/mistri/tool_call.rb +18 -0
- data/lib/mistri/tool_context.rb +15 -0
- data/lib/mistri/tool_executor.rb +66 -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 +187 -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 +87 -0
- metadata +68 -5
|
@@ -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,30 @@
|
|
|
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.
|
|
13
|
+
Result = Data.define(:message, :status, :pending, :output) do
|
|
14
|
+
def initialize(message:, status:, pending: [], output: nil)
|
|
15
|
+
super
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def completed? = status == :completed
|
|
19
|
+
def awaiting_approval? = status == :awaiting_approval
|
|
20
|
+
def aborted? = status == :aborted
|
|
21
|
+
def stopped_by_budget? = status == :budget
|
|
22
|
+
def errored? = status == :error
|
|
23
|
+
|
|
24
|
+
def text = message&.text
|
|
25
|
+
def stop_reason = message&.stop_reason
|
|
26
|
+
def error_message = message&.error_message
|
|
27
|
+
def tool_calls = message ? message.tool_calls : []
|
|
28
|
+
def to_s = text.to_s
|
|
29
|
+
end
|
|
30
|
+
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
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require "time"
|
|
6
|
+
|
|
7
|
+
module Mistri
|
|
8
|
+
# The durable record of a run: an append-only log of typed entries over a
|
|
9
|
+
# pluggable store. Messages replay into provider-ready history; other entry
|
|
10
|
+
# types (reminders, custom host data) ride alongside without the loop
|
|
11
|
+
# knowing their shapes.
|
|
12
|
+
#
|
|
13
|
+
# A store implements two methods: append(id, entry_hash) and load(id) ->
|
|
14
|
+
# array of entry hashes in append order. Everything else lives here.
|
|
15
|
+
class Session
|
|
16
|
+
attr_reader :id, :store
|
|
17
|
+
|
|
18
|
+
def initialize(store:, id: nil)
|
|
19
|
+
@store = store
|
|
20
|
+
@id = id || SecureRandom.uuid
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def append_message(message)
|
|
24
|
+
append("message", "message" => message.to_h)
|
|
25
|
+
message
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Entries are normalized through JSON at write time, so every store holds
|
|
29
|
+
# the same canonical shape (string keys, JSON values) and reads behave
|
|
30
|
+
# identically whether the entry round-tripped a database or stayed in
|
|
31
|
+
# memory.
|
|
32
|
+
def append(type, data = {})
|
|
33
|
+
entry = { "type" => type, "at" => Time.now.utc.iso8601 }.merge(data)
|
|
34
|
+
@store.append(@id, JSON.parse(JSON.generate(entry)))
|
|
35
|
+
nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def entries = @store.load(@id)
|
|
39
|
+
|
|
40
|
+
# The conversation as the model replays it.
|
|
41
|
+
def messages = replay.map(&:first)
|
|
42
|
+
|
|
43
|
+
# Replay messages paired with the entry index each came from, starting at
|
|
44
|
+
# the latest compaction boundary. The synthetic summary message carries a
|
|
45
|
+
# nil index. Compaction places its cuts by these indexes; the full entry
|
|
46
|
+
# log stays in the store for transcript views.
|
|
47
|
+
def replay
|
|
48
|
+
compaction = last_compaction
|
|
49
|
+
from = compaction ? compaction["kept_from"] : 0
|
|
50
|
+
pairs = entries.each_with_index.filter_map do |entry, index|
|
|
51
|
+
[Message.from_h(entry["message"]), index] if index >= from && entry["type"] == "message"
|
|
52
|
+
end
|
|
53
|
+
compaction ? [[summary_message(compaction["summary"]), nil], *pairs] : pairs
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def last_compaction
|
|
57
|
+
entries.reverse_each.find { |entry| entry["type"] == "compaction" }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Queue a message for a running exchange from any process. The loop folds
|
|
61
|
+
# pending steers into the transcript at the next turn boundary, so the
|
|
62
|
+
# model sees them mid-run; one that arrives as the model finishes cleanly
|
|
63
|
+
# extends the run another turn so it is answered, not left dangling.
|
|
64
|
+
def steer(text)
|
|
65
|
+
append("steer", "id" => SecureRandom.uuid, "message" => Message.user(text).to_h)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Steers not yet folded into the transcript, oldest first. The folding
|
|
69
|
+
# message entry carries the steer id, so consumption is derived from the
|
|
70
|
+
# log alone and reads the same from every process.
|
|
71
|
+
def pending_steers
|
|
72
|
+
folded = entries.filter_map { |entry| entry["steer_id"] }
|
|
73
|
+
entries.select { |entry| entry["type"] == "steer" && !folded.include?(entry["id"]) }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Record a human's decision on a parked tool call. Decisions are session
|
|
77
|
+
# entries, so they can be written from any process, days later, with no
|
|
78
|
+
# agent constructed; the next resume settles them.
|
|
79
|
+
def approve(call_id, note: nil) = decide(call_id, approved: true, note: note)
|
|
80
|
+
|
|
81
|
+
def deny(call_id, note: nil) = decide(call_id, approved: false, note: note)
|
|
82
|
+
|
|
83
|
+
# Parked tool calls not yet settled by a tool result, each with its
|
|
84
|
+
# decision when one has been recorded. Derived from the entry log alone,
|
|
85
|
+
# so it survives crashes and reads the same from every process.
|
|
86
|
+
def open_approvals
|
|
87
|
+
answered = []
|
|
88
|
+
decisions = {}
|
|
89
|
+
requests = []
|
|
90
|
+
entries.each do |entry|
|
|
91
|
+
case entry["type"]
|
|
92
|
+
when "message"
|
|
93
|
+
call_id = entry.dig("message", "tool_call_id")
|
|
94
|
+
answered << call_id if call_id
|
|
95
|
+
when "approval_request" then requests << entry["call"]
|
|
96
|
+
when "approval_decision" then decisions[entry["call_id"]] = entry
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
requests.reject { |call| answered.include?(call["id"]) }
|
|
100
|
+
.map { |call| { call: rebuild_call(call), decision: decisions[call["id"]] } }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
def summary_message(summary)
|
|
106
|
+
Message.user("#{Compaction::SUMMARY_PREFACE}\n\n#{summary}")
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def decide(call_id, approved:, note:)
|
|
110
|
+
unless open_approvals.any? { |open| open[:call].id == call_id }
|
|
111
|
+
raise ConfigurationError, "no open approval for #{call_id.inspect}"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
entry = { "call_id" => call_id, "approved" => approved }
|
|
115
|
+
entry["note"] = note if note
|
|
116
|
+
append("approval_decision", entry)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def rebuild_call(hash)
|
|
120
|
+
ToolCall.new(id: hash["id"], name: hash["name"],
|
|
121
|
+
arguments: hash["arguments"] || {}, signature: hash["signature"])
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mistri
|
|
4
|
+
module Sinks
|
|
5
|
+
# Broadcasts every event to an Action Cable stream as its to_h shape.
|
|
6
|
+
# The server resolves lazily at first call, so this file loads without
|
|
7
|
+
# Rails; pass server: explicitly to use another broadcaster.
|
|
8
|
+
#
|
|
9
|
+
# sink = Mistri::Sinks::ActionCable.new("agent_#{session.id}")
|
|
10
|
+
# agent.run(input, &sink)
|
|
11
|
+
class ActionCable
|
|
12
|
+
def initialize(stream, server: nil)
|
|
13
|
+
@stream = stream
|
|
14
|
+
@server = server
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def call(event)
|
|
18
|
+
server.broadcast(@stream, event.to_h)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def to_proc = method(:call).to_proc
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def server
|
|
26
|
+
@server ||= ::ActionCable.server
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mistri
|
|
4
|
+
module Sinks
|
|
5
|
+
# Wraps any sink and merges bursts of streaming deltas, so a transport
|
|
6
|
+
# broadcasts at UI speed instead of token speed. Deltas buffer per
|
|
7
|
+
# content block and flush merged when the interval elapses or any other
|
|
8
|
+
# event arrives, so ordering is preserved and a turn always ends flushed.
|
|
9
|
+
#
|
|
10
|
+
# sink = Mistri::Sinks::Coalesced.new(
|
|
11
|
+
# Mistri::Sinks::ActionCable.new("agent_1"), interval: 0.1,
|
|
12
|
+
# )
|
|
13
|
+
class Coalesced
|
|
14
|
+
DELTAS = %i[text_delta thinking_delta toolcall_delta].freeze
|
|
15
|
+
|
|
16
|
+
def initialize(sink, interval: 0.05)
|
|
17
|
+
@sink = sink
|
|
18
|
+
@interval = interval
|
|
19
|
+
@buffer = nil
|
|
20
|
+
@flushed_at = now
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def call(event)
|
|
24
|
+
unless DELTAS.include?(event.type)
|
|
25
|
+
flush
|
|
26
|
+
return @sink.call(event)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
merge(event)
|
|
30
|
+
flush if now - @flushed_at >= @interval
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def to_proc = method(:call).to_proc
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
# Merged deltas keep the newest partial, so a consumer that renders
|
|
38
|
+
# snapshots always renders the latest one.
|
|
39
|
+
def merge(event)
|
|
40
|
+
same = @buffer && @buffer.type == event.type &&
|
|
41
|
+
@buffer.content_index == event.content_index
|
|
42
|
+
flush if @buffer && !same
|
|
43
|
+
@buffer = if same
|
|
44
|
+
@buffer.with(delta: @buffer.delta.to_s + event.delta.to_s,
|
|
45
|
+
partial: event.partial)
|
|
46
|
+
else
|
|
47
|
+
event
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def flush
|
|
52
|
+
pending = @buffer
|
|
53
|
+
@buffer = nil
|
|
54
|
+
@flushed_at = now
|
|
55
|
+
@sink.call(pending) if pending
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Mistri
|
|
6
|
+
module Sinks
|
|
7
|
+
# Writes events as Server-Sent Events to any IO-like object: a Rack
|
|
8
|
+
# streaming body, an ActionController::Live stream, a socket. Pure
|
|
9
|
+
# formatting, the outbound counterpart of the Mistri::SSE decoder.
|
|
10
|
+
#
|
|
11
|
+
# response.headers["Content-Type"] = "text/event-stream"
|
|
12
|
+
# agent.run(input, &Mistri::Sinks::SSE.new(response.stream))
|
|
13
|
+
class SSE
|
|
14
|
+
def initialize(io)
|
|
15
|
+
@io = io
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def call(event)
|
|
19
|
+
@io.write("event: #{event.type}\ndata: #{JSON.generate(event.to_h)}\n\n")
|
|
20
|
+
@io.flush if @io.respond_to?(:flush)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def to_proc = method(:call).to_proc
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
data/lib/mistri/skill.rb
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mistri
|
|
4
|
+
# One expert playbook: a name the model selects by, a description that
|
|
5
|
+
# earns the selection, and the full body it reads before acting. Build
|
|
6
|
+
# them from anywhere — Skills.load reads a directory, and a host with
|
|
7
|
+
# skills in a database constructs these directly.
|
|
8
|
+
Skill = Data.define(:name, :description, :body) do
|
|
9
|
+
def initialize(name:, description: "", body: "")
|
|
10
|
+
raise ConfigurationError, "a skill needs a name" if name.to_s.empty?
|
|
11
|
+
|
|
12
|
+
super(name: name.to_s, description: description.to_s, body: body.to_s)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|