mistri 0.0.3 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +215 -0
- data/README.md +367 -3
- data/lib/generators/mistri/install/install_generator.rb +54 -0
- data/lib/generators/mistri/install/templates/migration.rb.tt +14 -0
- data/lib/generators/mistri/install/templates/model.rb.tt +4 -0
- data/lib/generators/mistri/mcp/mcp_generator.rb +57 -0
- data/lib/generators/mistri/mcp/templates/migration.rb.tt +27 -0
- data/lib/generators/mistri/mcp/templates/model.rb.tt +63 -0
- data/lib/mistri/abort_signal.rb +63 -0
- data/lib/mistri/agent.rb +389 -0
- data/lib/mistri/budget.rb +29 -0
- data/lib/mistri/compaction.rb +78 -0
- data/lib/mistri/compactor.rb +182 -0
- data/lib/mistri/content.rb +89 -0
- data/lib/mistri/edit.rb +238 -0
- data/lib/mistri/errors.rb +94 -0
- data/lib/mistri/event.rb +54 -0
- data/lib/mistri/mcp/client.rb +156 -0
- data/lib/mistri/mcp/oauth.rb +286 -0
- data/lib/mistri/mcp/wires.rb +164 -0
- data/lib/mistri/mcp.rb +96 -0
- data/lib/mistri/memory.rb +26 -0
- data/lib/mistri/message.rb +90 -0
- data/lib/mistri/models.rb +43 -0
- data/lib/mistri/partial_json.rb +210 -0
- data/lib/mistri/providers/anthropic/assembler.rb +205 -0
- data/lib/mistri/providers/anthropic/serializer.rb +106 -0
- data/lib/mistri/providers/anthropic.rb +106 -0
- data/lib/mistri/providers/fake.rb +109 -0
- data/lib/mistri/providers/gemini/assembler.rb +163 -0
- data/lib/mistri/providers/gemini/serializer.rb +109 -0
- data/lib/mistri/providers/gemini.rb +73 -0
- data/lib/mistri/providers/openai/assembler.rb +205 -0
- data/lib/mistri/providers/openai/serializer.rb +104 -0
- data/lib/mistri/providers/openai.rb +72 -0
- data/lib/mistri/reminder.rb +36 -0
- data/lib/mistri/result.rb +32 -0
- data/lib/mistri/retry_policy.rb +47 -0
- data/lib/mistri/schema.rb +162 -0
- data/lib/mistri/session.rb +124 -0
- data/lib/mistri/sinks/action_cable.rb +30 -0
- data/lib/mistri/sinks/coalesced.rb +61 -0
- data/lib/mistri/sinks/sse.rb +26 -0
- data/lib/mistri/skill.rb +15 -0
- data/lib/mistri/skills.rb +81 -0
- data/lib/mistri/sse.rb +50 -0
- data/lib/mistri/stop_reason.rb +25 -0
- data/lib/mistri/stores/active_record.rb +47 -0
- data/lib/mistri/stores/jsonl.rb +37 -0
- data/lib/mistri/stores/memory.rb +22 -0
- data/lib/mistri/sub_agent.rb +211 -0
- data/lib/mistri/tool.rb +95 -0
- data/lib/mistri/tool_call.rb +18 -0
- data/lib/mistri/tool_context.rb +15 -0
- data/lib/mistri/tool_executor.rb +87 -0
- data/lib/mistri/tool_result.rb +23 -0
- data/lib/mistri/tools/edit_file.rb +37 -0
- data/lib/mistri/tools/find_in_file.rb +36 -0
- data/lib/mistri/tools/list_files.rb +16 -0
- data/lib/mistri/tools/read_file.rb +38 -0
- data/lib/mistri/tools/read_memory.rb +16 -0
- data/lib/mistri/tools/update_memory.rb +22 -0
- data/lib/mistri/tools/write_file.rb +20 -0
- data/lib/mistri/tools.rb +50 -0
- data/lib/mistri/transport.rb +228 -0
- data/lib/mistri/usage.rb +79 -0
- data/lib/mistri/version.rb +1 -1
- data/lib/mistri/workspace/active_record.rb +47 -0
- data/lib/mistri/workspace/directory.rb +52 -0
- data/lib/mistri/workspace/memory.rb +40 -0
- data/lib/mistri/workspace/single.rb +48 -0
- data/lib/mistri.rb +89 -0
- metadata +79 -10
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/named_base"
|
|
5
|
+
require "rails/generators/active_record"
|
|
6
|
+
|
|
7
|
+
module Mistri
|
|
8
|
+
module Generators
|
|
9
|
+
# bin/rails generate mistri:mcp McpConnection
|
|
10
|
+
#
|
|
11
|
+
# Creates a host-named MCP connection model and migration: each row is
|
|
12
|
+
# one server connection and carries its own OAuth flow state, so the
|
|
13
|
+
# connect/callback pair works from a controller, a GraphQL mutation, or
|
|
14
|
+
# anywhere else the host prefers.
|
|
15
|
+
class McpGenerator < Rails::Generators::NamedBase
|
|
16
|
+
include ActiveRecord::Generators::Migration
|
|
17
|
+
|
|
18
|
+
source_root File.expand_path("templates", __dir__)
|
|
19
|
+
|
|
20
|
+
argument :name, type: :string, default: "McpConnection"
|
|
21
|
+
|
|
22
|
+
def create_model
|
|
23
|
+
template "model.rb.tt", File.join("app/models", class_path, "#{file_name}.rb")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def create_migration_file
|
|
27
|
+
migration_template "migration.rb.tt",
|
|
28
|
+
File.join(db_migrate_path, "create_#{table_name}.rb")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def show_wiring
|
|
32
|
+
say <<~NOTE
|
|
33
|
+
|
|
34
|
+
Connect (from a controller, GraphQL mutation, wherever):
|
|
35
|
+
|
|
36
|
+
connection, authorize_url = #{class_name}.connect(
|
|
37
|
+
name: "Linear", url: params[:url],
|
|
38
|
+
client_name: "YourApp", redirect_uri: mcp_callback_url,
|
|
39
|
+
)
|
|
40
|
+
redirect_to authorize_url, allow_other_host: true
|
|
41
|
+
|
|
42
|
+
Callback:
|
|
43
|
+
|
|
44
|
+
connection = #{class_name}.complete(state: params[:state], code: params[:code])
|
|
45
|
+
|
|
46
|
+
Then hand its tools to an agent:
|
|
47
|
+
|
|
48
|
+
agent = Mistri.agent("claude-opus-4-8", tools: connection.tools(prefix: "linear"))
|
|
49
|
+
|
|
50
|
+
Tokens are encrypted; run bin/rails db:encryption:init if you have
|
|
51
|
+
not set up Active Record encryption.
|
|
52
|
+
|
|
53
|
+
NOTE
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
class Create<%= table_name.camelize %> < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
|
2
|
+
def change
|
|
3
|
+
create_table :<%= table_name %> do |t|
|
|
4
|
+
t.string :name, null: false
|
|
5
|
+
t.string :url, null: false
|
|
6
|
+
t.string :status, null: false, default: "pending"
|
|
7
|
+
|
|
8
|
+
# OAuth flow state; cleared once connected.
|
|
9
|
+
t.string :state
|
|
10
|
+
t.string :code_verifier
|
|
11
|
+
t.string :redirect_uri
|
|
12
|
+
|
|
13
|
+
t.string :client_id
|
|
14
|
+
t.string :client_secret
|
|
15
|
+
t.string :token_endpoint
|
|
16
|
+
t.string :token_auth_method
|
|
17
|
+
t.text :access_token
|
|
18
|
+
t.text :refresh_token
|
|
19
|
+
t.datetime :expires_at
|
|
20
|
+
t.string :scope
|
|
21
|
+
t.timestamps
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# The callback finds its pending row by state.
|
|
25
|
+
add_index :<%= table_name %>, :state, unique: true
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# One MCP server connection: its own OAuth flow state, its tokens, and the
|
|
2
|
+
# bridge into agent tools. Rows with a manually supplied access_token (API
|
|
3
|
+
# key servers) work the same way without the OAuth columns.
|
|
4
|
+
class <%= class_name %> < ApplicationRecord
|
|
5
|
+
encrypts :access_token, :refresh_token, :client_secret
|
|
6
|
+
|
|
7
|
+
# Begin the OAuth flow: persists a pending connection and returns it with
|
|
8
|
+
# the URL to send the user to.
|
|
9
|
+
def self.connect(name:, url:, client_name:, redirect_uri:, scope: nil)
|
|
10
|
+
flow = Mistri::MCP::OAuth.start(url: url, client_name: client_name,
|
|
11
|
+
redirect_uri: redirect_uri, scope: scope)
|
|
12
|
+
connection = create!(name: name, url: url, status: "pending",
|
|
13
|
+
state: flow["state"], code_verifier: flow["code_verifier"],
|
|
14
|
+
client_id: flow["client_id"], client_secret: flow["client_secret"],
|
|
15
|
+
token_endpoint: flow["token_endpoint"],
|
|
16
|
+
token_auth_method: flow["token_auth_method"],
|
|
17
|
+
redirect_uri: flow["redirect_uri"])
|
|
18
|
+
[connection, flow["authorize_url"]]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Finish the flow from the OAuth callback.
|
|
22
|
+
def self.complete(state:, code:)
|
|
23
|
+
connection = find_by!(state: state)
|
|
24
|
+
tokens = Mistri::MCP::OAuth.complete(code: code,
|
|
25
|
+
code_verifier: connection.code_verifier,
|
|
26
|
+
client_id: connection.client_id,
|
|
27
|
+
client_secret: connection.client_secret,
|
|
28
|
+
token_endpoint: connection.token_endpoint,
|
|
29
|
+
token_auth_method: connection.token_auth_method,
|
|
30
|
+
resource: connection.url,
|
|
31
|
+
redirect_uri: connection.redirect_uri)
|
|
32
|
+
connection.update!(status: "connected", state: nil, code_verifier: nil,
|
|
33
|
+
access_token: tokens["access_token"],
|
|
34
|
+
refresh_token: tokens["refresh_token"],
|
|
35
|
+
expires_at: tokens["expires_at"], scope: tokens["scope"])
|
|
36
|
+
connection
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def client
|
|
40
|
+
Mistri::MCP::Client.new(url: url, token: -> { bearer_token })
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def tools(**options)
|
|
44
|
+
Mistri::MCP.tools(client, **options)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# A fresh bearer for every request; refreshes ahead of expiry and persists
|
|
48
|
+
# the rotated refresh token.
|
|
49
|
+
def bearer_token
|
|
50
|
+
refresh! if refresh_token.present? && expires_at && expires_at <= 1.minute.from_now
|
|
51
|
+
access_token
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def refresh!
|
|
55
|
+
tokens = Mistri::MCP::OAuth.refresh(refresh_token: refresh_token,
|
|
56
|
+
client_id: client_id, client_secret: client_secret,
|
|
57
|
+
token_endpoint: token_endpoint,
|
|
58
|
+
token_auth_method: token_auth_method, resource: url)
|
|
59
|
+
update!(access_token: tokens["access_token"],
|
|
60
|
+
refresh_token: tokens["refresh_token"] || refresh_token,
|
|
61
|
+
expires_at: tokens["expires_at"], scope: tokens["scope"] || scope)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mistri
|
|
4
|
+
# A thread-safe, one-way latch for cancelling a run. The host trips it from
|
|
5
|
+
# any thread; the loop and tools check it cooperatively at safe points, and
|
|
6
|
+
# the transport registers an on-abort callback to close an in-flight socket,
|
|
7
|
+
# so even a stalled read stops immediately instead of waiting out its
|
|
8
|
+
# read timeout.
|
|
9
|
+
class AbortSignal
|
|
10
|
+
def initialize
|
|
11
|
+
@mutex = Mutex.new
|
|
12
|
+
@aborted = false
|
|
13
|
+
@reason = nil
|
|
14
|
+
@callbacks = []
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def aborted? = @aborted
|
|
18
|
+
|
|
19
|
+
attr_reader :reason
|
|
20
|
+
|
|
21
|
+
# Trip the latch and fire every registered callback exactly once.
|
|
22
|
+
# Subsequent calls are no-ops.
|
|
23
|
+
def abort!(reason = nil)
|
|
24
|
+
callbacks = @mutex.synchronize do
|
|
25
|
+
break [] if @aborted
|
|
26
|
+
|
|
27
|
+
@aborted = true
|
|
28
|
+
@reason = reason
|
|
29
|
+
@callbacks.dup.tap { @callbacks.clear }
|
|
30
|
+
end
|
|
31
|
+
callbacks.each { |callback| safely(callback) }
|
|
32
|
+
nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Register a callback for the moment of abort. Fires immediately when the
|
|
36
|
+
# signal is already tripped. Returns a handle for #remove_callback.
|
|
37
|
+
def on_abort(&callback)
|
|
38
|
+
fire_now = @mutex.synchronize do
|
|
39
|
+
@callbacks << callback unless @aborted
|
|
40
|
+
@aborted
|
|
41
|
+
end
|
|
42
|
+
safely(callback) if fire_now
|
|
43
|
+
callback
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Deregister a callback, so a completed request does not leak its socket
|
|
47
|
+
# closer into a later abort.
|
|
48
|
+
def remove_callback(handle)
|
|
49
|
+
@mutex.synchronize { @callbacks.delete(handle) }
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
# An abort must reach every callback; one raising observer cannot be
|
|
56
|
+
# allowed to strand the others.
|
|
57
|
+
def safely(callback)
|
|
58
|
+
callback.call
|
|
59
|
+
rescue StandardError
|
|
60
|
+
nil
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
data/lib/mistri/agent.rb
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Mistri
|
|
6
|
+
# The agent loop: prompt the provider, run any tools it calls, feed the
|
|
7
|
+
# results back, and repeat until it answers without calling tools. Every
|
|
8
|
+
# streamed event reaches the caller's block as it arrives, and every run
|
|
9
|
+
# returns a Result.
|
|
10
|
+
#
|
|
11
|
+
# Each message persists to the session the moment it completes, so a crash
|
|
12
|
+
# or an abort leaves a replay-valid transcript with no repair step. A tool
|
|
13
|
+
# marked needs_approval suspends the run instead of executing: the run
|
|
14
|
+
# returns at once (no thread ever waits on a human), the decision arrives
|
|
15
|
+
# later as a session entry from any process, and resume settles it and
|
|
16
|
+
# carries on. Session#steer queues a user message from any process while
|
|
17
|
+
# the loop runs; it folds into the transcript at the next turn boundary.
|
|
18
|
+
class Agent
|
|
19
|
+
# compaction defaults on so long sessions survive their context window;
|
|
20
|
+
# pass false to disable, or a tuned Compaction. It only ever triggers
|
|
21
|
+
# when the model's window is known (catalog or Compaction#window).
|
|
22
|
+
# skills: an array of Skill (or a directory path for Skills.load). Their
|
|
23
|
+
# descriptions join the system prompt and a read_skill tool serves full
|
|
24
|
+
# bodies on demand.
|
|
25
|
+
# before_tool and after_tool are the programmatic gates around every
|
|
26
|
+
# execution: before_tool(call, context) blocks a call by returning the
|
|
27
|
+
# reason as a String, which answers the model in band (and it runs again
|
|
28
|
+
# when an approved call finally executes, so a decision that aged days
|
|
29
|
+
# still passes current policy); after_tool(call, result, context) may
|
|
30
|
+
# return a replacement result, or nil to keep the original.
|
|
31
|
+
def initialize(provider:, session: nil, system: nil, tools: [], budget: nil,
|
|
32
|
+
max_concurrency: 4, transform_context: nil, compaction: Compaction.new,
|
|
33
|
+
retries: RetryPolicy.new, skills: [], before_tool: nil, after_tool: nil)
|
|
34
|
+
@provider = provider
|
|
35
|
+
@session = session || Session.new(store: Stores::Memory.new)
|
|
36
|
+
skills = skills.is_a?(String) ? Skills.load(skills) : Array(skills)
|
|
37
|
+
@system = Skills.amend(system, skills)
|
|
38
|
+
@tools = skills.empty? ? tools : tools + [Skills.reader(skills)]
|
|
39
|
+
@tools_by_name = @tools.to_h { |tool| [tool.name, tool] }
|
|
40
|
+
raise ConfigurationError, "duplicate tool names" if @tools_by_name.length != @tools.length
|
|
41
|
+
|
|
42
|
+
@budget = budget || Budget.new
|
|
43
|
+
@max_concurrency = max_concurrency
|
|
44
|
+
@transform_context = Array(transform_context)
|
|
45
|
+
@compaction = compaction || nil
|
|
46
|
+
@retries = retries || nil
|
|
47
|
+
@before_tool = before_tool
|
|
48
|
+
@after_tool = after_tool
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
attr_reader :session
|
|
52
|
+
|
|
53
|
+
# Run one exchange: append the user turn, then loop until the model
|
|
54
|
+
# answers without tools, a gated tool suspends the run, the run aborts,
|
|
55
|
+
# or a budget stops it.
|
|
56
|
+
# output_schema constrains every non-tool answer to JSON matching the
|
|
57
|
+
# schema, natively where the provider supports it. task adds validation
|
|
58
|
+
# on top; run alone does not validate.
|
|
59
|
+
def run(input, images: [], signal: nil, output_schema: nil, &emit)
|
|
60
|
+
if @session.open_approvals.any?
|
|
61
|
+
raise ConfigurationError, "session is awaiting approval decisions; call resume"
|
|
62
|
+
end
|
|
63
|
+
if input.to_s.empty? && Array(images).empty?
|
|
64
|
+
raise ArgumentError, "run needs input text or images"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
fold_steers # steers queued while idle arrived first; keep that order
|
|
68
|
+
@session.append_message(Message.user_with_images(input, images))
|
|
69
|
+
loop_turns(signal, output_schema, &emit)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Continue a suspended run. Undecided approvals return immediately, still
|
|
73
|
+
# suspended. Decided ones settle first: approved calls execute, denied
|
|
74
|
+
# calls answer in band so the model knows and can react. Then the loop
|
|
75
|
+
# carries on as if it never stopped.
|
|
76
|
+
def resume(signal: nil, &emit)
|
|
77
|
+
open = @session.open_approvals
|
|
78
|
+
pending = open.select { |approval| approval[:decision].nil? }
|
|
79
|
+
if pending.any?
|
|
80
|
+
return Result.new(message: nil, status: :awaiting_approval,
|
|
81
|
+
pending: pending.map { |approval| approval[:call] },
|
|
82
|
+
usage: Usage.zero)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
settle(open, signal, &emit)
|
|
86
|
+
loop_turns(signal, nil, &emit)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Run an exchange that must end in a JSON value matching schema. Tools
|
|
90
|
+
# run as usual; providers constrain the final answer natively where they
|
|
91
|
+
# can, and the answer is validated here regardless. A violation goes
|
|
92
|
+
# back to the model (fixes more times), then raises SchemaError. The
|
|
93
|
+
# Result carries the validated value as output.
|
|
94
|
+
#
|
|
95
|
+
# A run that suspends for approval returns as-is: validation applies to
|
|
96
|
+
# completed runs only, so resume the session and re-ask if that happens
|
|
97
|
+
# mid-task.
|
|
98
|
+
def task(input, schema:, images: [], signal: nil, fixes: 1, &emit)
|
|
99
|
+
result = run(task_input(input, schema), images: images, signal: signal,
|
|
100
|
+
output_schema: schema, &emit)
|
|
101
|
+
spent = result.usage
|
|
102
|
+
fixes.downto(0) do |remaining|
|
|
103
|
+
result = result.with(usage: spent)
|
|
104
|
+
return result unless result.completed?
|
|
105
|
+
|
|
106
|
+
value = parse_output(result.text)
|
|
107
|
+
errors = task_errors(value, schema)
|
|
108
|
+
return result.with(output: value) if errors.empty?
|
|
109
|
+
raise SchemaError, "task output failed validation: #{errors.join("; ")}" if remaining.zero?
|
|
110
|
+
|
|
111
|
+
result = run(fix_prompt(errors), signal: signal, output_schema: schema, &emit)
|
|
112
|
+
spent += result.usage
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# How full the context is: {tokens:, window:, fraction:}. Hosts render
|
|
117
|
+
# meters and near-limit warnings from this; window is nil for models the
|
|
118
|
+
# catalog does not know unless Compaction#window supplies one.
|
|
119
|
+
def context_usage
|
|
120
|
+
tokens = Compaction.context_tokens(@session.messages)
|
|
121
|
+
window = context_window
|
|
122
|
+
{ tokens: tokens, window: window,
|
|
123
|
+
fraction: window && (tokens.to_f / window).round(3) }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Compact now (a UI button, a pre-flight trim before a big task). Returns
|
|
127
|
+
# the Compactor result, or nil when there is nothing worth compacting.
|
|
128
|
+
def compact(&)
|
|
129
|
+
Compactor.call(session: @session, provider: @provider,
|
|
130
|
+
settings: @compaction || Compaction.new, &)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
private
|
|
134
|
+
|
|
135
|
+
def loop_turns(signal, output_schema = nil, &emit)
|
|
136
|
+
turns = 0
|
|
137
|
+
usage = Usage.zero
|
|
138
|
+
started = monotonic_now
|
|
139
|
+
loop do
|
|
140
|
+
reason = @budget.exceeded(turns: turns, usage: usage, elapsed: monotonic_now - started)
|
|
141
|
+
return stop_for_budget(reason, usage, &emit) if reason
|
|
142
|
+
|
|
143
|
+
fold_steers
|
|
144
|
+
compacted = auto_compact(&emit)
|
|
145
|
+
usage += compacted[:usage] if compacted&.dig(:usage)
|
|
146
|
+
last = run_turn(signal, output_schema, &emit)
|
|
147
|
+
turns += 1
|
|
148
|
+
usage += last.usage if last.usage
|
|
149
|
+
|
|
150
|
+
# Any tool call the turn made must be answered or parked, or the
|
|
151
|
+
# transcript is unpairable and replay fails.
|
|
152
|
+
parked = last.tool_calls? ? run_tools(last, signal, &emit) : []
|
|
153
|
+
return suspended(last, parked, usage) if parked.any?
|
|
154
|
+
return finished(last, usage) if done?(last, signal)
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# A steer that lands while the model finishes cleanly extends the run one
|
|
159
|
+
# more turn so it gets answered. Aborts, errors, and length stops always
|
|
160
|
+
# end the run; the steer stays pending for the next one.
|
|
161
|
+
def done?(last, signal)
|
|
162
|
+
return false if last.stop_reason == StopReason::TOOL_USE && !signal&.aborted?
|
|
163
|
+
return true if signal&.aborted? || last.stop_reason != StopReason::STOP
|
|
164
|
+
|
|
165
|
+
@session.pending_steers.empty?
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Compact when the context has grown into the reserve. A failed
|
|
169
|
+
# summarization skips quietly here: if the context genuinely no longer
|
|
170
|
+
# fits, the next turn surfaces the real provider error.
|
|
171
|
+
def auto_compact(&)
|
|
172
|
+
return nil unless @compaction
|
|
173
|
+
|
|
174
|
+
tokens = Compaction.context_tokens(@session.messages)
|
|
175
|
+
return nil unless @compaction.needed?(tokens, context_window)
|
|
176
|
+
|
|
177
|
+
Compactor.call(session: @session, provider: @provider, settings: @compaction, &)
|
|
178
|
+
rescue CompactionError
|
|
179
|
+
nil
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def context_window
|
|
183
|
+
@compaction&.window || Models.find(@provider.model)&.context_window
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Materialize queued steers into the transcript in arrival order. The
|
|
187
|
+
# folded message entry carries the steer id, which is what marks the steer
|
|
188
|
+
# consumed: one append is both the fold and the marker, so a crash between
|
|
189
|
+
# steers never double-delivers.
|
|
190
|
+
def fold_steers
|
|
191
|
+
@session.pending_steers.each do |steer|
|
|
192
|
+
@session.append("message", "message" => steer["message"], "steer_id" => steer["id"])
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# transform_context reshapes what the model sees each turn (reminders,
|
|
197
|
+
# redaction, windowing) without touching what the session stores. The
|
|
198
|
+
# lambda gets the replay messages and returns the messages to send; it
|
|
199
|
+
# must keep every tool call paired with its result or providers reject
|
|
200
|
+
# the request.
|
|
201
|
+
#
|
|
202
|
+
# A transient failure retries the same request with backoff; the failed
|
|
203
|
+
# attempt is recorded as a retry entry, never as a message, so retries
|
|
204
|
+
# stay invisible to the model. Only the final outcome persists.
|
|
205
|
+
def run_turn(signal, output_schema = nil, &emit)
|
|
206
|
+
history = @transform_context.reduce(@session.messages) do |messages, transform|
|
|
207
|
+
transform.call(messages)
|
|
208
|
+
end
|
|
209
|
+
attempt = 0
|
|
210
|
+
loop do
|
|
211
|
+
message = @provider.stream(messages: history, system: @system,
|
|
212
|
+
tools: @tools.map(&:spec), signal: signal,
|
|
213
|
+
output_schema: output_schema, &emit)
|
|
214
|
+
attempt += 1
|
|
215
|
+
if retry_turn?(message, attempt, signal)
|
|
216
|
+
pause = @retries.delay(attempt, message.error&.dig("retry_after"))
|
|
217
|
+
record_retry(message, attempt, pause, &emit)
|
|
218
|
+
wait(pause, signal)
|
|
219
|
+
next unless signal&.aborted?
|
|
220
|
+
end
|
|
221
|
+
@session.append_message(message)
|
|
222
|
+
return message
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def retry_turn?(message, attempt, signal)
|
|
227
|
+
return false unless @retries && message.stop_reason == StopReason::ERROR
|
|
228
|
+
return false if signal&.aborted?
|
|
229
|
+
|
|
230
|
+
@retries.retry?(message.error, attempt)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def record_retry(message, attempt, pause, &emit)
|
|
234
|
+
@session.append("retry", "attempt" => attempt, "error" => message.error,
|
|
235
|
+
"delay" => pause.round(2))
|
|
236
|
+
note = format("attempt %<attempt>d failed; retrying in %<pause>.1fs",
|
|
237
|
+
attempt: attempt, pause: pause)
|
|
238
|
+
emit&.call(Event.new(type: :retry, content: note, reason: StopReason::ERROR,
|
|
239
|
+
message: message))
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Backoff that an abort can cut short.
|
|
243
|
+
def wait(seconds, signal)
|
|
244
|
+
deadline = monotonic_now + seconds
|
|
245
|
+
sleep(0.1) while monotonic_now < deadline && !signal&.aborted?
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Answer or park the assistant's tool calls. Ungated calls execute (only
|
|
249
|
+
# on a genuine tool_use turn with no abort; otherwise they pair with
|
|
250
|
+
# interrupted results). Gated calls park as approval requests and are
|
|
251
|
+
# returned, so the loop can suspend. Nothing is left dangling either way.
|
|
252
|
+
def run_tools(assistant, signal, &emit)
|
|
253
|
+
calls = assistant.tool_calls
|
|
254
|
+
unless assistant.stop_reason == StopReason::TOOL_USE && !signal&.aborted?
|
|
255
|
+
calls.each { |call| answer(call, ToolExecutor::INTERRUPTED, &emit) }
|
|
256
|
+
return []
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
parked, free = screen(calls, signal, &emit).partition { |call| gated?(call) }
|
|
260
|
+
execute(free, signal, &emit)
|
|
261
|
+
parked.each do |call|
|
|
262
|
+
@session.append("approval_request", "call" => call.to_h)
|
|
263
|
+
emit&.call(Event.new(type: :approval_needed, tool_call: call))
|
|
264
|
+
end
|
|
265
|
+
parked
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def settle(open, signal, &emit)
|
|
269
|
+
approved, denied = open.partition { |approval| approval[:decision]["approved"] }
|
|
270
|
+
cleared = screen(approved.map { |approval| approval[:call] }, signal, &emit)
|
|
271
|
+
execute(cleared, signal, &emit)
|
|
272
|
+
denied.each do |approval|
|
|
273
|
+
note = approval[:decision]["note"]
|
|
274
|
+
text = "The user denied this tool call#{note ? ": #{note}" : "."}"
|
|
275
|
+
answer(approval[:call], text, &emit)
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def execute(calls, signal, &emit)
|
|
280
|
+
return if calls.empty?
|
|
281
|
+
|
|
282
|
+
results = ToolExecutor.call(calls, @tools_by_name, signal: signal,
|
|
283
|
+
max_concurrency: @max_concurrency,
|
|
284
|
+
session: @session, emit: emit)
|
|
285
|
+
context = hook_context(signal, emit)
|
|
286
|
+
results.each do |call, result, seconds|
|
|
287
|
+
result = rewrite(call, result, context) if @after_tool
|
|
288
|
+
answer(call, result, duration: seconds, &emit)
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# A blocked call answers in band, so the model reads the reason and
|
|
293
|
+
# reacts; a hook that raises blocks conservatively rather than letting
|
|
294
|
+
# an unpoliced call through.
|
|
295
|
+
def screen(calls, signal, &emit)
|
|
296
|
+
return calls unless @before_tool
|
|
297
|
+
|
|
298
|
+
context = hook_context(signal, emit)
|
|
299
|
+
calls.reject do |call|
|
|
300
|
+
reason = begin
|
|
301
|
+
@before_tool.call(call, context)
|
|
302
|
+
rescue StandardError => e
|
|
303
|
+
"the before_tool hook failed: #{e.class}: #{e.message}"
|
|
304
|
+
end
|
|
305
|
+
next false unless reason.is_a?(String)
|
|
306
|
+
|
|
307
|
+
answer(call, "Blocked: #{reason}", &emit)
|
|
308
|
+
true
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def rewrite(call, result, context)
|
|
313
|
+
@after_tool.call(call, result, context) || result
|
|
314
|
+
rescue StandardError => e
|
|
315
|
+
"Error in after_tool hook: #{e.class}: #{e.message}"
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def hook_context(signal, emit)
|
|
319
|
+
ToolContext.new(session: @session, signal: signal, emit: emit)
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
# The tool message carries both channels; the :tool_result event exposes
|
|
323
|
+
# it whole so hosts read event.message.ui for their side of the result.
|
|
324
|
+
def answer(call, result, duration: nil, &emit)
|
|
325
|
+
content, ui = result.is_a?(ToolResult) ? [result.content, result.ui] : [result, nil]
|
|
326
|
+
message = @session.append_message(Message.tool(content: content, tool_call_id: call.id,
|
|
327
|
+
tool_name: call.name, ui: ui))
|
|
328
|
+
text = content.is_a?(String) ? content : "[content]"
|
|
329
|
+
emit&.call(Event.new(type: :tool_result, tool_call: call, content: text,
|
|
330
|
+
message: message, duration: duration))
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def gated?(call)
|
|
334
|
+
tool = @tools_by_name[call.name]
|
|
335
|
+
tool ? tool.needs_approval?(call.arguments) : false
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def finished(message, usage)
|
|
339
|
+
status = { StopReason::ABORTED => :aborted, StopReason::BUDGET => :budget,
|
|
340
|
+
StopReason::ERROR => :error }.fetch(message.stop_reason, :completed)
|
|
341
|
+
Result.new(message: message, status: status, usage: usage)
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def suspended(message, parked, usage)
|
|
345
|
+
Result.new(message: message, status: :awaiting_approval, pending: parked, usage: usage)
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def stop_for_budget(reason, usage, &emit)
|
|
349
|
+
message = Message.assistant(content: "Run stopped: #{reason} budget reached.",
|
|
350
|
+
stop_reason: StopReason::BUDGET,
|
|
351
|
+
error_message: "budget_#{reason}")
|
|
352
|
+
@session.append_message(message)
|
|
353
|
+
emit&.call(Event.new(type: :error, reason: StopReason::BUDGET, message: message,
|
|
354
|
+
error_message: "budget_#{reason}"))
|
|
355
|
+
Result.new(message: message, status: :budget, usage: usage)
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
# Distinguishable from a parsed nil: JSON "null" is a valid value.
|
|
359
|
+
PARSE_FAILED = Object.new.freeze
|
|
360
|
+
private_constant :PARSE_FAILED
|
|
361
|
+
|
|
362
|
+
def task_input(input, schema)
|
|
363
|
+
"#{input}\n\nAnswer with ONLY a JSON value matching this schema:\n" \
|
|
364
|
+
"#{JSON.generate(Schema.strict(schema))}"
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def parse_output(text)
|
|
368
|
+
body = text.to_s.strip
|
|
369
|
+
body = body[/\A```(?:json)?\s*(.*?)```\z/m, 1] || body
|
|
370
|
+
JSON.parse(body)
|
|
371
|
+
rescue JSON::ParserError
|
|
372
|
+
PARSE_FAILED
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def task_errors(value, schema)
|
|
376
|
+
return ["the answer is not valid JSON"] if value.equal?(PARSE_FAILED)
|
|
377
|
+
|
|
378
|
+
Schema.violations(value, schema)
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def fix_prompt(errors)
|
|
382
|
+
lines = errors.map { |error| "- #{error}" }.join("\n")
|
|
383
|
+
"Your answer did not satisfy the required output schema. Problems:\n" \
|
|
384
|
+
"#{lines}\nReply with ONLY the corrected JSON."
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def monotonic_now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
388
|
+
end
|
|
389
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mistri
|
|
4
|
+
# Optional per-run ceilings: turns, tokens, dollars, wall-clock seconds.
|
|
5
|
+
# Nothing is enforced unless the host sets it; an empty budget never stops a
|
|
6
|
+
# run. Pure limits with no clock of its own, so one Budget shared across
|
|
7
|
+
# agents or runs behaves identically for each: the loop measures and asks
|
|
8
|
+
# between turns, and a run always finishes the turn it is in.
|
|
9
|
+
class Budget
|
|
10
|
+
def initialize(turns: nil, tokens: nil, cost_usd: nil, wall_clock: nil)
|
|
11
|
+
@turns = turns
|
|
12
|
+
@tokens = tokens
|
|
13
|
+
@cost_usd = cost_usd
|
|
14
|
+
@wall_clock = wall_clock
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def none? = [@turns, @tokens, @cost_usd, @wall_clock].all?(&:nil?)
|
|
18
|
+
|
|
19
|
+
# The reason the run should stop, or nil to continue.
|
|
20
|
+
def exceeded(turns:, usage:, elapsed: 0)
|
|
21
|
+
return :turns if @turns && turns >= @turns
|
|
22
|
+
return :tokens if @tokens && usage.total_tokens >= @tokens
|
|
23
|
+
return :cost if @cost_usd && usage.cost.total >= @cost_usd
|
|
24
|
+
return :wall_clock if @wall_clock && elapsed >= @wall_clock
|
|
25
|
+
|
|
26
|
+
nil
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|