Class: Insika::ToolDefinition

Inherits:
Data
  • Object
show all
Defined in:
lib/insika/tool_definition.rb,
lib/insika/tool_definition.rb

Overview

Definition of a DATA-DEFINED TOOL (no Ruby code): name, description, parameters and an HTTP call. Immutable value object, persisted by ToolStore and materialized at runtime by Tools::DataDefinedTool (one class, N instances — the same pattern as A2ARemote).,; parameters migrated to JSON Schema in,.

Persisted form (JSON-serializable Hash; ConfigStore stringifies the keys):

{ "name", "description",
"parameters" => <JSON Schema>,        # { "type":"object", "properties":{…}, "required":[…] }
"request"    => { "method","url","headers"=>{},"query"=>{},"body" },
"response"   => { "extract","path" },
"secret_headers" => [ "Authorization", ... ],
"side_effect" => bool, "timeout" => int|nil,
"group" => string|nil, "tags" => ["b2b",...] }  #//

parameters is JSON Schema (the interlingua of OpenAI/Anthropic/MCP): a nestable object, fed straight into RubyLLM's params_schema (provider- agnostic). The flat array ([{name,type,required}]) is SUGAR for the simple case: it is lifted to JSON Schema at build time. The sugar covers scalars and array:<scalar> — it CANNOT express an array of objects, and says so instead of guessing an item type. Ingestion validates a safe subset of JSON Schema (R1): it rejects composition (oneOf/anyOf/allOf/$ref/…) that not every provider supports.

Validation lives HERE (single source): build/from_h raise ValidationError on malformed input. Name uniqueness and collision with a code tool are NOT validated here (the value object does not know the registry) — that belongs to the overlay. Secrets (credential headers) are the ToolStore's responsibility (masks/reconciles); the definition itself is agnostic to masking.

Constant Summary collapse

PARAM_TYPES =

Flat-sugar types: the SCALARS, plus array:<scalar> for a list. There is no bare array: an array without an item type is an INCOMPLETE declaration, and the engine refuses to guess one (see lift_flat_params).

%w[string number integer boolean].freeze
ARRAY_SUGAR_RE =
/\Aarray:(string|number|integer|boolean)\z/
ARRAY_SUGAR =
PARAM_TYPES.map { |t| "array:#{t}" }.freeze
HTTP_METHODS =
%w[GET HEAD POST PUT PATCH DELETE].freeze
IDEMPOTENT =

side_effect default = false

%w[GET HEAD].freeze
EXTRACTS =
%w[body_raw status json_path].freeze
NAME_RE =

identifier for the model

/\A[a-z][a-z0-9_]*\z/
PLACEHOLDER_RE =

A . in the placeholder enables the turn-context namespace {{ctx.*}} separate from the model's {{param}}. Params follow NAME_RE (no dot) -> a placeholder with a dot can only be a ctx ref.

/\{\{\s*([a-zA-Z0-9_.]+)\s*\}\}/
CTX_PREFIX =

Turn-context namespace: values coming from the TURN (not the model), resolved by DataDefinedTool. Closed allowlist (a typo becomes a validation error, not a silently empty header).

"ctx."
CTX_FIELDS =
%w[chat_id store_id agent_id tenant].freeze
SCHEMA_TYPES =

---- safe subset of JSON Schema (R1) -------------------------------------- Types supported by EVERY provider (OpenAI/Anthropic/Gemini/DeepSeek/Bedrock).

%w[object array string number integer boolean].freeze
FORBIDDEN_KEYWORDS =

Composition/ref constructs that are NOT universally supported -> a clear error at ingestion time (instead of an opaque failure in the provider).

%w[
  oneOf anyOf allOf not $ref if then else
  patternProperties dependencies dependentSchemas
  propertyNames unevaluatedProperties $defs definitions
].freeze
SAY_KEY =

HOW say REACHES THE EXECUTOR. RubyLLM's Tool::Halt carries one value, and that value is the tool's payload (the trace records it, and the model never sees it — the halt ends the loop). So a halt that has something to publish carries BOTH, under keys distinctive enough that a trace reader knows what they are on sight. Unwrapped tools are untouched: no say, no wrapper.

"__insika_halt_say"
PAYLOAD_KEY =
"__insika_halt_payload"
PATH_MISS =

Walks a dotted path. Returns PATH_MISS (not nil) when a segment is absent, so a key whose stored value IS nil stays distinguishable from a missing key.

Object.new.freeze

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Instance Attribute Details

#descriptionObject (readonly)

Returns the value of attribute description

Returns:

  • (Object)

    the current value of description



35
36
37
# File 'lib/insika/tool_definition.rb', line 35

def description
  @description
end

#groupObject (readonly)

Returns the value of attribute group

Returns:

  • (Object)

    the current value of group



35
36
37
# File 'lib/insika/tool_definition.rb', line 35

def group
  @group
end

#halt_whenObject (readonly)

Returns the value of attribute halt_when

Returns:

  • (Object)

    the current value of halt_when



35
36
37
# File 'lib/insika/tool_definition.rb', line 35

def halt_when
  @halt_when
end

#nameObject (readonly)

Returns the value of attribute name

Returns:

  • (Object)

    the current value of name



35
36
37
# File 'lib/insika/tool_definition.rb', line 35

def name
  @name
end

#parametersObject (readonly)

Returns the value of attribute parameters

Returns:

  • (Object)

    the current value of parameters



35
36
37
# File 'lib/insika/tool_definition.rb', line 35

def parameters
  @parameters
end

#requestObject (readonly)

Returns the value of attribute request

Returns:

  • (Object)

    the current value of request



35
36
37
# File 'lib/insika/tool_definition.rb', line 35

def request
  @request
end

#responseObject (readonly)

Returns the value of attribute response

Returns:

  • (Object)

    the current value of response



35
36
37
# File 'lib/insika/tool_definition.rb', line 35

def response
  @response
end

#secret_headersObject (readonly)

Returns the value of attribute secret_headers

Returns:

  • (Object)

    the current value of secret_headers



35
36
37
# File 'lib/insika/tool_definition.rb', line 35

def secret_headers
  @secret_headers
end

#side_effectObject (readonly)

Returns the value of attribute side_effect

Returns:

  • (Object)

    the current value of side_effect



35
36
37
# File 'lib/insika/tool_definition.rb', line 35

def side_effect
  @side_effect
end

#tagsObject (readonly)

Returns the value of attribute tags

Returns:

  • (Object)

    the current value of tags



35
36
37
# File 'lib/insika/tool_definition.rb', line 35

def tags
  @tags
end

#timeoutObject (readonly)

Returns the value of attribute timeout

Returns:

  • (Object)

    the current value of timeout



35
36
37
# File 'lib/insika/tool_definition.rb', line 35

def timeout
  @timeout
end

Class Method Details

.build(name:, description:, request:, parameters: nil, response: nil, secret_headers: nil, side_effect: nil, timeout: nil, group: nil, tags: nil, halt_when: nil) ⇒ Object

Builds + validates. Raises Insika::ValidationError. Accepts keyword args (already-normalized symbol keys); use from_h for a raw Hash from the store/UI. parameters accepts JSON Schema (Hash) OR the legacy flat array.



75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
# File 'lib/insika/tool_definition.rb', line 75

def self.build(name:, description:, request:, parameters: nil, response: nil,
               secret_headers: nil, side_effect: nil, timeout: nil, group: nil, tags: nil,
               halt_when: nil)
  name = name.to_s
  raise Insika::ValidationError, "name must match #{NAME_RE.inspect}" unless NAME_RE.match?(name)

  desc = description.to_s
  raise Insika::ValidationError, "description is required" if desc.empty?

  schema = normalize_params(parameters)
  req = normalize_request(request, top_level_names(schema))
  resp = normalize_response(response)

  method = req[:method]
  effect = side_effect.nil? ? !IDEMPOTENT.include?(method) : (side_effect ? true : false)

  new(
    name: name, description: desc, parameters: schema, request: req, response: resp,
    secret_headers: Array(secret_headers).map(&:to_s), side_effect: effect,
    timeout: timeout.nil? ? nil : Integer(timeout),
    group: normalize_group(group), tags: normalize_tags(tags),
    halt_when: normalize_halt_when(halt_when)
  )
end

.from_h(hash) ⇒ Object

Raw Hash (string or symbol keys, from the store/payload) -> ToolDefinition.



101
102
103
104
105
106
107
108
109
# File 'lib/insika/tool_definition.rb', line 101

def self.from_h(hash)
  h = deep_symbolize(hash)
  build(
    name: h[:name], description: h[:description], parameters: h[:parameters],
    request: h[:request] || {}, response: h[:response],
    secret_headers: h[:secret_headers], side_effect: h[:side_effect], timeout: h[:timeout],
    group: h[:group], tags: h[:tags], halt_when: h[:halt_when]
  )
end

.halt_say_of(content) ⇒ Object

-> the text to publish, or nil when this halt carries none.



483
484
485
# File 'lib/insika/tool_definition.rb', line 483

def self.halt_say_of(content)
  content.is_a?(Hash) ? Coercion.presence(content[SAY_KEY]) : nil
end

.wrap_halt(payload, say) ⇒ Object



480
# File 'lib/insika/tool_definition.rb', line 480

def self.wrap_halt(payload, say) = { SAY_KEY => say, PAYLOAD_KEY => payload }

Instance Method Details

#halt?(body) ⇒ Boolean

-> true when this response ENDS the turn (no further model call). body is the raw response body; a parse failure or a missing path means "does not halt" — never end a turn on a guess.

Returns:

  • (Boolean)


423
424
425
426
427
428
429
430
431
432
433
# File 'lib/insika/tool_definition.rb', line 423

def halt?(body)
  return false if halt_when.nil?

  parsed = JSON.parse(body.to_s)
  value = dig_path(parsed, halt_when[:json_path])
  return false if value == PATH_MISS

  halt_when[:equals].include?(value.to_s)
rescue JSON::ParserError
  false
end

#halt_say(body) ⇒ Object

WHAT THE CUSTOMER GETS WHEN THE MODEL SAID NOTHING FIRST (halt_when.say).

A halt is worth the model's lead-in ("vou te conectar agora") and nothing after — but the model does not always write one, and then the turn published an EMPTY answer. Measured on a real store: two escalation turns in a row delivered silence, where the same agent without the halt at least said "o time de suporte já está cuidando do seu caso".

The value cannot be guessed. json_path + equals cannot supply it either: the matched value is by definition one of the equals tokens, so publishing it would ship "SUBSCRIBED" to a person as often as it ships a sentence. So the operator names it, in one of two shapes:

"say" => { "json_path" => "tool_result" }   # the sentence the backend returned
"say" => { "text" => "CALL_SUPPORT" }       # a literal the CHANNEL resolves

The literal form is the one that replaces the usual workaround — forcing the prompt to emit a control token and parsing it downstream. The token then comes from the tool's own contract, deterministically, instead of depending on the model complying with an instruction.

-> String | nil. nil means "publish nothing", which is the pre-existing behaviour and stays the default for every tool that declares no say.



458
459
460
461
462
463
464
465
466
467
468
469
470
# File 'lib/insika/tool_definition.rb', line 458

def halt_say(body)
  say = halt_when && halt_when[:say]
  return nil if say.nil?
  return Coercion.presence(say[:text]) if say[:text]

  parsed = JSON.parse(body.to_s)
  value = dig_path(parsed, say[:json_path])
  # Only a String is publishable: a hash or a number reaching a customer as the
  # answer is never what someone meant.
  value.is_a?(String) ? Coercion.presence(value) : nil
rescue JSON::ParserError
  nil
end

#required_paramsObject

Names of the required top-level parameters (DataDefinedTool validates presence before the call). Derived from the JSON Schema's required.



502
# File 'lib/insika/tool_definition.rb', line 502

def required_params = Array(parameters["required"]).map(&:to_s)

#to_hObject

String-keyed Hash for persistence (ConfigStore stringifies again, but we normalize here so the record is stable across backends).



407
408
409
410
411
412
413
414
415
416
417
418
# File 'lib/insika/tool_definition.rb', line 407

def to_h
  {
    "name" => name, "description" => description,
    "parameters" => parameters,
    "request" => request.transform_keys(&:to_s),
    "response" => response.transform_keys(&:to_s),
    "secret_headers" => secret_headers,
    "side_effect" => side_effect, "timeout" => timeout,
    "group" => group, "tags" => tags,
    "halt_when" => halt_when&.transform_keys(&:to_s)
  }
end

#top_level_paramsObject

FLAT view of the top-level properties (name/type/description/required) — for RubyLLM's #parameters (discovery/tool_search) and the simple authoring UI. The full nested schema goes through params_schema (DataDefinedTool). Symbol- keyed for compat with callers that already consumed the flat params.



508
509
510
511
512
513
514
515
516
# File 'lib/insika/tool_definition.rb', line 508

def top_level_params
  props = parameters["properties"] || {}
  required = required_params
  props.map do |pname, pschema|
    pschema ||= {}
    { name: pname.to_s, type: (pschema["type"] || "string").to_s,
      description: pschema["description"].to_s, required: required.include?(pname.to_s) }
  end
end