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/mcp_live_tool.rb
CHANGED
|
@@ -1,43 +1,89 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "json"
|
|
3
4
|
require "ruby_llm"
|
|
4
5
|
|
|
5
6
|
module Insika
|
|
6
|
-
# One live MCP tool. `name`/`description`/`
|
|
7
|
+
# One live MCP tool. `name`/`description`/`parameters_schema`
|
|
7
8
|
# come from the CACHED descriptor (McpStore#tools_cache — no I/O to build
|
|
8
9
|
# this instance, same cost as any other tool the registry hands out).
|
|
9
10
|
# `#execute` is the only thing that touches the network, calling through
|
|
10
|
-
# `client_for` (Insika::McpToolRegistry's memoized,
|
|
11
|
-
#
|
|
12
|
-
# `RubyLLM::MCP::Tool#execute`, which already unwraps `content[].text` and
|
|
11
|
+
# `client_for` (Insika::McpToolRegistry's memoized, lazy, per-conversation client) into
|
|
12
|
+
# `RubyLLM::MCP::Tool#call`, which already unwraps `content[].text` and
|
|
13
13
|
# turns `isError` into `{error:}`.
|
|
14
14
|
#
|
|
15
15
|
# Like every tool in this codebase, `#execute` NEVER raises: a connection
|
|
16
16
|
# or protocol failure becomes `{error:}`, the model's normal failure path
|
|
17
17
|
# (see Tools::DataDefinedTool's own rule).
|
|
18
18
|
class McpLiveTool < RubyLLM::Tool
|
|
19
|
-
|
|
19
|
+
# `name:` is what the model calls (the registry's instance-qualified name);
|
|
20
|
+
# the server is still asked for its own tool name.
|
|
21
|
+
def initialize(instance_name:, tool:, client_for:, overrides: {}, name: tool["name"])
|
|
20
22
|
@instance_name = instance_name
|
|
21
23
|
@tool = tool
|
|
24
|
+
@name = name
|
|
22
25
|
@client_for = client_for
|
|
26
|
+
@evidence = overrides["evidence"] && Insika::Evidence::Spec.parse(overrides["evidence"])
|
|
27
|
+
@requires_evidence = Insika::ToolDefinition.normalize_requires_evidence(
|
|
28
|
+
overrides["requires_evidence"], top_level_params
|
|
29
|
+
)
|
|
23
30
|
super()
|
|
24
31
|
end
|
|
25
32
|
|
|
26
|
-
def name = @
|
|
33
|
+
def name = @name
|
|
27
34
|
def description = @tool["description"].to_s
|
|
28
35
|
|
|
29
|
-
|
|
36
|
+
# The two questions the envelope asks any tool: what of this result is
|
|
37
|
+
# evidence, and which of these parameters may only carry an id some tool
|
|
38
|
+
# already returned. A server cannot answer either — it does not know what the
|
|
39
|
+
# deployment trusts — so they come from the instance's own configuration and
|
|
40
|
+
# are nil for every tool nobody configured, which is what they were before.
|
|
41
|
+
attr_reader :evidence, :requires_evidence
|
|
42
|
+
|
|
43
|
+
def parameters_schema
|
|
30
44
|
schema = @tool["inputSchema"]
|
|
31
45
|
schema.nil? || schema.empty? ? { "type" => "object", "properties" => {} } : schema
|
|
32
46
|
end
|
|
33
47
|
|
|
48
|
+
# The conversation this instance serves, deposited by ToolAssembly. An MCP
|
|
49
|
+
# server keeps state per MCP session (a target store, a pending
|
|
50
|
+
# confirmation token), so each conversation gets its own client.
|
|
51
|
+
def turn_context=(ctx)
|
|
52
|
+
@session_id = ctx && (ctx[:session_id] || ctx["session_id"])
|
|
53
|
+
end
|
|
54
|
+
|
|
34
55
|
def execute(**params)
|
|
35
|
-
live = @client_for.call.tool
|
|
56
|
+
live = @client_for.call(@session_id).tools.find { |tool| tool.name == @tool["name"] }
|
|
36
57
|
raise Insika::NotFoundError, "tool '#{@tool["name"]}' no longer offered" if live.nil?
|
|
37
58
|
|
|
38
|
-
live.
|
|
59
|
+
result = live.call(**params)
|
|
60
|
+
# Hand the envelope what a chat would send the model (text, JSON of the
|
|
61
|
+
# structured content, or text plus attachments), not the gem's Result.
|
|
62
|
+
parsed(result.is_a?(RubyLLM::MCP::Result) ? result.content : result)
|
|
39
63
|
rescue StandardError => e
|
|
40
64
|
{ error: "MCP instance '#{@instance_name}' tool '#{@tool["name"]}' failed: #{e.message}" }
|
|
41
65
|
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
# An MCP result arrives as a String, text plus attachments (an Array), or
|
|
70
|
+
# {error:}. Only a tool someone declared evidence for is
|
|
71
|
+
# parsed into the object the extractor can dig into; everywhere else the result
|
|
72
|
+
# reaches the model exactly as it did before.
|
|
73
|
+
def parsed(result)
|
|
74
|
+
return result unless @evidence && result.is_a?(String)
|
|
75
|
+
|
|
76
|
+
object = begin
|
|
77
|
+
JSON.parse(result)
|
|
78
|
+
rescue JSON::ParserError
|
|
79
|
+
nil
|
|
80
|
+
end
|
|
81
|
+
object.is_a?(Hash) ? object : result
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def top_level_params
|
|
85
|
+
properties = parameters_schema["properties"] || parameters_schema[:properties] || {}
|
|
86
|
+
properties.keys.map(&:to_s)
|
|
87
|
+
end
|
|
42
88
|
end
|
|
43
89
|
end
|
data/lib/insika/mcp_store.rb
CHANGED
|
@@ -94,12 +94,43 @@ module Insika
|
|
|
94
94
|
"headers" => reconcile_hash(h[:headers], existing && existing["headers"]),
|
|
95
95
|
# editing an instance's connection details doesn't change its discovered
|
|
96
96
|
# tools -> preserved across upsert; only set_tools_cache writes it.
|
|
97
|
-
"tools_cache" => (existing && existing["tools_cache"]) || []
|
|
97
|
+
"tools_cache" => (existing && existing["tools_cache"]) || [],
|
|
98
|
+
# WHAT EACH TOOL MEANS to the engine: which of its results are evidence,
|
|
99
|
+
# and which of its parameters may only carry an id some tool returned.
|
|
100
|
+
# A server describes what its tools DO; only the deployment can say what
|
|
101
|
+
# they are worth trusting with. An edit that does not mention them keeps
|
|
102
|
+
# what is there — the same rule `env` and `headers` follow.
|
|
103
|
+
"tool_overrides" => (h.key?(:tools) ? tool_overrides(h[:tools]) : nil) ||
|
|
104
|
+
(existing && existing["tool_overrides"]) || {}
|
|
98
105
|
}
|
|
99
106
|
@cs.put(SCOPE, name, record)
|
|
100
107
|
mask(record)
|
|
101
108
|
end
|
|
102
109
|
|
|
110
|
+
# { "add_to_cart" => { "requires_evidence" => ["product_id"] },
|
|
111
|
+
# "search_products" => { "evidence" => { "kind" => "products", … } } }
|
|
112
|
+
#
|
|
113
|
+
# Refused HERE, never at the turn: an `evidence:` typo that survived to turn
|
|
114
|
+
# time would extract nothing and the gate below it would block every write.
|
|
115
|
+
def tool_overrides(raw)
|
|
116
|
+
return nil if raw.nil?
|
|
117
|
+
raise Insika::ValidationError, "mcp tools must be a mapping of tool name -> overrides" unless raw.is_a?(Hash)
|
|
118
|
+
|
|
119
|
+
Coercion.deep_stringify(raw).to_h do |tool, over|
|
|
120
|
+
raise Insika::ValidationError, "mcp tools['#{tool}'] must be a mapping" unless over.is_a?(Hash)
|
|
121
|
+
|
|
122
|
+
unknown = over.keys - %w[evidence requires_evidence]
|
|
123
|
+
unless unknown.empty?
|
|
124
|
+
raise Insika::ValidationError,
|
|
125
|
+
"mcp tools['#{tool}']: unknown key(s) #{unknown.join(", ")} — known: evidence, requires_evidence"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
params = Array(over["requires_evidence"]).map(&:to_s).reject(&:empty?)
|
|
129
|
+
[tool.to_s, { "evidence" => over["evidence"] && Insika::Evidence::Spec.parse(over["evidence"]).to_h,
|
|
130
|
+
"requires_evidence" => (params unless params.empty?) }.compact]
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
103
134
|
# -> bool (did it exist?).
|
|
104
135
|
def delete(name) = @cs.delete(SCOPE, name.to_s)
|
|
105
136
|
|
|
@@ -11,14 +11,30 @@ module Insika
|
|
|
11
11
|
# `refresh` is the ONLY thing that talks to a server ahead of time: it
|
|
12
12
|
# connects, lists live, and writes the result back to tools_cache (display/
|
|
13
13
|
# doctor). Calling a tool never depends on that cache — Insika::McpLiveTool
|
|
14
|
-
# always goes through the live, memoized client (and RubyLLM::MCP
|
|
14
|
+
# always goes through the live, memoized client (and RubyLLM::MCP
|
|
15
15
|
# does its own real `tools/list` on ITS first use per instance, regardless
|
|
16
16
|
# of whether `refresh` ever ran).
|
|
17
17
|
class McpToolRegistry
|
|
18
|
-
|
|
18
|
+
# Providers reject a tool name longer than this.
|
|
19
|
+
MAX_TOOL_NAME = 64
|
|
20
|
+
|
|
21
|
+
# The name the model and the agent's allowlist see: the instance, then the
|
|
22
|
+
# server's tool. Two servers offering the same tool (a store's prod and
|
|
23
|
+
# staging) stay two tools, each reaching its own instance.
|
|
24
|
+
def self.tool_name(instance, tool)
|
|
25
|
+
"#{instance.to_s.downcase.gsub(/[^a-z0-9]+/, '_')}__#{tool}"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Live clients kept per process. Past this, the least recently used one is
|
|
29
|
+
# closed; its conversation reconnects on its next call, as a NEW MCP session.
|
|
30
|
+
# ponytail: count cap, not idle TTL — add a TTL if idle sockets pile up.
|
|
31
|
+
MAX_CLIENTS = 200
|
|
32
|
+
|
|
33
|
+
def initialize(mcp_store:, client_factory: Insika::McpClient.method(:for), max_clients: MAX_CLIENTS)
|
|
19
34
|
@mcp_store = mcp_store
|
|
20
35
|
@client_factory = client_factory
|
|
21
|
-
@
|
|
36
|
+
@max_clients = max_clients
|
|
37
|
+
@clients = {} # [instance, conversation] -> client, oldest use first
|
|
22
38
|
@mutex = Mutex.new
|
|
23
39
|
end
|
|
24
40
|
|
|
@@ -29,7 +45,7 @@ module Insika
|
|
|
29
45
|
|
|
30
46
|
# Connects to `name` LIVE, lists its tools, and writes McpStore#tools_cache.
|
|
31
47
|
# ALWAYS drops any memoized client first and rebuilds from the current
|
|
32
|
-
# record: a stdio process can be
|
|
48
|
+
# record: a stdio process can still be running yet permanently
|
|
33
49
|
# broken (e.g. wrong transport args — see grafana-stg gotcha), and an
|
|
34
50
|
# edited command/url/env must take effect without a process restart —
|
|
35
51
|
# there's no SSH into a Railway dyno to do that by hand.
|
|
@@ -43,7 +59,10 @@ module Insika
|
|
|
43
59
|
|
|
44
60
|
evict(record["name"])
|
|
45
61
|
client = client_for(record)
|
|
46
|
-
discovered = client.tools.map
|
|
62
|
+
discovered = client.tools.map do |tool|
|
|
63
|
+
{ "name" => tool.name, "description" => tool.description, "inputSchema" => tool.parameters_schema,
|
|
64
|
+
"annotations" => { "readOnlyHint" => tool.read_only? } }
|
|
65
|
+
end
|
|
47
66
|
@mcp_store.set_tools_cache(name, discovered)
|
|
48
67
|
discovered
|
|
49
68
|
end
|
|
@@ -55,24 +74,30 @@ module Insika
|
|
|
55
74
|
# NEXT tool call or refresh, no process restart required (there's no way
|
|
56
75
|
# to restart a process by hand on a Railway dyno).
|
|
57
76
|
def evict(name)
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
77
|
+
gone = @mutex.synchronize do
|
|
78
|
+
keys = @clients.keys.select { |instance, _| instance == name.to_s }
|
|
79
|
+
keys.map { |key| @clients.delete(key) }
|
|
80
|
+
end
|
|
81
|
+
gone.each { |client| close_quietly(client) }
|
|
62
82
|
end
|
|
63
83
|
|
|
64
84
|
private
|
|
65
85
|
|
|
66
86
|
def entries_for(record)
|
|
67
|
-
Array(record["tools_cache"]).
|
|
87
|
+
Array(record["tools_cache"]).filter_map do |tool|
|
|
88
|
+
name = self.class.tool_name(record["name"], tool["name"])
|
|
89
|
+
next entry_for(record, tool, name) if name.length <= MAX_TOOL_NAME
|
|
90
|
+
|
|
91
|
+
warn "[mcp] skipping '#{name}': longer than #{MAX_TOOL_NAME} characters; shorten the instance name"
|
|
92
|
+
end
|
|
68
93
|
end
|
|
69
94
|
|
|
70
|
-
def entry_for(record, tool)
|
|
95
|
+
def entry_for(record, tool, name)
|
|
71
96
|
instance = record["name"]
|
|
72
97
|
Insika::Registry::Entry.new(
|
|
73
|
-
name:
|
|
98
|
+
name: name, plugin: "mcp:#{instance}",
|
|
74
99
|
metadata: { optional: false, side_effect: !read_only?(tool), group: "mcp:#{instance}", tags: [] },
|
|
75
|
-
factory: -> { build_tool(record, tool) }
|
|
100
|
+
factory: -> { build_tool(record, tool, name) }
|
|
76
101
|
)
|
|
77
102
|
end
|
|
78
103
|
|
|
@@ -80,28 +105,46 @@ module Insika
|
|
|
80
105
|
# (`annotations.readOnlyHint`): a write is never re-run on resume and runs
|
|
81
106
|
# serially within the session; a declared read keeps `tool_concurrency`.
|
|
82
107
|
def read_only?(tool)
|
|
83
|
-
|
|
108
|
+
tool.dig("annotations", "readOnlyHint") == true
|
|
84
109
|
end
|
|
85
110
|
|
|
86
111
|
# Lazy require (McpLiveTool < RubyLLM::Tool pulls in ruby_llm) — kept out
|
|
87
112
|
# of insika.rb load-time, loaded on the 1st instance (turn time), same
|
|
88
113
|
# discipline as OverlayToolRegistry#build_tool for data-tools.
|
|
89
|
-
def build_tool(record, tool)
|
|
114
|
+
def build_tool(record, tool, name)
|
|
90
115
|
require_relative "mcp_live_tool"
|
|
91
|
-
Insika::McpLiveTool.new(instance_name: record["name"], tool: tool,
|
|
116
|
+
Insika::McpLiveTool.new(instance_name: record["name"], tool: tool, name: name,
|
|
117
|
+
overrides: record.dig("tool_overrides", tool["name"]) || {},
|
|
118
|
+
client_for: ->(session_id = nil) { client_for(record, session_id) })
|
|
92
119
|
end
|
|
93
120
|
|
|
94
|
-
# A
|
|
95
|
-
#
|
|
96
|
-
#
|
|
97
|
-
#
|
|
98
|
-
#
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
121
|
+
# A lazy, MEMOIZED client for `record` — one per (instance, conversation),
|
|
122
|
+
# reused across that conversation's turns. The server ties its state to the
|
|
123
|
+
# MCP session (Mcp-Session-Id): a client shared by every conversation leaked
|
|
124
|
+
# one chat's target store and confirmation tokens into another. A stdio
|
|
125
|
+
# instance stays one shared process — a process per chat is too costly.
|
|
126
|
+
# Raises on a gated/unreachable instance; only called from `refresh` and
|
|
127
|
+
# from a running McpLiveTool's `#execute` (which rescues) — never from
|
|
128
|
+
# `entries`/`build_tool`, so a downed server never breaks turn ASSEMBLY,
|
|
129
|
+
# only that tool's own call.
|
|
130
|
+
def client_for(record, session_id = nil)
|
|
131
|
+
session_id = nil if record["transport"].to_s == "stdio"
|
|
132
|
+
key = [record["name"], session_id&.to_s]
|
|
133
|
+
stale = nil
|
|
134
|
+
client = @mutex.synchronize do
|
|
135
|
+
found = @clients.delete(key) || @client_factory.call(record)
|
|
136
|
+
@clients[key] = found # re-insert: most recently used goes last
|
|
137
|
+
stale = @clients.shift.last if @clients.size > @max_clients
|
|
138
|
+
found
|
|
104
139
|
end
|
|
140
|
+
close_quietly(stale) if stale
|
|
141
|
+
client
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def close_quietly(client)
|
|
145
|
+
client.close
|
|
146
|
+
rescue StandardError
|
|
147
|
+
nil # best-effort teardown of a possibly already-dead process
|
|
105
148
|
end
|
|
106
149
|
end
|
|
107
150
|
end
|
data/lib/insika/media.rb
CHANGED
|
@@ -93,9 +93,9 @@ module Insika
|
|
|
93
93
|
# when the turn first carries audio. `stt_prompt` is the Whisper-family
|
|
94
94
|
# vocabulary hint (product names, brand terms) — OPERATOR config
|
|
95
95
|
# (agent profile / deployment env), never customer input.
|
|
96
|
-
def self.default_transcriber(stt_model:, stt_language: nil, stt_prompt: nil)
|
|
96
|
+
def self.default_transcriber(stt_model:, stt_language: nil, stt_prompt: nil, context: nil)
|
|
97
97
|
lambda do |url|
|
|
98
|
-
fetch_and_transcribe(url, model: stt_model, language: stt_language, prompt: stt_prompt)
|
|
98
|
+
fetch_and_transcribe(url, model: stt_model, language: stt_language, prompt: stt_prompt, context: context)
|
|
99
99
|
end
|
|
100
100
|
end
|
|
101
101
|
|
|
@@ -110,7 +110,7 @@ module Insika
|
|
|
110
110
|
# RubyLLM raises ArgumentError when it's true without an explicit
|
|
111
111
|
# `provider` (see ModelSelection#assume_model_exists?), and `stt_model` here
|
|
112
112
|
# is a bare ref like `utility_model` elsewhere — the registry resolves it.
|
|
113
|
-
def self.fetch_and_transcribe(url, model:, language:, prompt: nil)
|
|
113
|
+
def self.fetch_and_transcribe(url, model:, language:, prompt: nil, context: nil)
|
|
114
114
|
require "net/http"
|
|
115
115
|
require "uri"
|
|
116
116
|
require "ruby_llm" # lazy — the core loads without it (load-guard)
|
|
@@ -120,6 +120,7 @@ module Insika
|
|
|
120
120
|
options = { model: model }
|
|
121
121
|
options[:language] = language if language
|
|
122
122
|
options[:prompt] = prompt if prompt
|
|
123
|
+
options[:context] = context if context
|
|
123
124
|
Tempfile.create(["insika-media-", File.extname(filename_for(url).to_s)]) do |file|
|
|
124
125
|
file.binmode
|
|
125
126
|
file.write(bytes)
|
|
@@ -128,6 +129,23 @@ module Insika
|
|
|
128
129
|
end
|
|
129
130
|
end
|
|
130
131
|
|
|
132
|
+
# Durable attachment bytes from a checkpoint; hydrate only at the gem boundary.
|
|
133
|
+
def self.hydrate_attachment(value)
|
|
134
|
+
return value unless value.is_a?(Hash)
|
|
135
|
+
require 'base64'
|
|
136
|
+
require 'stringio'
|
|
137
|
+
require 'ruby_llm'
|
|
138
|
+
unless (value.keys - %w[base64 filename]).empty? && value['base64'].is_a?(String) && value['filename'].is_a?(String)
|
|
139
|
+
raise Insika::MediaError, 'invalid stored attachment'
|
|
140
|
+
end
|
|
141
|
+
raise Insika::MediaError, 'stored attachment too large' if value['base64'].bytesize > 14*1024*1024
|
|
142
|
+
bytes = Base64.strict_decode64(value['base64'])
|
|
143
|
+
raise Insika::MediaError, 'stored attachment too large' if bytes.bytesize > MAX_DOCUMENT_BYTES
|
|
144
|
+
RubyLLM::Attachment.new(StringIO.new(bytes), filename: value['filename'])
|
|
145
|
+
rescue ArgumentError
|
|
146
|
+
raise Insika::MediaError, 'invalid stored attachment'
|
|
147
|
+
end
|
|
148
|
+
|
|
131
149
|
# An inbound URL -> a RubyLLM::Attachment over bytes WE fetched (egress-
|
|
132
150
|
# guarded, size-capped — the `media_attachment` recipe). Shared by the
|
|
133
151
|
# Executor (inbound image/document parts) and `Output.generate_image`
|
|
@@ -201,8 +219,7 @@ module Insika
|
|
|
201
219
|
# behind lazy requires:
|
|
202
220
|
# · image — RubyLLM.paint (the gem has vision AND painting), billed
|
|
203
221
|
# tokens merged into the turn's usage like any ask;
|
|
204
|
-
# · tts —
|
|
205
|
-
# is a thin POST to the OpenAI-compatible `<base>/audio/speech`
|
|
222
|
+
# · tts — a capped POST to the OpenAI-compatible `<base>/audio/speech`
|
|
206
223
|
# endpoint (base + key from the provider config the chat uses — a
|
|
207
224
|
# deployment pointing OpenAI at a gateway keeps TTS pointing there).
|
|
208
225
|
# OpenAI's speech API reports no token usage; the part carries the
|
|
@@ -256,8 +273,8 @@ module Insika
|
|
|
256
273
|
|
|
257
274
|
enforce_embedded_size!(data, "generated image")
|
|
258
275
|
mime = image.respond_to?(:mime_type) ? image.mime_type : nil
|
|
259
|
-
model_id = image.
|
|
260
|
-
usage = image.
|
|
276
|
+
model_id = image.model
|
|
277
|
+
usage = { input_tokens: image.tokens.input, output_tokens: image.tokens.output }.compact
|
|
261
278
|
part = { "type" => "image", "mime_type" => presence(mime) || "image/png",
|
|
262
279
|
"base64" => data, "model" => presence(model_id) }
|
|
263
280
|
[part.compact, usage]
|
|
@@ -292,20 +309,24 @@ module Insika
|
|
|
292
309
|
req.body = JSON.generate(model: model, voice: voice, input: text.to_s,
|
|
293
310
|
response_format: format)
|
|
294
311
|
opts = { use_ssl: uri.scheme == "https", open_timeout: 30, read_timeout: 60 }
|
|
312
|
+
# RubyLLM 2.0 buffers non-audio/error responses without yielding chunks.
|
|
313
|
+
# Keep this bounded transport until native speech caps every response.
|
|
295
314
|
bytes = Net::HTTP.start(uri.host, uri.port, opts) do |http|
|
|
296
|
-
resp = http.request(req)
|
|
297
|
-
raise Insika::MediaError, "TTS HTTP #{resp.code}" unless resp.is_a?(Net::HTTPSuccess)
|
|
298
|
-
|
|
299
|
-
# stream into the cap — a rogue/broken endpoint must not grow the
|
|
300
|
-
# process past MAX_EMBEDDED_BYTES before the refusal.
|
|
301
315
|
buf = +"".b
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
316
|
+
http.request(req) do |resp|
|
|
317
|
+
raise Insika::MediaError, "TTS HTTP #{resp.code}" unless resp.is_a?(Net::HTTPSuccess)
|
|
318
|
+
|
|
319
|
+
resp.read_body do |chunk|
|
|
320
|
+
size = buf.bytesize + chunk.bytesize
|
|
321
|
+
if size > MAX_EMBEDDED_BYTES
|
|
322
|
+
raise Insika::MediaError,
|
|
323
|
+
"synthesized speech too large to embed (#{size} bytes > #{MAX_EMBEDDED_BYTES})"
|
|
324
|
+
end
|
|
325
|
+
buf << chunk
|
|
326
|
+
end
|
|
305
327
|
end
|
|
306
328
|
buf
|
|
307
329
|
end
|
|
308
|
-
enforce_embedded_size!(bytes, "synthesized speech")
|
|
309
330
|
part = { "type" => "audio", "mime_type" => mime_for(format), "base64" => Base64.strict_encode64(bytes), "model" => model }
|
|
310
331
|
[part.compact, {}]
|
|
311
332
|
rescue URI::InvalidURIError
|
|
@@ -352,14 +373,6 @@ module Insika
|
|
|
352
373
|
Insika::Media.url_attachment(url, max_bytes: Insika::Media::MAX_IMAGE_BYTES)
|
|
353
374
|
end
|
|
354
375
|
|
|
355
|
-
def token_usage(raw)
|
|
356
|
-
usage = raw.is_a?(Hash) ? raw : {}
|
|
357
|
-
{
|
|
358
|
-
input_tokens: usage[:input_tokens] || usage["input_tokens"] || usage["prompt_tokens"],
|
|
359
|
-
output_tokens: usage[:output_tokens] || usage["output_tokens"] || usage["completion_tokens"]
|
|
360
|
-
}.compact
|
|
361
|
-
end
|
|
362
|
-
|
|
363
376
|
def mime_for(format)
|
|
364
377
|
{ "mp3" => "audio/mpeg", "opus" => "audio/opus", "aac" => "audio/aac",
|
|
365
378
|
"wav" => "audio/wav", "flac" => "audio/flac" }[format.to_s] || "audio/mpeg"
|
|
@@ -379,4 +392,4 @@ module Insika
|
|
|
379
392
|
end
|
|
380
393
|
end
|
|
381
394
|
end
|
|
382
|
-
end
|
|
395
|
+
end
|
data/lib/insika/memory_store.rb
CHANGED
|
@@ -155,9 +155,10 @@ module Insika
|
|
|
155
155
|
# "memory:chat:<tenant>:<id>"). The scope enumeration is the Store's — no
|
|
156
156
|
# session-derived list, so a customer cell whose session was already
|
|
157
157
|
# deleted is still purged. ONE transaction for the whole tenant (the repo
|
|
158
|
-
# rule): the
|
|
159
|
-
#
|
|
160
|
-
#
|
|
158
|
+
# rule): the purge commits or fails whole. On SQLite, whose transactions
|
|
159
|
+
# run one at a time, a cell cannot be written mid-purge at all; on a backend
|
|
160
|
+
# with concurrent writers a cell written while the purge runs lands after it
|
|
161
|
+
# (a later write, which a later purge removes). -> count of records purged.
|
|
161
162
|
def purge_tenant(tenant)
|
|
162
163
|
cell = scope_for(tenant)
|
|
163
164
|
@store.transaction do
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
# Hands freed memory back to the OS once a worker goes quiet. After a burst, glibc
|
|
5
|
+
# keeps what the turns freed inside its malloc arenas and the process stays at its
|
|
6
|
+
# peak until something calls malloc_trim — a full GC alone returns nothing. On a
|
|
7
|
+
# quiet pass (no turn in flight, enough allocated since the last trim) this runs a
|
|
8
|
+
# full GC and then malloc_trim(0).
|
|
9
|
+
#
|
|
10
|
+
# A no-op where malloc_trim does not exist (macOS, musl, a jemalloc LD_PRELOAD
|
|
11
|
+
# returns 0) or where `fiddle` is not in the bundle: it is not a gem dependency,
|
|
12
|
+
# the reference deployment adds it to its Gemfile.
|
|
13
|
+
class MemoryTrim
|
|
14
|
+
INTERVAL = 60 # seconds between checks
|
|
15
|
+
MIN_ALLOCATED = 1_000_000 # objects allocated since the last trim; less is not worth a full GC
|
|
16
|
+
|
|
17
|
+
def self.malloc_trim
|
|
18
|
+
require "fiddle"
|
|
19
|
+
fn = Fiddle::Function.new(Fiddle::Handle::DEFAULT["malloc_trim"], [Fiddle::TYPE_SIZE_T], Fiddle::TYPE_INT)
|
|
20
|
+
-> { fn.call(0) }
|
|
21
|
+
rescue LoadError, StandardError # no fiddle, or no malloc_trim symbol (Fiddle::DLError)
|
|
22
|
+
nil
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def initialize(executor:, trim: self.class.malloc_trim, allocated: -> { GC.stat(:total_allocated_objects) })
|
|
26
|
+
@executor = executor
|
|
27
|
+
@trim = trim
|
|
28
|
+
@allocated = allocated
|
|
29
|
+
@last = @allocated.call
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def enabled? = !@trim.nil?
|
|
33
|
+
|
|
34
|
+
# One check. -> true when it trimmed.
|
|
35
|
+
def run_once
|
|
36
|
+
return false unless @executor.in_flight.empty?
|
|
37
|
+
return false if @allocated.call - @last < MIN_ALLOCATED
|
|
38
|
+
|
|
39
|
+
GC.start
|
|
40
|
+
@trim.call
|
|
41
|
+
@last = @allocated.call
|
|
42
|
+
true
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def start(parent:, interval: INTERVAL)
|
|
46
|
+
return false unless enabled?
|
|
47
|
+
|
|
48
|
+
parent.async do |t|
|
|
49
|
+
t.annotate("insika-memory-trim")
|
|
50
|
+
loop do
|
|
51
|
+
sleep(interval)
|
|
52
|
+
run_once
|
|
53
|
+
rescue StandardError => e
|
|
54
|
+
warn "[memory_trim] #{e.class}: #{e.message}"
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
true
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
data/lib/insika/middleware.rb
CHANGED
|
@@ -35,11 +35,20 @@ module Insika
|
|
|
35
35
|
self
|
|
36
36
|
end
|
|
37
37
|
|
|
38
|
-
|
|
39
|
-
|
|
38
|
+
# Paid retrieval prepares context inside the leading edge limiter. Other
|
|
39
|
+
# middleware still receives the completed context and policy resolution.
|
|
40
|
+
def call(state, prepare: nil, &terminal)
|
|
41
|
+
edge = @middlewares.first if prepare && @middlewares.first.is_a?(EdgeLimiter)
|
|
42
|
+
links = edge ? @middlewares.drop(1) : @middlewares
|
|
43
|
+
chain = links.reverse.reduce(terminal) do |nxt, mw|
|
|
40
44
|
proc { |s| mw.call(s, &nxt) }
|
|
41
45
|
end
|
|
42
|
-
|
|
46
|
+
if edge
|
|
47
|
+
edge.call(state, prepare: prepare, &chain)
|
|
48
|
+
else
|
|
49
|
+
prepare&.call(state)
|
|
50
|
+
chain.call(state)
|
|
51
|
+
end
|
|
43
52
|
end
|
|
44
53
|
end
|
|
45
54
|
end
|