insika 0.9.0 → 1.0.1
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 +237 -2
- data/README.md +5 -3
- data/bin/insika +3 -0
- data/bin/insika-router +0 -1
- data/docs/AGENTS.md +90 -7
- data/docs/ARCHITECTURE.md +12 -7
- data/docs/ARTIFACTS.md +33 -3
- data/docs/BENCHMARK.md +50 -7
- data/docs/CONTEXT.md +63 -0
- data/docs/CROSS-HARNESS-BENCH.md +157 -0
- data/docs/DEPLOY.md +57 -11
- data/docs/EMBEDDING.md +10 -0
- data/docs/EVALS.md +33 -4
- data/docs/FACTS.md +8 -6
- data/docs/HARVEST.md +8 -3
- data/docs/KNOWLEDGE.md +7 -0
- data/docs/MEDIA.md +6 -2
- data/docs/OBSERVABILITY.md +143 -22
- data/docs/PERFORMANCE.md +110 -0
- data/docs/PLUGINS.md +4 -4
- data/docs/POLICY.md +22 -13
- data/docs/RELEASING.md +5 -0
- data/docs/ROUTER.md +7 -0
- data/docs/RUBYLLM_2_MIGRATION.md +307 -0
- data/docs/SECURITY.md +7 -0
- data/docs/SHARED-CONVERSATIONS.md +40 -0
- data/docs/SKILLS.md +5 -3
- data/docs/STUDIO.md +122 -0
- data/docs/TOOLS.md +111 -9
- data/docs/operate.md +3 -0
- data/lib/insika/agent_defaults.rb +140 -0
- data/lib/insika/agent_file_store.rb +12 -0
- data/lib/insika/agent_profile.rb +78 -8
- data/lib/insika/artifact_signing.rb +11 -0
- data/lib/insika/chat_builder.rb +79 -25
- data/lib/insika/checkpoint.rb +6 -1
- data/lib/insika/checkpoint_store.rb +59 -7
- data/lib/insika/commands/agent_payload.rb +3 -3
- data/lib/insika/commands/delete_kit.rb +26 -0
- data/lib/insika/commands/gate_harvest.rb +3 -0
- data/lib/insika/commands/review_guardrail_flag.rb +38 -0
- data/lib/insika/commands/run_harvest.rb +17 -1
- data/lib/insika/commands/seed_session.rb +11 -1
- data/lib/insika/commands/send_message.rb +9 -3
- data/lib/insika/commands/update_settings.rb +23 -1
- data/lib/insika/commands/write_kit.rb +27 -0
- data/lib/insika/compaction.rb +33 -7
- data/lib/insika/config_store.rb +87 -7
- data/lib/insika/context/priority.rb +3 -0
- data/lib/insika/context/provider.rb +2 -2
- data/lib/insika/context/providers/knowledge.rb +25 -2
- data/lib/insika/context/providers/memory.rb +53 -4
- data/lib/insika/context/providers/pending_confirmation.rb +52 -0
- data/lib/insika/context/providers/prompt.rb +17 -4
- data/lib/insika/context/providers/session.rb +39 -3
- data/lib/insika/context/reranker.rb +75 -0
- data/lib/insika/distill.rb +22 -13
- data/lib/insika/doctor.rb +34 -4
- data/lib/insika/dsl/runtime.rb +36 -4
- data/lib/insika/dsl/server_boot.rb +4 -12
- data/lib/insika/dsl.rb +13 -5
- data/lib/insika/edge_limiter.rb +23 -15
- data/lib/insika/egress_guard.rb +11 -3
- data/lib/insika/env_schema.rb +33 -73
- data/lib/insika/evals/assertions.rb +171 -16
- data/lib/insika/evals/golden.rb +141 -4
- data/lib/insika/evals/judge.rb +19 -10
- data/lib/insika/evals/moderator_compare.rb +47 -0
- data/lib/insika/evals/report.rb +61 -9
- data/lib/insika/evals/runner.rb +44 -4
- data/lib/insika/evals/simulator.rb +10 -1
- data/lib/insika/evals/transport.rb +200 -0
- data/lib/insika/event_stream.rb +63 -7
- data/lib/insika/evidence.rb +29 -8
- data/lib/insika/executor.rb +668 -141
- data/lib/insika/harvest.rb +26 -14
- data/lib/insika/harvest_engine.rb +1 -0
- data/lib/insika/harvest_store.rb +12 -0
- data/lib/insika/http_client.rb +87 -4
- data/lib/insika/kits.rb +54 -0
- data/lib/insika/knowledge.rb +34 -20
- data/lib/insika/llm_configurator.rb +46 -4
- data/lib/insika/llm_provider_store.rb +1 -3
- data/lib/insika/llm_trace_store.rb +54 -0
- data/lib/insika/mcp_client.rb +27 -63
- data/lib/insika/mcp_live_tool.rb +55 -9
- data/lib/insika/mcp_store.rb +32 -1
- data/lib/insika/mcp_tool_registry.rb +69 -26
- data/lib/insika/media.rb +38 -25
- data/lib/insika/memory_store.rb +4 -3
- data/lib/insika/memory_trim.rb +60 -0
- data/lib/insika/middleware.rb +12 -3
- data/lib/insika/model_metrics_store.rb +285 -0
- data/lib/insika/model_registry.rb +110 -0
- data/lib/insika/model_resolver.rb +1 -1
- data/lib/insika/model_selection.rb +14 -21
- data/lib/insika/model_visible.rb +17 -6
- data/lib/insika/onboarding.rb +1 -0
- data/lib/insika/outcome_store.rb +1 -1
- data/lib/insika/overlay_tool_registry.rb +25 -2
- data/lib/insika/pack_importer.rb +54 -9
- data/lib/insika/packaging.rb +14 -5
- data/lib/insika/pending_action_store.rb +45 -18
- data/lib/insika/plugin/loader.rb +4 -2
- data/lib/insika/profile_source.rb +4 -0
- data/lib/insika/provider_error_classifier.rb +18 -3
- data/lib/insika/queue_policy.rb +7 -2
- data/lib/insika/refinement/proposer.rb +42 -17
- data/lib/insika/reliability.rb +12 -3
- data/lib/insika/router/app.rb +14 -5
- data/lib/insika/router/session_key.rb +35 -5
- data/lib/insika/safety/config.rb +20 -3
- data/lib/insika/safety/corpus.rb +10 -3
- data/lib/insika/safety/factory.rb +39 -1
- data/lib/insika/safety/input_guardrail.rb +21 -1
- data/lib/insika/safety/judge_moderator.rb +70 -0
- data/lib/insika/safety/output_validator.rb +29 -5
- data/lib/insika/safety/prompt_echo.rb +89 -0
- data/lib/insika/schema_guard.rb +53 -76
- data/lib/insika/server/app.rb +17 -8
- data/lib/insika/server/responses.rb +19 -1
- data/lib/insika/session_actor.rb +58 -28
- data/lib/insika/session_store.rb +159 -10
- data/lib/insika/settings_store.rb +47 -1
- data/lib/insika/shared_conversations.rb +305 -0
- data/lib/insika/skill_catalog.rb +28 -2
- data/lib/insika/skill_store.rb +6 -0
- data/lib/insika/soak/runner.rb +1 -1
- data/lib/insika/steer_injector.rb +7 -48
- data/lib/insika/store.rb +29 -1
- data/lib/insika/stores/json_value.rb +37 -0
- data/lib/insika/stores/memory.rb +22 -35
- data/lib/insika/stores/postgres.rb +295 -0
- data/lib/insika/stores/sqlite.rb +257 -43
- data/lib/insika/stores/turn_counter.rb +25 -0
- data/lib/insika/studio/app.rb +1453 -161
- data/lib/insika/studio/assets/dist/application.css +1 -1
- data/lib/insika/studio/assets/dist/application.js +25 -25
- data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-400-normal.woff2 +0 -0
- data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-500-normal.woff2 +0 -0
- data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-600-normal.woff2 +0 -0
- data/lib/insika/studio/assets/dist/fonts/ibm-plex-sans-latin-wght-normal.woff2 +0 -0
- data/lib/insika/studio/forms.rb +171 -9
- data/lib/insika/studio/nav_icons.rb +3 -0
- data/lib/insika/studio/views/_agent_config_retrieval.erb +73 -0
- data/lib/insika/studio/views/_agent_picker.erb +15 -0
- data/lib/insika/studio/views/_agent_tab_config.erb +78 -242
- data/lib/insika/studio/views/_agent_tab_history.erb +56 -1
- data/lib/insika/studio/views/_agent_tab_performance.erb +34 -0
- data/lib/insika/studio/views/_agent_tab_prompts.erb +7 -9
- data/lib/insika/studio/views/_agent_tab_skills.erb +16 -7
- data/lib/insika/studio/views/_agent_tab_tools.erb +90 -0
- data/lib/insika/studio/views/_agents_master.erb +8 -12
- data/lib/insika/studio/views/_cfg_alerts.erb +4 -0
- data/lib/insika/studio/views/_cfg_budget.erb +20 -0
- data/lib/insika/studio/views/_cfg_distill.erb +26 -0
- data/lib/insika/studio/views/_cfg_followup.erb +30 -0
- data/lib/insika/studio/views/_cfg_grounding.erb +21 -0
- data/lib/insika/studio/views/_cfg_guardrails.erb +47 -0
- data/lib/insika/studio/views/_cfg_harvest.erb +35 -0
- data/lib/insika/studio/views/_cfg_reliability.erb +32 -0
- data/lib/insika/studio/views/_chats_master.erb +66 -0
- data/lib/insika/studio/views/_customers_master.erb +37 -0
- data/lib/insika/studio/views/_empty.erb +15 -0
- data/lib/insika/studio/views/_facts_master.erb +42 -0
- data/lib/insika/studio/views/_harvest_candidate_detail.erb +14 -0
- data/lib/insika/studio/views/_identity.erb +16 -0
- data/lib/insika/studio/views/_kpi.erb +17 -0
- data/lib/insika/studio/views/_message.erb +40 -2
- data/lib/insika/studio/views/_model_chart.erb +82 -0
- data/lib/insika/studio/views/_refinement_master.erb +38 -0
- data/lib/insika/studio/views/{tool_edit.erb → _tool_edit.erb} +32 -9
- data/lib/insika/studio/views/_tool_users.erb +17 -0
- data/lib/insika/studio/views/_why.erb +53 -0
- data/lib/insika/studio/views/agent_detail.erb +17 -10
- data/lib/insika/studio/views/agents.erb +28 -16
- data/lib/insika/studio/views/approvals.erb +16 -11
- data/lib/insika/studio/views/artifact.erb +20 -7
- data/lib/insika/studio/views/artifacts.erb +19 -14
- data/lib/insika/studio/views/chats.erb +22 -29
- data/lib/insika/studio/views/customer.erb +88 -43
- data/lib/insika/studio/views/customers.erb +16 -26
- data/lib/insika/studio/views/evals.erb +49 -31
- data/lib/insika/studio/views/fact_detail.erb +114 -0
- data/lib/insika/studio/views/facts.erb +15 -116
- data/lib/insika/studio/views/followups.erb +63 -52
- data/lib/insika/studio/views/funnel.erb +104 -43
- data/lib/insika/studio/views/harvest.erb +85 -59
- data/lib/insika/studio/views/home.erb +138 -23
- data/lib/insika/studio/views/inbox.erb +36 -0
- data/lib/insika/studio/views/kits.erb +176 -0
- data/lib/insika/studio/views/knowledge.erb +92 -80
- data/lib/insika/studio/views/layout.erb +42 -11
- data/lib/insika/studio/views/live.erb +45 -0
- data/lib/insika/studio/views/mcp.erb +43 -13
- data/lib/insika/studio/views/models.erb +187 -0
- data/lib/insika/studio/views/parity.erb +38 -32
- data/lib/insika/studio/views/playground.erb +13 -9
- data/lib/insika/studio/views/refinement.erb +15 -224
- data/lib/insika/studio/views/refinement_detail.erb +243 -0
- data/lib/insika/studio/views/session.erb +152 -117
- data/lib/insika/studio/views/settings.erb +86 -22
- data/lib/insika/studio/views/skills.erb +53 -19
- data/lib/insika/studio/views/system_files.erb +7 -7
- data/lib/insika/studio/views/task.erb +45 -15
- data/lib/insika/studio/views/tasks.erb +35 -18
- data/lib/insika/studio/views/tools.erb +62 -73
- data/lib/insika/task_actor.rb +4 -1
- data/lib/insika/task_store.rb +67 -1
- data/lib/insika/telemetry/pricing.rb +16 -14
- data/lib/insika/telemetry/recorder.rb +103 -9
- data/lib/insika/telemetry/ruby_llm_instrumenter.rb +110 -0
- data/lib/insika/telemetry.rb +12 -2
- data/lib/insika/templates/daily-digest/README.md +13 -0
- data/lib/insika/templates/daily-digest/agent.rb +22 -11
- data/lib/insika/testing/store_contract.rb +65 -0
- data/lib/insika/text_tool_call.rb +66 -0
- data/lib/insika/tick.rb +7 -1
- data/lib/insika/token_store.rb +5 -0
- data/lib/insika/tool_batch.rb +1 -1
- data/lib/insika/tool_catalog.rb +12 -2
- data/lib/insika/tool_definition.rb +10 -4
- data/lib/insika/tool_envelope.rb +165 -28
- data/lib/insika/tool_manifest.rb +1 -0
- data/lib/insika/tool_store.rb +3 -0
- data/lib/insika/tool_trace_store.rb +15 -8
- data/lib/insika/tool_usage_report.rb +2 -2
- data/lib/insika/tools/a2a_remote.rb +1 -1
- data/lib/insika/tools/agent_enum.rb +1 -1
- data/lib/insika/tools/confirm_pending.rb +112 -0
- data/lib/insika/tools/data_defined_tool.rb +23 -15
- data/lib/insika/tools/generate_image.rb +2 -2
- data/lib/insika/tools/load_knowledge.rb +7 -3
- data/lib/insika/tools/load_skill.rb +3 -3
- data/lib/insika/tools/present.rb +1 -8
- data/lib/insika/tools/remember.rb +7 -2
- data/lib/insika/tools/run_persona_eval.rb +3 -5
- data/lib/insika/tools/save_artifact.rb +16 -6
- data/lib/insika/tools/schedule_followup.rb +3 -3
- data/lib/insika/tools/stuck_signal.rb +3 -3
- data/lib/insika/tools/subagent.rb +5 -5
- data/lib/insika/tools/subagents.rb +3 -3
- data/lib/insika/tools/tool_search.rb +7 -5
- data/lib/insika/tools/tts.rb +2 -2
- data/lib/insika/tools/update_briefing.rb +4 -4
- data/lib/insika/turn_output.rb +2 -2
- data/lib/insika/turn_state.rb +18 -0
- data/lib/insika/turn_timing.rb +51 -14
- data/lib/insika/version.rb +1 -1
- data/lib/insika/wiring/graph.rb +81 -16
- data/lib/insika/workflow.rb +25 -64
- data/lib/insika.rb +19 -3
- metadata +69 -13
- data/lib/insika/commands/import_mcp_tools.rb +0 -48
- data/lib/insika/mcp_http_client.rb +0 -67
- data/lib/insika/mcp_tool_ingestor.rb +0 -165
data/lib/insika/harvest.rb
CHANGED
|
@@ -89,7 +89,7 @@ module Insika
|
|
|
89
89
|
# first-10 audit reads back.
|
|
90
90
|
|
|
91
91
|
# ask: ->(prompt) { "<raw model text>" } | something answering #content
|
|
92
|
-
# (+ #
|
|
92
|
+
# (+ RubyLLM-compatible #tokens for cost).
|
|
93
93
|
# model: the ref recorded as the candidate's `proposer` ("utility_model"
|
|
94
94
|
# default).
|
|
95
95
|
attr_reader :model
|
|
@@ -110,7 +110,7 @@ module Insika
|
|
|
110
110
|
# max_proposals: cap on surviving raw skills. Drops counted, never fixed.
|
|
111
111
|
def mine(prompt:, message_counts:, max_proposals: 10)
|
|
112
112
|
answer = @ask.call(prompt)
|
|
113
|
-
raw = parse(
|
|
113
|
+
raw = parse(answer)
|
|
114
114
|
skills = []
|
|
115
115
|
dropped = DROP_KEYS.to_h { |k| [k, 0] }
|
|
116
116
|
seen = {}
|
|
@@ -142,20 +142,25 @@ module Insika
|
|
|
142
142
|
# discipline). The cached prefix is INCLUDED in the spent total (E1: the
|
|
143
143
|
# run's cost is the harvest's only side spend).
|
|
144
144
|
def cost_of(answer)
|
|
145
|
-
return nil unless answer.respond_to?(:
|
|
145
|
+
return nil unless answer.respond_to?(:tokens)
|
|
146
146
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
147
|
+
usage = answer.tokens
|
|
148
|
+
input = usage.input.to_i
|
|
149
|
+
output = usage.output.to_i
|
|
150
|
+
cached = usage.cache_read.to_i
|
|
151
|
+
spent = input + output + cached + usage.cache_write.to_i
|
|
151
152
|
spent.positive? ? { "spent" => spent, "cached" => cached } : nil
|
|
152
153
|
end
|
|
153
154
|
|
|
154
|
-
#
|
|
155
|
-
#
|
|
156
|
-
def parse(
|
|
157
|
-
|
|
158
|
-
|
|
155
|
+
# Native responses own JSON parsing; injected text asks retain their
|
|
156
|
+
# existing fenced-JSON contract. Domain validation still runs below.
|
|
157
|
+
def parse(answer)
|
|
158
|
+
parsed = if answer.respond_to?(:parsed)
|
|
159
|
+
answer.parsed
|
|
160
|
+
else
|
|
161
|
+
JSON.parse(text_of(answer).strip.gsub(/\A```(?:json)?\s*|\s*```\z/, ""))
|
|
162
|
+
end
|
|
163
|
+
parsed = parsed["items"] if parsed.is_a?(Hash)
|
|
159
164
|
raise Unusable, "the miner's answer is not an array" unless parsed.is_a?(Array)
|
|
160
165
|
|
|
161
166
|
parsed
|
|
@@ -214,6 +219,10 @@ module Insika
|
|
|
214
219
|
return nil if ref.nil?
|
|
215
220
|
|
|
216
221
|
provider, model = split_ref(ref)
|
|
222
|
+
# A bare "model" ref falls back to the agent's own provider (stamped
|
|
223
|
+
# by RunHarvest) — RubyLLM refuses a nil provider with
|
|
224
|
+
# assume_model_exists, so a bare ref used to fail every run.
|
|
225
|
+
provider ||= Coercion.presence(config && config["provider"])
|
|
217
226
|
factory = ask_factory || ->(m, p) { ruby_llm_ask(m, p, llm: llm) }
|
|
218
227
|
Miner.new(ask: factory.call(model, provider), model: ref)
|
|
219
228
|
end
|
|
@@ -232,10 +241,13 @@ module Insika
|
|
|
232
241
|
require "ruby_llm"
|
|
233
242
|
llm ||= RubyLLM
|
|
234
243
|
lambda do |prompt|
|
|
244
|
+
schema = { "type" => "object", "properties" => { "items" => SKILL_SCHEMA.json_schema },
|
|
245
|
+
"required" => ["items"], "additionalProperties" => false }
|
|
235
246
|
llm.chat(model: model, provider: provider, assume_model_exists: true)
|
|
236
|
-
.with_temperature(0).
|
|
247
|
+
.with_temperature(0).with_schema("schema" => schema, "strict" => false)
|
|
248
|
+
.ask("#{prompt}\n\nReturn the requested array inside a JSON object with the key \"items\".")
|
|
237
249
|
end
|
|
238
250
|
end
|
|
239
251
|
end
|
|
240
252
|
end
|
|
241
|
-
end
|
|
253
|
+
end
|
|
@@ -125,6 +125,7 @@ module Insika
|
|
|
125
125
|
def due_sessions
|
|
126
126
|
due = []
|
|
127
127
|
@session_store.each_id do |id|
|
|
128
|
+
next if Insika::SessionStore.synthetic?(id) # not customers — and skips reading the record
|
|
128
129
|
session = @session_store.find(id)
|
|
129
130
|
next unless session
|
|
130
131
|
next if @harvest_store.mined?(id)
|
data/lib/insika/harvest_store.rb
CHANGED
|
@@ -196,6 +196,18 @@ module Insika
|
|
|
196
196
|
|
|
197
197
|
# gated -> awaiting_approval. The refusal parks: a conversion REFUSAL
|
|
198
198
|
# keeps the candidate AT gated (the page shows the ruler's hole).
|
|
199
|
+
# A parked candidate (gated on a missing ruler: no judge, no criterion,
|
|
200
|
+
# no origin) goes back to pending so the operator can re-run the gate
|
|
201
|
+
# once the hole is fixed. The last reports stay until the new gate
|
|
202
|
+
# overwrites them. gated -> pending only.
|
|
203
|
+
def reopen(candidate_id)
|
|
204
|
+
update_candidate(candidate_id) do |record|
|
|
205
|
+
guard_candidate_state!(record, "gated")
|
|
206
|
+
record["status"] = "pending"
|
|
207
|
+
record["updated_at"] = timestamp
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
199
211
|
def mark_awaiting(candidate_id)
|
|
200
212
|
update_candidate(candidate_id) do |record|
|
|
201
213
|
guard_candidate_state!(record, "gated")
|
data/lib/insika/http_client.rb
CHANGED
|
@@ -24,8 +24,21 @@ module Insika
|
|
|
24
24
|
|
|
25
25
|
class ResponseTooLarge < Insika::Error; end
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
DNS_TTL = 60 # seconds
|
|
28
|
+
# A connection idle longer than this is reopened instead of reused. Kept well
|
|
29
|
+
# under common server idle timeouts: Net::HTTP retries only idempotent methods,
|
|
30
|
+
# so a POST on a socket the server already closed would fail the tool call.
|
|
31
|
+
KEEP_ALIVE = 5 # seconds
|
|
32
|
+
MAX_IDLE_PER_HOST = 16
|
|
33
|
+
|
|
34
|
+
def initialize(max_bytes: MAX_BYTES, dns_ttl: DNS_TTL, keep_alive: KEEP_ALIVE)
|
|
28
35
|
@max_bytes = max_bytes
|
|
36
|
+
@dns_ttl = dns_ttl
|
|
37
|
+
@keep_alive = keep_alive
|
|
38
|
+
@dns = {}
|
|
39
|
+
@idle = Hash.new { |h, k| h[k] = [] }
|
|
40
|
+
@pid = Process.pid
|
|
41
|
+
@lock = Mutex.new
|
|
29
42
|
end
|
|
30
43
|
|
|
31
44
|
def request(method:, url:, headers: {}, body: nil, timeout: nil)
|
|
@@ -34,9 +47,7 @@ module Insika
|
|
|
34
47
|
headers.each { |k, v| req[k] = v }
|
|
35
48
|
req.body = body if body && !body.to_s.empty?
|
|
36
49
|
|
|
37
|
-
|
|
38
|
-
opts = { use_ssl: uri.scheme == "https", open_timeout: t, read_timeout: t }
|
|
39
|
-
Net::HTTP.start(uri.host, uri.port, opts) do |http|
|
|
50
|
+
with_connection(uri, timeout || DEFAULT_TIMEOUT) do |http|
|
|
40
51
|
result = nil
|
|
41
52
|
http.request(req) do |resp|
|
|
42
53
|
# Accumulate in BINARY: Net::HTTP yields ASCII-8BIT chunks and a
|
|
@@ -55,9 +66,81 @@ module Insika
|
|
|
55
66
|
# The redirect TARGET, so a moved API is reported as "moved to <url>"
|
|
56
67
|
# instead of a bare 3xx with the empty body servers send with it.
|
|
57
68
|
result[:location] = resp["location"].to_s if resp["location"]
|
|
69
|
+
# The server's own processing time (Rack::Runtime's X-Runtime, seconds),
|
|
70
|
+
# so a slow call can be split into server work and everything else.
|
|
71
|
+
runtime = Float(resp["x-runtime"], exception: false)
|
|
72
|
+
result[:server_ms] = (runtime * 1000).round if runtime
|
|
58
73
|
end
|
|
59
74
|
result
|
|
60
75
|
end
|
|
61
76
|
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
# Reuses an idle keep-alive connection to the same origin (a new TLS connection
|
|
81
|
+
# costs several round trips). One caller holds a connection at a time; one that
|
|
82
|
+
# raised is closed, never returned, since its state is unknown.
|
|
83
|
+
def with_connection(uri, timeout)
|
|
84
|
+
key = [uri.scheme, uri.host, uri.port]
|
|
85
|
+
http = checkout(key) || connect(uri, timeout)
|
|
86
|
+
http.read_timeout = timeout
|
|
87
|
+
result = yield http
|
|
88
|
+
checkin(key, http)
|
|
89
|
+
result
|
|
90
|
+
rescue StandardError
|
|
91
|
+
http&.finish if http&.started?
|
|
92
|
+
raise
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def connect(uri, timeout)
|
|
96
|
+
# `ipaddr` connects to the cached address; TLS (SNI, certificate check) and the
|
|
97
|
+
# Host header still use the hostname.
|
|
98
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
99
|
+
http.ipaddr = address(uri.host)
|
|
100
|
+
http.use_ssl = uri.scheme == "https"
|
|
101
|
+
http.open_timeout = timeout
|
|
102
|
+
http.keep_alive_timeout = @keep_alive
|
|
103
|
+
http.start
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def checkout(key)
|
|
107
|
+
@lock.synchronize do
|
|
108
|
+
reset_after_fork
|
|
109
|
+
@idle[key].pop
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def checkin(key, http)
|
|
114
|
+
kept = @lock.synchronize do
|
|
115
|
+
next false if @idle[key].size >= MAX_IDLE_PER_HOST
|
|
116
|
+
|
|
117
|
+
@idle[key].push(http)
|
|
118
|
+
true
|
|
119
|
+
end
|
|
120
|
+
http.finish unless kept
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# A forked child must not share its parent's sockets.
|
|
124
|
+
def reset_after_fork
|
|
125
|
+
return if @pid == Process.pid
|
|
126
|
+
|
|
127
|
+
@pid = Process.pid
|
|
128
|
+
@idle.clear
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Net::HTTP resolves with getaddrinfo, which blocks the whole reactor under
|
|
132
|
+
# Falcon: a burst of N tool calls queued N lookups and froze every turn of the
|
|
133
|
+
# process meanwhile. Resolving once per host per TTL makes that rare.
|
|
134
|
+
# ponytail: the lookup on a miss still blocks; a fiber-aware resolver removes it
|
|
135
|
+
# if one host per minute ever shows up in a profile.
|
|
136
|
+
def address(host)
|
|
137
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
138
|
+
hit = @lock.synchronize { @dns[host] }
|
|
139
|
+
return hit[0] if hit && hit[1] > now
|
|
140
|
+
|
|
141
|
+
ip = Addrinfo.getaddrinfo(host, nil, nil, :STREAM).first.ip_address
|
|
142
|
+
@lock.synchronize { @dns[host] = [ip, now + @dns_ttl] }
|
|
143
|
+
ip
|
|
144
|
+
end
|
|
62
145
|
end
|
|
63
146
|
end
|
data/lib/insika/kits.rb
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
# Kits: named bundles of skills, tools and tool groups that agents subscribe to
|
|
5
|
+
# (AgentProfile#kits). Stored in Settings["kits"]; applied at READ time by
|
|
6
|
+
# AgentDefaults::ProfileSource, so editing a kit reaches every subscriber on its
|
|
7
|
+
# next turn and the agent's own record never holds a kit's items.
|
|
8
|
+
#
|
|
9
|
+
# A kit only ADDS to an explicit allowlist. An agent whose allowlist is "all"
|
|
10
|
+
# (nil) already sees everything, so the kit leaves it alone.
|
|
11
|
+
module Kits
|
|
12
|
+
NAME = /\A[a-z0-9][a-z0-9_-]{0,63}\z/
|
|
13
|
+
LISTS = %w[skills tools tool_groups].freeze
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
# -> the normalized kit { "description", "skills", "tools", "tool_groups" }.
|
|
18
|
+
def validate!(name, kit)
|
|
19
|
+
raise Insika::ValidationError, "kit name must be a lowercase slug" unless name.to_s.match?(NAME)
|
|
20
|
+
raise Insika::ValidationError, "kit must be an object" unless kit.is_a?(Hash)
|
|
21
|
+
|
|
22
|
+
kit = kit.transform_keys(&:to_s)
|
|
23
|
+
out = { "description" => kit["description"].to_s.strip }
|
|
24
|
+
LISTS.each do |key|
|
|
25
|
+
value = kit[key]
|
|
26
|
+
raise Insika::ValidationError, "kit #{key} must be a list" unless value.nil? || value.is_a?(Array)
|
|
27
|
+
|
|
28
|
+
out[key] = Array(value).map { |v| v.to_s.strip }.reject(&:empty?).uniq
|
|
29
|
+
end
|
|
30
|
+
out
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# -> the profile with its kits' items unioned in (the same object when there is
|
|
34
|
+
# nothing to add). Unknown kit names are ignored: a deleted kit just stops applying.
|
|
35
|
+
def apply(profile, kits_by_name)
|
|
36
|
+
# A malformed stored kit is skipped, never raised: one bad record must not stop
|
|
37
|
+
# every turn of every subscriber (the same rule as AgentDefaults).
|
|
38
|
+
chosen = Array(profile.kits).filter_map { |name| kits_by_name[name] }.grep(Hash)
|
|
39
|
+
return profile if chosen.empty?
|
|
40
|
+
|
|
41
|
+
pick = ->(key) { chosen.flat_map { |k| Array(k[key]) }.uniq }
|
|
42
|
+
changes = {}
|
|
43
|
+
unless profile.tools_allow.nil? && profile.tools_allow_groups.nil?
|
|
44
|
+
tools = pick.("tools")
|
|
45
|
+
groups = pick.("tool_groups")
|
|
46
|
+
changes[:tools_allow] = Array(profile.tools_allow) | tools if tools.any?
|
|
47
|
+
changes[:tools_allow_groups] = Array(profile.tools_allow_groups) | groups if groups.any?
|
|
48
|
+
end
|
|
49
|
+
skills = pick.("skills")
|
|
50
|
+
changes[:skills] = Array(profile.skills) | skills if !profile.skills.nil? && skills.any?
|
|
51
|
+
changes.empty? ? profile : profile.with(**changes)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
data/lib/insika/knowledge.rb
CHANGED
|
@@ -244,7 +244,7 @@ module Insika
|
|
|
244
244
|
attr_reader :model, :types
|
|
245
245
|
|
|
246
246
|
# ask: ->(prompt) { "<raw model text>" } | something answering #content
|
|
247
|
-
# (+ #
|
|
247
|
+
# (+ RubyLLM-compatible #tokens for cost).
|
|
248
248
|
# model: the ref recorded on the concept's provenance ("utility_model"
|
|
249
249
|
# default).
|
|
250
250
|
# types: the allowed concept types (the profile's `knowledge.types`,
|
|
@@ -262,7 +262,7 @@ module Insika
|
|
|
262
262
|
# cost: { "spent" => N, "cached" => N } | nil }
|
|
263
263
|
def extract(prompt:, max_concepts: 10)
|
|
264
264
|
answer = @ask.call(prompt)
|
|
265
|
-
raw = parse(
|
|
265
|
+
raw = parse(answer)
|
|
266
266
|
concepts = []
|
|
267
267
|
dropped = DROP_KEYS.to_h { |k| [k, 0] }
|
|
268
268
|
seen = {}
|
|
@@ -291,21 +291,25 @@ module Insika
|
|
|
291
291
|
def text_of(answer) = (answer.respond_to?(:content) ? answer.content : answer).to_s
|
|
292
292
|
|
|
293
293
|
def cost_of(answer)
|
|
294
|
-
return nil unless answer.respond_to?(:
|
|
294
|
+
return nil unless answer.respond_to?(:tokens)
|
|
295
295
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
296
|
+
usage = answer.tokens
|
|
297
|
+
input = usage.input.to_i
|
|
298
|
+
output = usage.output.to_i
|
|
299
|
+
cached = usage.cache_read.to_i
|
|
300
|
+
spent = input + output + cached + usage.cache_write.to_i
|
|
300
301
|
spent.positive? ? { "spent" => spent, "cached" => cached } : nil
|
|
301
302
|
end
|
|
302
303
|
|
|
303
|
-
#
|
|
304
|
-
#
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
304
|
+
# Native responses own JSON parsing; injected text asks retain their
|
|
305
|
+
# existing fenced-JSON contract. Domain validation still runs below.
|
|
306
|
+
def parse(answer)
|
|
307
|
+
parsed = if answer.respond_to?(:parsed)
|
|
308
|
+
answer.parsed
|
|
309
|
+
else
|
|
310
|
+
JSON.parse(text_of(answer).strip.gsub(/\A```(?:json)?\s*|\s*```\z/, ""))
|
|
311
|
+
end
|
|
312
|
+
parsed = parsed["items"] if parsed.is_a?(Hash)
|
|
309
313
|
raise Unusable, "the extractor's answer is not an array" unless parsed.is_a?(Array)
|
|
310
314
|
|
|
311
315
|
parsed
|
|
@@ -368,12 +372,17 @@ module Insika
|
|
|
368
372
|
# Temperature 0: extraction should be deterministic for the same turn.
|
|
369
373
|
# `ruby_llm` required lazily so nothing loads a provider gem until an
|
|
370
374
|
# extractor is actually configured (load_guard stays green).
|
|
371
|
-
def ruby_llm_ask(model, provider, llm: nil)
|
|
375
|
+
def ruby_llm_ask(model, provider, llm: nil, schema: nil)
|
|
372
376
|
require "ruby_llm"
|
|
373
377
|
llm ||= RubyLLM
|
|
374
378
|
lambda do |prompt|
|
|
379
|
+
unless schema
|
|
380
|
+
prompt = "#{prompt}\n\nReturn the requested array inside a JSON object with the key \"items\"."
|
|
381
|
+
end
|
|
382
|
+
response_schema = schema || { "type" => "object", "properties" => { "items" => CONCEPT_SCHEMA.json_schema },
|
|
383
|
+
"required" => ["items"], "additionalProperties" => false }
|
|
375
384
|
llm.chat(model: model, provider: provider, assume_model_exists: true)
|
|
376
|
-
.with_temperature(0).ask(prompt)
|
|
385
|
+
.with_temperature(0).with_schema("schema" => response_schema, "strict" => false).ask(prompt)
|
|
377
386
|
end
|
|
378
387
|
end
|
|
379
388
|
end
|
|
@@ -434,7 +443,7 @@ module Insika
|
|
|
434
443
|
# -> { verdict: :related, merged_body: String } | { verdict: :contradicting }
|
|
435
444
|
def resolve(existing_body:, new_body:)
|
|
436
445
|
answer = @ask.call(prompt_for(existing_body, new_body))
|
|
437
|
-
parsed = parse(
|
|
446
|
+
parsed = parse(answer)
|
|
438
447
|
raise Unusable, "not a JSON object" unless VERDICT_SCHEMA.call(parsed).success?
|
|
439
448
|
|
|
440
449
|
classify(parsed)
|
|
@@ -455,9 +464,12 @@ module Insika
|
|
|
455
464
|
|
|
456
465
|
def text_of(answer) = (answer.respond_to?(:content) ? answer.content : answer).to_s
|
|
457
466
|
|
|
458
|
-
def parse(
|
|
459
|
-
|
|
460
|
-
|
|
467
|
+
def parse(answer)
|
|
468
|
+
parsed = if answer.respond_to?(:parsed)
|
|
469
|
+
answer.parsed
|
|
470
|
+
else
|
|
471
|
+
JSON.parse(text_of(answer).strip.gsub(/\A```(?:json)?\s*|\s*```\z/, ""))
|
|
472
|
+
end
|
|
461
473
|
raise Unusable, "the consolidator's answer is not an object" unless parsed.is_a?(Hash)
|
|
462
474
|
|
|
463
475
|
parsed
|
|
@@ -492,7 +504,9 @@ module Insika
|
|
|
492
504
|
return nil if ref.nil?
|
|
493
505
|
|
|
494
506
|
provider, model = ExtractorFactory.split_ref(ref)
|
|
495
|
-
factory = ask_factory || ->(m, p) {
|
|
507
|
+
factory = ask_factory || ->(m, p) {
|
|
508
|
+
ExtractorFactory.ruby_llm_ask(m, p, llm: llm, schema: Consolidator::VERDICT_SCHEMA.json_schema)
|
|
509
|
+
}
|
|
496
510
|
Consolidator.new(ask: factory.call(model, provider), model: ref)
|
|
497
511
|
end
|
|
498
512
|
end
|
|
@@ -48,6 +48,7 @@ module Insika
|
|
|
48
48
|
next
|
|
49
49
|
end
|
|
50
50
|
|
|
51
|
+
remember_boot(config, api)
|
|
51
52
|
if set_accessor(config, "#{api}_api_key", key)
|
|
52
53
|
base = fetch(rec, :base_url)
|
|
53
54
|
set_accessor(config, "#{api}_api_base", base) if base && !base.to_s.empty?
|
|
@@ -61,16 +62,43 @@ module Insika
|
|
|
61
62
|
{ applied: applied, skipped: skipped }
|
|
62
63
|
end
|
|
63
64
|
|
|
64
|
-
#
|
|
65
|
-
#
|
|
65
|
+
# Re-applies the stored providers when they changed since THIS process last
|
|
66
|
+
# applied them. The Studio's `apply` reaches only the worker that served the
|
|
67
|
+
# edit; every other worker (and every worker after a restart) gets the
|
|
68
|
+
# provider here. Called per chat build, so it mutates the config only when the
|
|
69
|
+
# store changed, which is rare. Inside a turn the read comes from the
|
|
70
|
+
# ConfigStore cache. A provider this method applied and that no longer has a
|
|
71
|
+
# key (deleted, or its key cleared) is unapplied; a key it never applied, such
|
|
72
|
+
# as one from the environment, is never cleared. A store error keeps the
|
|
73
|
+
# current config: the next call retries, and the chat is not failed for it.
|
|
74
|
+
def refresh
|
|
75
|
+
records = @provider_store.all_raw
|
|
76
|
+
digest = records.hash
|
|
77
|
+
return if digest == @refreshed_digest
|
|
78
|
+
|
|
79
|
+
applied = apply(records)[:applied].map(&:to_s)
|
|
80
|
+
(Array(@refreshed_apis) - applied).each { |api| unapply(api) }
|
|
81
|
+
@refreshed_apis = applied
|
|
82
|
+
@refreshed_digest = digest
|
|
83
|
+
rescue StandardError => e
|
|
84
|
+
warn "[insika] LLM providers not refreshed, keeping the current ones: #{e.class}: #{e.message}"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# UNDOES a provider's config in RubyLLM at runtime (delete without a restart):
|
|
88
|
+
# `<api>_api_key`/`<api>_api_base` go back to what they were before this
|
|
89
|
+
# configurator first set them (nil when nothing was). A provider that RubyLLM
|
|
66
90
|
# doesn't recognize (no accessor) -> unapplied: false (nothing applied, nothing to undo).
|
|
67
91
|
# -> { unapplied: bool }.
|
|
68
92
|
def unapply(api)
|
|
69
93
|
api = api.to_s
|
|
94
|
+
# never applied here (another worker served the edit): nothing of ours to undo
|
|
95
|
+
return { unapplied: false } unless boot_values.key?(api)
|
|
96
|
+
|
|
70
97
|
unapplied = false
|
|
71
98
|
with_config do |config|
|
|
72
|
-
|
|
73
|
-
set_accessor(config, "#{api}
|
|
99
|
+
key, base = boot_values[api]
|
|
100
|
+
unapplied = set_accessor(config, "#{api}_api_key", key)
|
|
101
|
+
set_accessor(config, "#{api}_api_base", base)
|
|
74
102
|
end
|
|
75
103
|
{ unapplied: unapplied }
|
|
76
104
|
end
|
|
@@ -86,6 +114,20 @@ module Insika
|
|
|
86
114
|
end
|
|
87
115
|
end
|
|
88
116
|
|
|
117
|
+
# The key/base a provider had before this configurator first overrode it (the
|
|
118
|
+
# environment's, or nil), so removing the authored one puts those back instead
|
|
119
|
+
# of leaving the provider without credentials.
|
|
120
|
+
def remember_boot(config, api)
|
|
121
|
+
return if boot_values.key?(api.to_s)
|
|
122
|
+
|
|
123
|
+
boot_values[api.to_s] = %w[key base].map do |part|
|
|
124
|
+
getter = "#{api}_api_#{part}"
|
|
125
|
+
config.public_send(getter) if config.respond_to?(getter)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def boot_values = (@boot_values ||= {})
|
|
130
|
+
|
|
89
131
|
def set_accessor(config, name, value)
|
|
90
132
|
setter = "#{name}="
|
|
91
133
|
return false unless config.respond_to?(setter)
|
|
@@ -38,9 +38,7 @@ module Insika
|
|
|
38
38
|
end
|
|
39
39
|
|
|
40
40
|
# -> [Hash] all with REAL api_key (for the configurator). Never goes to the screen.
|
|
41
|
-
def all_raw
|
|
42
|
-
apis.filter_map { |a| raw(a) }
|
|
43
|
-
end
|
|
41
|
+
def all_raw = @cs.all(SCOPE).compact
|
|
44
42
|
|
|
45
43
|
# Upsert with secret reconciliation. `attrs` (string|symbol keys):
|
|
46
44
|
# api (required), base_url, auth_header, api_key (sentinel-aware), models[]
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
# Content-free native diagnostics. Task deletion owns their lifetime.
|
|
5
|
+
class LLMTraceStore
|
|
6
|
+
SCOPE = "llm_traces"
|
|
7
|
+
MAX_PER_TASK = 200
|
|
8
|
+
TEXT_FIELDS = %w[type operation agent provider model status request_id exception_class at].freeze
|
|
9
|
+
NUMBER_FIELDS = %w[turn duration_ms input_tokens output_tokens cache_read_tokens cache_write_tokens thinking_tokens cost].freeze
|
|
10
|
+
|
|
11
|
+
def initialize(store:)
|
|
12
|
+
@store = store
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Also used at the telemetry boundary; never stringify arbitrary objects.
|
|
16
|
+
def self.sanitize(entry)
|
|
17
|
+
entry.each_with_object({}) do |(key, value), result|
|
|
18
|
+
if TEXT_FIELDS.include?(key) && value.is_a?(String)
|
|
19
|
+
result[key] = Coercion.utf8(value)[0, 256].gsub(/[[:cntrl:]]/, "")
|
|
20
|
+
elsif NUMBER_FIELDS.include?(key) && (value.nil? ||
|
|
21
|
+
((value.is_a?(Integer) || value.is_a?(Float)) && value.finite? && value >= 0 && value <= 1e18))
|
|
22
|
+
result[key] = value
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def record(task_id:, entry:) = record_many(task_id: task_id, entries: [entry])
|
|
28
|
+
|
|
29
|
+
# A turn's events in one transaction: one read and one write of the trace,
|
|
30
|
+
# instead of one transaction per event.
|
|
31
|
+
def record_many(task_id:, entries:)
|
|
32
|
+
return if entries.empty?
|
|
33
|
+
|
|
34
|
+
@store.transaction do
|
|
35
|
+
next unless @store.get(TaskStore::SCOPE, "#{TaskStore::KEY_PREFIX}#{task_id}")
|
|
36
|
+
|
|
37
|
+
trace = for_task(task_id)
|
|
38
|
+
all = trace["entries"] + entries.map { |entry| self.class.sanitize(entry) }
|
|
39
|
+
@store.set(SCOPE, task_id.to_s, {
|
|
40
|
+
"entries" => all.last(MAX_PER_TASK),
|
|
41
|
+
"truncated" => trace["truncated"] || all.size > MAX_PER_TASK
|
|
42
|
+
})
|
|
43
|
+
end
|
|
44
|
+
rescue StandardError
|
|
45
|
+
nil
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def for_task(task_id)
|
|
49
|
+
@store.get(SCOPE, task_id.to_s) || { "entries" => [], "truncated" => false }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def clear(task_id) = @store.delete(SCOPE, task_id.to_s)
|
|
53
|
+
end
|
|
54
|
+
end
|
data/lib/insika/mcp_client.rb
CHANGED
|
@@ -1,94 +1,58 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Insika
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
# Streamable HTTP and SSE with the full lifecycle (initialize handshake,
|
|
7
|
-
# session-id, notifications) — Insika stops maintaining its own protocol
|
|
8
|
-
# client. Swapping the gem later (e.g. to modelcontextprotocol/ruby-sdk)
|
|
9
|
-
# touches only this file.
|
|
10
|
-
#
|
|
11
|
-
# `require "ruby_llm/mcp"` is LAZY, done here rather than at `require
|
|
12
|
-
# "insika"` time (spec/insika/load_guard_spec.rb): the gem drags in
|
|
13
|
-
# ruby_llm + httpx, the same opt-in-cost discipline as every other provider
|
|
14
|
-
# surface.
|
|
15
|
-
#
|
|
16
|
-
# Client LIFECYCLE (start/stop, registering the child process with
|
|
17
|
-
# Insika::Shutdown so it dies on SIGTERM) belongs to whoever holds the
|
|
18
|
-
# client across calls — the live tool registry, later work. `for` itself
|
|
19
|
-
# returns an unstarted client (`start: false`); building one is cheap and
|
|
20
|
-
# side-effect-free.
|
|
4
|
+
# Maps persisted MCP configuration to RubyLLM's native, lazy client.
|
|
5
|
+
# Insika retains the stdio opt-in and deployment egress policy.
|
|
21
6
|
module McpClient
|
|
22
|
-
# A stdio instance is arbitrary command execution by config — refused
|
|
23
|
-
# unless the operator opted in (config-over-convention, same pattern as
|
|
24
|
-
# the egress envs).
|
|
25
7
|
class StdioDisabled < Insika::Error; end
|
|
26
8
|
|
|
27
9
|
module_function
|
|
28
10
|
|
|
29
|
-
# `record` — the RAW McpStore record (get_raw/all_raw; never the masked
|
|
30
|
-
# one — a masked record's credentials are the __OCULTO__ sentinel, not
|
|
31
|
-
# something a client can connect with).
|
|
32
|
-
# -> RubyLLM::MCP::Client, not started. Raises StdioDisabled (stdio, gate
|
|
33
|
-
# off), Insika::Error (http/sse, egress-blocked url) or
|
|
34
|
-
# Insika::ValidationError (unknown transport).
|
|
35
11
|
def for(record, env_reader: Insika::EnvSchema, egress: Insika::EgressGuard)
|
|
36
|
-
require "ruby_llm
|
|
37
|
-
apply_origin_header_fix!
|
|
12
|
+
require "ruby_llm"
|
|
38
13
|
|
|
39
14
|
case record["transport"].to_s
|
|
40
15
|
when "stdio" then stdio_client(record, env_reader: env_reader)
|
|
41
|
-
when "http" then http_client(record, :
|
|
42
|
-
when "sse"
|
|
16
|
+
when "http" then http_client(record, egress: egress, env_reader: env_reader)
|
|
17
|
+
when "sse"
|
|
18
|
+
raise Insika::ValidationError,
|
|
19
|
+
"MCP instance '#{record['name']}' uses legacy SSE; configure its Streamable HTTP endpoint instead"
|
|
43
20
|
else
|
|
44
21
|
raise Insika::ValidationError,
|
|
45
|
-
"MCP instance '#{record[
|
|
22
|
+
"MCP instance '#{record['name']}' has an unknown transport: #{record['transport'].inspect}"
|
|
46
23
|
end
|
|
47
24
|
end
|
|
48
25
|
|
|
26
|
+
# The operator registers an MCP URL and the model cannot change it, so the
|
|
27
|
+
# host allowlist (which pins model-shaped data-tool requests) only pins
|
|
28
|
+
# private destinations: a public server needs no redeploy to be added.
|
|
29
|
+
def egress_options(env_reader)
|
|
30
|
+
hosts = env_reader.read("INSIKA_EGRESS_HOSTS").to_s.split(",").map(&:strip).reject(&:empty?)
|
|
31
|
+
{ allow_http: env_reader.truthy?(env_reader.read("INSIKA_EGRESS_ALLOW_HTTP")),
|
|
32
|
+
allow_private: env_reader.truthy?(env_reader.read("INSIKA_EGRESS_ALLOW_PRIVATE")),
|
|
33
|
+
host_allowlist: (hosts unless hosts.empty?), pin_private_only: true }.compact
|
|
34
|
+
end
|
|
35
|
+
|
|
49
36
|
def stdio_client(record, env_reader:)
|
|
50
37
|
unless env_reader.truthy?(env_reader.read("INSIKA_MCP_STDIO"))
|
|
51
38
|
raise StdioDisabled,
|
|
52
|
-
"MCP instance '#{record[
|
|
39
|
+
"MCP instance '#{record['name']}' is stdio (arbitrary command execution by config) — " \
|
|
53
40
|
"set INSIKA_MCP_STDIO=1 to allow it to start"
|
|
54
41
|
end
|
|
55
42
|
|
|
56
|
-
RubyLLM
|
|
57
|
-
|
|
58
|
-
config: { command: record["command"], args: Array(record["args"]), env: record["env"] || {} }
|
|
59
|
-
)
|
|
43
|
+
RubyLLM.mcp(name: record["name"], command: [record["command"], *Array(record["args"])],
|
|
44
|
+
env: record["env"] || {})
|
|
60
45
|
end
|
|
61
46
|
|
|
62
|
-
def http_client(record,
|
|
47
|
+
def http_client(record, egress:, env_reader:)
|
|
63
48
|
url = record["url"]
|
|
64
|
-
reason = egress.violation(url)
|
|
49
|
+
reason = egress.violation(url, **egress_options(env_reader))
|
|
65
50
|
raise Insika::Error, "MCP target blocked: #{reason}" if reason
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
name: record["name"], transport_type: transport_type, start: false,
|
|
69
|
-
config: { url: url, headers: record["headers"] || {} }
|
|
70
|
-
)
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
# https://github.com/patvice/ruby_llm-mcp/issues/140 (open, unfixed as of
|
|
74
|
-
# 1.0.1): StreamableHTTP#build_common_headers sets `Origin` to the MCP
|
|
75
|
-
# endpoint's OWN url (path included) on every request. That's invalid per
|
|
76
|
-
# RFC 6454 §7 (Origin is scheme+host[+port], never a path) and unneeded —
|
|
77
|
-
# a server-to-server client has no browser Origin to report, and the
|
|
78
|
-
# reference TS/Python SDKs never send one. No allowlist can ever match a
|
|
79
|
-
# value that carries a path, so any server enforcing the spec's
|
|
80
|
-
# DNS-rebinding Origin check (GitHub's remote MCP, Grafana, Metabase, ...)
|
|
81
|
-
# rejects every request. Same prepend the issue's own author ships in
|
|
82
|
-
# production. `Module#prepend` no-ops if the module is already in the
|
|
83
|
-
# ancestor chain, so calling this on every `for` is cheap and safe.
|
|
84
|
-
def apply_origin_header_fix!
|
|
85
|
-
RubyLLM::MCP::Native::Transports::StreamableHTTP.prepend(OriginHeaderFix)
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
module OriginHeaderFix
|
|
89
|
-
def build_common_headers
|
|
90
|
-
super.except("Origin")
|
|
51
|
+
unless RubyLLM::MCP::HTTP.secure?(url)
|
|
52
|
+
raise Insika::ValidationError, "Native MCP requires HTTPS without URL credentials, except on loopback hosts"
|
|
91
53
|
end
|
|
54
|
+
|
|
55
|
+
RubyLLM.mcp(name: record["name"], url: url, headers: record["headers"] || {})
|
|
92
56
|
end
|
|
93
57
|
end
|
|
94
58
|
end
|