insika 0.8.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 +312 -2
- data/README.md +10 -6
- data/bin/insika +4 -1
- data/bin/insika-router +0 -1
- data/docs/AGENTS.md +142 -18
- data/docs/API.md +73 -0
- data/docs/ARCHITECTURE.md +56 -50
- data/docs/ARTIFACTS.md +33 -3
- data/docs/BENCHMARK.md +50 -7
- data/docs/CHANNELS.md +19 -2
- data/docs/CONTEXT.md +96 -27
- data/docs/CROSS-HARNESS-BENCH.md +157 -0
- data/docs/DEPLOY.md +70 -13
- data/docs/EMBEDDING.md +10 -0
- data/docs/EVALS.md +129 -10
- data/docs/FACTS.md +12 -6
- data/docs/HARVEST.md +8 -3
- data/docs/KNOWLEDGE.md +14 -0
- data/docs/MEDIA.md +6 -2
- data/docs/OBSERVABILITY.md +164 -28
- data/docs/PERFORMANCE.md +110 -0
- data/docs/PLUGINS.md +4 -4
- data/docs/POLICY.md +26 -14
- data/docs/RELEASING.md +9 -0
- data/docs/ROUTER.md +7 -0
- data/docs/RUBYLLM_2_MIGRATION.md +307 -0
- data/docs/SECURITY.md +34 -1
- data/docs/SHARED-CONVERSATIONS.md +40 -0
- data/docs/SKILLS.md +5 -3
- data/docs/STUDIO.md +122 -0
- data/docs/TOOLS.md +238 -42
- data/docs/operate.md +3 -0
- data/docs/prompts/ADD-TOOL.md +12 -2
- data/docs/prompts/DIAGNOSE-TURN.md +3 -0
- data/docs/prompts/GO-LIVE.md +3 -1
- data/lib/insika/agent_defaults.rb +140 -0
- data/lib/insika/agent_file_store.rb +12 -0
- data/lib/insika/agent_profile.rb +99 -17
- data/lib/insika/artifact_signing.rb +11 -0
- data/lib/insika/channels/web/widget.js +33 -0
- data/lib/insika/channels/web.rb +5 -2
- data/lib/insika/chat_builder.rb +124 -28
- 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_distillation.rb +5 -8
- data/lib/insika/commands/run_harvest.rb +17 -1
- data/lib/insika/commands/seed_session.rb +128 -0
- 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/builder.rb +29 -9
- data/lib/insika/context/priority.rb +5 -0
- data/lib/insika/context/provider.rb +7 -2
- data/lib/insika/context/providers/briefing.rb +11 -8
- data/lib/insika/context/providers/fence_notice.rb +27 -0
- data/lib/insika/context/providers/knowledge.rb +32 -6
- data/lib/insika/context/providers/memory.rb +61 -8
- 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 +46 -6
- data/lib/insika/context/reranker.rb +75 -0
- data/lib/insika/distill.rb +22 -13
- data/lib/insika/doctor.rb +142 -4
- data/lib/insika/dsl/runtime.rb +37 -4
- data/lib/insika/dsl/server_boot.rb +4 -12
- data/lib/insika/dsl.rb +19 -5
- data/lib/insika/edge_limiter.rb +27 -16
- data/lib/insika/egress_guard.rb +11 -3
- data/lib/insika/env_schema.rb +33 -73
- data/lib/insika/errors.rb +1 -0
- data/lib/insika/evals/assertions.rb +259 -18
- data/lib/insika/evals/golden.rb +230 -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 +64 -4
- data/lib/insika/evals/simulator.rb +21 -3
- data/lib/insika/evals/transport.rb +317 -15
- data/lib/insika/event_stream.rb +63 -7
- data/lib/insika/evidence.rb +105 -17
- data/lib/insika/executor.rb +698 -164
- data/lib/insika/fence.rb +96 -0
- data/lib/insika/golden_store.rb +3 -0
- 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 +37 -3
- data/lib/insika/mcp_tool_registry.rb +76 -26
- data/lib/insika/media.rb +38 -25
- data/lib/insika/memory_store.rb +16 -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 +30 -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/prefix_fingerprint.rb +32 -27
- data/lib/insika/profile_source.rb +5 -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 +58 -7
- data/lib/insika/server/rack_app.rb +2 -0
- data/lib/insika/server/responses.rb +50 -5
- data/lib/insika/session_actor.rb +58 -28
- data/lib/insika/session_store.rb +163 -11
- data/lib/insika/settings_store.rb +57 -2
- 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/spoken_transcript.rb +31 -0
- 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 +1459 -163
- 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 +189 -12
- 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 +83 -243
- 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} +38 -11
- 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 +153 -118
- 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 +115 -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_assembly.rb +21 -13
- data/lib/insika/tool_batch.rb +1 -1
- data/lib/insika/tool_catalog.rb +12 -2
- data/lib/insika/tool_definition.rb +82 -13
- data/lib/insika/tool_envelope.rb +266 -29
- data/lib/insika/tool_manifest.rb +1 -0
- data/lib/insika/tool_store.rb +12 -4
- data/lib/insika/tool_trace_store.rb +15 -8
- data/lib/insika/tool_usage_report.rb +14 -4
- 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 +24 -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 +115 -0
- data/lib/insika/tools/remember.rb +7 -2
- data/lib/insika/tools/run_persona_eval.rb +9 -6
- 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 +11 -7
- 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 +31 -1
- data/lib/insika/turn_timing.rb +51 -14
- data/lib/insika/version.rb +1 -1
- data/lib/insika/wiring/graph.rb +88 -16
- data/lib/insika/wiring/graph_chat.rb +4 -0
- data/lib/insika/workflow.rb +25 -64
- data/lib/insika.rb +23 -3
- metadata +74 -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
|
@@ -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
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
# Content-free request summaries; independent of the bounded diagnostic trace.
|
|
7
|
+
class ModelMetricsStore
|
|
8
|
+
SCOPE = "model_metrics"
|
|
9
|
+
# One row per completed turn (TurnTiming#metrics + agent/model/provider),
|
|
10
|
+
# keyed by task id; TaskStore#delete drops it with the task.
|
|
11
|
+
TURN_SCOPE = "turn_metrics"
|
|
12
|
+
# One row per closed UTC day the home charts: cost and requests, keyed "agent|YYYY-MM-DD"
|
|
13
|
+
# ("*" = every agent). See #home.
|
|
14
|
+
DAY_SCOPE = "model_metrics_day"
|
|
15
|
+
TURN_WINDOWS = %w[ttft_ms total_ms first_balloon_ms queue_ms prep_ms tools_ms].freeze
|
|
16
|
+
PERIODS = { "24h" => 86_400, "7d" => 7 * 86_400, "30d" => 30 * 86_400 }.freeze
|
|
17
|
+
VALUES = %w[cost input_tokens output_tokens cache_read_tokens cache_write_tokens thinking_tokens].freeze
|
|
18
|
+
FIELDS = %w[type request_id at operation agent provider model status duration_ms turn].freeze + VALUES
|
|
19
|
+
|
|
20
|
+
def initialize(store:)
|
|
21
|
+
@store = store
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.task_prefix(task_id) = "#{task_id.to_s.bytesize}:#{task_id}:"
|
|
25
|
+
|
|
26
|
+
def record(task_id:, entry:) = record_many(task_id: task_id, entries: [entry])
|
|
27
|
+
|
|
28
|
+
# A turn's events in one transaction: each request's row is read and written
|
|
29
|
+
# once, however many events (request, usage, retries) it got.
|
|
30
|
+
def record_many(task_id:, entries:)
|
|
31
|
+
rows = entries.filter_map do |entry|
|
|
32
|
+
data = LLMTraceStore.sanitize(entry.slice(*FIELDS))
|
|
33
|
+
next unless %w[llm_request llm_usage].include?(data["type"])
|
|
34
|
+
next if data["request_id"].nil? || data["request_id"].empty?
|
|
35
|
+
|
|
36
|
+
data
|
|
37
|
+
end
|
|
38
|
+
return if rows.empty?
|
|
39
|
+
|
|
40
|
+
@store.transaction do
|
|
41
|
+
next unless @store.get(TaskStore::SCOPE, "#{TaskStore::KEY_PREFIX}#{task_id}")
|
|
42
|
+
|
|
43
|
+
rows.group_by { |data| data["request_id"] }.each do |request_id, events|
|
|
44
|
+
key = self.class.task_prefix(task_id) + request_id
|
|
45
|
+
row = @store.get(SCOPE, key) || {
|
|
46
|
+
"task_id" => task_id.to_s, "request_id" => request_id,
|
|
47
|
+
"attempts" => 0, "failed_attempts" => 0, "reported" => {}
|
|
48
|
+
}
|
|
49
|
+
events.each { |data| fold(row, data) }
|
|
50
|
+
@store.set(SCOPE, key, row)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
rescue StandardError
|
|
54
|
+
nil
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def record_turn(task_id:, row:)
|
|
58
|
+
@store.set(TURN_SCOPE, task_id.to_s, row)
|
|
59
|
+
rescue StandardError
|
|
60
|
+
nil
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# One agent's (or everyone's) totals and turn summary for each fixed period,
|
|
64
|
+
# from a single read of the 30-day rows. -> { "24h" => { "totals", "turns" }, ... }
|
|
65
|
+
def windows(agent: nil, now: Time.now.utc)
|
|
66
|
+
from = now - PERIODS.values.max
|
|
67
|
+
rows = request_rows(from, now).select { |row| agent.nil? || row["agent"] == agent }
|
|
68
|
+
turns = turn_rows(from, now, agent: agent, provider: nil, model: nil)
|
|
69
|
+
PERIODS.to_h do |period, seconds|
|
|
70
|
+
cut = now - seconds
|
|
71
|
+
[period, { "totals" => summarize(rows.select { |row| Time.iso8601(row["completed_at"]) >= cut }),
|
|
72
|
+
"turns" => turn_summary(turns.select { |row| Time.iso8601(row["at"]) >= cut }) }]
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Everything the Studio home shows: the last 24h (totals + turns), the cost
|
|
77
|
+
# of the 24h before, and one bucket per UTC day (oldest first, today last).
|
|
78
|
+
# Rows are read for the last 48h only. A day that ended before that never
|
|
79
|
+
# changes again, so its cost and request count are kept in DAY_SCOPE the first
|
|
80
|
+
# time a home needs it: 14 days of rows, scattered across a big file, cost
|
|
81
|
+
# seconds on a cold disk.
|
|
82
|
+
# ponytail: day rows are never pruned (one per day per agent); a task deleted
|
|
83
|
+
# later still counts in a kept day.
|
|
84
|
+
def home(agent: nil, now: Time.now.utc, days: 14)
|
|
85
|
+
today = Time.utc(now.year, now.month, now.day)
|
|
86
|
+
day_ago = now - 86_400
|
|
87
|
+
recent_from = day_ago - 86_400
|
|
88
|
+
day_starts = Array.new(days) { |i| today - (days - 1 - i) * 86_400 }
|
|
89
|
+
tag = "#{agent || '*'}|"
|
|
90
|
+
kept = @store.entries(DAY_SCOPE, tag).to_h { |key, value| [key.delete_prefix(tag), value] }
|
|
91
|
+
missing = day_starts.select { |day| day + 86_400 <= recent_from && !kept.key?(day.strftime("%F")) }
|
|
92
|
+
timed = request_rows([recent_from, *missing].min, now)
|
|
93
|
+
.select { |row| agent.nil? || row["agent"] == agent }
|
|
94
|
+
.map { |row| [row, Time.iso8601(row["completed_at"])] }
|
|
95
|
+
by_day = timed.group_by { |_, t| t.strftime("%F") }.transform_values { |pairs| pairs.map(&:first) }
|
|
96
|
+
day_total = ->(key) { summarize(by_day.fetch(key, [])).slice("cost", "requests") }
|
|
97
|
+
fresh = missing.to_h { |day| [day.strftime("%F"), day_total.(day.strftime("%F"))] }
|
|
98
|
+
keep_days(tag, fresh)
|
|
99
|
+
{
|
|
100
|
+
"totals" => summarize(timed.filter_map { |row, t| row if t >= day_ago }),
|
|
101
|
+
"turns" => turn_summary(turn_rows(day_ago, now, agent: agent, provider: nil, model: nil)),
|
|
102
|
+
"day_before_cost" => summarize(timed.filter_map { |row, t| row if t >= recent_from && t < day_ago })["cost"],
|
|
103
|
+
"daily" => day_starts.map do |day|
|
|
104
|
+
key = day.strftime("%F")
|
|
105
|
+
(kept[key] || fresh[key] || day_total.(key)).merge("at" => day.iso8601)
|
|
106
|
+
end
|
|
107
|
+
}
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# `from`/`to` (Time) pick a custom range and win over `period`; the bucket size
|
|
111
|
+
# follows the span (see #bucket_count).
|
|
112
|
+
def report(period: "7d", agent: nil, provider: nil, model: nil, now: Time.now.utc, from: nil, to: nil)
|
|
113
|
+
if from && to
|
|
114
|
+
period, now = "custom", to
|
|
115
|
+
else
|
|
116
|
+
period = "7d" unless PERIODS.key?(period)
|
|
117
|
+
from = now - PERIODS.fetch(period)
|
|
118
|
+
end
|
|
119
|
+
rows = request_rows(from, now)
|
|
120
|
+
agents = rows.filter_map { |row| row["agent"] }.uniq.sort
|
|
121
|
+
providers = rows.filter_map { |row| row["provider"] }.uniq.sort
|
|
122
|
+
model_options = rows.filter_map { |row| row["model"] }.uniq.sort
|
|
123
|
+
rows.select! do |row|
|
|
124
|
+
(agent.nil? || row["agent"] == agent) &&
|
|
125
|
+
(provider.nil? || row["provider"] == provider) && (model.nil? || row["model"] == model)
|
|
126
|
+
end
|
|
127
|
+
{
|
|
128
|
+
"period" => period, "from" => from.utc.iso8601, "to" => now.utc.iso8601,
|
|
129
|
+
"step_seconds" => ((now - from) / bucket_count(period, now - from)).round,
|
|
130
|
+
"agents" => agents, "providers" => providers, "model_options" => model_options,
|
|
131
|
+
"totals" => summarize(rows),
|
|
132
|
+
"series" => time_series(rows, from: from, to: now, period: period),
|
|
133
|
+
"models" => rows.group_by { |row| [row["provider"], row["model"]] }
|
|
134
|
+
.sort_by { |key, _| key.map(&:to_s) }.map do |(name, model_name), group|
|
|
135
|
+
summarize(group).merge("provider" => name, "model" => model_name)
|
|
136
|
+
end,
|
|
137
|
+
# The same model serves the reply and the post-turn learning: this splits
|
|
138
|
+
# their cost ("chat" vs knowledge_*). Rows from before labeling read as "chat".
|
|
139
|
+
"operations" => rows.group_by { |row| row["operation"] || "chat" }.sort.map do |operation, group|
|
|
140
|
+
summarize(group).merge("operation" => operation)
|
|
141
|
+
end,
|
|
142
|
+
"turns" => turn_report(from, now, agent: agent, provider: provider, model: model),
|
|
143
|
+
"slowest" => rows.select { |row| row["duration_ms"] }.sort_by { |row| -row["duration_ms"] }
|
|
144
|
+
.first(20).map { |row| row.reject { |key, _| key == "reported" }.merge(summarize([row])) },
|
|
145
|
+
"history_note" => "History starts with deployment. Deleting a task removes its model history."
|
|
146
|
+
}
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
private
|
|
150
|
+
|
|
151
|
+
# a failed write only means the next home computes those days again
|
|
152
|
+
def keep_days(tag, days)
|
|
153
|
+
return if days.empty?
|
|
154
|
+
|
|
155
|
+
@store.transaction { days.each { |key, value| @store.set(DAY_SCOPE, "#{tag}#{key}", value) } }
|
|
156
|
+
rescue StandardError
|
|
157
|
+
nil
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def request_rows(from, to) = rows_between(SCOPE, "completed_at", from, to)
|
|
161
|
+
|
|
162
|
+
RECENT_PAGE = 500
|
|
163
|
+
# A row is written when its turn ends (or flushes before waiting), so its
|
|
164
|
+
# write can trail its own timestamp by up to a turn. Paging stops only past
|
|
165
|
+
# this margin, so a late write is never cut off.
|
|
166
|
+
LATE_WRITE = 6 * 3600
|
|
167
|
+
|
|
168
|
+
# The rows whose `field` falls in [from, to], read newest-written first and
|
|
169
|
+
# stopping at the first page entirely older than `from` (minus LATE_WRITE):
|
|
170
|
+
# a window costs its own rows, not the whole history. The stop test reads the
|
|
171
|
+
# page's oldest RECENT_PAGE rows, so a doubled page stops as early as a small one.
|
|
172
|
+
# ponytail: offset paging over write order; a time-keyed index if windows reach far back.
|
|
173
|
+
def rows_between(scope, field, from, to)
|
|
174
|
+
floor = from - LATE_WRITE
|
|
175
|
+
rows = []
|
|
176
|
+
offset = 0
|
|
177
|
+
limit = RECENT_PAGE
|
|
178
|
+
loop do
|
|
179
|
+
page = @store.recent(scope, nil, limit, offset)
|
|
180
|
+
times = page.map { |_key, row| row.is_a?(Hash) && row[field] ? Time.iso8601(row[field]) : nil }
|
|
181
|
+
page.each_with_index { |(_key, row), i| rows << row if times[i] && times[i] >= from && times[i] <= to }
|
|
182
|
+
break if page.size < limit || times.last(RECENT_PAGE).all? { |t| t && t < floor }
|
|
183
|
+
|
|
184
|
+
offset += limit
|
|
185
|
+
limit *= 2 # each call orders the whole scope: few big pages, not many small ones
|
|
186
|
+
end
|
|
187
|
+
rows
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def turn_report(from, to, agent:, provider:, model:)
|
|
191
|
+
turn_summary(turn_rows(from, to, agent: agent, provider: provider, model: model))
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def turn_rows(from, to, agent:, provider:, model:)
|
|
195
|
+
rows_between(TURN_SCOPE, "at", from, to).select do |row|
|
|
196
|
+
(agent.nil? || row["agent"] == agent) &&
|
|
197
|
+
(provider.nil? || row["provider"] == provider) && (model.nil? || row["model"] == model)
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Turn latency (each window's p50/p95) and per-tool health over turn rows.
|
|
202
|
+
def turn_summary(turns)
|
|
203
|
+
windows = TURN_WINDOWS.to_h do |window|
|
|
204
|
+
values = turns.filter_map { |row| row[window] }.sort
|
|
205
|
+
[window, { "measured" => values.size, "p50" => percentile(values, 50), "p95" => percentile(values, 95) }]
|
|
206
|
+
end
|
|
207
|
+
tools = turns.flat_map { |row| Array(row["tools"]) }.group_by(&:first).sort.map do |name, calls|
|
|
208
|
+
ms = calls.filter_map { |call| call[2] }.sort
|
|
209
|
+
{ "tool" => name, "calls" => calls.size, "failures" => calls.count { |call| call[1] == false },
|
|
210
|
+
"p50_ms" => percentile(ms, 50), "p95_ms" => percentile(ms, 95) }
|
|
211
|
+
end
|
|
212
|
+
failed = turns.select { |row| row["status"] == "failed" }
|
|
213
|
+
{ "count" => turns.size, "failures" => failed.size,
|
|
214
|
+
"failure_stages" => failed.group_by { |row| row["stage"] || "unknown" }.transform_values(&:size).sort.to_h,
|
|
215
|
+
"windows" => windows, "tools" => tools }
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Nearest rank on a sorted list; nil when empty.
|
|
219
|
+
def percentile(sorted, pct) = sorted.empty? ? nil : sorted[(sorted.size * pct / 100.0).ceil - 1]
|
|
220
|
+
|
|
221
|
+
def fold(row, data)
|
|
222
|
+
row.merge!(data.slice("operation", "agent", "provider", "model", "turn").compact)
|
|
223
|
+
if data["type"] == "llm_request"
|
|
224
|
+
row.merge!(data.slice("status", "duration_ms"))
|
|
225
|
+
row["completed_at"] = Time.iso8601(data.fetch("at")).utc.iso8601(6)
|
|
226
|
+
else
|
|
227
|
+
row["attempts"] += 1
|
|
228
|
+
row["failed_attempts"] += 1 if data["status"] == "failed"
|
|
229
|
+
VALUES.each do |field|
|
|
230
|
+
next if data[field].nil?
|
|
231
|
+
|
|
232
|
+
row[field] = row.fetch(field, 0) + data[field]
|
|
233
|
+
row["reported"][field] = row["reported"].fetch(field, 0) + 1
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
HOUR = 3600
|
|
239
|
+
MAX_BUCKETS = 92
|
|
240
|
+
|
|
241
|
+
# Fixed periods keep their buckets; a custom range goes hourly up to 2 days,
|
|
242
|
+
# 6-hourly up to 14, daily beyond (capped, so a year stays a readable chart).
|
|
243
|
+
def bucket_count(period, span)
|
|
244
|
+
fixed = { "24h" => 24, "7d" => 28, "30d" => 30 }[period]
|
|
245
|
+
return fixed if fixed
|
|
246
|
+
|
|
247
|
+
base = if span <= 48 * HOUR then HOUR elsif span <= 14 * 24 * HOUR then 6 * HOUR else 24 * HOUR end
|
|
248
|
+
(span / base).ceil.clamp(1, MAX_BUCKETS)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def time_series(rows, from:, to:, period:)
|
|
252
|
+
count = bucket_count(period, to - from)
|
|
253
|
+
step = (to - from) / count
|
|
254
|
+
buckets = Array.new(count) { [] }
|
|
255
|
+
rows.each do |row|
|
|
256
|
+
index = [((Time.iso8601(row["completed_at"]) - from) / step).floor, count - 1].min
|
|
257
|
+
buckets[index] << row
|
|
258
|
+
end
|
|
259
|
+
buckets.each_with_index.map do |group, index|
|
|
260
|
+
summarize(group).merge("at" => (from + index * step).utc.iso8601,
|
|
261
|
+
"until" => (from + (index + 1) * step).utc.iso8601)
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def summarize(rows)
|
|
266
|
+
durations = rows.filter_map { |row| row["duration_ms"] }.sort
|
|
267
|
+
result = {
|
|
268
|
+
"requests" => rows.size, "attempts" => rows.sum { |row| row["attempts"] },
|
|
269
|
+
"failures" => rows.count { |row| row["status"] == "failed" },
|
|
270
|
+
"failed_attempts" => rows.sum { |row| row["failed_attempts"] },
|
|
271
|
+
"retries" => rows.sum { |row| [row["attempts"] - 1, 0].max },
|
|
272
|
+
"measured_requests" => durations.size
|
|
273
|
+
}
|
|
274
|
+
[50, 90, 95].each { |pct| result["p#{pct}_ms"] = percentile(durations, pct) }
|
|
275
|
+
VALUES.each do |field|
|
|
276
|
+
values = rows.filter_map { |row| row[field] }
|
|
277
|
+
result[field] = values.empty? ? nil : values.sum
|
|
278
|
+
result["unknown_#{field}_requests"] = rows.count do |row|
|
|
279
|
+
row["attempts"].zero? || row["reported"].fetch(field, 0) < row["attempts"]
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
result
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Insika
|
|
7
|
+
# RubyLLM's model catalog (ids, context windows, prices) kept in the shared
|
|
8
|
+
# store instead of the gem's bundled file, which ages with every deploy.
|
|
9
|
+
#
|
|
10
|
+
# Two roles:
|
|
11
|
+
# - RubyLLM's `model_registry_store` (#read / #write): the catalog lives in
|
|
12
|
+
# one place for every worker and survives restarts.
|
|
13
|
+
# - a Tick duty (#run): one worker per interval refreshes from the published
|
|
14
|
+
# catalog and the configured providers; every other worker reloads the
|
|
15
|
+
# stored catalog when it changes. A failed refresh keeps the catalog it had
|
|
16
|
+
# and is retried after RETRY_AFTER, never on every tick.
|
|
17
|
+
class ModelRegistry
|
|
18
|
+
SCOPE = "model_registry"
|
|
19
|
+
INTERVAL = 86_400
|
|
20
|
+
RETRY_AFTER = 3600
|
|
21
|
+
|
|
22
|
+
def initialize(store:, interval: INTERVAL, logger: nil,
|
|
23
|
+
refresher: -> { RubyLLM::Models.refresh(remote_only: true) })
|
|
24
|
+
@store = store
|
|
25
|
+
@interval = interval.to_i
|
|
26
|
+
@logger = logger
|
|
27
|
+
@refresher = refresher
|
|
28
|
+
@loaded = nil
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# --- RubyLLM model_registry_store -------------------------------------
|
|
32
|
+
|
|
33
|
+
def read
|
|
34
|
+
raw = @store.get(SCOPE, "catalog") or return []
|
|
35
|
+
RubyLLM::Models::Registry.models_from_data(JSON.parse(raw, symbolize_names: true))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# RubyLLM hands a Models registry; stored as JSON so RubyLLM's own reader parses it back.
|
|
39
|
+
def write(registry)
|
|
40
|
+
models = registry.respond_to?(:all) ? registry.all : Array(registry)
|
|
41
|
+
@store.transaction do
|
|
42
|
+
@store.set(SCOPE, "catalog", JSON.generate(models.map(&:to_h)))
|
|
43
|
+
@store.set(SCOPE, "meta", { "refreshed_at" => Time.now.utc.iso8601(6), "models" => models.size })
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def description = "Insika store (#{SCOPE})"
|
|
48
|
+
|
|
49
|
+
# --- Tick duty ---------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
# -> { refreshed: true, models: n } | { reloaded: true } | nil (nothing to do)
|
|
52
|
+
def run(now: Time.now.utc)
|
|
53
|
+
return nil unless @interval.positive?
|
|
54
|
+
|
|
55
|
+
install
|
|
56
|
+
return refresh if claim(now)
|
|
57
|
+
|
|
58
|
+
reload
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def refreshed_at = @store.get(SCOPE, "meta")&.fetch("refreshed_at", nil)
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
# RubyLLM reads and refreshes through the store from here on; a worker that
|
|
66
|
+
# already loaded the bundled catalog switches on its next #reload.
|
|
67
|
+
def install
|
|
68
|
+
require "ruby_llm"
|
|
69
|
+
RubyLLM.config.model_registry_store = self unless RubyLLM.config.model_registry_store.equal?(self)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def refresh
|
|
73
|
+
@refresher.call
|
|
74
|
+
@loaded = refreshed_at
|
|
75
|
+
{ refreshed: true, models: RubyLLM.models.all.size }
|
|
76
|
+
rescue StandardError => e
|
|
77
|
+
@logger&.warn("model registry refresh failed: #{e.class}: #{e.message}")
|
|
78
|
+
{ refreshed: false, error: e.class.name }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def reload
|
|
82
|
+
stamp = refreshed_at
|
|
83
|
+
return nil if stamp.nil? || stamp == @loaded
|
|
84
|
+
|
|
85
|
+
RubyLLM.models.load_from_store
|
|
86
|
+
@loaded = stamp
|
|
87
|
+
{ reloaded: true }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# One refresher per window across workers (Tick's claim discipline): due once
|
|
91
|
+
# `refreshed_at` is an interval old, and a failed attempt waits RETRY_AFTER.
|
|
92
|
+
def claim(now)
|
|
93
|
+
@store.transaction do
|
|
94
|
+
attempted = time(@store.get(SCOPE, "claim")&.fetch("attempted_at", nil))
|
|
95
|
+
refreshed = time(refreshed_at)
|
|
96
|
+
due = refreshed.nil? || now - refreshed >= @interval
|
|
97
|
+
next false unless due && (attempted.nil? || now - attempted >= RETRY_AFTER)
|
|
98
|
+
|
|
99
|
+
@store.set(SCOPE, "claim", { "attempted_at" => now.iso8601 })
|
|
100
|
+
true
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def time(value)
|
|
105
|
+
value && Time.iso8601(value.to_s)
|
|
106
|
+
rescue ArgumentError
|
|
107
|
+
nil
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -164,7 +164,7 @@ module Insika
|
|
|
164
164
|
out = {}
|
|
165
165
|
params.each do |k, v|
|
|
166
166
|
key = k.to_sym
|
|
167
|
-
out[key] = v if %i[temperature max_tokens thinking].include?(key)
|
|
167
|
+
out[key] = v if %i[temperature max_tokens thinking provider_routing].include?(key)
|
|
168
168
|
end
|
|
169
169
|
out
|
|
170
170
|
end
|
|
@@ -37,6 +37,7 @@ module Insika
|
|
|
37
37
|
def apply_params(chat)
|
|
38
38
|
p = params || {}
|
|
39
39
|
apply(chat, :with_temperature, p[:temperature]) if numeric?(p[:temperature])
|
|
40
|
+
apply(chat, :with_max_output_tokens, p[:max_tokens]) if numeric?(p[:max_tokens])
|
|
40
41
|
apply_payload_params(chat, p)
|
|
41
42
|
apply_effort(chat, p[:thinking]) if present?(p[:thinking])
|
|
42
43
|
chat
|
|
@@ -48,27 +49,22 @@ module Insika
|
|
|
48
49
|
chat.public_send(method, value) if chat.respond_to?(method)
|
|
49
50
|
end
|
|
50
51
|
|
|
51
|
-
#
|
|
52
|
-
#
|
|
53
|
-
# `with_params` REPLACES the whole hash — a second call would silently drop
|
|
54
|
-
# the first one's keys.
|
|
55
|
-
#
|
|
56
|
-
# max_tokens goes here because the gem has NO with_max_* setter: the chat's
|
|
57
|
-
# fluent API covers temperature/thinking only, and Anthropic is the only
|
|
58
|
-
# provider that emits a cap at all (from the model registry, 4096 by default).
|
|
59
|
-
# `max_tokens` is the wire key for Anthropic and for every OpenAI-compatible
|
|
60
|
-
# provider (DeepSeek included); OpenAI's o-series wants
|
|
61
|
-
# `max_completion_tokens` instead — that per-provider mapping is not modelled
|
|
62
|
-
# yet, and a raw provider key is not authorable (the profile whitelists
|
|
63
|
-
# temperature/max_tokens/thinking).
|
|
52
|
+
# Provider-specific controls retain their existing wire shape. Token limits
|
|
53
|
+
# use RubyLLM's native setter so each protocol chooses its own wire key.
|
|
64
54
|
def apply_payload_params(chat, p)
|
|
65
|
-
return unless chat.respond_to?(:
|
|
55
|
+
return unless chat.respond_to?(:with_provider_options)
|
|
66
56
|
|
|
67
57
|
payload = {}
|
|
68
|
-
payload[:max_tokens] = p[:max_tokens] if numeric?(p[:max_tokens])
|
|
69
58
|
toggle = thinking_toggle(p[:thinking])
|
|
70
59
|
payload[:thinking] = { type: toggle } if toggle
|
|
71
|
-
|
|
60
|
+
# WHICH upstream serves the model, for a gateway that has several. Authored
|
|
61
|
+
# as `provider_routing` and emitted under the wire key `provider` —
|
|
62
|
+
# OpenRouter's, the only shape that exists for this today; a provider
|
|
63
|
+
# without the field ignores an unknown body key. A raw provider key is
|
|
64
|
+
# not authorable, so the one
|
|
65
|
+
# capability gets a name of its own.
|
|
66
|
+
payload[:provider] = p[:provider_routing] if p[:provider_routing].is_a?(Hash)
|
|
67
|
+
chat.with_provider_options(payload) unless payload.empty?
|
|
72
68
|
end
|
|
73
69
|
|
|
74
70
|
# The resolved reasoning control (4-layer). Two axes folded into one field:
|
|
@@ -78,11 +74,8 @@ module Insika
|
|
|
78
74
|
# Blank/nil never reaches here (apply_params guards on present?), so no config
|
|
79
75
|
# anywhere = the provider's own default.
|
|
80
76
|
#
|
|
81
|
-
#
|
|
82
|
-
#
|
|
83
|
-
# thinking:{type:} shape is DeepSeek's OpenAI-compat contract — gated to it
|
|
84
|
-
# (nil provider = the platform default, DeepSeek here); other providers get
|
|
85
|
-
# effort-only until their toggle wire is mapped (e.g. Anthropic output_config).
|
|
77
|
+
# Native on/off requires model-registry controls, which custom DeepSeek ids
|
|
78
|
+
# may not have. Keep the explicit DeepSeek toggle valid for those models.
|
|
86
79
|
def thinking_toggle(value)
|
|
87
80
|
return nil unless present?(value) && reasoning_toggle_supported?
|
|
88
81
|
|
data/lib/insika/model_visible.rb
CHANGED
|
@@ -20,19 +20,30 @@ module Insika
|
|
|
20
20
|
# -> ModelVisible. `chat` is the RubyLLM chat at the boundary (instructions,
|
|
21
21
|
# tools, messages are exactly the three parts the provider serializes).
|
|
22
22
|
def self.capture(chat)
|
|
23
|
+
messages = chat.respond_to?(:messages) ? Array(chat.messages) : []
|
|
24
|
+
tools = chat.respond_to?(:tools) ? chat.tools : []
|
|
23
25
|
new(
|
|
24
|
-
instructions:
|
|
25
|
-
tools:
|
|
26
|
-
messages:
|
|
26
|
+
instructions: instructions_of(chat, messages),
|
|
27
|
+
tools: (tools.is_a?(Hash) ? tools.values : Array(tools)).map { |t| tool_schema(t) },
|
|
28
|
+
messages: messages
|
|
27
29
|
)
|
|
28
30
|
end
|
|
29
31
|
|
|
30
|
-
# ->
|
|
32
|
+
# -> String | nil: the system text the chat sends (the instructions reader, else
|
|
33
|
+
# its system messages). Also what Safety::PromptEcho compares a reply against.
|
|
34
|
+
def self.instructions_of(chat, messages = chat.respond_to?(:messages) ? Array(chat.messages) : [])
|
|
35
|
+
return chat.instructions if chat.respond_to?(:instructions)
|
|
36
|
+
|
|
37
|
+
system = messages.select { |m| m.respond_to?(:role) && m.role.to_s == "system" }
|
|
38
|
+
Coercion.presence(system.map(&:content).join("\n\n"))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# -> Hash: the JSON-safe schema of ONE tool. Reads `parameters_schema` then
|
|
31
42
|
# `schema` (the two reader shapes the house FakeChat and the gem's tools
|
|
32
43
|
# answer); a tool answering none degrades to nil, never raises.
|
|
33
44
|
def self.tool_schema(tool)
|
|
34
|
-
params = if tool.respond_to?(:
|
|
35
|
-
tool.
|
|
45
|
+
params = if tool.respond_to?(:parameters_schema)
|
|
46
|
+
tool.parameters_schema
|
|
36
47
|
elsif tool.respond_to?(:schema)
|
|
37
48
|
tool.schema
|
|
38
49
|
end
|
data/lib/insika/onboarding.rb
CHANGED
|
@@ -55,6 +55,7 @@ module Insika
|
|
|
55
55
|
"embedding" => "docs/EMBEDDING.md",
|
|
56
56
|
"sandbox" => "docs/SANDBOX.md",
|
|
57
57
|
"benchmark" => "docs/BENCHMARK.md",
|
|
58
|
+
"rubyllm-2-migration" => "docs/RUBYLLM_2_MIGRATION.md",
|
|
58
59
|
"observability" => "docs/OBSERVABILITY.md",
|
|
59
60
|
"schedules" => "docs/SCHEDULING.md",
|
|
60
61
|
"artifacts" => "docs/ARTIFACTS.md",
|
data/lib/insika/outcome_store.rb
CHANGED
|
@@ -43,7 +43,7 @@ module Insika
|
|
|
43
43
|
# the no-tenant records must pass `tenant: ""` explicitly (the FOLD uses
|
|
44
44
|
# `for_pair`, never this nil-means-everything trap).
|
|
45
45
|
def all(tenant: nil, agent: nil)
|
|
46
|
-
records = @store.
|
|
46
|
+
records = @store.entries(SCOPE).filter_map { |_k, rec| to_record(rec) }
|
|
47
47
|
records.select! { |r| r.tenant == tenant } if tenant
|
|
48
48
|
records.select! { |r| r.agent == agent } if agent
|
|
49
49
|
records.sort_by(&:at).reverse
|
|
@@ -27,8 +27,10 @@ module Insika
|
|
|
27
27
|
# are "data-defined" or MCP-discovered.
|
|
28
28
|
class OverlayToolRegistry
|
|
29
29
|
def initialize(base:, tool_store:, http:, egress: Insika::EgressGuard, egress_options: {}, event_stream: nil,
|
|
30
|
-
mcp_registry: nil)
|
|
30
|
+
mcp_registry: nil, recheck: 2, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
|
|
31
31
|
@base = base
|
|
32
|
+
@recheck = recheck
|
|
33
|
+
@clock = clock
|
|
32
34
|
@tool_store = tool_store
|
|
33
35
|
@http = http
|
|
34
36
|
@egress = egress
|
|
@@ -70,16 +72,37 @@ module Insika
|
|
|
70
72
|
# Atomic swap of the dynamic index (after writing/removing a data-tool).
|
|
71
73
|
def reload
|
|
72
74
|
@dynamic = build_dynamic
|
|
75
|
+
@token = @tool_store.version_token
|
|
76
|
+
@checked_at = @clock.call
|
|
73
77
|
self
|
|
74
78
|
end
|
|
75
79
|
|
|
80
|
+
# Changes when the data-tools were rebuilt; a catalog over this registry
|
|
81
|
+
# compares it to know its own index is stale.
|
|
82
|
+
def generation
|
|
83
|
+
dynamic
|
|
84
|
+
@token
|
|
85
|
+
end
|
|
86
|
+
|
|
76
87
|
# Is it a CODE tool (base)? Used by the authoring Command's validation.
|
|
77
88
|
def code_tool?(name) = @base.names.include?(name.to_s)
|
|
78
89
|
|
|
79
90
|
private
|
|
80
91
|
|
|
92
|
+
# Another worker may have written a data-tool (import, Studio edit): at most
|
|
93
|
+
# every `recheck` seconds, compare the config version token and rebuild when it
|
|
94
|
+
# moved. The token moves on any config write, so a rebuild may find nothing new.
|
|
81
95
|
def dynamic
|
|
82
|
-
@dynamic
|
|
96
|
+
reload if @dynamic.nil? || stale?
|
|
97
|
+
@dynamic
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def stale?
|
|
101
|
+
now = @clock.call
|
|
102
|
+
return false if now - @checked_at < @recheck
|
|
103
|
+
|
|
104
|
+
@checked_at = now
|
|
105
|
+
@tool_store.version_token != @token
|
|
83
106
|
end
|
|
84
107
|
|
|
85
108
|
# No I/O (McpToolRegistry#entries only reads McpStore#tools_cache) ->
|
|
@@ -112,6 +135,11 @@ module Insika
|
|
|
112
135
|
# Lazy require: DataDefinedTool inherits from RubyLLM::Tool (pulls in the gem) -> kept
|
|
113
136
|
# out of insika.rb load-time, loaded on the 1st instance (turn time).
|
|
114
137
|
def build_tool(definition)
|
|
138
|
+
if definition.presentation?
|
|
139
|
+
require_relative "tools/present"
|
|
140
|
+
return Insika::Tools::Present.new(definition: definition, event_stream: @event_stream)
|
|
141
|
+
end
|
|
142
|
+
|
|
115
143
|
require_relative "tools/data_defined_tool"
|
|
116
144
|
Insika::Tools::DataDefinedTool.new(
|
|
117
145
|
definition: definition, http: @http, egress: @egress,
|