Class: Mistri::Agent

Inherits:
Object
  • Object
show all
Defined in:
lib/mistri/agent.rb

Overview

The agent loop: prompt the provider, run any tools it calls, feed the results back, and repeat until it answers without calling tools. Every streamed event reaches the caller's block as it arrives, and every run returns a Result.

Each message persists to the session the moment it completes, so a crash or an abort leaves a replay-valid transcript with no repair step. A tool marked needs_approval suspends the run instead of executing: the run returns at once (no thread ever waits on a human), the decision arrives later as a session entry from any process, and resume settles it and carries on. Session#steer queues a user message from any process while the loop runs; it folds into the transcript at the next turn boundary.

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(provider:, session: nil, system: nil, tools: [], budget: nil, max_concurrency: 4, transform_context: nil, compaction: Compaction.new, retries: RetryPolicy.new, skills: [], before_tool: nil, after_tool: nil, context: nil) ⇒ Agent

compaction defaults on so long sessions survive their context window; pass false to disable, or a tuned Compaction. It only ever triggers when the model's window is known (catalog or Compaction#window). skills: an array of Skill (or a directory path for Skills.load). Their descriptions join the system prompt and a read_skill tool serves full bodies on demand. before_tool and after_tool are the programmatic gates around every execution: before_tool(call, context) blocks a call by returning the reason as a String, which answers the model in band (and it runs again when an approved call finally executes, so a decision that aged days still passes current policy); after_tool(call, result, context) may return a replacement result, or nil to keep the original.

Raises:



31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
# File 'lib/mistri/agent.rb', line 31

def initialize(provider:, session: nil, system: nil, tools: [], budget: nil,
               max_concurrency: 4, transform_context: nil, compaction: Compaction.new,
               retries: RetryPolicy.new, skills: [], before_tool: nil, after_tool: nil,
               context: nil)
  @provider = provider
  @session = session || Session.new(store: Stores::Memory.new)
  skills = skills.is_a?(String) ? Skills.load(skills) : Array(skills)
  @system = Skills.amend(system, skills)
  @tools = skills.empty? ? tools : tools + [Skills.reader(skills)]
  @tools_by_name = @tools.to_h { |tool| [tool.name, tool] }
  raise ConfigurationError, "duplicate tool names" if @tools_by_name.length != @tools.length

  @budget = budget || Budget.new
  @max_concurrency = max_concurrency
  @transform_context = Array(transform_context)
  @compaction = compaction || nil
  @retries = retries || nil
  @before_tool = before_tool
  @after_tool = after_tool
  @context = context
end

Instance Attribute Details

#sessionObject (readonly)

Returns the value of attribute session.



53
54
55
# File 'lib/mistri/agent.rb', line 53

def session
  @session
end

Instance Method Details

#compactObject

Compact now (a UI button, a pre-flight trim before a big task). Returns the Compactor result, or nil when there is nothing worth compacting.



132
133
134
135
# File 'lib/mistri/agent.rb', line 132

def compact(&)
  Compactor.call(session: @session, provider: @provider,
                 settings: @compaction || Compaction.new, &)
end

#context_usageObject

How full the context is: window:, fraction:. Hosts render meters and near-limit warnings from this; window is nil for models the catalog does not know unless Compaction#window supplies one.



123
124
125
126
127
128
# File 'lib/mistri/agent.rb', line 123

def context_usage
  tokens = Compaction.context_tokens(@session.messages)
  window = context_window
  { tokens: tokens, window: window,
    fraction: window && (tokens.to_f / window).round(3) }
end

#resume(signal: nil, &emit) ⇒ Object

Continue a suspended run. Undecided approvals return immediately, still suspended. Decided ones settle first: approved calls execute, denied calls answer in band so the model knows and can react. Then the loop carries on as if it never stopped.



80
81
82
83
84
85
86
87
88
89
90
91
# File 'lib/mistri/agent.rb', line 80

def resume(signal: nil, &emit)
  open = @session.open_approvals
  pending = open.select { |approval| approval[:decision].nil? }
  if pending.any?
    return Result.new(message: nil, status: :awaiting_approval,
                      pending: pending.map { |approval| approval[:call] },
                      usage: Usage.zero)
  end

  settle(open, signal, &emit)
  loop_turns(signal, nil, &emit)
end

#run(input, images: [], signal: nil, output_schema: nil, &emit) ⇒ Object

Run one exchange: append the user turn, then loop until the model answers without tools, a gated tool suspends the run, the run aborts, or a budget stops it. output_schema constrains every non-tool answer to JSON matching the schema, natively where the provider supports it. task adds validation on top; run alone does not validate.



61
62
63
64
65
66
67
68
69
70
71
72
73
74
# File 'lib/mistri/agent.rb', line 61

def run(input, images: [], signal: nil, output_schema: nil, &emit)
  if @session.open_approvals.any?
    raise ConfigurationError,
          "session is awaiting approvals; call resume"
  end
  if input.to_s.empty? && Array(images).empty?
    raise ArgumentError,
          "run needs input text or images"
  end

  fold_steers # steers queued while idle arrived first; keep that order
  @session.append_message(Message.user_with_images(input, images))
  loop_turns(signal, output_schema, &emit)
end

#task(input, schema:, images: [], signal: nil, fixes: 1, &emit) ⇒ Object

Run an exchange that must end in a JSON value matching schema. Tools run as usual; providers constrain the final answer natively where they can, and the answer is validated here regardless. A violation goes back to the model (fixes more times), then raises SchemaError. The Result carries the validated value as output.

A run that suspends for approval returns as-is: validation applies to completed runs only, so resume the session and re-ask if that happens mid-task.



102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
# File 'lib/mistri/agent.rb', line 102

def task(input, schema:, images: [], signal: nil, fixes: 1, &emit)
  result = run(task_input(input, schema), images: images, signal: signal,
                                          output_schema: schema, &emit)
  spent = result.usage
  fixes.downto(0) do |remaining|
    result = result.with(usage: spent)
    return result unless result.completed?

    value = parse_output(result.text)
    errors = task_errors(value, schema)
    return result.with(output: value) if errors.empty?
    raise SchemaError, "task output failed validation: #{errors.join("; ")}" if remaining.zero?

    result = run(fix_prompt(errors), signal: signal, output_schema: schema, &emit)
    spent += result.usage
  end
end