insika 0.0.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +361 -0
- data/LICENSE +21 -0
- data/README.md +136 -2
- data/bin/insika +366 -0
- data/docs/AGENTS.md +618 -0
- data/docs/ARCHITECTURE.md +333 -0
- data/docs/BENCHMARK.md +114 -0
- data/docs/CHANNELS.md +453 -0
- data/docs/CONTEXT.md +117 -0
- data/docs/DEPLOY.md +354 -0
- data/docs/EMBEDDING.md +198 -0
- data/docs/EVALS.md +273 -0
- data/docs/LOADTEST.md +232 -0
- data/docs/OBSERVABILITY.md +374 -0
- data/docs/PLUGINS.md +211 -0
- data/docs/REFINEMENT.md +477 -0
- data/docs/RELEASING.md +70 -0
- data/docs/RUNNING-LOCAL.md +153 -0
- data/docs/SANDBOX.md +114 -0
- data/docs/SECURITY.md +375 -0
- data/docs/SKILLS.md +284 -0
- data/docs/TOOLS.md +302 -0
- data/docs/WHY.md +137 -0
- data/docs/WORKFLOWS.md +225 -0
- data/docs/build.md +14 -0
- data/docs/index.md +68 -0
- data/docs/onboarding/start.md +126 -0
- data/docs/operate.md +12 -0
- data/docs/ship.md +10 -0
- data/docs/understand.md +10 -0
- data/lib/insika/agent_file_store.rb +125 -0
- data/lib/insika/agent_profile.rb +255 -0
- data/lib/insika/alert_dispatcher.rb +139 -0
- data/lib/insika/allowlist.rb +28 -0
- data/lib/insika/baseline_store.rb +74 -0
- data/lib/insika/budget_ledger.rb +135 -0
- data/lib/insika/capability/resolved_tool.rb +34 -0
- data/lib/insika/capability_registry.rb +112 -0
- data/lib/insika/channel_delivery.rb +153 -0
- data/lib/insika/channel_registry.rb +30 -0
- data/lib/insika/channels/relay.rb +178 -0
- data/lib/insika/channels/web/widget.js +283 -0
- data/lib/insika/channels/web.rb +211 -0
- data/lib/insika/channels/webhook.rb +58 -0
- data/lib/insika/chat_builder.rb +303 -0
- data/lib/insika/checkpoint.rb +13 -0
- data/lib/insika/checkpoint_store.rb +153 -0
- data/lib/insika/circuit_state.rb +114 -0
- data/lib/insika/coercion.rb +58 -0
- data/lib/insika/command.rb +32 -0
- data/lib/insika/command_bus.rb +39 -0
- data/lib/insika/commands/agent_payload.rb +43 -0
- data/lib/insika/commands/approve_action.rb +46 -0
- data/lib/insika/commands/cancel_task.rb +33 -0
- data/lib/insika/commands/create_agent.rb +54 -0
- data/lib/insika/commands/create_session.rb +67 -0
- data/lib/insika/commands/delete_agent.rb +33 -0
- data/lib/insika/commands/delete_agent_file.rb +50 -0
- data/lib/insika/commands/delete_data_tool.rb +33 -0
- data/lib/insika/commands/delete_llm_provider.rb +36 -0
- data/lib/insika/commands/delete_mcp.rb +30 -0
- data/lib/insika/commands/delete_skill.rb +43 -0
- data/lib/insika/commands/delete_system_file.rb +29 -0
- data/lib/insika/commands/gate_refinement.rb +245 -0
- data/lib/insika/commands/import_mcp_tools.rb +48 -0
- data/lib/insika/commands/import_tools.rb +81 -0
- data/lib/insika/commands/issue_tenant_token.rb +41 -0
- data/lib/insika/commands/memory_add_note.rb +32 -0
- data/lib/insika/commands/memory_forget_fact.rb +32 -0
- data/lib/insika/commands/memory_put_fact.rb +35 -0
- data/lib/insika/commands/pause_task.rb +29 -0
- data/lib/insika/commands/resolve_refinement.rb +126 -0
- data/lib/insika/commands/restore_agent_file.rb +36 -0
- data/lib/insika/commands/restore_data_tool.rb +34 -0
- data/lib/insika/commands/restore_system_file.rb +31 -0
- data/lib/insika/commands/resume_task.rb +85 -0
- data/lib/insika/commands/revoke_token.rb +39 -0
- data/lib/insika/commands/rotate_tenant_token.rb +43 -0
- data/lib/insika/commands/run_refinement.rb +133 -0
- data/lib/insika/commands/send_message.rb +150 -0
- data/lib/insika/commands/set_agent_tools.rb +39 -0
- data/lib/insika/commands/set_skill_agents.rb +112 -0
- data/lib/insika/commands/trigger_workflow.rb +80 -0
- data/lib/insika/commands/update_agent.rb +49 -0
- data/lib/insika/commands/update_settings.rb +33 -0
- data/lib/insika/commands/upsert_llm_provider.rb +34 -0
- data/lib/insika/commands/upsert_mcp.rb +32 -0
- data/lib/insika/commands/write_agent_file.rb +57 -0
- data/lib/insika/commands/write_data_tool.rb +43 -0
- data/lib/insika/commands/write_golden.rb +58 -0
- data/lib/insika/commands/write_skill.rb +60 -0
- data/lib/insika/commands/write_system_file.rb +31 -0
- data/lib/insika/config_store.rb +89 -0
- data/lib/insika/context/builder.rb +166 -0
- data/lib/insika/context/catalog_provider.rb +23 -0
- data/lib/insika/context/fragment.rb +43 -0
- data/lib/insika/context/priority.rb +30 -0
- data/lib/insika/context/provider.rb +19 -0
- data/lib/insika/context/providers/memory.rb +60 -0
- data/lib/insika/context/providers/prompt.rb +105 -0
- data/lib/insika/context/providers/request.rb +32 -0
- data/lib/insika/context/providers/session.rb +123 -0
- data/lib/insika/context/providers/skill.rb +24 -0
- data/lib/insika/context/providers/skill_trigger.rb +128 -0
- data/lib/insika/context/providers/tool_search.rb +20 -0
- data/lib/insika/context_trace_store.rb +92 -0
- data/lib/insika/delegation_store.rb +153 -0
- data/lib/insika/doctor.rb +539 -0
- data/lib/insika/dsl/definition.rb +55 -0
- data/lib/insika/dsl/runtime.rb +382 -0
- data/lib/insika/dsl/server_boot.rb +98 -0
- data/lib/insika/dsl/system.rb +93 -0
- data/lib/insika/dsl/workflow_adapter.rb +59 -0
- data/lib/insika/dsl.rb +364 -0
- data/lib/insika/edge_limiter.rb +268 -0
- data/lib/insika/egress_guard.rb +75 -0
- data/lib/insika/env_schema.rb +249 -0
- data/lib/insika/errors.rb +201 -0
- data/lib/insika/evals/assertions.rb +247 -0
- data/lib/insika/evals/baseline.rb +69 -0
- data/lib/insika/evals/golden.rb +172 -0
- data/lib/insika/evals/judge.rb +225 -0
- data/lib/insika/evals/pairwise.rb +178 -0
- data/lib/insika/evals/report.rb +115 -0
- data/lib/insika/evals/runner.rb +141 -0
- data/lib/insika/evals/transport.rb +178 -0
- data/lib/insika/event.rb +18 -0
- data/lib/insika/event_stream.rb +132 -0
- data/lib/insika/executor.rb +1995 -0
- data/lib/insika/frontmatter.rb +42 -0
- data/lib/insika/golden_store.rb +145 -0
- data/lib/insika/hooks.rb +48 -0
- data/lib/insika/http_client.rb +63 -0
- data/lib/insika/inbound_log.rb +84 -0
- data/lib/insika/llm_configurator.rb +99 -0
- data/lib/insika/llm_provider_store.rb +83 -0
- data/lib/insika/loop_detector.rb +143 -0
- data/lib/insika/mcp_http_client.rb +67 -0
- data/lib/insika/mcp_store.rb +115 -0
- data/lib/insika/mcp_tool_ingestor.rb +143 -0
- data/lib/insika/memory_store.rb +93 -0
- data/lib/insika/message_origin.rb +76 -0
- data/lib/insika/middleware.rb +36 -0
- data/lib/insika/model_policy.rb +52 -0
- data/lib/insika/model_resolver.rb +176 -0
- data/lib/insika/model_selection.rb +115 -0
- data/lib/insika/onboarding.rb +208 -0
- data/lib/insika/outbox_store.rb +166 -0
- data/lib/insika/overlay_tool_registry.rb +102 -0
- data/lib/insika/pack.rb +102 -0
- data/lib/insika/pack_importer.rb +123 -0
- data/lib/insika/pending_action_store.rb +120 -0
- data/lib/insika/plugin/loader.rb +356 -0
- data/lib/insika/plugin.rb +35 -0
- data/lib/insika/policy/engine.rb +83 -0
- data/lib/insika/policy/policy.rb +120 -0
- data/lib/insika/policy_registry.rb +23 -0
- data/lib/insika/profile_source.rb +143 -0
- data/lib/insika/prompt_catalog.rb +61 -0
- data/lib/insika/provider_error_classifier.rb +160 -0
- data/lib/insika/queue_policy.rb +167 -0
- data/lib/insika/recovery.rb +168 -0
- data/lib/insika/refinement/candidate.rb +159 -0
- data/lib/insika/refinement/evidence_collector.rb +371 -0
- data/lib/insika/refinement/gate.rb +234 -0
- data/lib/insika/refinement/panel.rb +222 -0
- data/lib/insika/refinement/proposer.rb +262 -0
- data/lib/insika/refinement_store.rb +295 -0
- data/lib/insika/registry.rb +59 -0
- data/lib/insika/reliability.rb +185 -0
- data/lib/insika/safety/config.rb +109 -0
- data/lib/insika/safety/detectors.rb +176 -0
- data/lib/insika/safety/factory.rb +102 -0
- data/lib/insika/safety/input_guardrail.rb +102 -0
- data/lib/insika/safety/moderator.rb +94 -0
- data/lib/insika/safety/output_filter.rb +79 -0
- data/lib/insika/safety/output_validator.rb +101 -0
- data/lib/insika/safety/safe_responses.rb +47 -0
- data/lib/insika/sandbox/boundary.rb +93 -0
- data/lib/insika/sandbox/docker.rb +74 -0
- data/lib/insika/sandbox/local.rb +33 -0
- data/lib/insika/sandbox/runner.rb +80 -0
- data/lib/insika/sandbox.rb +85 -0
- data/lib/insika/schema_guard.rb +147 -0
- data/lib/insika/secret_masking.rb +34 -0
- data/lib/insika/server/a2a/agent_card.rb +27 -0
- data/lib/insika/server/a2a/app.rb +112 -0
- data/lib/insika/server/a2a/client.rb +101 -0
- data/lib/insika/server/a2a/errors.rb +32 -0
- data/lib/insika/server/a2a/http.rb +42 -0
- data/lib/insika/server/a2a/message.rb +27 -0
- data/lib/insika/server/a2a/protocol.rb +45 -0
- data/lib/insika/server/a2a/remotes.rb +25 -0
- data/lib/insika/server/a2a/task_projection.rb +40 -0
- data/lib/insika/server/app.rb +1022 -0
- data/lib/insika/server/boot.rb +119 -0
- data/lib/insika/server/rack_app.rb +118 -0
- data/lib/insika/server/responses.rb +165 -0
- data/lib/insika/server/sse_body.rb +96 -0
- data/lib/insika/server/tenant_auth.rb +61 -0
- data/lib/insika/session_actor.rb +162 -0
- data/lib/insika/session_store.rb +143 -0
- data/lib/insika/settings_store.rb +154 -0
- data/lib/insika/shutdown.rb +125 -0
- data/lib/insika/skill_catalog.rb +220 -0
- data/lib/insika/skill_store.rb +127 -0
- data/lib/insika/steer_injector.rb +110 -0
- data/lib/insika/store.rb +52 -0
- data/lib/insika/stores/memory.rb +123 -0
- data/lib/insika/stores/sqlite.rb +183 -0
- data/lib/insika/studio/app.rb +1693 -0
- data/lib/insika/studio/assets/dist/application.css +1 -0
- data/lib/insika/studio/assets/dist/application.js +70 -0
- data/lib/insika/studio/forms.rb +335 -0
- data/lib/insika/studio/nav_icons.rb +31 -0
- data/lib/insika/studio/views/_message.erb +44 -0
- data/lib/insika/studio/views/agent_detail.erb +285 -0
- data/lib/insika/studio/views/agents.erb +63 -0
- data/lib/insika/studio/views/approvals.erb +41 -0
- data/lib/insika/studio/views/chats.erb +34 -0
- data/lib/insika/studio/views/evals.erb +83 -0
- data/lib/insika/studio/views/home.erb +72 -0
- data/lib/insika/studio/views/layout.erb +94 -0
- data/lib/insika/studio/views/login.erb +17 -0
- data/lib/insika/studio/views/mcp.erb +91 -0
- data/lib/insika/studio/views/not_found.erb +5 -0
- data/lib/insika/studio/views/playground.erb +47 -0
- data/lib/insika/studio/views/refinement.erb +234 -0
- data/lib/insika/studio/views/session.erb +137 -0
- data/lib/insika/studio/views/settings.erb +168 -0
- data/lib/insika/studio/views/skills.erb +141 -0
- data/lib/insika/studio/views/system_files.erb +65 -0
- data/lib/insika/studio/views/task.erb +105 -0
- data/lib/insika/studio/views/tasks.erb +33 -0
- data/lib/insika/studio/views/tool_edit.erb +107 -0
- data/lib/insika/studio/views/tools.erb +89 -0
- data/lib/insika/subagent_graph.rb +96 -0
- data/lib/insika/system_file_store.rb +96 -0
- data/lib/insika/task_actor.rb +128 -0
- data/lib/insika/task_store.rb +250 -0
- data/lib/insika/telemetry/pricing.rb +104 -0
- data/lib/insika/telemetry/recorder.rb +228 -0
- data/lib/insika/telemetry.rb +127 -0
- data/lib/insika/testing/store_contract.rb +270 -0
- data/lib/insika/tick.rb +122 -0
- data/lib/insika/token_estimator.rb +16 -0
- data/lib/insika/token_store.rb +168 -0
- data/lib/insika/tool_assembly.rb +140 -0
- data/lib/insika/tool_catalog.rb +89 -0
- data/lib/insika/tool_definition.rb +518 -0
- data/lib/insika/tool_envelope.rb +140 -0
- data/lib/insika/tool_manifest.rb +218 -0
- data/lib/insika/tool_output_compressor.rb +100 -0
- data/lib/insika/tool_registry.rb +21 -0
- data/lib/insika/tool_store.rb +135 -0
- data/lib/insika/tool_trace_store.rb +92 -0
- data/lib/insika/tools/a2a_remote.rb +48 -0
- data/lib/insika/tools/agent_enum.rb +68 -0
- data/lib/insika/tools/concurrency.rb +54 -0
- data/lib/insika/tools/data_defined_tool.rb +219 -0
- data/lib/insika/tools/load_skill.rb +99 -0
- data/lib/insika/tools/remember.rb +53 -0
- data/lib/insika/tools/stuck_signal.rb +44 -0
- data/lib/insika/tools/subagent.rb +75 -0
- data/lib/insika/tools/subagents.rb +77 -0
- data/lib/insika/tools/tool_search.rb +94 -0
- data/lib/insika/turn_output.rb +139 -0
- data/lib/insika/turn_state.rb +162 -0
- data/lib/insika/turn_timing.rb +56 -0
- data/lib/insika/usage_ledger.rb +47 -0
- data/lib/insika/version.rb +3 -1
- data/lib/insika/wiring/graph.rb +249 -0
- data/lib/insika/workflow.rb +185 -0
- data/lib/insika/workflow_registry.rb +33 -0
- data/lib/insika.rb +220 -4
- metadata +412 -8
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Insika
|
|
7
|
+
# Definition of a DATA-DEFINED TOOL (no Ruby code): name, description, parameters
|
|
8
|
+
# and an HTTP call. Immutable value object, persisted by ToolStore and
|
|
9
|
+
# materialized at runtime by Tools::DataDefinedTool (one class, N instances —
|
|
10
|
+
# the same pattern as A2ARemote).,; parameters migrated to JSON
|
|
11
|
+
# Schema in,.
|
|
12
|
+
#
|
|
13
|
+
# Persisted form (JSON-serializable Hash; ConfigStore stringifies the keys):
|
|
14
|
+
# { "name", "description",
|
|
15
|
+
# "parameters" => <JSON Schema>, # { "type":"object", "properties":{…}, "required":[…] }
|
|
16
|
+
# "request" => { "method","url","headers"=>{},"query"=>{},"body" },
|
|
17
|
+
# "response" => { "extract","path" },
|
|
18
|
+
# "secret_headers" => [ "Authorization", ... ],
|
|
19
|
+
# "side_effect" => bool, "timeout" => int|nil,
|
|
20
|
+
# "group" => string|nil, "tags" => ["b2b",...] } #//
|
|
21
|
+
#
|
|
22
|
+
# `parameters` is **JSON Schema** (the interlingua of OpenAI/Anthropic/MCP):
|
|
23
|
+
# a nestable object, fed straight into RubyLLM's `params_schema` (provider-
|
|
24
|
+
# agnostic). The **flat array** (`[{name,type,required}]`) is SUGAR for the simple
|
|
25
|
+
# case: it is lifted to JSON Schema at build time. The sugar covers scalars and
|
|
26
|
+
# `array:<scalar>` — it CANNOT express an array of objects, and says so instead of
|
|
27
|
+
# guessing an item type. Ingestion validates a **safe subset** of JSON Schema (R1):
|
|
28
|
+
# it rejects composition (oneOf/anyOf/allOf/$ref/…) that not every provider supports.
|
|
29
|
+
#
|
|
30
|
+
# Validation lives HERE (single source): `build`/`from_h` raise ValidationError
|
|
31
|
+
# on malformed input. Name uniqueness and collision with a code tool are NOT
|
|
32
|
+
# validated here (the value object does not know the registry) — that belongs to the
|
|
33
|
+
# overlay. Secrets (credential headers) are the ToolStore's responsibility
|
|
34
|
+
# (masks/reconciles); the definition itself is agnostic to masking.
|
|
35
|
+
ToolDefinition = Data.define(
|
|
36
|
+
:name, :description, :parameters, :request, :response,
|
|
37
|
+
:secret_headers, :side_effect, :timeout, :group, :tags, :halt_when
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
class ToolDefinition
|
|
41
|
+
# Flat-sugar types: the SCALARS, plus `array:<scalar>` for a list. There is no bare
|
|
42
|
+
# `array`: an array without an item type is an INCOMPLETE declaration, and the
|
|
43
|
+
# engine refuses to guess one (see lift_flat_params).
|
|
44
|
+
PARAM_TYPES = %w[string number integer boolean].freeze
|
|
45
|
+
ARRAY_SUGAR_RE = /\Aarray:(string|number|integer|boolean)\z/
|
|
46
|
+
ARRAY_SUGAR = PARAM_TYPES.map { |t| "array:#{t}" }.freeze
|
|
47
|
+
HTTP_METHODS = %w[GET HEAD POST PUT PATCH DELETE].freeze
|
|
48
|
+
IDEMPOTENT = %w[GET HEAD].freeze # side_effect default = false
|
|
49
|
+
EXTRACTS = %w[body_raw status json_path].freeze
|
|
50
|
+
NAME_RE = /\A[a-z][a-z0-9_]*\z/ # identifier for the model
|
|
51
|
+
# A `.` in the placeholder enables the turn-context namespace `{{ctx.*}}`
|
|
52
|
+
# separate from the model's `{{param}}`. Params follow NAME_RE (no
|
|
53
|
+
# dot) -> a placeholder with a dot can only be a ctx ref.
|
|
54
|
+
PLACEHOLDER_RE = /\{\{\s*([a-zA-Z0-9_.]+)\s*\}\}/
|
|
55
|
+
# Turn-context namespace: values coming from the TURN (not the model),
|
|
56
|
+
# resolved by DataDefinedTool. Closed allowlist (a typo becomes a validation
|
|
57
|
+
# error, not a silently empty header).
|
|
58
|
+
CTX_PREFIX = "ctx."
|
|
59
|
+
CTX_FIELDS = %w[chat_id store_id agent_id tenant].freeze
|
|
60
|
+
|
|
61
|
+
# ---- safe subset of JSON Schema (R1) --------------------------------------
|
|
62
|
+
# Types supported by EVERY provider (OpenAI/Anthropic/Gemini/DeepSeek/Bedrock).
|
|
63
|
+
SCHEMA_TYPES = %w[object array string number integer boolean].freeze
|
|
64
|
+
# Composition/ref constructs that are NOT universally supported -> a clear error
|
|
65
|
+
# at ingestion time (instead of an opaque failure in the provider).
|
|
66
|
+
FORBIDDEN_KEYWORDS = %w[
|
|
67
|
+
oneOf anyOf allOf not $ref if then else
|
|
68
|
+
patternProperties dependencies dependentSchemas
|
|
69
|
+
propertyNames unevaluatedProperties $defs definitions
|
|
70
|
+
].freeze
|
|
71
|
+
|
|
72
|
+
# Builds + validates. Raises Insika::ValidationError. Accepts keyword args
|
|
73
|
+
# (already-normalized symbol keys); use from_h for a raw Hash from the store/UI.
|
|
74
|
+
# `parameters` accepts JSON Schema (Hash) OR the legacy flat array.
|
|
75
|
+
def self.build(name:, description:, request:, parameters: nil, response: nil,
|
|
76
|
+
secret_headers: nil, side_effect: nil, timeout: nil, group: nil, tags: nil,
|
|
77
|
+
halt_when: nil)
|
|
78
|
+
name = name.to_s
|
|
79
|
+
raise Insika::ValidationError, "name must match #{NAME_RE.inspect}" unless NAME_RE.match?(name)
|
|
80
|
+
|
|
81
|
+
desc = description.to_s
|
|
82
|
+
raise Insika::ValidationError, "description is required" if desc.empty?
|
|
83
|
+
|
|
84
|
+
schema = normalize_params(parameters)
|
|
85
|
+
req = normalize_request(request, top_level_names(schema))
|
|
86
|
+
resp = normalize_response(response)
|
|
87
|
+
|
|
88
|
+
method = req[:method]
|
|
89
|
+
effect = side_effect.nil? ? !IDEMPOTENT.include?(method) : (side_effect ? true : false)
|
|
90
|
+
|
|
91
|
+
new(
|
|
92
|
+
name: name, description: desc, parameters: schema, request: req, response: resp,
|
|
93
|
+
secret_headers: Array(secret_headers).map(&:to_s), side_effect: effect,
|
|
94
|
+
timeout: timeout.nil? ? nil : Integer(timeout),
|
|
95
|
+
group: normalize_group(group), tags: normalize_tags(tags),
|
|
96
|
+
halt_when: normalize_halt_when(halt_when)
|
|
97
|
+
)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Raw Hash (string or symbol keys, from the store/payload) -> ToolDefinition.
|
|
101
|
+
def self.from_h(hash)
|
|
102
|
+
h = deep_symbolize(hash)
|
|
103
|
+
build(
|
|
104
|
+
name: h[:name], description: h[:description], parameters: h[:parameters],
|
|
105
|
+
request: h[:request] || {}, response: h[:response],
|
|
106
|
+
secret_headers: h[:secret_headers], side_effect: h[:side_effect], timeout: h[:timeout],
|
|
107
|
+
group: h[:group], tags: h[:tags], halt_when: h[:halt_when]
|
|
108
|
+
)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Group: enablement label by DATA (not name convention),
|
|
112
|
+
# target of AgentProfile's `tools_allow_groups`. Trimmed; empty/nil -> nil.
|
|
113
|
+
def self.normalize_group(group)
|
|
114
|
+
g = group.to_s.strip
|
|
115
|
+
g.empty? ? nil : g
|
|
116
|
+
end
|
|
117
|
+
private_class_method :normalize_group
|
|
118
|
+
|
|
119
|
+
# Free-form tags (metadata/discovery). List of non-empty, unique strings.
|
|
120
|
+
def self.normalize_tags(tags)
|
|
121
|
+
Array(tags).map { |t| t.to_s.strip }.reject(&:empty?).uniq
|
|
122
|
+
end
|
|
123
|
+
private_class_method :normalize_tags
|
|
124
|
+
|
|
125
|
+
# ---- parameter validation/normalization (class-private) -------------------
|
|
126
|
+
|
|
127
|
+
# Input (Hash=JSON Schema | Array=flat sugar | nil) -> canonical string-keyed
|
|
128
|
+
# JSON Schema, validated against the safe subset.
|
|
129
|
+
def self.normalize_params(params)
|
|
130
|
+
schema =
|
|
131
|
+
case params
|
|
132
|
+
when nil then empty_schema
|
|
133
|
+
when Array then lift_flat_params(params)
|
|
134
|
+
when Hash then deep_stringify(params)
|
|
135
|
+
else raise Insika::ValidationError, "parameters must be JSON Schema (object) or a list of params"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
schema = coerce_object_schema(schema)
|
|
139
|
+
validate_schema!(schema, path: "parameters")
|
|
140
|
+
validate_top_level_names!(schema)
|
|
141
|
+
schema
|
|
142
|
+
end
|
|
143
|
+
private_class_method :normalize_params
|
|
144
|
+
|
|
145
|
+
def self.empty_schema = { "type" => "object", "properties" => {}, "required" => [] }
|
|
146
|
+
private_class_method :empty_schema
|
|
147
|
+
|
|
148
|
+
# Flat sugar -> JSON Schema. Preserves validation (NAME_RE, duplicates,
|
|
149
|
+
# PARAM_TYPES) and lifts `array:<scalar>` into proper `items`.
|
|
150
|
+
#
|
|
151
|
+
# A bare `array` is REFUSED. It used to lift to `items: {type:"string"}` — the
|
|
152
|
+
# engine inventing half the contract. That default is invisible in the authoring
|
|
153
|
+
# UI and silently correct-looking, so an array-of-OBJECTS param (the common shape:
|
|
154
|
+
# `[{query, filters}]`) reached the provider declared as an array of STRINGS. The
|
|
155
|
+
# model then obeyed the schema it was given, the backend answered 200, and the
|
|
156
|
+
# results were garbage — a failure with no error anywhere. The JSON Schema path
|
|
157
|
+
# already refuses `array` without `items` (validate_array!); the sugar now agrees.
|
|
158
|
+
def self.lift_flat_params(list)
|
|
159
|
+
seen = {}
|
|
160
|
+
properties = {}
|
|
161
|
+
required = []
|
|
162
|
+
Array(list).each do |p|
|
|
163
|
+
p = deep_symbolize(p)
|
|
164
|
+
pname = p[:name].to_s
|
|
165
|
+
raise Insika::ValidationError, "param name must match #{NAME_RE.inspect}" unless NAME_RE.match?(pname)
|
|
166
|
+
raise Insika::ValidationError, "param '#{pname}' duplicated" if seen[pname]
|
|
167
|
+
|
|
168
|
+
seen[pname] = true
|
|
169
|
+
prop = flat_property(pname, (p[:type] || "string").to_s)
|
|
170
|
+
prop["description"] = p[:description].to_s unless p[:description].to_s.empty?
|
|
171
|
+
properties[pname] = prop
|
|
172
|
+
required << pname if p.fetch(:required, true)
|
|
173
|
+
end
|
|
174
|
+
{ "type" => "object", "properties" => properties, "required" => required }
|
|
175
|
+
end
|
|
176
|
+
private_class_method :lift_flat_params
|
|
177
|
+
|
|
178
|
+
# One flat type -> the property schema. Raises on a bare `array` with the spelling
|
|
179
|
+
# that fixes it, and on anything else unknown.
|
|
180
|
+
def self.flat_property(pname, type)
|
|
181
|
+
if (m = ARRAY_SUGAR_RE.match(type))
|
|
182
|
+
{ "type" => "array", "items" => { "type" => m[1] } }
|
|
183
|
+
elsif PARAM_TYPES.include?(type)
|
|
184
|
+
{ "type" => type }
|
|
185
|
+
elsif type == "array"
|
|
186
|
+
raise Insika::ValidationError,
|
|
187
|
+
"param '#{pname}': type 'array' needs an item type — use #{ARRAY_SUGAR.join('/')} " \
|
|
188
|
+
"for a list of scalars, or declare the full JSON Schema for a list of objects"
|
|
189
|
+
else
|
|
190
|
+
raise Insika::ValidationError,
|
|
191
|
+
"param '#{pname}': invalid type #{type.inspect} (#{(PARAM_TYPES + ARRAY_SUGAR).join('/')})"
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
private_class_method :flat_property
|
|
195
|
+
|
|
196
|
+
# The top level is always an object (the model always sends an args object). A Hash
|
|
197
|
+
# without "type" but with "properties" is assumed to be an object; any other top-level
|
|
198
|
+
# type is an error.
|
|
199
|
+
def self.coerce_object_schema(schema)
|
|
200
|
+
s = schema.dup
|
|
201
|
+
s["type"] ||= "object" if s.key?("properties") || !s.key?("type")
|
|
202
|
+
unless s["type"].to_s == "object"
|
|
203
|
+
raise Insika::ValidationError, "parameters (top) must be type object, not #{s['type'].inspect}"
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
s["properties"] ||= {}
|
|
207
|
+
s["required"] ||= []
|
|
208
|
+
s
|
|
209
|
+
end
|
|
210
|
+
private_class_method :coerce_object_schema
|
|
211
|
+
|
|
212
|
+
# Recursively validates the safe subset: type ∈ SCHEMA_TYPES, no composition/ref
|
|
213
|
+
# constructs, an object recurses into its properties, an array requires items.
|
|
214
|
+
def self.validate_schema!(node, path:)
|
|
215
|
+
raise Insika::ValidationError, "#{path}: schema must be a JSON Schema object" unless node.is_a?(Hash)
|
|
216
|
+
|
|
217
|
+
forbidden = node.keys.map(&:to_s) & FORBIDDEN_KEYWORDS
|
|
218
|
+
unless forbidden.empty?
|
|
219
|
+
raise Insika::ValidationError,
|
|
220
|
+
"#{path}: unsupported construct (#{forbidden.join(', ')}); safe subset: #{SCHEMA_TYPES.join('/')}/enum"
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
type = node["type"].to_s
|
|
224
|
+
raise Insika::ValidationError, "#{path}: 'type' is required" if type.empty?
|
|
225
|
+
raise Insika::ValidationError, "#{path}: invalid type #{node['type'].inspect}" unless SCHEMA_TYPES.include?(type)
|
|
226
|
+
|
|
227
|
+
case type
|
|
228
|
+
when "object" then validate_object!(node, path)
|
|
229
|
+
when "array" then validate_array!(node, path)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
validate_enum!(node, path)
|
|
233
|
+
end
|
|
234
|
+
private_class_method :validate_schema!
|
|
235
|
+
|
|
236
|
+
def self.validate_object!(node, path)
|
|
237
|
+
props = node["properties"] || {}
|
|
238
|
+
raise Insika::ValidationError, "#{path}: 'properties' must be an object" unless props.is_a?(Hash)
|
|
239
|
+
|
|
240
|
+
props.each { |pname, pschema| validate_schema!(pschema, path: "#{path}.#{pname}") }
|
|
241
|
+
|
|
242
|
+
required = node["required"] || []
|
|
243
|
+
raise Insika::ValidationError, "#{path}: 'required' must be a list" unless required.is_a?(Array)
|
|
244
|
+
|
|
245
|
+
unknown = required.map(&:to_s) - props.keys.map(&:to_s)
|
|
246
|
+
raise Insika::ValidationError, "#{path}: required cites nonexistent property: #{unknown.join(', ')}" unless unknown.empty?
|
|
247
|
+
end
|
|
248
|
+
private_class_method :validate_object!
|
|
249
|
+
|
|
250
|
+
def self.validate_array!(node, path)
|
|
251
|
+
items = node["items"]
|
|
252
|
+
raise Insika::ValidationError, "#{path}: array requires 'items'" if items.nil?
|
|
253
|
+
|
|
254
|
+
validate_schema!(items, path: "#{path}[]")
|
|
255
|
+
end
|
|
256
|
+
private_class_method :validate_array!
|
|
257
|
+
|
|
258
|
+
def self.validate_enum!(node, path)
|
|
259
|
+
return unless node.key?("enum")
|
|
260
|
+
|
|
261
|
+
enum = node["enum"]
|
|
262
|
+
raise Insika::ValidationError, "#{path}: 'enum' must be a non-empty list" unless enum.is_a?(Array) && !enum.empty?
|
|
263
|
+
end
|
|
264
|
+
private_class_method :validate_enum!
|
|
265
|
+
|
|
266
|
+
# TOP-LEVEL property names are both model args AND {{placeholder}} targets ->
|
|
267
|
+
# they require NAME_RE (no dot, lowercase). Nested properties may be free-form.
|
|
268
|
+
def self.validate_top_level_names!(schema)
|
|
269
|
+
(schema["properties"] || {}).each_key do |pname|
|
|
270
|
+
raise Insika::ValidationError, "top-level param '#{pname}' must match #{NAME_RE.inspect}" unless NAME_RE.match?(pname.to_s)
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
private_class_method :validate_top_level_names!
|
|
274
|
+
|
|
275
|
+
def self.top_level_names(schema) = (schema["properties"] || {}).keys.map(&:to_s)
|
|
276
|
+
private_class_method :top_level_names
|
|
277
|
+
|
|
278
|
+
# ---- request/response validation/normalization (class-private) ------------
|
|
279
|
+
|
|
280
|
+
def self.normalize_request(request, param_names)
|
|
281
|
+
r = deep_symbolize(request)
|
|
282
|
+
method = (r[:method] || "GET").to_s.upcase
|
|
283
|
+
raise Insika::ValidationError, "invalid method #{method.inspect}" unless HTTP_METHODS.include?(method)
|
|
284
|
+
|
|
285
|
+
url = r[:url].to_s
|
|
286
|
+
raise Insika::ValidationError, "url is required" if url.empty?
|
|
287
|
+
|
|
288
|
+
# The URL is a TEMPLATE: {{x}} is not a valid URI character. Validate against a
|
|
289
|
+
# probe with the placeholders swapped for a safe token.
|
|
290
|
+
probe = url.gsub(PLACEHOLDER_RE, "x")
|
|
291
|
+
uri = begin
|
|
292
|
+
URI.parse(probe)
|
|
293
|
+
rescue URI::InvalidURIError
|
|
294
|
+
raise Insika::ValidationError, "invalid url"
|
|
295
|
+
end
|
|
296
|
+
raise Insika::ValidationError, "url must be http/https" unless %w[http https].include?(uri.scheme)
|
|
297
|
+
|
|
298
|
+
headers = stringify_values(r[:headers])
|
|
299
|
+
query = stringify_values(r[:query])
|
|
300
|
+
body = r[:body].nil? ? nil : r[:body].to_s
|
|
301
|
+
|
|
302
|
+
check_placeholders!([url, *headers.values, *query.values, body].compact, param_names)
|
|
303
|
+
|
|
304
|
+
{ method: method, url: url, headers: headers, query: query, body: body }
|
|
305
|
+
end
|
|
306
|
+
private_class_method :normalize_request
|
|
307
|
+
|
|
308
|
+
# HALT CONDITION (optional): when the tool's RESPONSE says the turn is already
|
|
309
|
+
# answered, the model must not speak again. The classic case is a backend that
|
|
310
|
+
# performs the side effect AND sends its own confirmation to the customer: with
|
|
311
|
+
# the model free to comment, the person gets the message twice.
|
|
312
|
+
#
|
|
313
|
+
# "halt_when" => { "json_path" => "tool_result.status", "equals" => ["SUBSCRIBED"] }
|
|
314
|
+
#
|
|
315
|
+
# By RESULT, not by tool: the same call that halts on SUBSCRIBED must let the
|
|
316
|
+
# model explain a SUBSCRIPTION_FAILED. Evaluated against the parsed response
|
|
317
|
+
# body (independent of `response.extract`, which shapes what the MODEL sees) —
|
|
318
|
+
# a non-JSON body simply never matches. `equals` is compared as strings: JSON
|
|
319
|
+
# gives no type guarantee across backends and a status is a label, not a number.
|
|
320
|
+
# -> { json_path:, equals: [String] } | nil
|
|
321
|
+
def self.normalize_halt_when(halt_when)
|
|
322
|
+
return nil if halt_when.nil?
|
|
323
|
+
|
|
324
|
+
h = deep_symbolize(halt_when)
|
|
325
|
+
path = h[:json_path].to_s
|
|
326
|
+
raise Insika::ValidationError, "halt_when requires json_path" if path.empty?
|
|
327
|
+
|
|
328
|
+
values = Array(h[:equals]).map(&:to_s)
|
|
329
|
+
raise Insika::ValidationError, "halt_when requires a non-empty equals list" if values.empty?
|
|
330
|
+
|
|
331
|
+
{ json_path: path, equals: values, say: normalize_halt_say(h[:say]) }.compact
|
|
332
|
+
end
|
|
333
|
+
private_class_method :normalize_halt_when
|
|
334
|
+
|
|
335
|
+
# `say` is EITHER a literal or a path, never both — two answers to "what does the
|
|
336
|
+
# customer get" is a configuration nobody can read. Refused at load rather than
|
|
337
|
+
# resolved by precedence: a silently ignored half would publish the wrong one.
|
|
338
|
+
def self.normalize_halt_say(say)
|
|
339
|
+
return nil if say.nil?
|
|
340
|
+
|
|
341
|
+
s = deep_symbolize(say)
|
|
342
|
+
text = s[:text].nil? ? nil : s[:text].to_s
|
|
343
|
+
path = s[:json_path].nil? ? nil : s[:json_path].to_s
|
|
344
|
+
given = [text, path].compact.reject(&:empty?)
|
|
345
|
+
if given.length != 1
|
|
346
|
+
raise Insika::ValidationError,
|
|
347
|
+
"halt_when.say takes exactly one of 'text' or 'json_path' (got #{given.length})"
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
text.nil? || text.empty? ? { json_path: path } : { text: text }
|
|
351
|
+
end
|
|
352
|
+
private_class_method :normalize_halt_say
|
|
353
|
+
|
|
354
|
+
def self.normalize_response(response)
|
|
355
|
+
r = deep_symbolize(response || {})
|
|
356
|
+
extract = (r[:extract] || "body_raw").to_s
|
|
357
|
+
raise Insika::ValidationError, "invalid extract #{extract.inspect}" unless EXTRACTS.include?(extract)
|
|
358
|
+
|
|
359
|
+
path = r[:path].nil? ? nil : r[:path].to_s
|
|
360
|
+
raise Insika::ValidationError, "extract 'json_path' requires path" if extract == "json_path" && (path.nil? || path.empty?)
|
|
361
|
+
|
|
362
|
+
{ extract: extract, path: path }
|
|
363
|
+
end
|
|
364
|
+
private_class_method :normalize_response
|
|
365
|
+
|
|
366
|
+
# Every {{x}} in the templates must reference a declared TOP-LEVEL parameter OR a
|
|
367
|
+
# known turn-context field ({{ctx.chat_id}} etc.). The ctx refs are
|
|
368
|
+
# NOT model parameters — they are resolved per-turn by the engine.
|
|
369
|
+
def self.check_placeholders!(strings, param_names)
|
|
370
|
+
used = strings.flat_map { |s| s.to_s.scan(PLACEHOLDER_RE).flatten }.uniq
|
|
371
|
+
ctx_refs, params = used.partition { |u| u.start_with?(CTX_PREFIX) }
|
|
372
|
+
|
|
373
|
+
unknown_ctx = ctx_refs.reject { |u| CTX_FIELDS.include?(u.delete_prefix(CTX_PREFIX)) }
|
|
374
|
+
unless unknown_ctx.empty?
|
|
375
|
+
raise Insika::ValidationError,
|
|
376
|
+
"unknown turn context: #{unknown_ctx.join(', ')} " \
|
|
377
|
+
"(available: #{CTX_FIELDS.map { |f| CTX_PREFIX + f }.join(', ')})"
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
unknown = params - param_names
|
|
381
|
+
raise Insika::ValidationError, "placeholder(s) without a parameter: #{unknown.join(', ')}" unless unknown.empty?
|
|
382
|
+
end
|
|
383
|
+
private_class_method :check_placeholders!
|
|
384
|
+
|
|
385
|
+
def self.stringify_values(hash)
|
|
386
|
+
(deep_symbolize(hash || {})).each_with_object({}) { |(k, v), acc| acc[k.to_s] = v.to_s }
|
|
387
|
+
end
|
|
388
|
+
private_class_method :stringify_values
|
|
389
|
+
|
|
390
|
+
def self.deep_symbolize(obj)
|
|
391
|
+
case obj
|
|
392
|
+
when Hash then obj.each_with_object({}) { |(k, v), acc| acc[k.to_sym] = deep_symbolize(v) }
|
|
393
|
+
when Array then obj.map { |v| deep_symbolize(v) }
|
|
394
|
+
else obj
|
|
395
|
+
end
|
|
396
|
+
end
|
|
397
|
+
private_class_method :deep_symbolize
|
|
398
|
+
|
|
399
|
+
# Canonical JSON-clean: keys AND symbols become strings (JSON has no symbol).
|
|
400
|
+
def self.deep_stringify(obj) = Insika::Coercion.deep_stringify(obj)
|
|
401
|
+
private_class_method :deep_stringify
|
|
402
|
+
|
|
403
|
+
# ---- instance -------------------------------------------------------------
|
|
404
|
+
|
|
405
|
+
# String-keyed Hash for persistence (ConfigStore stringifies again, but we
|
|
406
|
+
# normalize here so the record is stable across backends).
|
|
407
|
+
def to_h
|
|
408
|
+
{
|
|
409
|
+
"name" => name, "description" => description,
|
|
410
|
+
"parameters" => parameters,
|
|
411
|
+
"request" => request.transform_keys(&:to_s),
|
|
412
|
+
"response" => response.transform_keys(&:to_s),
|
|
413
|
+
"secret_headers" => secret_headers,
|
|
414
|
+
"side_effect" => side_effect, "timeout" => timeout,
|
|
415
|
+
"group" => group, "tags" => tags,
|
|
416
|
+
"halt_when" => halt_when&.transform_keys(&:to_s)
|
|
417
|
+
}
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
# -> true when this response ENDS the turn (no further model call). `body` is the
|
|
421
|
+
# raw response body; a parse failure or a missing path means "does not halt" —
|
|
422
|
+
# never end a turn on a guess.
|
|
423
|
+
def halt?(body)
|
|
424
|
+
return false if halt_when.nil?
|
|
425
|
+
|
|
426
|
+
parsed = JSON.parse(body.to_s)
|
|
427
|
+
value = dig_path(parsed, halt_when[:json_path])
|
|
428
|
+
return false if value == PATH_MISS
|
|
429
|
+
|
|
430
|
+
halt_when[:equals].include?(value.to_s)
|
|
431
|
+
rescue JSON::ParserError
|
|
432
|
+
false
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
# WHAT THE CUSTOMER GETS WHEN THE MODEL SAID NOTHING FIRST (`halt_when.say`).
|
|
436
|
+
#
|
|
437
|
+
# A halt is worth the model's lead-in ("vou te conectar agora") and nothing
|
|
438
|
+
# after — but the model does not always write one, and then the turn published
|
|
439
|
+
# an EMPTY answer. Measured on a real store: two escalation turns in a row
|
|
440
|
+
# delivered silence, where the same agent without the halt at least said "o time
|
|
441
|
+
# de suporte já está cuidando do seu caso".
|
|
442
|
+
#
|
|
443
|
+
# The value cannot be guessed. `json_path` + `equals` cannot supply it either:
|
|
444
|
+
# the matched value is by definition one of the `equals` tokens, so publishing
|
|
445
|
+
# it would ship "SUBSCRIBED" to a person as often as it ships a sentence. So the
|
|
446
|
+
# operator names it, in one of two shapes:
|
|
447
|
+
#
|
|
448
|
+
# "say" => { "json_path" => "tool_result" } # the sentence the backend returned
|
|
449
|
+
# "say" => { "text" => "CALL_SUPPORT" } # a literal the CHANNEL resolves
|
|
450
|
+
#
|
|
451
|
+
# The literal form is the one that replaces the usual workaround — forcing the
|
|
452
|
+
# prompt to emit a control token and parsing it downstream. The token then comes
|
|
453
|
+
# from the tool's own contract, deterministically, instead of depending on the
|
|
454
|
+
# model complying with an instruction.
|
|
455
|
+
#
|
|
456
|
+
# -> String | nil. nil means "publish nothing", which is the pre-existing
|
|
457
|
+
# behaviour and stays the default for every tool that declares no `say`.
|
|
458
|
+
def halt_say(body)
|
|
459
|
+
say = halt_when && halt_when[:say]
|
|
460
|
+
return nil if say.nil?
|
|
461
|
+
return Coercion.presence(say[:text]) if say[:text]
|
|
462
|
+
|
|
463
|
+
parsed = JSON.parse(body.to_s)
|
|
464
|
+
value = dig_path(parsed, say[:json_path])
|
|
465
|
+
# Only a String is publishable: a hash or a number reaching a customer as the
|
|
466
|
+
# answer is never what someone meant.
|
|
467
|
+
value.is_a?(String) ? Coercion.presence(value) : nil
|
|
468
|
+
rescue JSON::ParserError
|
|
469
|
+
nil
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
# HOW `say` REACHES THE EXECUTOR. RubyLLM's `Tool::Halt` carries one value, and
|
|
473
|
+
# that value is the tool's payload (the trace records it, and the model never
|
|
474
|
+
# sees it — the halt ends the loop). So a halt that has something to publish
|
|
475
|
+
# carries BOTH, under keys distinctive enough that a trace reader knows what
|
|
476
|
+
# they are on sight. Unwrapped tools are untouched: no `say`, no wrapper.
|
|
477
|
+
SAY_KEY = "__insika_halt_say"
|
|
478
|
+
PAYLOAD_KEY = "__insika_halt_payload"
|
|
479
|
+
|
|
480
|
+
def self.wrap_halt(payload, say) = { SAY_KEY => say, PAYLOAD_KEY => payload }
|
|
481
|
+
|
|
482
|
+
# -> the text to publish, or nil when this halt carries none.
|
|
483
|
+
def self.halt_say_of(content)
|
|
484
|
+
content.is_a?(Hash) ? Coercion.presence(content[SAY_KEY]) : nil
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
# Walks a dotted path. Returns PATH_MISS (not nil) when a segment is absent, so a
|
|
488
|
+
# key whose stored value IS nil stays distinguishable from a missing key.
|
|
489
|
+
PATH_MISS = Object.new.freeze
|
|
490
|
+
|
|
491
|
+
def dig_path(parsed, path)
|
|
492
|
+
path.to_s.split(".").reduce(parsed) do |cur, seg|
|
|
493
|
+
return PATH_MISS unless cur.is_a?(Hash) && cur.key?(seg)
|
|
494
|
+
|
|
495
|
+
cur[seg]
|
|
496
|
+
end
|
|
497
|
+
end
|
|
498
|
+
private :dig_path
|
|
499
|
+
|
|
500
|
+
# Names of the required top-level parameters (DataDefinedTool validates presence
|
|
501
|
+
# before the call). Derived from the JSON Schema's `required`.
|
|
502
|
+
def required_params = Array(parameters["required"]).map(&:to_s)
|
|
503
|
+
|
|
504
|
+
# FLAT view of the top-level properties (name/type/description/required) — for
|
|
505
|
+
# RubyLLM's `#parameters` (discovery/tool_search) and the simple authoring UI.
|
|
506
|
+
# The full nested schema goes through `params_schema` (DataDefinedTool). Symbol-
|
|
507
|
+
# keyed for compat with callers that already consumed the flat params.
|
|
508
|
+
def top_level_params
|
|
509
|
+
props = parameters["properties"] || {}
|
|
510
|
+
required = required_params
|
|
511
|
+
props.map do |pname, pschema|
|
|
512
|
+
pschema ||= {}
|
|
513
|
+
{ name: pname.to_s, type: (pschema["type"] || "string").to_s,
|
|
514
|
+
description: pschema["description"].to_s, required: required.include?(pname.to_s) }
|
|
515
|
+
end
|
|
516
|
+
end
|
|
517
|
+
end
|
|
518
|
+
end
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "async"
|
|
4
|
+
require "delegate"
|
|
5
|
+
require "time"
|
|
6
|
+
|
|
7
|
+
module Insika
|
|
8
|
+
# Wraps each allowed tool: per-call timeout
|
|
9
|
+
# + recording of a non-idempotent side-effect BEFORE the result returns to the
|
|
10
|
+
# model. Delegates everything else (name/description/params) to the real tool.
|
|
11
|
+
#
|
|
12
|
+
# The tool loop belongs to RubyLLM; this is a decorator over the instances —
|
|
13
|
+
# the Executor never drives roundtrips.
|
|
14
|
+
class ToolEnvelope < SimpleDelegator
|
|
15
|
+
# The tool timeout's OWN class: distinct from Async::TimeoutError so that
|
|
16
|
+
# the rescue below NEVER swallows the TURN timeout (which uses the default of
|
|
17
|
+
# with_timeout). Without this, a turn overflowing while the fiber is inside a
|
|
18
|
+
# tool would be masked as a tool timeout and the turn would run past the
|
|
19
|
+
# deadline (a durability defect).
|
|
20
|
+
ToolTimeout = Class.new(StandardError)
|
|
21
|
+
private_constant :ToolTimeout
|
|
22
|
+
|
|
23
|
+
def initialize(tool, state:, checkpoint_store:, tool_registry:, timeout:,
|
|
24
|
+
skip_side_effects: [], trace_recorder: nil)
|
|
25
|
+
super(tool)
|
|
26
|
+
@state = state
|
|
27
|
+
@checkpoint_store = checkpoint_store
|
|
28
|
+
@tool_registry = tool_registry
|
|
29
|
+
@timeout = timeout
|
|
30
|
+
@skip_side_effects = Array(skip_side_effects) # ids already completed in the interrupted turn
|
|
31
|
+
@trace_recorder = trace_recorder # duck-type: #record(session_id:, entry:). nil = no trace.
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Entry point that RubyLLM invokes (Tool#call in the pinned version).
|
|
35
|
+
# A timeout overflow returns to the MODEL as a serialized error — it does
|
|
36
|
+
# not bring down the turn.
|
|
37
|
+
def call(args)
|
|
38
|
+
# A non-idempotent tool call ALREADY COMPLETED in the interrupted
|
|
39
|
+
# turn -> respond with a marker, NEVER re-execute. The marker returns to
|
|
40
|
+
# the model, keeping the tool-use protocol intact.
|
|
41
|
+
call_id = correlation_id
|
|
42
|
+
return { "skipped" => "already_executed" } if call_id && @skip_side_effects.include?(call_id)
|
|
43
|
+
|
|
44
|
+
# Approval gate: a tool marked `approval` suspends the turn in
|
|
45
|
+
# :waiting until the operator resolves it. Delegates to the coordinator (the
|
|
46
|
+
# Executor), which creates/queries the PendingAction and blocks via the
|
|
47
|
+
# mailbox. Rejection returns to the MODEL as an error (the turn continues),
|
|
48
|
+
# it does not bring down the turn. CancelledError/TimeoutError from the wait
|
|
49
|
+
# propagate (they are not ToolTimeout).
|
|
50
|
+
if approval_required?
|
|
51
|
+
decision = @state.approval_coordinator.request_approval(
|
|
52
|
+
task: @state.task, turn: @state.turn, tool: real_name, args: args, actor: @state.actor
|
|
53
|
+
)
|
|
54
|
+
return { error: "rejected by operator" } unless decision.to_s == "approved"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
started = monotonic
|
|
58
|
+
result = with_gate { Async::Task.current.with_timeout(@timeout, ToolTimeout) { __getobj__.call(args) } }
|
|
59
|
+
record_side_effect!(call_id) if side_effect?
|
|
60
|
+
trace(call_id, args, result, started)
|
|
61
|
+
result
|
|
62
|
+
rescue ToolTimeout
|
|
63
|
+
err = { error: "TimeoutError: tool exceeded #{@timeout}s" }
|
|
64
|
+
trace(call_id, args, err, started)
|
|
65
|
+
err
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
71
|
+
|
|
72
|
+
# with parallel tool calls on, the turn's shared semaphore
|
|
73
|
+
# (TurnState#tool_gate, sized by `limits[:tool_concurrency]`) caps how many run
|
|
74
|
+
# at once. Wraps the REAL call ONLY — the approval wait and the skip check are
|
|
75
|
+
# outside it, so a call blocked on a human never holds a slot, and the per-call
|
|
76
|
+
# `tool_timeout` clock starts after the slot is granted rather than while
|
|
77
|
+
# queueing for one. The trace's `ms` DOES include the queue wait: that is the
|
|
78
|
+
# wall-clock the model waited. No gate (the default, serial) = straight through.
|
|
79
|
+
def with_gate(&)
|
|
80
|
+
gate = @state.respond_to?(:tool_gate) ? @state.tool_gate : nil
|
|
81
|
+
gate ? gate.acquire(&) : yield
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Records the call for debugging in the Studio (name + model args + result +
|
|
85
|
+
# ms), keyed by the SESSION. Masking/truncation is the ToolTraceStore's job;
|
|
86
|
+
# here we only collect. NEVER breaks the turn (trace is observability).
|
|
87
|
+
def trace(call_id, args, result, started)
|
|
88
|
+
return unless @trace_recorder && @state.task&.session_id
|
|
89
|
+
|
|
90
|
+
@trace_recorder.record(
|
|
91
|
+
session_id: @state.task.session_id,
|
|
92
|
+
entry: { "turn" => @state.turn, "tool" => real_name, "call_id" => call_id.to_s,
|
|
93
|
+
"args" => args, "result" => result,
|
|
94
|
+
"ms" => started ? ((monotonic - started) * 1000).round : nil,
|
|
95
|
+
"at" => Time.now.utc.iso8601 }
|
|
96
|
+
)
|
|
97
|
+
rescue StandardError
|
|
98
|
+
nil
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# The real impl_name when the delegate is a Capability::ResolvedTool:
|
|
102
|
+
# side_effect?/approval/correlation operate on the REAL name registered in
|
|
103
|
+
# the tool_registry (the capability alias does not exist there). A direct
|
|
104
|
+
# tool = #name.
|
|
105
|
+
def real_name
|
|
106
|
+
__getobj__.respond_to?(:impl_name) ? __getobj__.impl_name.to_s : __getobj__.name.to_s
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Does the current tool require approval? (names come from the Resolution
|
|
110
|
+
# via state).
|
|
111
|
+
def approval_required?
|
|
112
|
+
@state.respond_to?(:requires_approval) &&
|
|
113
|
+
Array(@state.requires_approval).include?(real_name)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# The call's correlation: the provider id (RubyLLM chat, via
|
|
117
|
+
# before_tool_call) when it exists; otherwise the tool NAME — the workflow
|
|
118
|
+
# case, which calls the instances directly and has no provider-generated id.
|
|
119
|
+
# LIMITATION: name-based correlation is per-TOOL, not per-call. If a
|
|
120
|
+
# workflow calls the SAME side-effect tool more than once in a turn,
|
|
121
|
+
# the resume skips ALL calls of that name (over-skip) — per-step
|
|
122
|
+
# checkpointing is future work. One call per tool is safe.
|
|
123
|
+
def correlation_id
|
|
124
|
+
(@state.current_tool_call&.id || real_name).to_s
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def side_effect?
|
|
128
|
+
@tool_registry.respond_to?(:side_effect?) &&
|
|
129
|
+
@tool_registry.side_effect?(real_name)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Written BEFORE the tool result returns to the model.
|
|
133
|
+
def record_side_effect!(call_id)
|
|
134
|
+
return if call_id.to_s.empty?
|
|
135
|
+
|
|
136
|
+
@checkpoint_store.record_side_effect(@state.task.id, turn: @state.turn,
|
|
137
|
+
tool_call_id: call_id)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|