mistri 0.1.0 → 0.2.1
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 +60 -0
- data/README.md +60 -2
- 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/agent.rb +68 -19
- data/lib/mistri/event.rb +7 -3
- 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/reminder.rb +36 -0
- data/lib/mistri/result.rb +5 -3
- data/lib/mistri/tool.rb +3 -2
- data/lib/mistri/tool_executor.rb +25 -4
- data/lib/mistri/transport.rb +41 -0
- data/lib/mistri/version.rb +1 -1
- data/lib/mistri.rb +2 -0
- metadata +14 -8
data/lib/mistri/mcp.rb
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Mistri
|
|
6
|
+
# Bridge Model Context Protocol servers into Mistri tools: list a server's
|
|
7
|
+
# tools, hand them to an agent, and everything the harness already does
|
|
8
|
+
# composes — approval gates on third-party write tools, retries, sub-agent
|
|
9
|
+
# pools, the ui channel.
|
|
10
|
+
#
|
|
11
|
+
# client = Mistri::MCP::Client.new(url: "https://mcp.linear.app/mcp",
|
|
12
|
+
# token: -> { connection.fresh_token })
|
|
13
|
+
# agent = Mistri.agent("claude-opus-4-8",
|
|
14
|
+
# tools: Mistri::MCP.tools(client, prefix: "linear"))
|
|
15
|
+
#
|
|
16
|
+
# The bridge is duck-typed: any client responding to tools (an array of
|
|
17
|
+
# {"name", "description", "inputSchema"} hashes) and call_tool(name, args)
|
|
18
|
+
# bridges the same way, so the official mcp gem's client plugs in too.
|
|
19
|
+
module MCP
|
|
20
|
+
# A protocol-level failure: a JSON-RPC error, a missing response, an
|
|
21
|
+
# unsupported negotiation.
|
|
22
|
+
class Error < Mistri::Error
|
|
23
|
+
attr_reader :code
|
|
24
|
+
|
|
25
|
+
def initialize(message = nil, code: nil)
|
|
26
|
+
@code = code
|
|
27
|
+
super(message)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# The server expired this client's session (a 404 with a session
|
|
32
|
+
# attached); the spec says start a fresh one, and Client does.
|
|
33
|
+
class SessionExpired < Error; end
|
|
34
|
+
|
|
35
|
+
module_function
|
|
36
|
+
|
|
37
|
+
# The server's tools as Mistri tools. allow/deny filter by remote name,
|
|
38
|
+
# prefix namespaces local names ("linear__create_issue") against
|
|
39
|
+
# collisions, and gates marks tools needing human approval
|
|
40
|
+
# (gates: { "create_issue" => true }, or needs_approval: for all).
|
|
41
|
+
def tools(client, allow: nil, deny: [], prefix: nil, needs_approval: false, gates: {})
|
|
42
|
+
listed = client.tools
|
|
43
|
+
listed = listed.select { |tool| allow.include?(tool["name"]) } if allow
|
|
44
|
+
listed = listed.reject { |tool| deny.include?(tool["name"]) }
|
|
45
|
+
listed.map do |tool|
|
|
46
|
+
bridge(client, tool, prefix: prefix, gate: gates.fetch(tool["name"], needs_approval))
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def bridge(client, spec, prefix: nil, gate: false)
|
|
51
|
+
remote = spec.fetch("name")
|
|
52
|
+
local = prefix ? "#{prefix}__#{remote}" : remote
|
|
53
|
+
Tool.define(local, spec["description"].to_s,
|
|
54
|
+
input_schema: spec["inputSchema"] || Tool::EMPTY_SCHEMA,
|
|
55
|
+
needs_approval: gate) do |args|
|
|
56
|
+
answer(client.call_tool(remote, args || {}))
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# An MCP result becomes model-readable content: text joins, images ride
|
|
61
|
+
# as image blocks, and isError answers in band so the model can react.
|
|
62
|
+
def answer(result)
|
|
63
|
+
blocks = Array(result["content"]).map { |block| convert(block) }
|
|
64
|
+
if result["isError"]
|
|
65
|
+
text = blocks.grep(String).join("\n")
|
|
66
|
+
return "MCP tool error: #{text.empty? ? "unknown error" : text}"
|
|
67
|
+
end
|
|
68
|
+
if blocks.empty? && result["structuredContent"]
|
|
69
|
+
return JSON.generate(result["structuredContent"])
|
|
70
|
+
end
|
|
71
|
+
return blocks.join("\n") if blocks.all?(String)
|
|
72
|
+
|
|
73
|
+
blocks
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def convert(block)
|
|
77
|
+
case block["type"]
|
|
78
|
+
when "text" then block["text"].to_s
|
|
79
|
+
when "image"
|
|
80
|
+
Content::Image.from_bytes(block["data"].to_s.unpack1("m"),
|
|
81
|
+
mime_type: block["mimeType"] || "image/png")
|
|
82
|
+
when "resource" then resource_text(block["resource"] || {})
|
|
83
|
+
when "resource_link" then "[resource: #{block["uri"]}]"
|
|
84
|
+
else "[unsupported #{block["type"]} content]"
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def resource_text(resource)
|
|
89
|
+
resource["text"] || "[resource: #{resource["uri"]}]"
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
require_relative "mcp/wires"
|
|
95
|
+
require_relative "mcp/client"
|
|
96
|
+
require_relative "mcp/oauth"
|
|
@@ -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
|
data/lib/mistri/result.rb
CHANGED
|
@@ -9,9 +9,11 @@ module Mistri
|
|
|
9
9
|
#
|
|
10
10
|
# Reads delegate to the final message, so result.text works whether the run
|
|
11
11
|
# completed or suspended.
|
|
12
|
-
# output is a task's validated value, nil on plain runs.
|
|
13
|
-
|
|
14
|
-
|
|
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)
|
|
15
17
|
super
|
|
16
18
|
end
|
|
17
19
|
|
data/lib/mistri/tool.rb
CHANGED
|
@@ -13,7 +13,7 @@ module Mistri
|
|
|
13
13
|
# bare empty hash.
|
|
14
14
|
EMPTY_SCHEMA = { type: "object", properties: {} }.freeze
|
|
15
15
|
|
|
16
|
-
attr_reader :name, :description, :input_schema
|
|
16
|
+
attr_reader :name, :description, :input_schema, :timeout
|
|
17
17
|
|
|
18
18
|
# Define a tool. Give the argument shape as a raw JSON Schema hash via
|
|
19
19
|
# input_schema:, or build it in Ruby with a schema: block.
|
|
@@ -28,7 +28,7 @@ module Mistri
|
|
|
28
28
|
end
|
|
29
29
|
|
|
30
30
|
def initialize(name:, description:, input_schema: EMPTY_SCHEMA, eager_input_streaming: false,
|
|
31
|
-
needs_approval: false, &handler)
|
|
31
|
+
needs_approval: false, timeout: nil, &handler)
|
|
32
32
|
raise ArgumentError, "tool #{name.inspect} needs a handler block" unless handler
|
|
33
33
|
|
|
34
34
|
@name = name.to_s
|
|
@@ -36,6 +36,7 @@ module Mistri
|
|
|
36
36
|
@input_schema = input_schema
|
|
37
37
|
@eager_input_streaming = eager_input_streaming
|
|
38
38
|
@needs_approval = needs_approval
|
|
39
|
+
@timeout = timeout
|
|
39
40
|
@handler = handler
|
|
40
41
|
end
|
|
41
42
|
|
data/lib/mistri/tool_executor.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "timeout"
|
|
4
|
+
|
|
3
5
|
module Mistri
|
|
4
6
|
# Runs a turn's tool calls and returns their results in the order the model
|
|
5
7
|
# emitted them, regardless of completion order. Independent calls run
|
|
@@ -23,7 +25,10 @@ module Mistri
|
|
|
23
25
|
calls.each_with_index { |call, index| queue << [call, index] }
|
|
24
26
|
workers = max_concurrency.clamp(1, calls.length)
|
|
25
27
|
Array.new(workers) { worker(queue, results, tools_by_name, context) }.each(&:join)
|
|
26
|
-
calls.zip(results).map
|
|
28
|
+
calls.zip(results).map do |call, entry|
|
|
29
|
+
value, seconds = entry || [INTERRUPTED, nil]
|
|
30
|
+
[call, value, seconds]
|
|
31
|
+
end
|
|
27
32
|
end
|
|
28
33
|
|
|
29
34
|
def worker(queue, results, tools_by_name, context)
|
|
@@ -34,8 +39,14 @@ module Mistri
|
|
|
34
39
|
rescue ThreadError
|
|
35
40
|
break
|
|
36
41
|
end
|
|
37
|
-
|
|
38
|
-
|
|
42
|
+
if context.signal&.aborted?
|
|
43
|
+
results[index] = [INTERRUPTED, nil]
|
|
44
|
+
next
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
48
|
+
value = run_one(call, tools_by_name, context)
|
|
49
|
+
results[index] = [value, Process.clock_gettime(Process::CLOCK_MONOTONIC) - started]
|
|
39
50
|
end
|
|
40
51
|
end
|
|
41
52
|
end
|
|
@@ -44,11 +55,21 @@ module Mistri
|
|
|
44
55
|
tool = tools_by_name[call.name]
|
|
45
56
|
return "Error: unknown tool #{call.name.inspect}" unless tool
|
|
46
57
|
|
|
47
|
-
with_rails_executor { tool
|
|
58
|
+
with_rails_executor { invoke(tool, call, context) }
|
|
48
59
|
rescue StandardError => e
|
|
49
60
|
"Error running tool #{call.name.inspect}: #{e.class}: #{e.message}"
|
|
50
61
|
end
|
|
51
62
|
|
|
63
|
+
# A tool with a timeout answers in band when it stalls, so one hung
|
|
64
|
+
# handler cannot stall the whole run.
|
|
65
|
+
def invoke(tool, call, context)
|
|
66
|
+
return tool.call(call.arguments, context) unless tool.timeout
|
|
67
|
+
|
|
68
|
+
Timeout.timeout(tool.timeout) { tool.call(call.arguments, context) }
|
|
69
|
+
rescue Timeout::Error
|
|
70
|
+
"Error running tool #{call.name.inspect}: timed out after #{tool.timeout}s"
|
|
71
|
+
end
|
|
72
|
+
|
|
52
73
|
# Concurrent tools share the caller's sink; sinks are not required to be
|
|
53
74
|
# thread-safe, so forwarded events serialize here.
|
|
54
75
|
def thread_safe(emit)
|
data/lib/mistri/transport.rb
CHANGED
|
@@ -45,12 +45,53 @@ module Mistri
|
|
|
45
45
|
@mutex.synchronize { stream_locked(path, body, headers, signal, &block) }
|
|
46
46
|
end
|
|
47
47
|
|
|
48
|
+
# POST for Streamable-HTTP endpoints (the MCP shape) that answer either
|
|
49
|
+
# a JSON body or an SSE stream: yields each JSON record either way and
|
|
50
|
+
# returns the response headers, downcased. Retries only a dead idle
|
|
51
|
+
# socket that failed before any response started, so a side-effecting
|
|
52
|
+
# call can never run twice.
|
|
53
|
+
def post_either(path, body:, headers: {}, &block)
|
|
54
|
+
@mutex.synchronize do
|
|
55
|
+
retried = false
|
|
56
|
+
begin
|
|
57
|
+
started = false
|
|
58
|
+
response_headers = nil
|
|
59
|
+
connection.request(build_request(path, body, headers, streaming: true)) do |response|
|
|
60
|
+
started = true
|
|
61
|
+
raise_for_status(response)
|
|
62
|
+
response_headers = response.to_hash.transform_values(&:first)
|
|
63
|
+
read_either(response, &block)
|
|
64
|
+
end
|
|
65
|
+
response_headers
|
|
66
|
+
rescue IOError, SocketError, SystemCallError, Timeout::Error => e
|
|
67
|
+
teardown
|
|
68
|
+
if started || retried || e.is_a?(Timeout::Error)
|
|
69
|
+
raise ProviderError, "connection failed: #{e.message}"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
retried = true
|
|
73
|
+
retry
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
48
78
|
def close
|
|
49
79
|
@mutex.synchronize { teardown }
|
|
50
80
|
end
|
|
51
81
|
|
|
52
82
|
private
|
|
53
83
|
|
|
84
|
+
def read_either(response, &block)
|
|
85
|
+
if response["content-type"].to_s.include?("text/event-stream")
|
|
86
|
+
sse = SSE.new
|
|
87
|
+
response.read_body { |chunk| sse.feed(chunk, &block) }
|
|
88
|
+
sse.finish(&block)
|
|
89
|
+
else
|
|
90
|
+
raw = response.read_body
|
|
91
|
+
block.call(JSON.parse(raw)) unless raw.to_s.strip.empty?
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
54
95
|
def stream_locked(path, body, headers, signal, &block)
|
|
55
96
|
retried = false
|
|
56
97
|
begin
|
data/lib/mistri/version.rb
CHANGED
data/lib/mistri.rb
CHANGED
|
@@ -28,6 +28,7 @@ require_relative "mistri/skills"
|
|
|
28
28
|
require_relative "mistri/tool_executor"
|
|
29
29
|
require_relative "mistri/budget"
|
|
30
30
|
require_relative "mistri/retry_policy"
|
|
31
|
+
require_relative "mistri/reminder"
|
|
31
32
|
require_relative "mistri/compaction"
|
|
32
33
|
require_relative "mistri/stores/memory"
|
|
33
34
|
require_relative "mistri/stores/jsonl"
|
|
@@ -36,6 +37,7 @@ require_relative "mistri/compactor"
|
|
|
36
37
|
require_relative "mistri/result"
|
|
37
38
|
require_relative "mistri/agent"
|
|
38
39
|
require_relative "mistri/sub_agent"
|
|
40
|
+
require_relative "mistri/mcp"
|
|
39
41
|
require_relative "mistri/sinks/action_cable"
|
|
40
42
|
require_relative "mistri/sinks/sse"
|
|
41
43
|
require_relative "mistri/sinks/coalesced"
|
metadata
CHANGED
|
@@ -1,20 +1,18 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: mistri
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1
|
|
4
|
+
version: 0.2.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Muhammad Ahmed Cheema
|
|
8
|
-
autorequire:
|
|
9
8
|
bindir: bin
|
|
10
9
|
cert_chain: []
|
|
11
|
-
date:
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
12
11
|
dependencies: []
|
|
13
12
|
description: 'Mistri (مستری) is the fixer: an agent harness that lives inside your
|
|
14
13
|
app. Durable sessions in your own database, streaming, tools, fire-and-forget human
|
|
15
14
|
approval, steering, compaction, structured output, skills, and sub-agents, across
|
|
16
15
|
Anthropic, OpenAI, and Gemini, with zero runtime dependencies.'
|
|
17
|
-
email:
|
|
18
16
|
executables: []
|
|
19
17
|
extensions: []
|
|
20
18
|
extra_rdoc_files: []
|
|
@@ -26,6 +24,9 @@ files:
|
|
|
26
24
|
- lib/generators/mistri/install/install_generator.rb
|
|
27
25
|
- lib/generators/mistri/install/templates/migration.rb.tt
|
|
28
26
|
- lib/generators/mistri/install/templates/model.rb.tt
|
|
27
|
+
- lib/generators/mistri/mcp/mcp_generator.rb
|
|
28
|
+
- lib/generators/mistri/mcp/templates/migration.rb.tt
|
|
29
|
+
- lib/generators/mistri/mcp/templates/model.rb.tt
|
|
29
30
|
- lib/mistri.rb
|
|
30
31
|
- lib/mistri/abort_signal.rb
|
|
31
32
|
- lib/mistri/agent.rb
|
|
@@ -36,6 +37,10 @@ files:
|
|
|
36
37
|
- lib/mistri/edit.rb
|
|
37
38
|
- lib/mistri/errors.rb
|
|
38
39
|
- lib/mistri/event.rb
|
|
40
|
+
- lib/mistri/mcp.rb
|
|
41
|
+
- lib/mistri/mcp/client.rb
|
|
42
|
+
- lib/mistri/mcp/oauth.rb
|
|
43
|
+
- lib/mistri/mcp/wires.rb
|
|
39
44
|
- lib/mistri/memory.rb
|
|
40
45
|
- lib/mistri/message.rb
|
|
41
46
|
- lib/mistri/models.rb
|
|
@@ -50,6 +55,7 @@ files:
|
|
|
50
55
|
- lib/mistri/providers/openai.rb
|
|
51
56
|
- lib/mistri/providers/openai/assembler.rb
|
|
52
57
|
- lib/mistri/providers/openai/serializer.rb
|
|
58
|
+
- lib/mistri/reminder.rb
|
|
53
59
|
- lib/mistri/result.rb
|
|
54
60
|
- lib/mistri/retry_policy.rb
|
|
55
61
|
- lib/mistri/schema.rb
|
|
@@ -85,14 +91,15 @@ files:
|
|
|
85
91
|
- lib/mistri/workspace/directory.rb
|
|
86
92
|
- lib/mistri/workspace/memory.rb
|
|
87
93
|
- lib/mistri/workspace/single.rb
|
|
88
|
-
homepage: https://
|
|
94
|
+
homepage: https://mistri.sh
|
|
89
95
|
licenses:
|
|
90
96
|
- MIT
|
|
91
97
|
metadata:
|
|
92
98
|
source_code_uri: https://github.com/mcheemaa/mistri
|
|
93
99
|
changelog_uri: https://github.com/mcheemaa/mistri/blob/main/CHANGELOG.md
|
|
94
100
|
rubygems_mfa_required: 'true'
|
|
95
|
-
|
|
101
|
+
documentation_uri: https://mistri.sh/docs/getting-started/
|
|
102
|
+
bug_tracker_uri: https://github.com/mcheemaa/mistri/issues
|
|
96
103
|
rdoc_options: []
|
|
97
104
|
require_paths:
|
|
98
105
|
- lib
|
|
@@ -107,8 +114,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
107
114
|
- !ruby/object:Gem::Version
|
|
108
115
|
version: '0'
|
|
109
116
|
requirements: []
|
|
110
|
-
rubygems_version: 3.
|
|
111
|
-
signing_key:
|
|
117
|
+
rubygems_version: 3.6.9
|
|
112
118
|
specification_version: 4
|
|
113
119
|
summary: The agent harness for Ruby applications.
|
|
114
120
|
test_files: []
|