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.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +162 -0
  3. data/README.md +314 -3
  4. data/lib/generators/mistri/install/install_generator.rb +54 -0
  5. data/lib/generators/mistri/install/templates/migration.rb.tt +14 -0
  6. data/lib/generators/mistri/install/templates/model.rb.tt +4 -0
  7. data/lib/mistri/abort_signal.rb +63 -0
  8. data/lib/mistri/agent.rb +340 -0
  9. data/lib/mistri/budget.rb +29 -0
  10. data/lib/mistri/compaction.rb +78 -0
  11. data/lib/mistri/compactor.rb +182 -0
  12. data/lib/mistri/content.rb +89 -0
  13. data/lib/mistri/edit.rb +238 -0
  14. data/lib/mistri/errors.rb +94 -0
  15. data/lib/mistri/event.rb +50 -0
  16. data/lib/mistri/memory.rb +26 -0
  17. data/lib/mistri/message.rb +90 -0
  18. data/lib/mistri/models.rb +43 -0
  19. data/lib/mistri/partial_json.rb +210 -0
  20. data/lib/mistri/providers/anthropic/assembler.rb +205 -0
  21. data/lib/mistri/providers/anthropic/serializer.rb +106 -0
  22. data/lib/mistri/providers/anthropic.rb +106 -0
  23. data/lib/mistri/providers/fake.rb +109 -0
  24. data/lib/mistri/providers/gemini/assembler.rb +163 -0
  25. data/lib/mistri/providers/gemini/serializer.rb +109 -0
  26. data/lib/mistri/providers/gemini.rb +73 -0
  27. data/lib/mistri/providers/openai/assembler.rb +205 -0
  28. data/lib/mistri/providers/openai/serializer.rb +104 -0
  29. data/lib/mistri/providers/openai.rb +72 -0
  30. data/lib/mistri/result.rb +30 -0
  31. data/lib/mistri/retry_policy.rb +47 -0
  32. data/lib/mistri/schema.rb +162 -0
  33. data/lib/mistri/session.rb +124 -0
  34. data/lib/mistri/sinks/action_cable.rb +30 -0
  35. data/lib/mistri/sinks/coalesced.rb +61 -0
  36. data/lib/mistri/sinks/sse.rb +26 -0
  37. data/lib/mistri/skill.rb +15 -0
  38. data/lib/mistri/skills.rb +81 -0
  39. data/lib/mistri/sse.rb +50 -0
  40. data/lib/mistri/stop_reason.rb +25 -0
  41. data/lib/mistri/stores/active_record.rb +47 -0
  42. data/lib/mistri/stores/jsonl.rb +37 -0
  43. data/lib/mistri/stores/memory.rb +22 -0
  44. data/lib/mistri/sub_agent.rb +211 -0
  45. data/lib/mistri/tool.rb +94 -0
  46. data/lib/mistri/tool_call.rb +18 -0
  47. data/lib/mistri/tool_context.rb +15 -0
  48. data/lib/mistri/tool_executor.rb +66 -0
  49. data/lib/mistri/tool_result.rb +23 -0
  50. data/lib/mistri/tools/edit_file.rb +37 -0
  51. data/lib/mistri/tools/find_in_file.rb +36 -0
  52. data/lib/mistri/tools/list_files.rb +16 -0
  53. data/lib/mistri/tools/read_file.rb +38 -0
  54. data/lib/mistri/tools/read_memory.rb +16 -0
  55. data/lib/mistri/tools/update_memory.rb +22 -0
  56. data/lib/mistri/tools/write_file.rb +20 -0
  57. data/lib/mistri/tools.rb +50 -0
  58. data/lib/mistri/transport.rb +187 -0
  59. data/lib/mistri/usage.rb +79 -0
  60. data/lib/mistri/version.rb +1 -1
  61. data/lib/mistri/workspace/active_record.rb +47 -0
  62. data/lib/mistri/workspace/directory.rb +52 -0
  63. data/lib/mistri/workspace/memory.rb +40 -0
  64. data/lib/mistri/workspace/single.rb +48 -0
  65. data/lib/mistri.rb +87 -0
  66. 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
@@ -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