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 evidence_envelope].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 image_url].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

#evidenceObject (readonly)

Returns the value of attribute evidence

Returns:

  • (Object)

    the current value of evidence



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

def evidence
  @evidence
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, evidence: 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.



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

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, evidence: 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)
  if resp[:extract] == "evidence_envelope" && evidence.nil?
    raise Insika::ValidationError,
          "extract 'evidence_envelope' requires an 'evidence' declaration"
  end

  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),
    evidence: Insika::Evidence::Spec.parse(evidence)
  )
end

.from_h(hash) ⇒ Object

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



107
108
109
110
111
112
113
114
115
# File 'lib/insika/tool_definition.rb', line 107

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], evidence: h[:evidence]
  )
end

.halt_say_of(content) ⇒ Object

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



493
494
495
# File 'lib/insika/tool_definition.rb', line 493

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

.wrap_halt(payload, say) ⇒ Object



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

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)


433
434
435
436
437
438
439
440
441
442
443
# File 'lib/insika/tool_definition.rb', line 433

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.



468
469
470
471
472
473
474
475
476
477
478
479
480
# File 'lib/insika/tool_definition.rb', line 468

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.



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

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).



413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
# File 'lib/insika/tool_definition.rb', line 413

def to_h
  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)
  }
  # present only when declared — a tool without evidence is byte-identical
  # to today (no declaration, no envelope processing).
  h["evidence"] = evidence.to_h if evidence
  h
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.



518
519
520
521
522
523
524
525
526
# File 'lib/insika/tool_definition.rb', line 518

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