Module: Protege::ToolMixin

Included in:
Tool
Defined in:
lib/protege/extensions/tool_mixin.rb

Overview

Contract mixin for harness tools — the surface the agent uses to take action within the LOGI Orchestrator. Concrete tools subclass Protege::Tool (which includes this module), declare their description and JSON Schema via the class-level DSL, and implement #use(context:, **input).

Registration is automatic: every Tool subclass is discoverable via Tool.registered (resolved from Tool.descendants, so it survives Zeitwerk reloads), and the Harness enumerates that list to build the LLM-visible tool catalog. Tools have no protege_id DSL — the id is derived from the class basename with the Tool suffix stripped, then snake_cased (+SendEmailTool+ → :send_email, HTTPFetchTool:http_fetch).

Tools must return a Protege::Result from #use — construct one via Result.success(**data) / Result.failure(reason:, **data) (or the +#success+/+#failure+ helpers). The Harness serializes the result to JSON for the LLM's tool message, so a failed result lets the model self-correct.

Contract

def self.id;                end  # Symbol derived from class basename
def self.description;       end  # String declared via the DSL
def self.input_schema;      end  # Hash declared via the DSL (JSON Schema)
def use(context:, **input); end  # Context + parsed input → Protege::Result

Examples:

A concrete tool

class SendEmailTool < Protege::Tool
  description 'Sends an email reply to the inbound sender.'

  input_schema(
    type: 'object',
    required: %w[subject body],
    properties: {
      subject: { type: 'string' },
      body:    { type: 'string' }
    }
  )

  def use(context:, subject:, body:)
    # ...
  end
end

Defined Under Namespace

Modules: ClassMethods

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.find!(name) ⇒ Object

Find a tool instance by its string id.

Parameters:

  • name (String)

    the tool id to look up

Returns:

  • (Object)

    a fresh instance of the matching tool class

Raises:



67
68
69
70
71
72
# File 'lib/protege/extensions/tool_mixin.rb', line 67

def find!(name)
  klass = registered.find { _1.id.to_s == name }
  raise Protege::ToolNotFoundError.new(name:) unless klass

  klass.new
end

.included(base) ⇒ void

This method returns an undefined value.

Extend the includer with ClassMethods so the class-level DSL becomes available.

Parameters:

  • base (Class)

    the class including Tool



48
49
50
# File 'lib/protege/extensions/tool_mixin.rb', line 48

def included(base)
  base.extend(ClassMethods)
end

.invoke(tool_call, context:) ⇒ Protege::ToolResult

Look up a tool by name, execute it, and pair the call with its outcome.

Both failure modes resolve to a failed Result so the caller (the Harness) never sees a raw error — the model gets a serialized failure it can self-correct from:

  • an exception raised inside #use (or by find!) is caught, and
  • a #use that returns a non-+Result+ violates the tool contract and is reported as Protege::InvalidToolResultError, rather than blowing up later when the Harness calls #success? on the bad value.

Enforcement of tool scoping lives here: a call to any tool outside the persona's effective scope (its code grant minus disabled_tool_ids, read from context.persona) is refused before #use runs. Advertising a narrowed catalogue in the request isn't enough — a tool disabled mid-thread stays visible to the model in earlier turns and could still be called — so dispatch is the backstop.

Parameters:

Returns:



93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
# File 'lib/protege/extensions/tool_mixin.rb', line 93

def invoke(tool_call, context:)
  if context.persona.available_tool_ids.exclude?(tool_call.name.to_sym)
    return failed_tool_result(tool_call:,
                              error:     Protege::ToolNotAvailableError.new(name: tool_call.name))
  end

  tool   = find!(tool_call.name)
  input  = (tool_call.input || {}).symbolize_keys
  result = tool.use(context:, **input)

  return Protege::ToolResult.new(tool_call:, result:) if result.is_a?(Result)

  failed_tool_result(tool_call:,
                     error:     Protege::InvalidToolResultError.new(tool: tool.class, got: result.class))
rescue StandardError => e
  failed_tool_result(tool_call:, error: e)
end

.registeredArray<Class>

List all concrete tool classes.

Resolved via Tool.descendants so the registry survives Zeitwerk reloads without manual bookkeeping. Anonymous classes (nil name) are excluded.

Returns:

  • (Array<Class>)

    the registered tool classes



58
59
60
# File 'lib/protege/extensions/tool_mixin.rb', line 58

def registered
  Tool.descendants.reject { _1.name.nil? }
end

Instance Method Details

#failure(reason:, **data) ⇒ Protege::Result

Build a failed Protege::Result.

Call from #use to signal the tool could not complete its work. reason may be a String or an Exception; strings are wrapped in Protege::GenericError. data is merged into the payload.

Examples:

failure(reason: 'recipient looks invalid', to: to)
failure(reason: e, transport: 'smtp')

Parameters:

  • reason (String, Exception)

    the failure cause

  • data (Hash)

    arbitrary payload keyed by symbol

Returns:



169
170
171
# File 'lib/protege/extensions/tool_mixin.rb', line 169

def failure(reason:, **data)
  Result.failure(reason:, **data)
end

#success(**data) ⇒ Protege::Result

Build a successful Protege::Result.

Call from #use to signal the tool completed its work. data becomes the payload the LLM sees in the tool-result message.

Examples:

success(message_id: reply.message_id)

Parameters:

  • data (Hash)

    arbitrary payload keyed by symbol

Returns:



153
154
155
# File 'lib/protege/extensions/tool_mixin.rb', line 153

def success(**data)
  Result.success(**data)
end

#use(context:, **_input) ⇒ Protege::Result

Execute the tool's work. Concrete tools must override.

Parameters:

Returns:

Raises:

  • (NotImplementedError)

    always, when not overridden by the including class



138
139
140
141
# File 'lib/protege/extensions/tool_mixin.rb', line 138

def use(context:, **_input)
  raise NotImplementedError,
        "#{self.class} must implement #use(context:, **input) -> Protege::Result"
end