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
|
@@ -1,44 +1,59 @@
|
|
|
1
|
-
<%# Tools — miller columns:
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
<%# Tools — the catalog, as miller columns: every tool in the master (grouped by
|
|
2
|
+
origin, searchable, with how many agents can call it) | the detail in a
|
|
3
|
+
turbo-frame: a data tool's editor, or a read-only card for code and MCP
|
|
4
|
+
tools. Which agent may call what lives on the agent (its Tools tab).
|
|
5
|
+
@detail: nil (landing) | :edit (data-tool editor) | :info (read-only card). %>
|
|
6
6
|
<% frame_only = defined?(frame_only) && frame_only %>
|
|
7
7
|
<% unless frame_only %>
|
|
8
|
+
<header class="page-head">
|
|
9
|
+
<h1>Tools<span class="sub">Every tool agents can call — edit HTTP tools, see who uses each one</span></h1>
|
|
10
|
+
</header>
|
|
8
11
|
<div class="drill" data-controller="list-filter">
|
|
9
12
|
<aside class="drill-pane drill-master">
|
|
10
13
|
<div class="drill-pane-head">
|
|
11
|
-
<div class="title"><strong>Tools <span class="count"><%= @tools.size %></span></strong></div>
|
|
12
|
-
<a class="btn primary btn-sm" href="/studio/tools/def/new"
|
|
14
|
+
<div class="title"><strong>Tools <span class="count" data-list-filter-target="count"><%= @tools.size %></span></strong></div>
|
|
15
|
+
<a class="btn primary btn-sm" href="/studio/tools/def/new" data-turbo-frame="tool-detail" data-turbo-action="advance">+ New tool</a>
|
|
13
16
|
</div>
|
|
14
17
|
<div class="drill-search">
|
|
15
|
-
<input type="search" class="filter-input" placeholder="
|
|
18
|
+
<input type="search" class="filter-input" placeholder="Search tools…" aria-label="Search tools"
|
|
16
19
|
data-list-filter-target="query" data-action="input->list-filter#filter keydown->list-filter#clear">
|
|
20
|
+
<label class="switch" title="show only tools no agent can call">
|
|
21
|
+
<input type="checkbox" id="only-unused">
|
|
22
|
+
<span class="switch-track" aria-hidden="true"></span>
|
|
23
|
+
<span class="switch-label">only unused</span>
|
|
24
|
+
</label>
|
|
17
25
|
</div>
|
|
18
|
-
<nav class="drill-pane-body drill-list" aria-label="tools
|
|
19
|
-
<%
|
|
20
|
-
<
|
|
21
|
-
|
|
22
|
-
<%
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
<
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
<nav class="drill-pane-body drill-list" aria-label="tools" data-controller="active-link">
|
|
27
|
+
<% @tool_groups.each do |label, tools| %>
|
|
28
|
+
<details class="drill-group" data-list-filter-target="group"<%== " open" unless label.start_with?("MCP") %>>
|
|
29
|
+
<summary class="drill-group-label"><%= label %> <span class="count"><%= tools.size %></span></summary>
|
|
30
|
+
<% tools.each do |t| %>
|
|
31
|
+
<% data = @data_tool_names.include?(t.name) %>
|
|
32
|
+
<% users = @tool_reach[t.name].size %>
|
|
33
|
+
<a class="drill-item drill-item-sm<%== ' active' if @selected == t.name %>"
|
|
34
|
+
href="/studio/tools/<%= data ? "def" : "info" %>/<%= Rack::Utils.escape(t.name) %>"<%== " data-unused" if users.zero? %>
|
|
35
|
+
data-turbo-frame="tool-detail" data-turbo-action="advance"
|
|
36
|
+
data-list-filter-target="item" data-filter-text="<%= "#{t.name} #{label}" %>"
|
|
37
|
+
data-active-link-target="item" data-action="click->active-link#select" title="<%= t.description %>">
|
|
38
|
+
<div class="drill-item-name"><span class="txt"><%= t.name %></span><% if data %><span class="pill plain">data</span><% end %><span class="pill <%= users.zero? ? "warn" : "plain" %>"><%= users %> <%= users == 1 ? "agent" : "agents" %></span></div>
|
|
39
|
+
</a>
|
|
40
|
+
<% end %>
|
|
41
|
+
</details>
|
|
28
42
|
<% end %>
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
43
|
+
<% if @dropped_tool_names.any? %>
|
|
44
|
+
<details class="drill-group" data-list-filter-target="group" open>
|
|
45
|
+
<summary class="drill-group-label">Invalid definitions <span class="count"><%= @dropped_tool_names.size %></span></summary>
|
|
46
|
+
<% @dropped_tool_names.sort.each do |name| %>
|
|
47
|
+
<a class="drill-item drill-item-sm" href="/studio/tools/def/<%= Rack::Utils.escape(name) %>"
|
|
48
|
+
data-turbo-frame="tool-detail" data-turbo-action="advance"
|
|
49
|
+
data-list-filter-target="item" data-filter-text="<%= name %>"
|
|
50
|
+
title="its definition is invalid — no agent can call it until it is fixed">
|
|
51
|
+
<div class="drill-item-name"><span class="txt"><%= name %></span><span class="pill locked">dropped</span></div>
|
|
52
|
+
</a>
|
|
53
|
+
<% end %>
|
|
54
|
+
</details>
|
|
40
55
|
<% end %>
|
|
41
|
-
<div class="empty" data-list-filter-target="empty" hidden>No
|
|
56
|
+
<div class="empty" data-list-filter-target="empty" hidden>No tools match your search.</div>
|
|
42
57
|
</nav>
|
|
43
58
|
</aside>
|
|
44
59
|
<% end %>
|
|
@@ -47,54 +62,28 @@
|
|
|
47
62
|
<% if flash["error"] %><div class="flash err" role="alert"><%= flash["error"] %></div><% end %>
|
|
48
63
|
<% if flash["notice"] %><div class="flash ok" role="status"><%= flash["notice"] %></div><% end %>
|
|
49
64
|
<% end %>
|
|
50
|
-
<%
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
</div>
|
|
56
|
-
</div>
|
|
57
|
-
<% else %>
|
|
58
|
-
<% a = @sel_agent %>
|
|
65
|
+
<% case @detail %>
|
|
66
|
+
<% when :edit %>
|
|
67
|
+
<%== render("_tool_edit") %>
|
|
68
|
+
<% when :info %>
|
|
69
|
+
<% t = @info_tool %>
|
|
59
70
|
<div class="drill-pane-head">
|
|
60
71
|
<div class="title">
|
|
61
|
-
<strong class="mono"><%=
|
|
72
|
+
<strong class="mono"><%= t.name %></strong>
|
|
73
|
+
<span class="count"><%= @info_label %></span>
|
|
62
74
|
</div>
|
|
63
|
-
<button type="submit" form="tools-form" class="btn primary" data-turbo-submits-with="Saving…">Save tools</button>
|
|
64
75
|
</div>
|
|
65
|
-
<div class="drill-pane-body"
|
|
66
|
-
<
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
</
|
|
74
|
-
<
|
|
75
|
-
title="uncheck every tool (deny-all — Save tools to apply)">none</button>
|
|
76
|
+
<div class="drill-pane-body">
|
|
77
|
+
<p><%= t.description %></p>
|
|
78
|
+
<p class="muted"><%= t.plugin.to_s.start_with?("mcp:") ? "Comes from an MCP server; edit the server under MCP." : "Built into the engine; it cannot be edited here." %></p>
|
|
79
|
+
<%== render("_tool_users", locals: { name: t.name }) %>
|
|
80
|
+
</div>
|
|
81
|
+
<% else %>
|
|
82
|
+
<div class="drill-empty">
|
|
83
|
+
<div>
|
|
84
|
+
<h2>Tools</h2>
|
|
85
|
+
<p>Pick a tool to see which agents can call it. HTTP tools open their editor; MCP and built-in tools are read-only. Which tools an agent may call is set on the agent, in its <strong>Tools</strong> tab.</p>
|
|
76
86
|
</div>
|
|
77
|
-
<p class="muted">Checked tools are allowed next turn. A <span class="pill locked">locked</span> tool is on the denylist — deny always wins, so it can't be granted here.</p>
|
|
78
|
-
<form id="tools-form" method="post" action="/studio/tools/<%= Rack::Utils.escape(a.id) %>">
|
|
79
|
-
<%== csrf_tag %>
|
|
80
|
-
<% if @tools.empty? %>
|
|
81
|
-
<p class="muted">No tools registered in the runtime.</p>
|
|
82
|
-
<% else %>
|
|
83
|
-
<div class="check-grid tool-grid<%== " is-open" if a.tools_allow.nil? %>" data-toggle-counter-target="grid">
|
|
84
|
-
<% @tools.each do |t| %>
|
|
85
|
-
<% denied = tool_denied_for?(a, t.name) %>
|
|
86
|
-
<label class="check tool-check<%== " locked" if denied %>" title="<%= denied ? "denied — deny wins" : t.description %>">
|
|
87
|
-
<input type="checkbox" name="tools[]" value="<%= t.name %>"
|
|
88
|
-
<%== " checked" if tool_allowed_for?(a, t.name) && !denied %>
|
|
89
|
-
<%== " disabled" if denied %>
|
|
90
|
-
data-toggle-counter-target="tool" data-locked="<%= denied %>"
|
|
91
|
-
data-action="change->toggle-counter#update">
|
|
92
|
-
<span class="mono"><%= t.name %></span><% if @data_tool_names.include?(t.name) %> <span class="pill plain">data</span><% end %><% if denied %> <span class="pill locked">locked</span><% end %>
|
|
93
|
-
</label>
|
|
94
|
-
<% end %>
|
|
95
|
-
</div>
|
|
96
|
-
<% end %>
|
|
97
|
-
</form>
|
|
98
87
|
</div>
|
|
99
88
|
<% end %>
|
|
100
89
|
</turbo-frame>
|
data/lib/insika/task_actor.rb
CHANGED
|
@@ -41,8 +41,11 @@ module Insika
|
|
|
41
41
|
end
|
|
42
42
|
|
|
43
43
|
# Runs the block on an Async fiber CHILD of the parent. Returns the Task.
|
|
44
|
+
# The fiber yields nil: Async keeps a finished task linked to the siblings that
|
|
45
|
+
# follow it (List#remove! leaves `tail`), so one stale reference to an old turn
|
|
46
|
+
# keeps every later one reachable — with its result, unless the result is nil.
|
|
44
47
|
def run(&turn_block)
|
|
45
|
-
@async_task = @parent.async { turn_block.call(self) }
|
|
48
|
+
@async_task = @parent.async { turn_block.call(self); nil }
|
|
46
49
|
end
|
|
47
50
|
|
|
48
51
|
# Drains the mailbox WITHOUT blocking (boundaries). `:cancel` raises (the top
|
data/lib/insika/task_store.rb
CHANGED
|
@@ -117,6 +117,48 @@ module Insika
|
|
|
117
117
|
to_task(record)
|
|
118
118
|
end
|
|
119
119
|
|
|
120
|
+
# -> Task; begin_execution + the move to :running in ONE write and one
|
|
121
|
+
# transaction (the dispatch claim, like transition). Only queued, paused and
|
|
122
|
+
# waiting move to :running; any other status (a recovered orphan already
|
|
123
|
+
# :running) keeps its own, exactly as the two separate calls did.
|
|
124
|
+
def start_execution(id)
|
|
125
|
+
@store.transaction do
|
|
126
|
+
record = fetch!(id)
|
|
127
|
+
raise ArgumentError, "an open Execution already exists on task #{id}" if open_execution(record)
|
|
128
|
+
|
|
129
|
+
record["status"] = "running" if %w[queued paused waiting].include?(record["status"])
|
|
130
|
+
record["executions"] += [{ "attempt" => record["executions"].size + 1, "started_at" => timestamp,
|
|
131
|
+
"finished_at" => nil, "outcome" => nil, "error" => nil }]
|
|
132
|
+
record["updated_at"] = timestamp
|
|
133
|
+
@store.set(SCOPE, key_for(id), record)
|
|
134
|
+
to_task(record)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# -> Task; finish_execution + transition(to: outcome) in ONE write and one
|
|
139
|
+
# transaction. ArgumentError (nothing written) for no open Execution or an
|
|
140
|
+
# invalid transition.
|
|
141
|
+
def complete_execution(id, outcome:)
|
|
142
|
+
target = outcome.to_sym
|
|
143
|
+
raise ArgumentError, "invalid status: #{outcome}" unless STATUSES.include?(target)
|
|
144
|
+
|
|
145
|
+
@store.transaction do
|
|
146
|
+
record = fetch!(id)
|
|
147
|
+
open = open_execution(record)
|
|
148
|
+
raise ArgumentError, "no open Execution on task #{id}" if open.nil?
|
|
149
|
+
|
|
150
|
+
from = record["status"].to_sym
|
|
151
|
+
raise ArgumentError, "invalid transition: #{from} -> #{target}" unless TRANSITIONS.fetch(from).include?(target)
|
|
152
|
+
|
|
153
|
+
open["finished_at"] = timestamp
|
|
154
|
+
open["outcome"] = target.to_s
|
|
155
|
+
record["status"] = target.to_s
|
|
156
|
+
record["updated_at"] = timestamp
|
|
157
|
+
@store.set(SCOPE, key_for(id), record)
|
|
158
|
+
to_task(record)
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
120
162
|
# -> Task; closes the current Execution. ArgumentError if none is open.
|
|
121
163
|
# Does NOT touch status (that is transition's job).
|
|
122
164
|
def finish_execution(id, outcome:)
|
|
@@ -189,6 +231,20 @@ module Insika
|
|
|
189
231
|
def queued = with_status(:queued)
|
|
190
232
|
|
|
191
233
|
# -> enumerates ids without the "task:" prefix; without a block returns an Enumerator.
|
|
234
|
+
# -> [Task] every record, from ONE bulk read of the backend (see
|
|
235
|
+
# Store#entries) instead of a read per id.
|
|
236
|
+
def all
|
|
237
|
+
@store.entries(SCOPE, KEY_PREFIX).map { |_, record| to_task(record) }
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# -> [Task] one page, most recently written first (every status change
|
|
241
|
+
# rewrites the task, so this is "most recently updated"). Reads only the page.
|
|
242
|
+
def recent(limit, offset: 0)
|
|
243
|
+
# offset only when paging: a backend written for the 3-argument #recent keeps working
|
|
244
|
+
rows = offset.zero? ? @store.recent(SCOPE, KEY_PREFIX, limit) : @store.recent(SCOPE, KEY_PREFIX, limit, offset)
|
|
245
|
+
rows.map { |_, record| to_task(record) }
|
|
246
|
+
end
|
|
247
|
+
|
|
192
248
|
def each_id
|
|
193
249
|
return enum_for(:each_id) unless block_given?
|
|
194
250
|
|
|
@@ -201,7 +257,17 @@ module Insika
|
|
|
201
257
|
# tasks go with it, terminal tasks only (a live :running task must never
|
|
202
258
|
# be deleted under its own fiber).
|
|
203
259
|
def delete(id)
|
|
204
|
-
@store.
|
|
260
|
+
@store.transaction do
|
|
261
|
+
# Read the task first: the trace recorders read it before writing, so on a
|
|
262
|
+
# backend that locks per key read this orders a purge against them.
|
|
263
|
+
@store.get(SCOPE, key_for(id))
|
|
264
|
+
@store.list(ModelMetricsStore::SCOPE, ModelMetricsStore.task_prefix(id)).each do |key|
|
|
265
|
+
@store.delete(ModelMetricsStore::SCOPE, key)
|
|
266
|
+
end
|
|
267
|
+
@store.delete(LLMTraceStore::SCOPE, id.to_s)
|
|
268
|
+
@store.delete(ModelMetricsStore::TURN_SCOPE, id.to_s)
|
|
269
|
+
@store.delete(SCOPE, key_for(id))
|
|
270
|
+
end
|
|
205
271
|
end
|
|
206
272
|
|
|
207
273
|
private
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
+
require_relative "../env_schema"
|
|
4
5
|
|
|
5
6
|
module Insika
|
|
6
7
|
module Telemetry
|
|
@@ -22,9 +23,9 @@ module Insika
|
|
|
22
23
|
# nothing is emitted and the dashboard shows a gap instead of a lie.
|
|
23
24
|
#
|
|
24
25
|
# Token accounting (matches `Executor#usage_of`, which mirrors the providers):
|
|
25
|
-
#
|
|
26
|
-
# - `cached_input` given -> cached tokens are billed at that rate
|
|
27
|
-
#
|
|
26
|
+
# All buckets are disjoint, matching RubyLLM::Tokens.
|
|
27
|
+
# - `cached_input` given -> cached tokens are billed at that rate;
|
|
28
|
+
# absent -> they use the input rate.
|
|
28
29
|
# - `cache_write` given -> cache-creation tokens billed at that rate; absent
|
|
29
30
|
# -> at the input rate (they are input tokens the provider did write).
|
|
30
31
|
class Pricing
|
|
@@ -47,21 +48,22 @@ module Insika
|
|
|
47
48
|
|
|
48
49
|
rate = rate_for(usage[:model]) or return nil
|
|
49
50
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
51
|
+
buckets = [[usage[:input_tokens], rate["input"]],
|
|
52
|
+
[usage[:output_tokens], rate["output"]],
|
|
53
|
+
[usage[:cached_tokens], rate["cached_input"] || rate["input"]],
|
|
54
|
+
[usage[:cache_creation_tokens], rate["cache_write"] || rate["input"]]]
|
|
55
|
+
return nil if buckets.all? { |count, _| count.nil? }
|
|
56
|
+
return nil if buckets.any? { |count, price| count.to_i.positive? && price.nil? }
|
|
54
57
|
|
|
55
|
-
|
|
56
|
-
fresh = cached_rate ? [input - cached, 0].max : input
|
|
57
|
-
|
|
58
|
-
millionths = fresh * rate["input"].to_f +
|
|
59
|
-
(cached_rate ? cached * cached_rate.to_f : 0.0) +
|
|
60
|
-
written * (rate["cache_write"] || rate["input"]).to_f +
|
|
61
|
-
output * rate["output"].to_f
|
|
58
|
+
millionths = buckets.sum { |count, price| count.to_i * price.to_f }
|
|
62
59
|
(millionths / 1_000_000.0).round(PRECISION)
|
|
63
60
|
end
|
|
64
61
|
|
|
62
|
+
# The operator's table from INSIKA_MODEL_PRICING (empty when unset): the ONE
|
|
63
|
+
# cost rule Studio, traces and metrics share — the cost the provider library
|
|
64
|
+
# reported, and these rates only for what it could not price.
|
|
65
|
+
def self.from_env(env = ENV) = parse(Insika::EnvSchema.read("INSIKA_MODEL_PRICING", env))
|
|
66
|
+
|
|
65
67
|
# Parses the operator's table. Accepts a JSON object; anything else (blank,
|
|
66
68
|
# malformed, not an object) -> an EMPTY Pricing, never an exception: telemetry
|
|
67
69
|
# config must not be able to stop a boot.
|
|
@@ -18,8 +18,8 @@ module Insika
|
|
|
18
18
|
# a deliberate LOW-CARDINALITY subset of the span attributes — never task_id or
|
|
19
19
|
# session_id. `meter:` nil -> spans only (the metrics SDK is optional).
|
|
20
20
|
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
21
|
+
# Native attempt-time cost is the default. An operator-declared rates table
|
|
22
|
+
# can override it with a negotiated-price estimate (see Pricing).
|
|
23
23
|
#
|
|
24
24
|
# PURE/testable: talks to a DUCK-TYPED `tracer` (start_span/set_attribute/
|
|
25
25
|
# record_error/finish) and `meter` (create_counter/create_histogram -> add/
|
|
@@ -29,7 +29,7 @@ module Insika
|
|
|
29
29
|
# Robust: `record` NEVER raises (telemetry doesn't bring down a turn). Timestamps
|
|
30
30
|
# come from each event's `meta.at` (spans reconstructed with real time).
|
|
31
31
|
class Recorder
|
|
32
|
-
Turn = Struct.new(:span, :tools, :labels, :start) # tools = FIFO queue of open tools
|
|
32
|
+
Turn = Struct.new(:span, :tools, :labels, :start, :requests, :requests_truncated) # tools = FIFO queue of open tools
|
|
33
33
|
OpenTool = Struct.new(:span, :name, :start)
|
|
34
34
|
|
|
35
35
|
# Ceiling of open turns: a kill -9 without a terminal event would leave the turn
|
|
@@ -41,9 +41,11 @@ module Insika
|
|
|
41
41
|
# renaming one breaks every dashboard built on it.
|
|
42
42
|
class Instruments
|
|
43
43
|
attr_reader :turns, :turn_duration, :tokens, :cost, :tool_calls, :tool_duration,
|
|
44
|
-
:cache_hit_rate, :loop_intervened, :context_compacted, :tool_blocked
|
|
44
|
+
:cache_hit_rate, :loop_intervened, :context_compacted, :tool_blocked, :confirmations, :llm_attempts, :llm_failures
|
|
45
45
|
|
|
46
46
|
def initialize(meter)
|
|
47
|
+
@llm_attempts = meter.create_counter("insika.llm.attempts", unit: "{attempt}", description: "Native model attempts")
|
|
48
|
+
@llm_failures = meter.create_counter("insika.llm.failures", unit: "{attempt}", description: "Failed native model attempts")
|
|
47
49
|
@turns = meter.create_counter("insika.turns", unit: "{turn}",
|
|
48
50
|
description: "Turns completed, by outcome")
|
|
49
51
|
@turn_duration = meter.create_histogram("insika.turn.duration", unit: "s",
|
|
@@ -62,6 +64,8 @@ module Insika
|
|
|
62
64
|
description: "Loop-detector warnings delivered to the model")
|
|
63
65
|
@tool_blocked = meter.create_counter("insika.tool.blocked", unit: "{call}",
|
|
64
66
|
description: "Tool calls blocked before execution")
|
|
67
|
+
@confirmations = meter.create_counter("insika.confirmation", unit: "{hold}",
|
|
68
|
+
description: "Calls held for the customer's confirmation, by outcome")
|
|
65
69
|
@context_compacted = meter.create_counter("insika.context.compacted", unit: "{compaction}",
|
|
66
70
|
description: "In-session compactions persisted (RFC-0044)")
|
|
67
71
|
end
|
|
@@ -79,11 +83,16 @@ module Insika
|
|
|
79
83
|
data = event.data || {}
|
|
80
84
|
case event.type
|
|
81
85
|
when :task_started then start_turn(meta, data)
|
|
86
|
+
when :llm_request, :llm_usage then model_event(meta, data, event.type)
|
|
82
87
|
when :tool_call then start_tool(meta, data)
|
|
83
88
|
when :tool_result then finish_tool(meta)
|
|
84
89
|
when :data_tool_call then point_tool(meta, data)
|
|
85
90
|
when :tool_loop_intervened then count_loop(meta, data)
|
|
86
91
|
when :tool_blocked then count_blocked(meta, data)
|
|
92
|
+
when :confirmation_requested then count_confirmation(meta, data, "requested")
|
|
93
|
+
when :confirmation_confirmed then count_confirmation(meta, data, "confirmed")
|
|
94
|
+
when :confirmation_cancelled then count_confirmation(meta, data, "cancelled")
|
|
95
|
+
when :confirmation_expired then count_confirmation(meta, data, "expired")
|
|
87
96
|
when :context_compacted then count_compaction(data)
|
|
88
97
|
when :task_completed then finish_turn(meta, data, :ok)
|
|
89
98
|
when :task_failed then finish_turn(meta, data, :error)
|
|
@@ -108,7 +117,7 @@ module Insika
|
|
|
108
117
|
"insika.turn", parent: nil, start_time: at,
|
|
109
118
|
attributes: labels.merge(attrs("insika.task_id" => id, "insika.session_id" => meta[:session_id]))
|
|
110
119
|
)
|
|
111
|
-
@turns[id] = Turn.new(span, [], labels, at)
|
|
120
|
+
@turns[id] = Turn.new(span, [], labels, at, [], false)
|
|
112
121
|
end
|
|
113
122
|
|
|
114
123
|
def start_tool(meta, data)
|
|
@@ -147,11 +156,80 @@ module Insika
|
|
|
147
156
|
at = ts(meta[:at])
|
|
148
157
|
usage = data[:usage]
|
|
149
158
|
set_usage(turn.span, usage)
|
|
159
|
+
rerank_cost = rerank_cost(turn)
|
|
160
|
+
if rerank_cost
|
|
161
|
+
turn.span.set_attribute("insika.cost.rerank_usd", rerank_cost)
|
|
162
|
+
chat_cost = estimated_cost(usage)
|
|
163
|
+
turn.span.set_attribute("insika.cost.total_usd", chat_cost + rerank_cost) if chat_cost
|
|
164
|
+
end
|
|
150
165
|
turn.span.set_attribute("insika.status", status.to_s)
|
|
151
|
-
turn.span.record_error(data[:
|
|
166
|
+
turn.span.record_error(exception_class(data[:error])) if status == :error
|
|
167
|
+
finish_requests(turn)
|
|
152
168
|
turn.tools.each { |t| t.span.finish(end_time: at) } # orphans (failure mid-way)
|
|
153
169
|
turn.span.finish(end_time: at)
|
|
154
|
-
count_turn(turn, usage, status.to_s, elapsed(turn.start, at))
|
|
170
|
+
count_turn(turn, usage, status.to_s, elapsed(turn.start, at), rerank_cost)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def rerank_cost(turn)
|
|
174
|
+
return nil if turn.requests_truncated
|
|
175
|
+
|
|
176
|
+
requests = turn.requests.select { |entry| entry["type"] == "llm_request" && entry["operation"] == "rerank" }
|
|
177
|
+
attempts = turn.requests.select { |entry| entry["type"] == "llm_usage" && entry["operation"] == "rerank" }
|
|
178
|
+
return nil if attempts.empty? || attempts.any? { |entry| !entry["cost"].is_a?(Numeric) }
|
|
179
|
+
return nil if requests.any? { |request| attempts.none? { |entry| entry["request_id"] == request["request_id"] } }
|
|
180
|
+
|
|
181
|
+
attempts.sum { |entry| entry["cost"] }
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def exception_class(value)
|
|
185
|
+
value.is_a?(String) && value.match?(/\A[A-Z]\w*(?:::[A-Z]\w*)*\z/) ? value[0, 256] : "Task failed"
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def model_event(meta, data, type)
|
|
189
|
+
turn = @turns[meta[:task_id]] or return
|
|
190
|
+
entry = LLMTraceStore.sanitize(data.merge("type" => type.to_s, "at" => meta[:at]))
|
|
191
|
+
turn.requests << entry
|
|
192
|
+
if turn.requests.size > LLMTraceStore::MAX_PER_TASK
|
|
193
|
+
turn.requests.shift
|
|
194
|
+
turn.requests_truncated = true
|
|
195
|
+
end
|
|
196
|
+
return unless @instruments && type == :llm_usage
|
|
197
|
+
|
|
198
|
+
labels = turn.labels.merge(attrs("insika.model" => entry["model"], "insika.provider" => entry["provider"],
|
|
199
|
+
"insika.operation" => entry["operation"], "insika.status" => entry["status"]))
|
|
200
|
+
@instruments.llm_attempts.add(1, attributes: labels)
|
|
201
|
+
@instruments.llm_failures.add(1, attributes: labels) if entry["status"] == "failed"
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Success usage can arrive after request completion. Reconstruct at the turn
|
|
205
|
+
# terminal, so exported spans include late usage without modifying closed spans.
|
|
206
|
+
def finish_requests(turn)
|
|
207
|
+
turn.span.set_attribute("insika.llm.truncated", true) if turn.requests_truncated
|
|
208
|
+
turn.requests.group_by { |entry| entry["request_id"] }.each do |id, entries|
|
|
209
|
+
next if id.nil? || id.empty?
|
|
210
|
+
request = entries.find { |entry| entry["type"] == "llm_request" }
|
|
211
|
+
next unless request && request["duration_ms"]
|
|
212
|
+
|
|
213
|
+
at = ts(request["at"])
|
|
214
|
+
next unless at
|
|
215
|
+
attempts = entries.select { |entry| entry["type"] == "llm_usage" }
|
|
216
|
+
attributes = attrs("insika.llm.request_id" => id, "insika.model" => request["model"],
|
|
217
|
+
"insika.provider" => request["provider"], "insika.operation" => request["operation"],
|
|
218
|
+
"insika.status" => request["status"], "insika.llm.duration_ms" => request["duration_ms"],
|
|
219
|
+
"insika.llm.truncated" => turn.requests_truncated,
|
|
220
|
+
"insika.llm.attempts" => attempts.size, "insika.llm.failures" => attempts.count { |e| e["status"] == "failed" })
|
|
221
|
+
attempts.each_with_index do |attempt, index|
|
|
222
|
+
(%w[status] + LLMTraceStore::NUMBER_FIELDS - %w[turn duration_ms]).each do |key|
|
|
223
|
+
attributes["insika.llm.attempt.#{index + 1}.#{key}"] = attempt[key] unless attempt[key].nil?
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
span = @tracer.start_span("insika.llm.request", parent: turn.span,
|
|
227
|
+
start_time: at - request["duration_ms"] / 1000.0, attributes: attributes)
|
|
228
|
+
span.record_error(exception_class(request["exception_class"])) if request["status"] == "failed"
|
|
229
|
+
span.finish(end_time: at)
|
|
230
|
+
rescue StandardError
|
|
231
|
+
# A failed exporter must not prevent remaining requests or the turn closing.
|
|
232
|
+
end
|
|
155
233
|
end
|
|
156
234
|
|
|
157
235
|
def set_usage(span, usage)
|
|
@@ -164,13 +242,14 @@ module Insika
|
|
|
164
242
|
span.set_attribute("insika.tokens.cache_creation", usage[:cache_creation_tokens]) if usage[:cache_creation_tokens]
|
|
165
243
|
span.set_attribute("insika.model", usage[:model].to_s) if usage[:model]
|
|
166
244
|
span.set_attribute("insika.model_source", usage[:model_source].to_s) if usage[:model_source]
|
|
245
|
+
span.set_attribute("insika.provider", usage[:provider].to_s) if usage[:provider]
|
|
167
246
|
cost = estimated_cost(usage)
|
|
168
247
|
span.set_attribute("insika.cost.usd", cost) if cost
|
|
169
248
|
end
|
|
170
249
|
|
|
171
250
|
# --- metrics (no-op when no meter was injected) ------------------------
|
|
172
251
|
|
|
173
|
-
def count_turn(turn, usage, status, seconds)
|
|
252
|
+
def count_turn(turn, usage, status, seconds, rerank_cost = nil)
|
|
174
253
|
return unless @instruments
|
|
175
254
|
|
|
176
255
|
model = usage && usage[:model]
|
|
@@ -178,6 +257,7 @@ module Insika
|
|
|
178
257
|
@instruments.turns.add(1, attributes: labels)
|
|
179
258
|
@instruments.turn_duration.record(seconds, attributes: labels) if seconds
|
|
180
259
|
count_usage(turn, usage)
|
|
260
|
+
@instruments.cost.add(rerank_cost, attributes: turn.labels.merge("insika.operation" => "rerank")) if rerank_cost
|
|
181
261
|
count_cache_hit(turn, usage)
|
|
182
262
|
end
|
|
183
263
|
|
|
@@ -209,6 +289,17 @@ module Insika
|
|
|
209
289
|
@instruments.loop_intervened.add(1, attributes: labels)
|
|
210
290
|
end
|
|
211
291
|
|
|
292
|
+
# One counter for the whole life of a customer hold; `outcome` tells the
|
|
293
|
+
# four moments apart, so requested minus the other three is what is open.
|
|
294
|
+
def count_confirmation(meta, data, outcome)
|
|
295
|
+
return unless @instruments
|
|
296
|
+
|
|
297
|
+
turn = @turns[meta[:task_id]]
|
|
298
|
+
labels = (turn ? turn.labels : {}).merge(attrs("insika.tool" => data[:tool]&.to_s,
|
|
299
|
+
"insika.outcome" => outcome))
|
|
300
|
+
@instruments.confirmations.add(1, attributes: labels)
|
|
301
|
+
end
|
|
302
|
+
|
|
212
303
|
def count_blocked(meta, data)
|
|
213
304
|
return unless @instruments
|
|
214
305
|
|
|
@@ -250,7 +341,9 @@ module Insika
|
|
|
250
341
|
@instruments.tool_duration.record(seconds, attributes: labels) if seconds
|
|
251
342
|
end
|
|
252
343
|
|
|
253
|
-
|
|
344
|
+
# Turn usage arrives already priced per response (Executor#usage_of applies
|
|
345
|
+
# the same rule), so its cost_usd wins; the table covers usage that was not.
|
|
346
|
+
def estimated_cost(usage) = usage&.fetch(:cost_usd, nil) || @pricing&.cost(usage)
|
|
254
347
|
|
|
255
348
|
# -----------------------------------------------------------------------
|
|
256
349
|
|
|
@@ -260,6 +353,7 @@ module Insika
|
|
|
260
353
|
|
|
261
354
|
turn.tools.each { |t| t.span.finish(end_time: nil) }
|
|
262
355
|
turn.span.set_attribute("insika.status", "abandoned")
|
|
356
|
+
finish_requests(turn)
|
|
263
357
|
turn.span.finish(end_time: nil)
|
|
264
358
|
count_turn(turn, nil, "abandoned", nil)
|
|
265
359
|
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
module Telemetry
|
|
7
|
+
# One instance belongs to one chat context, never to a graph or current fiber.
|
|
8
|
+
class RubyLLMInstrumenter
|
|
9
|
+
def initialize(emit:, delegate: nil, operation: nil, model: nil, pricing: nil)
|
|
10
|
+
@emit, @delegate, @operation, @model, @pricing = emit, delegate, operation, model, pricing
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def instrument(name, payload = {}, &block)
|
|
14
|
+
return chat(payload, &block) if name == "chat.ruby_llm" && block
|
|
15
|
+
return request(payload, &block) if name == "request.ruby_llm" && block
|
|
16
|
+
|
|
17
|
+
result = delegate(name, payload, &block)
|
|
18
|
+
record(:llm_usage, payload) if name == "usage.ruby_llm"
|
|
19
|
+
result
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def chat(payload, &block)
|
|
25
|
+
previous_model, previous_request = @model, @request_id
|
|
26
|
+
@model, @request_id = scalar(payload[:model]), nil
|
|
27
|
+
delegate("chat.ruby_llm", payload, &block)
|
|
28
|
+
ensure
|
|
29
|
+
@model, @request_id = previous_model, previous_request
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def request(payload, &block)
|
|
33
|
+
@request_id = SecureRandom.uuid
|
|
34
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
35
|
+
error = nil
|
|
36
|
+
begin
|
|
37
|
+
delegate("request.ruby_llm", payload, &block)
|
|
38
|
+
rescue Exception => error # Preserve cancellation and the original model exception too.
|
|
39
|
+
raise
|
|
40
|
+
ensure
|
|
41
|
+
record(:llm_request, payload, error: error,
|
|
42
|
+
duration_ms: (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Host notifications still wrap the operation. A broken subscriber must not
|
|
47
|
+
# retry the model, replace its result, or swallow/replace its exception.
|
|
48
|
+
def delegate(name, payload, &block)
|
|
49
|
+
return block&.call(payload) unless @delegate.respond_to?(:instrument)
|
|
50
|
+
|
|
51
|
+
called = false
|
|
52
|
+
result = error = nil
|
|
53
|
+
operation = proc do |*args|
|
|
54
|
+
called = true
|
|
55
|
+
begin
|
|
56
|
+
result = block.call(*args)
|
|
57
|
+
rescue Exception => caught
|
|
58
|
+
error = caught
|
|
59
|
+
raise
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
begin
|
|
63
|
+
block ? @delegate.instrument(name, payload, &operation) : @delegate.instrument(name, payload)
|
|
64
|
+
rescue StandardError
|
|
65
|
+
# Telemetry is best effort; the operation's outcome is authoritative.
|
|
66
|
+
end
|
|
67
|
+
raise error if error
|
|
68
|
+
return unless block
|
|
69
|
+
|
|
70
|
+
called ? result : block.call(payload)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def record(type, payload, error: nil, duration_ms: nil)
|
|
74
|
+
data = {
|
|
75
|
+
"operation" => scalar(payload[:operation] || @operation),
|
|
76
|
+
"provider" => scalar(payload[:provider]),
|
|
77
|
+
"model" => scalar(payload[:model] || @model),
|
|
78
|
+
"status" => type == :llm_request ? (error ? "failed" : "succeeded") : scalar(payload[:status]),
|
|
79
|
+
"request_id" => @request_id
|
|
80
|
+
}
|
|
81
|
+
if type == :llm_request
|
|
82
|
+
data["duration_ms"] = duration_ms
|
|
83
|
+
data["exception_class"] = error.class.name if error
|
|
84
|
+
else
|
|
85
|
+
%i[input output cache_read cache_write thinking].each do |field|
|
|
86
|
+
value = payload[:tokens]&.public_send(field)
|
|
87
|
+
data["#{field}_tokens"] = value.is_a?(Numeric) ? value : nil
|
|
88
|
+
end
|
|
89
|
+
value = payload[:cost]&.total
|
|
90
|
+
value = priced(data) unless value.is_a?(Numeric)
|
|
91
|
+
data["cost"] = value.is_a?(Numeric) ? value : nil
|
|
92
|
+
end
|
|
93
|
+
@emit.call(type, data)
|
|
94
|
+
rescue StandardError
|
|
95
|
+
nil
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# The operator's rates fill in only what the library could not price (Pricing.from_env).
|
|
99
|
+
def priced(data)
|
|
100
|
+
@pricing&.cost(model: data["model"], input_tokens: data["input_tokens"],
|
|
101
|
+
output_tokens: data["output_tokens"], cached_tokens: data["cache_read_tokens"],
|
|
102
|
+
cache_creation_tokens: data["cache_write_tokens"])
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def scalar(value)
|
|
106
|
+
value.to_s if value.is_a?(String) || value.is_a?(Symbol)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|