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. A tool marked ends_turn ends the run once it executes: the model does not get another word, and whatever comes next (a human's answer to ask_user, say) arrives as the next run's input. Session#steer queues a user message from any process while the loop runs; it folds into the transcript at the next turn boundary, and a background child's report arrives through the same inbox.

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:



35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
# File 'lib/mistri/agent.rb', line 35

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.



57
58
59
# File 'lib/mistri/agent.rb', line 57

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.



145
146
147
148
# File 'lib/mistri/agent.rb', line 145

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.



136
137
138
139
140
141
# File 'lib/mistri/agent.rb', line 136

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, unless a settled call's tool ends the turn, in which case its execution was the run's last word.



85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
# File 'lib/mistri/agent.rb', line 85

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

  executed = settle(open, signal, &emit)
  if executed.any? { |call| ends_turn?(call) }
    last = @session.messages.reverse_each.find(&:assistant?)
    return finished(last, Usage.zero, signal, handed_off: true)
  end

  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.



65
66
67
68
69
70
71
72
73
74
75
76
77
78
# File 'lib/mistri/agent.rb', line 65

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_inbox # anything queued while this session sat 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. A run an ends_turn tool ended returns as-is too: the floor belongs to whoever answers, and re-prompting for JSON would steal it back. Ask again once the answer arrives.



114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
# File 'lib/mistri/agent.rb', line 114

def task(input, schema:, images: [], signal: nil, fixes: 1, &emit)
  result = run(TaskOutput.prompt(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?
    return result if result.handed_off?

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

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