mistri 0.4.1 → 0.6.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 +596 -3
- data/CONTRIBUTING.md +52 -0
- data/README.md +291 -306
- data/SECURITY.md +40 -0
- data/UPGRADING.md +640 -0
- data/assets/logo-animated.svg +30 -0
- data/assets/logo-dark.svg +14 -0
- data/assets/logo-light.svg +14 -0
- data/assets/logo.svg +14 -0
- data/assets/social-preview.png +0 -0
- data/docs/README.md +87 -0
- data/docs/context-and-workspaces.md +378 -0
- data/docs/mcp.md +366 -0
- data/docs/reliability.md +450 -0
- data/docs/sessions.md +295 -0
- data/docs/sub-agents.md +401 -0
- data/docs/tool-contracts.md +324 -0
- data/examples/approval.rb +36 -0
- data/examples/browser.rb +27 -0
- data/examples/page_editor.rb +31 -0
- data/examples/quickstart.rb +21 -0
- data/lib/generators/mistri/install/install_generator.rb +7 -3
- data/lib/generators/mistri/install/templates/migration.rb.tt +2 -2
- data/lib/generators/mistri/mcp/templates/migration.rb.tt +1 -1
- data/lib/generators/mistri/mcp/templates/model.rb.tt +15 -8
- data/lib/mistri/abort_signal.rb +10 -0
- data/lib/mistri/agent.rb +635 -108
- data/lib/mistri/budget.rb +26 -1
- data/lib/mistri/child.rb +186 -0
- data/lib/mistri/compaction.rb +26 -10
- data/lib/mistri/compactor.rb +35 -12
- data/lib/mistri/console.rb +209 -0
- data/lib/mistri/content.rb +9 -3
- data/lib/mistri/dispatchers.rb +49 -0
- data/lib/mistri/errors.rb +83 -4
- data/lib/mistri/event.rb +30 -8
- data/lib/mistri/event_delivery.rb +60 -0
- data/lib/mistri/locks/rails_cache.rb +48 -0
- data/lib/mistri/locks.rb +141 -0
- data/lib/mistri/mcp/client.rb +74 -19
- data/lib/mistri/mcp/egress.rb +216 -0
- data/lib/mistri/mcp/oauth.rb +476 -127
- data/lib/mistri/mcp/wires.rb +115 -23
- data/lib/mistri/mcp.rb +43 -9
- data/lib/mistri/message.rb +21 -11
- data/lib/mistri/models.rb +160 -22
- data/lib/mistri/providers/anthropic/assembler.rb +282 -44
- data/lib/mistri/providers/anthropic/serializer.rb +14 -9
- data/lib/mistri/providers/anthropic.rb +29 -6
- data/lib/mistri/providers/fake.rb +36 -6
- data/lib/mistri/providers/gemini/assembler.rb +148 -21
- data/lib/mistri/providers/gemini/serializer.rb +78 -9
- data/lib/mistri/providers/gemini.rb +31 -5
- data/lib/mistri/providers/openai/assembler.rb +337 -60
- data/lib/mistri/providers/openai/serializer.rb +13 -12
- data/lib/mistri/providers/openai.rb +29 -5
- data/lib/mistri/providers/schema_capabilities.rb +214 -0
- data/lib/mistri/result.rb +8 -3
- data/lib/mistri/retry_policy.rb +2 -2
- data/lib/mistri/schema.rb +893 -75
- data/lib/mistri/session.rb +649 -47
- data/lib/mistri/sinks/coalesced.rb +17 -10
- data/lib/mistri/skill.rb +1 -1
- data/lib/mistri/skills.rb +1 -1
- data/lib/mistri/spawner.rb +316 -0
- data/lib/mistri/sse.rb +57 -14
- data/lib/mistri/stores/active_record.rb +22 -7
- data/lib/mistri/stores/jsonl.rb +3 -1
- data/lib/mistri/stores/memory.rb +21 -2
- data/lib/mistri/sub_agent/execution.rb +81 -0
- data/lib/mistri/sub_agent/runtime.rb +297 -0
- data/lib/mistri/sub_agent.rb +238 -103
- data/lib/mistri/task_output.rb +58 -0
- data/lib/mistri/tool.rb +102 -13
- data/lib/mistri/tool_arguments.rb +377 -0
- data/lib/mistri/tool_call.rb +43 -9
- data/lib/mistri/tool_context.rb +7 -5
- data/lib/mistri/tool_executor.rb +117 -26
- data/lib/mistri/tool_result.rb +15 -10
- data/lib/mistri/tools/edit_file.rb +62 -8
- data/lib/mistri/tools.rb +41 -4
- data/lib/mistri/transport.rb +149 -44
- data/lib/mistri/usage.rb +65 -13
- data/lib/mistri/version.rb +1 -1
- data/lib/mistri/workspace/active_record.rb +183 -3
- data/lib/mistri/workspace/directory.rb +28 -8
- data/lib/mistri/workspace/memory.rb +34 -9
- data/lib/mistri/workspace/single.rb +62 -5
- data/lib/mistri/workspace.rb +39 -0
- data/lib/mistri.rb +17 -1
- data/mistri.gemspec +34 -0
- metadata +38 -3
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mistri
|
|
4
|
+
# How a background child actually executes. The spawn tool hands every
|
|
5
|
+
# dispatcher two things: a versioned serializable spec (name, session_id,
|
|
6
|
+
# parent_session_id, type, instructions, tool_names, model, task) and a
|
|
7
|
+
# runner closure that invokes the host runtime factory inside this process.
|
|
8
|
+
#
|
|
9
|
+
# In-process dispatchers just call the runner. A queue dispatcher ignores
|
|
10
|
+
# the runner, enqueues the spec, and its job reconstructs the pieces from
|
|
11
|
+
# host registries and calls SubAgent.run_dispatched:
|
|
12
|
+
#
|
|
13
|
+
# dispatcher: ->(spec, _runner) { ChildRunJob.perform_async(spec) }
|
|
14
|
+
#
|
|
15
|
+
# The runner invokes the host factory inside the worker and keeps the
|
|
16
|
+
# spawn-time event sink. In-process children stream to whoever watched the
|
|
17
|
+
# spawn; that sink must outlive the parent's turn (a broadcast lambda does,
|
|
18
|
+
# a request-scoped object may not) and hears from the worker's thread,
|
|
19
|
+
# concurrently with the parent's own events. The gem's sinks tolerate that;
|
|
20
|
+
# a custom one must too.
|
|
21
|
+
module Dispatchers
|
|
22
|
+
# Runs the child synchronously inside the spawn call and still answers
|
|
23
|
+
# in receipt form: background degrades gracefully where no concurrency
|
|
24
|
+
# exists (consoles, tests), and the receipt stays truthful because it
|
|
25
|
+
# reads the child's status after dispatch.
|
|
26
|
+
class Inline
|
|
27
|
+
def call(_spec, runner) = runner.call
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Real in-process background: the child runs on its own thread and the
|
|
31
|
+
# spawn call returns immediately. Enough for development and for hosts
|
|
32
|
+
# whose children are short; queue-backed hosts plug their own lambda.
|
|
33
|
+
class Thread
|
|
34
|
+
def call(spec, runner)
|
|
35
|
+
::Thread.new do
|
|
36
|
+
runner.call
|
|
37
|
+
rescue StandardError => e
|
|
38
|
+
reported = EventDelivery.original(e)
|
|
39
|
+
# Execution failures have a durable terminal before re-raising;
|
|
40
|
+
# cleanup may raise after a successful terminal. Neither should
|
|
41
|
+
# make a thread die loudly or disappear without a diagnostic.
|
|
42
|
+
warn "mistri: background runner for #{spec["name"].inspect} crashed: " \
|
|
43
|
+
"#{reported.class}: #{reported.message}"
|
|
44
|
+
end
|
|
45
|
+
nil
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
data/lib/mistri/errors.rb
CHANGED
|
@@ -11,6 +11,10 @@ module Mistri
|
|
|
11
11
|
# Missing or contradictory setup: an unknown model, an absent API key.
|
|
12
12
|
class ConfigurationError < Error; end
|
|
13
13
|
|
|
14
|
+
# A queue payload is not the grant persisted for its child. The unchanged
|
|
15
|
+
# delivery is poison: discard and alert instead of retrying it.
|
|
16
|
+
class DispatchGrantError < ConfigurationError; end
|
|
17
|
+
|
|
14
18
|
# A provider request failed. Carries the HTTP status and response body when
|
|
15
19
|
# the transport got that far.
|
|
16
20
|
class ProviderError < Error
|
|
@@ -57,20 +61,89 @@ module Mistri
|
|
|
57
61
|
def self.default_message = "provider server error"
|
|
58
62
|
end
|
|
59
63
|
|
|
64
|
+
# An inbound body or protocol record crossed its configured byte boundary.
|
|
65
|
+
class ResponseTooLargeError < Error
|
|
66
|
+
attr_reader :kind, :limit
|
|
67
|
+
|
|
68
|
+
def initialize(kind:, limit:)
|
|
69
|
+
@kind = kind
|
|
70
|
+
@limit = limit
|
|
71
|
+
label = kind.to_s.tr("_", " ")
|
|
72
|
+
super("#{label} exceeded the byte limit (#{limit} bytes)")
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# A JSON protocol record crossed a structural boundary before parsing.
|
|
77
|
+
class ResponseTooComplexError < Error
|
|
78
|
+
attr_reader :kind, :limit
|
|
79
|
+
|
|
80
|
+
def initialize(kind:, limit:)
|
|
81
|
+
@kind = kind
|
|
82
|
+
@limit = limit
|
|
83
|
+
label = kind.to_s.tr("_", " ")
|
|
84
|
+
super("#{label} exceeded the complexity limit (#{limit})")
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# A non-replayable request has no confirmed response, so execution is unknown.
|
|
89
|
+
class AmbiguousDeliveryError < ProviderError
|
|
90
|
+
def self.default_message
|
|
91
|
+
"connection failed before the response was confirmed; the operation may have completed; " \
|
|
92
|
+
"do not retry automatically; verify external state first"
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# The provider rejected the request itself: an invalid prompt, a bad
|
|
97
|
+
# image, a policy violation. Never retried; the same input cannot succeed.
|
|
98
|
+
class InvalidRequestError < ProviderError
|
|
99
|
+
def self.default_message = "provider rejected the request"
|
|
100
|
+
end
|
|
101
|
+
|
|
60
102
|
# Tool arguments or structured output that violate their declared schema.
|
|
61
103
|
class SchemaError < Error; end
|
|
62
104
|
|
|
63
105
|
# A run cancelled by the host, raised only when the caller opts into raising.
|
|
64
106
|
class AbortError < Error; end
|
|
65
107
|
|
|
66
|
-
# A
|
|
67
|
-
|
|
108
|
+
# A cost-budgeted request returned without trustworthy accounting. Usage and
|
|
109
|
+
# provider_message preserve the uncertain attempt for host reconciliation.
|
|
110
|
+
class BudgetError < Error
|
|
111
|
+
attr_reader :usage, :provider_message
|
|
112
|
+
|
|
113
|
+
def initialize(message = nil, usage: nil, provider_message: nil)
|
|
114
|
+
@usage = usage
|
|
115
|
+
@provider_message = provider_message
|
|
116
|
+
super(message)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
68
119
|
|
|
69
120
|
# A text edit that did not match uniquely, or overlapped another edit.
|
|
70
121
|
class EditError < Error; end
|
|
71
122
|
|
|
72
|
-
#
|
|
73
|
-
|
|
123
|
+
# A conditional document write lost to a different committed version. The
|
|
124
|
+
# revisions stay off the message so a stale full replacement cannot borrow
|
|
125
|
+
# a fresh token without reading the matching content.
|
|
126
|
+
class WorkspaceConflictError < Error
|
|
127
|
+
attr_reader :path, :expected_revision, :actual_revision
|
|
128
|
+
|
|
129
|
+
def initialize(path, expected_revision: nil, actual_revision: nil)
|
|
130
|
+
@path = path.to_s.dup.freeze
|
|
131
|
+
@expected_revision = expected_revision.nil? ? nil : expected_revision.to_s.dup.freeze
|
|
132
|
+
@actual_revision = actual_revision.nil? ? nil : actual_revision.to_s.dup.freeze
|
|
133
|
+
super("document #{@path.inspect} changed; read it again before retrying")
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Compaction could not produce a usable summary. Usage preserves any billed
|
|
138
|
+
# provider attempt even though no checkpoint was written.
|
|
139
|
+
class CompactionError < Error
|
|
140
|
+
attr_reader :usage
|
|
141
|
+
|
|
142
|
+
def initialize(message = nil, usage: nil)
|
|
143
|
+
@usage = usage
|
|
144
|
+
super(message)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
74
147
|
|
|
75
148
|
# The machine-readable shape of a stream failure, carried on errored
|
|
76
149
|
# assistant messages so retry policies and hosts can classify without
|
|
@@ -84,6 +157,12 @@ module Mistri
|
|
|
84
157
|
when RateLimitError
|
|
85
158
|
{ "type" => "RateLimitError", "status" => reason.status,
|
|
86
159
|
"retry_after" => reason.retry_after }.compact
|
|
160
|
+
when ResponseTooLargeError
|
|
161
|
+
{ "type" => "ResponseTooLargeError", "kind" => reason.kind.to_s,
|
|
162
|
+
"limit" => reason.limit }
|
|
163
|
+
when ResponseTooComplexError
|
|
164
|
+
{ "type" => "ResponseTooComplexError", "kind" => reason.kind.to_s,
|
|
165
|
+
"limit" => reason.limit }
|
|
87
166
|
when ProviderError
|
|
88
167
|
{ "type" => reason.class.name.split("::").last, "status" => reason.status }.compact
|
|
89
168
|
when Exception then { "type" => reason.class.name }
|
data/lib/mistri/event.rb
CHANGED
|
@@ -11,34 +11,54 @@ module Mistri
|
|
|
11
11
|
# message's content list.
|
|
12
12
|
# origin names the sub-agent an event came from: nil for this agent's own
|
|
13
13
|
# turns, and nesting joins names left to right ("researcher>writer").
|
|
14
|
-
# duration is the tool
|
|
15
|
-
#
|
|
14
|
+
# duration is the measured tool execution time on :tool_result events; nil
|
|
15
|
+
# means no duration is available, not necessarily that no side effect ran.
|
|
16
|
+
# tool_error is explicit on :tool_result and does not change Event#error?,
|
|
17
|
+
# which means a terminal provider-turn failure.
|
|
16
18
|
class Event < Data.define(:type, :content_index, :delta, :content, :tool_call,
|
|
17
19
|
:reason, :message, :error_message, :partial, :origin,
|
|
18
|
-
:duration, :attempt, :max_attempts, :delay
|
|
20
|
+
:duration, :attempt, :max_attempts, :delay,
|
|
21
|
+
:agent, :session_id, :status, :tool_error)
|
|
19
22
|
# The stream types come from a provider mid-turn; the loop adds
|
|
20
|
-
# :
|
|
21
|
-
# call parks for a human,
|
|
23
|
+
# :tool_started when a resolved tool commits to execution, :tool_result
|
|
24
|
+
# after it finishes, :approval_needed when a gated call parks for a human,
|
|
25
|
+
# :compacting/:compaction around a context
|
|
22
26
|
# compaction, and :retry (with attempt, max_attempts, delay) before it
|
|
23
27
|
# waits out a transient failure, so one subscription sees the whole
|
|
24
28
|
# exchange. :done and :error are loop-owned and terminal: only the
|
|
25
29
|
# accepted attempt's terminal event reaches the subscriber.
|
|
30
|
+
# :subagent_report announces a background child's terminal outcome
|
|
31
|
+
# (agent, session_id, status; content carries the report), so a UI that
|
|
32
|
+
# watched the spawn can settle the child's lane the moment it ends.
|
|
26
33
|
TYPES = %i[
|
|
27
34
|
start
|
|
28
35
|
text_start text_delta text_end
|
|
29
36
|
thinking_start thinking_delta thinking_end
|
|
30
37
|
toolcall_start toolcall_delta toolcall_end
|
|
31
38
|
done error
|
|
32
|
-
tool_result approval_needed
|
|
39
|
+
tool_started tool_result approval_needed
|
|
33
40
|
compacting compaction
|
|
34
41
|
retry
|
|
42
|
+
subagent_report
|
|
35
43
|
].freeze
|
|
36
44
|
|
|
37
45
|
def initialize(type:, content_index: nil, delta: nil, content: nil, tool_call: nil,
|
|
38
46
|
reason: nil, message: nil, error_message: nil, partial: nil, origin: nil,
|
|
39
|
-
duration: nil, attempt: nil, max_attempts: nil, delay: nil
|
|
47
|
+
duration: nil, attempt: nil, max_attempts: nil, delay: nil,
|
|
48
|
+
agent: nil, session_id: nil, status: nil, tool_error: nil)
|
|
40
49
|
raise ArgumentError, "unknown event type #{type.inspect}" unless TYPES.include?(type)
|
|
41
50
|
|
|
51
|
+
if type == :tool_result
|
|
52
|
+
unless [true, false].include?(tool_error)
|
|
53
|
+
raise ArgumentError, "tool_result events require a boolean tool_error"
|
|
54
|
+
end
|
|
55
|
+
elsif !tool_error.nil?
|
|
56
|
+
raise ArgumentError, "tool_error is only valid on tool_result events"
|
|
57
|
+
end
|
|
58
|
+
if type == :tool_result && message && message.tool_error != tool_error
|
|
59
|
+
raise ArgumentError, "event tool_error must match its message"
|
|
60
|
+
end
|
|
61
|
+
|
|
42
62
|
super
|
|
43
63
|
end
|
|
44
64
|
|
|
@@ -46,13 +66,15 @@ module Mistri
|
|
|
46
66
|
|
|
47
67
|
def error? = type == :error
|
|
48
68
|
|
|
69
|
+
def tool_error? = tool_error == true
|
|
70
|
+
|
|
49
71
|
def terminal? = done? || error?
|
|
50
72
|
|
|
51
73
|
# Partials are ephemeral streaming state and stay out of serialization.
|
|
52
74
|
def to_h
|
|
53
75
|
{ type:, content_index:, delta:, content:, tool_call: tool_call&.to_h,
|
|
54
76
|
reason:, message: message&.to_h, error_message:, origin:, duration:,
|
|
55
|
-
attempt:, max_attempts:, delay: }.compact
|
|
77
|
+
attempt:, max_attempts:, delay:, agent:, session_id:, status:, tool_error: }.compact
|
|
56
78
|
end
|
|
57
79
|
end
|
|
58
80
|
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mistri
|
|
4
|
+
# Preserves subscriber exception identity across internal rescue boundaries.
|
|
5
|
+
module EventDelivery
|
|
6
|
+
# Tags a subscriber failure with the boundary that first observed it.
|
|
7
|
+
class Failure < StandardError
|
|
8
|
+
attr_reader :original, :boundary
|
|
9
|
+
|
|
10
|
+
def initialize(original, boundary)
|
|
11
|
+
@original = original
|
|
12
|
+
@boundary = boundary
|
|
13
|
+
super(original.message)
|
|
14
|
+
set_backtrace(original.backtrace)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Wraps one subscriber and unwraps only failures that it created.
|
|
19
|
+
class Boundary
|
|
20
|
+
def initialize(subscriber, passthrough: [])
|
|
21
|
+
@subscriber = subscriber
|
|
22
|
+
@passthrough = passthrough.freeze
|
|
23
|
+
@callable = method(:call).to_proc
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def to_proc = @callable
|
|
27
|
+
|
|
28
|
+
def call(event)
|
|
29
|
+
@subscriber.call(event)
|
|
30
|
+
rescue Failure
|
|
31
|
+
raise
|
|
32
|
+
rescue StandardError => e
|
|
33
|
+
raise if @passthrough.any? { |error_class| e.is_a?(error_class) }
|
|
34
|
+
|
|
35
|
+
raise Failure.new(e, self)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def unwrap(error)
|
|
39
|
+
error.boundary.equal?(self) ? error.original : error
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
module_function
|
|
44
|
+
|
|
45
|
+
def wrap(subscriber, passthrough: [])
|
|
46
|
+
Boundary.new(subscriber, passthrough: passthrough) if subscriber
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def unwrap(error, boundary)
|
|
50
|
+
return error unless error.is_a?(Failure) && boundary
|
|
51
|
+
|
|
52
|
+
boundary.unwrap(error)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def original(error)
|
|
56
|
+
error.is_a?(Failure) ? error.original : error
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
private_constant :EventDelivery
|
|
60
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Opt-in, exactly like Stores::ActiveRecord: the gem never requires Rails.
|
|
4
|
+
# require "mistri/locks/rails_cache".
|
|
5
|
+
module Mistri
|
|
6
|
+
module Locks
|
|
7
|
+
# Leases and flags over an ActiveSupport::Cache::Store (Redis-backed in
|
|
8
|
+
# any real deployment), so every process in the fleet reads the same
|
|
9
|
+
# truth. Pass any cache store duck; defaults to Rails.cache.
|
|
10
|
+
class RailsCache
|
|
11
|
+
def initialize(cache: nil, namespace: "mistri:locks")
|
|
12
|
+
@cache = cache || (defined?(Rails) && Rails.cache) ||
|
|
13
|
+
raise(ConfigurationError, "no cache store; pass cache: or configure Rails.cache")
|
|
14
|
+
@namespace = namespace
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def acquire(key, ttl:)
|
|
18
|
+
@cache.write(scoped(key), true, unless_exist: true, expires_in: ttl)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def renew(key, ttl:)
|
|
22
|
+
@cache.write(scoped(key), true, expires_in: ttl)
|
|
23
|
+
nil
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def release(key)
|
|
27
|
+
@cache.delete(scoped(key))
|
|
28
|
+
nil
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def held?(key)
|
|
32
|
+
@cache.exist?(scoped(key))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def set_flag(key, ttl: 300)
|
|
36
|
+
renew(key, ttl: ttl)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def flag?(key) = held?(key)
|
|
40
|
+
|
|
41
|
+
def clear_flag(key) = release(key)
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def scoped(key) = "#{@namespace}:#{key}"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
data/lib/mistri/locks.rb
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "monitor"
|
|
4
|
+
|
|
5
|
+
module Mistri
|
|
6
|
+
# Cross-process coordination for hosts that run loops in more than one
|
|
7
|
+
# process: leases that say "this is alive right now" and flags that carry
|
|
8
|
+
# one-bit requests (stop) between processes. Everything expires on its
|
|
9
|
+
# own, so a crashed holder never wedges anything; a healthy holder renews
|
|
10
|
+
# on a heartbeat, while a stalled process can still outlive its lease.
|
|
11
|
+
#
|
|
12
|
+
# Configure once at boot:
|
|
13
|
+
#
|
|
14
|
+
# Mistri.locks = Mistri::Locks::Memory.new # single process
|
|
15
|
+
# Mistri.locks = Mistri::Locks::RailsCache.new # requires opt-in file
|
|
16
|
+
#
|
|
17
|
+
# An adapter implements seven methods: acquire(key, ttl:) -> bool,
|
|
18
|
+
# renew(key, ttl:), release(key), held?(key), set_flag(key, ttl:),
|
|
19
|
+
# flag?(key), clear_flag(key). With no adapter configured, everything
|
|
20
|
+
# lease-aware degrades gracefully: children read :running instead of
|
|
21
|
+
# :interrupted, and holds are no-ops.
|
|
22
|
+
module Locks
|
|
23
|
+
LEASE_TTL = 180
|
|
24
|
+
HEARTBEAT = 60
|
|
25
|
+
|
|
26
|
+
module_function
|
|
27
|
+
|
|
28
|
+
# Hold a lease for the duration of a block of work: acquire, renew on a
|
|
29
|
+
# heartbeat from a background thread, release on the way out. Returns a
|
|
30
|
+
# Hold to release in an ensure, or nil when no adapter is configured or
|
|
31
|
+
# the lease is already held elsewhere: a caller that was refused must
|
|
32
|
+
# never renew or delete the real holder's key. Callers that need to
|
|
33
|
+
# tell refusal apart from no-adapter use the adapter's acquire directly.
|
|
34
|
+
#
|
|
35
|
+
# With stop_key: and signal:, the same thread watches the flag and trips
|
|
36
|
+
# the signal within a tick, so a stop request written in another process
|
|
37
|
+
# becomes this run's cooperative abort.
|
|
38
|
+
def hold(key, ttl: LEASE_TTL, heartbeat: HEARTBEAT, stop_key: nil, signal: nil)
|
|
39
|
+
adapter = Mistri.locks
|
|
40
|
+
return nil unless adapter
|
|
41
|
+
return nil unless adapter.acquire(key, ttl: ttl)
|
|
42
|
+
|
|
43
|
+
Hold.new(adapter, key, ttl, heartbeat, stop_key: stop_key, signal: signal)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# A held lease: one thread renews it on the heartbeat and watches the
|
|
47
|
+
# stop flag between renewals. Renewal runs on a monotonic deadline (the
|
|
48
|
+
# last sleep before it is the remaining fraction, never a full tick, so
|
|
49
|
+
# the cadence is exact and a lease can never lapse waiting for a tick to
|
|
50
|
+
# round up). release stops the thread (and joins it, so a mid-renewal
|
|
51
|
+
# tick can never re-stamp a lease that was just released) and deletes
|
|
52
|
+
# the key.
|
|
53
|
+
class Hold
|
|
54
|
+
def initialize(adapter, key, ttl, heartbeat, stop_key: nil, signal: nil)
|
|
55
|
+
@adapter = adapter
|
|
56
|
+
@key = key
|
|
57
|
+
@stopping = false
|
|
58
|
+
@thread = Thread.new do
|
|
59
|
+
deadline = now + heartbeat
|
|
60
|
+
until @stopping
|
|
61
|
+
sleep (deadline - now).clamp(0.01, 1.0)
|
|
62
|
+
break if @stopping
|
|
63
|
+
|
|
64
|
+
signal.abort!("stopped by user") if stop_key && signal && adapter.flag?(stop_key)
|
|
65
|
+
if now >= deadline
|
|
66
|
+
adapter.renew(key, ttl: ttl)
|
|
67
|
+
deadline = now + heartbeat
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def release
|
|
74
|
+
@stopping = true
|
|
75
|
+
@thread.kill
|
|
76
|
+
@thread.join(2)
|
|
77
|
+
@adapter.release(@key)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# The in-process adapter: real TTL semantics over a hash, for tests,
|
|
86
|
+
# development, and the thread dispatcher. Monitor-synchronized; expiry
|
|
87
|
+
# is judged at read time, so nothing needs a sweeper.
|
|
88
|
+
class Memory
|
|
89
|
+
def initialize
|
|
90
|
+
@entries = {}
|
|
91
|
+
@lock = Monitor.new
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def acquire(key, ttl:)
|
|
95
|
+
@lock.synchronize do
|
|
96
|
+
return false if live?(key)
|
|
97
|
+
|
|
98
|
+
@entries[key] = deadline(ttl)
|
|
99
|
+
true
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def renew(key, ttl:)
|
|
104
|
+
@lock.synchronize { @entries[key] = deadline(ttl) }
|
|
105
|
+
nil
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def release(key)
|
|
109
|
+
@lock.synchronize { @entries.delete(key) }
|
|
110
|
+
nil
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def held?(key)
|
|
114
|
+
@lock.synchronize { live?(key) }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def set_flag(key, ttl: 300)
|
|
118
|
+
renew(key, ttl: ttl)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def flag?(key) = held?(key)
|
|
122
|
+
|
|
123
|
+
def clear_flag(key) = release(key)
|
|
124
|
+
|
|
125
|
+
private
|
|
126
|
+
|
|
127
|
+
def live?(key)
|
|
128
|
+
deadline = @entries[key]
|
|
129
|
+
return false unless deadline
|
|
130
|
+
return true if deadline > now
|
|
131
|
+
|
|
132
|
+
@entries.delete(key)
|
|
133
|
+
false
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def deadline(ttl) = now + ttl
|
|
137
|
+
|
|
138
|
+
def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
data/lib/mistri/mcp/client.rb
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "uri"
|
|
4
|
-
|
|
5
3
|
module Mistri
|
|
6
4
|
module MCP
|
|
7
5
|
# A Model Context Protocol client: the initialize handshake, tools/list
|
|
@@ -20,37 +18,45 @@ module Mistri
|
|
|
20
18
|
# re-resolving, so a host's refresh logic lives in one lambda. A session
|
|
21
19
|
# the server expires (404 with a session attached) transparently
|
|
22
20
|
# re-initializes, per spec.
|
|
21
|
+
# Remote URLs default to public HTTPS. allow_non_public: is consulted only
|
|
22
|
+
# for otherwise blocked addresses and plain HTTP remains loopback-only.
|
|
23
|
+
# max_record_bytes bounds one JSON body, SSE line, or stdio record without
|
|
24
|
+
# imposing a lifetime limit on an SSE stream.
|
|
23
25
|
#
|
|
24
26
|
# One client serializes its calls; parallel tool calls against one
|
|
25
27
|
# server queue rather than interleave.
|
|
26
28
|
class Client
|
|
27
29
|
PROTOCOL_VERSION = "2025-11-25"
|
|
28
30
|
SUPPORTED_VERSIONS = %w[2025-11-25 2025-06-18 2025-03-26 2024-11-05].freeze
|
|
29
|
-
LOOPBACK = %w[localhost 127.0.0.1 ::1].freeze
|
|
30
|
-
|
|
31
31
|
attr_reader :server_info
|
|
32
32
|
|
|
33
33
|
def initialize(url: nil, command: nil, env: {}, token: nil, headers: {},
|
|
34
|
-
client_name: "mistri", open_timeout: 15, read_timeout: 120
|
|
34
|
+
client_name: "mistri", open_timeout: 15, read_timeout: 120,
|
|
35
|
+
allow_non_public: nil, max_tool_pages: 100, max_tools: 10_000,
|
|
36
|
+
max_record_bytes: DEFAULT_MAX_RECORD_BYTES)
|
|
35
37
|
if [url, command].compact.length != 1
|
|
36
38
|
raise ConfigurationError, "pass exactly one of url: or command:"
|
|
37
39
|
end
|
|
38
40
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
end
|
|
41
|
+
validate_limit(:max_tool_pages, max_tool_pages)
|
|
42
|
+
validate_limit(:max_tools, max_tools)
|
|
43
|
+
validate_limit(:max_record_bytes, max_record_bytes)
|
|
43
44
|
|
|
44
45
|
@wire = if url
|
|
45
46
|
Wires::Http.new(url: url, token: token, headers: headers,
|
|
46
|
-
open_timeout: open_timeout, read_timeout: read_timeout
|
|
47
|
+
open_timeout: open_timeout, read_timeout: read_timeout,
|
|
48
|
+
allow_non_public: allow_non_public,
|
|
49
|
+
max_record_bytes: max_record_bytes)
|
|
47
50
|
else
|
|
48
|
-
Wires::Stdio.new(command: command, env: env, read_timeout: read_timeout
|
|
51
|
+
Wires::Stdio.new(command: command, env: env, read_timeout: read_timeout,
|
|
52
|
+
max_record_bytes: max_record_bytes)
|
|
49
53
|
end
|
|
50
54
|
@client_name = client_name
|
|
51
55
|
@mutex = Mutex.new
|
|
52
56
|
@serial = 0
|
|
53
57
|
@connected = false
|
|
58
|
+
@max_tool_pages = max_tool_pages
|
|
59
|
+
@max_tools = max_tools
|
|
54
60
|
end
|
|
55
61
|
|
|
56
62
|
# The server's tools as it describes them: hashes with "name",
|
|
@@ -72,8 +78,7 @@ module Mistri
|
|
|
72
78
|
end
|
|
73
79
|
|
|
74
80
|
def close
|
|
75
|
-
@
|
|
76
|
-
@connected = false
|
|
81
|
+
@mutex.synchronize { reset_wire }
|
|
77
82
|
nil
|
|
78
83
|
end
|
|
79
84
|
|
|
@@ -96,6 +101,9 @@ module Mistri
|
|
|
96
101
|
@server_info = result["serverInfo"]
|
|
97
102
|
@wire.notify({ jsonrpc: "2.0", method: "notifications/initialized" })
|
|
98
103
|
@connected = true
|
|
104
|
+
rescue Mistri::Error
|
|
105
|
+
reset_wire
|
|
106
|
+
raise
|
|
99
107
|
end
|
|
100
108
|
|
|
101
109
|
def request(method, params, reconnected: false, refreshed: false)
|
|
@@ -119,7 +127,9 @@ module Mistri
|
|
|
119
127
|
payload = { jsonrpc: "2.0", id: id, method: method, params: params }
|
|
120
128
|
result = nil
|
|
121
129
|
responded = false
|
|
122
|
-
|
|
130
|
+
replayable = method != "tools/call"
|
|
131
|
+
# A tool may commit its external side effect before the connection dies.
|
|
132
|
+
@wire.call(payload, replayable: replayable) do |record|
|
|
123
133
|
next unless record.is_a?(Hash) && record["id"] == id
|
|
124
134
|
|
|
125
135
|
responded = true
|
|
@@ -127,9 +137,28 @@ module Mistri
|
|
|
127
137
|
|
|
128
138
|
result = record["result"]
|
|
129
139
|
end
|
|
130
|
-
|
|
140
|
+
unless responded
|
|
141
|
+
detail = "the server sent no matching response to #{method}"
|
|
142
|
+
raise Error, detail if replayable
|
|
143
|
+
|
|
144
|
+
raise AmbiguousDeliveryError, "#{AmbiguousDeliveryError.default_message}: #{detail}"
|
|
145
|
+
end
|
|
131
146
|
|
|
132
147
|
result
|
|
148
|
+
rescue ResponseTooLargeError, ResponseTooComplexError, WireError => e
|
|
149
|
+
reset_wire
|
|
150
|
+
raise if replayable
|
|
151
|
+
return result if responded
|
|
152
|
+
|
|
153
|
+
message = "the request was sent but its tool outcome could not be confirmed: " \
|
|
154
|
+
"#{e.message}; the operation may have completed; do not retry automatically; " \
|
|
155
|
+
"verify external state first"
|
|
156
|
+
raise AmbiguousDeliveryError, message
|
|
157
|
+
rescue AmbiguousDeliveryError
|
|
158
|
+
reset_wire
|
|
159
|
+
return result if responded
|
|
160
|
+
|
|
161
|
+
raise
|
|
133
162
|
rescue ProviderError => e
|
|
134
163
|
raise SessionExpired if e.status == 404 && @wire.session?
|
|
135
164
|
|
|
@@ -139,13 +168,39 @@ module Mistri
|
|
|
139
168
|
def list_tools
|
|
140
169
|
collected = []
|
|
141
170
|
cursor = nil
|
|
142
|
-
|
|
171
|
+
seen_cursors = {}
|
|
172
|
+
@max_tool_pages.times do
|
|
143
173
|
result = request("tools/list", cursor ? { cursor: cursor } : {})
|
|
144
|
-
|
|
174
|
+
raise Error, "tools/list returned a malformed result" unless result.is_a?(Hash)
|
|
175
|
+
|
|
176
|
+
page = result["tools"]
|
|
177
|
+
raise Error, "tools/list returned a malformed tools collection" unless page.is_a?(Array)
|
|
178
|
+
if collected.length + page.length > @max_tools
|
|
179
|
+
raise Error, "tool discovery exceeded #{@max_tools} tools"
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
collected.concat(page)
|
|
145
183
|
cursor = result["nextCursor"]
|
|
146
|
-
|
|
184
|
+
return collected if cursor.nil?
|
|
185
|
+
raise Error, "tools/list returned a malformed cursor" unless cursor.is_a?(String)
|
|
186
|
+
raise Error, "tools/list repeated a cursor" if seen_cursors[cursor]
|
|
187
|
+
|
|
188
|
+
seen_cursors[cursor] = true
|
|
147
189
|
end
|
|
148
|
-
|
|
190
|
+
raise Error, "tool discovery exceeded #{@max_tool_pages} pages"
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def validate_limit(name, value)
|
|
194
|
+
return if value.is_a?(Integer) && value.positive?
|
|
195
|
+
|
|
196
|
+
raise ConfigurationError, "#{name}: must be a positive integer"
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def reset_wire
|
|
200
|
+
@wire.close
|
|
201
|
+
@wire.reset_session
|
|
202
|
+
@connected = false
|
|
203
|
+
@server_info = nil
|
|
149
204
|
end
|
|
150
205
|
|
|
151
206
|
def rpc_error(error)
|