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
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "net/http"
|
|
4
4
|
require "json"
|
|
5
5
|
require "uri"
|
|
6
|
+
require "open3"
|
|
6
7
|
require_relative "assertions"
|
|
7
8
|
|
|
8
9
|
module Insika
|
|
@@ -22,6 +23,11 @@ module Insika
|
|
|
22
23
|
# different facts, and a budget that confuses them stops being a budget.
|
|
23
24
|
TurnOutcome = Struct.new(:result, :ttfb, :total, :usage, keyword_init: true)
|
|
24
25
|
|
|
26
|
+
# The deployment answered the seed with a refusal (403: `evals.seeding` is off
|
|
27
|
+
# there). Its own class so the Runner can SKIP the case with the reason — a
|
|
28
|
+
# different fact from a seed that failed (409, 5xx, network), which fails it.
|
|
29
|
+
class SeedRefused < Insika::Error; end
|
|
30
|
+
|
|
25
31
|
# Pure reduction of the /v1/responses SSE stream. Kept separate from the HTTP so
|
|
26
32
|
# it's testable offline with canned frames (server/responses.rb is the producer).
|
|
27
33
|
module Sse
|
|
@@ -47,28 +53,67 @@ module Insika
|
|
|
47
53
|
|
|
48
54
|
# [payload] -> { output_text:, tool_calls:, usage:, error: }. Maps the Responses
|
|
49
55
|
# frames (see server/responses.rb#frame_for): text deltas accumulate; each
|
|
50
|
-
# function_call item
|
|
51
|
-
#
|
|
52
|
-
#
|
|
56
|
+
# function_call `added` item opens a tool call (name + arguments); its `done`
|
|
57
|
+
# item closes it with how the call ended (status ok/error/blocked + the gate
|
|
58
|
+
# that held it). An older deployment sends `added` only, and the entries keep
|
|
59
|
+
# `status: nil` — the shape widened, nothing moved. A `response.failed` sets
|
|
60
|
+
# the turn error.
|
|
53
61
|
def reduce(payloads)
|
|
54
62
|
text = +""
|
|
55
63
|
tools = []
|
|
64
|
+
by_id = {}
|
|
65
|
+
ui = []
|
|
56
66
|
usage = nil
|
|
57
67
|
error = nil
|
|
58
68
|
payloads.each do |o|
|
|
59
69
|
case o["type"]
|
|
60
70
|
when "response.output_text.delta"
|
|
61
71
|
text << o["delta"].to_s
|
|
72
|
+
when "insika.ui"
|
|
73
|
+
# A presentation tool's selection — the component and how many cards it
|
|
74
|
+
# showed. The `ui_components`/`no_ui` graders read this.
|
|
75
|
+
ui << { "component" => o["component"].to_s, "count" => o["count"].to_i }
|
|
62
76
|
when "response.output_item.added"
|
|
63
77
|
item = o["item"] || {}
|
|
64
|
-
|
|
78
|
+
next unless item["type"] == "function_call"
|
|
79
|
+
|
|
80
|
+
entry = { "name" => item["name"].to_s, "status" => nil }
|
|
81
|
+
(args = arguments_of(item["arguments"])) && (entry["arguments"] = args)
|
|
82
|
+
tools << entry
|
|
83
|
+
(id = item["call_id"]) && (by_id[id] = entry)
|
|
84
|
+
when "response.output_item.done"
|
|
85
|
+
item = o["item"] || {}
|
|
86
|
+
next unless item["type"] == "function_call"
|
|
87
|
+
|
|
88
|
+
# Paired by call_id. An older engine's frames carry none: then the FIRST
|
|
89
|
+
# still-open call of that name (a batch runs concurrently and its `done`
|
|
90
|
+
# frames arrive in completion order). No `added` at all = a system tool
|
|
91
|
+
# the engine reports the end of but never announced (load_skill): not a
|
|
92
|
+
# tool call here, exactly as it is not one in the in-process transport.
|
|
93
|
+
entry = by_id[item["call_id"]] || tools.find { |t| t["name"] == item["name"].to_s && t["status"].nil? }
|
|
94
|
+
next unless entry
|
|
95
|
+
|
|
96
|
+
entry["status"] = item["status"].to_s
|
|
97
|
+
entry["gate"] = item["gate"].to_s if item["gate"]
|
|
65
98
|
when "response.completed"
|
|
66
99
|
usage = o.dig("response", "usage")
|
|
67
100
|
when "response.failed"
|
|
68
101
|
error = o.dig("response", "error", "message") || "response.failed"
|
|
69
102
|
end
|
|
70
103
|
end
|
|
71
|
-
{ output_text: text, tool_calls: tools, usage: usage, error: error }
|
|
104
|
+
{ output_text: text, tool_calls: tools, ui: ui, usage: usage, error: error }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# The item's `arguments` is a JSON string on the wire (the OpenAI shape); the
|
|
108
|
+
# graders want the Hash. Anything unparseable stays the raw string — the call
|
|
109
|
+
# happened, and a report should still show what it saw. nil when absent.
|
|
110
|
+
def arguments_of(raw)
|
|
111
|
+
return nil if raw.nil?
|
|
112
|
+
return raw unless raw.is_a?(String)
|
|
113
|
+
|
|
114
|
+
JSON.parse(raw)
|
|
115
|
+
rescue JSON::ParserError
|
|
116
|
+
raw
|
|
72
117
|
end
|
|
73
118
|
end
|
|
74
119
|
|
|
@@ -160,14 +205,50 @@ module Insika
|
|
|
160
205
|
|
|
161
206
|
reduced = Sse.reduce(Sse.payloads(buffer))
|
|
162
207
|
TurnOutcome.new(
|
|
163
|
-
result: TurnResult.new(output_text: reduced[:output_text],
|
|
164
|
-
|
|
208
|
+
result: TurnResult.new(output_text: reduced[:output_text], tool_calls: reduced[:tool_calls],
|
|
209
|
+
ui: reduced[:ui], error: reduced[:error]),
|
|
165
210
|
ttfb: ttfb, total: (mono - t0) * 1000.0, usage: reduced[:usage]
|
|
166
211
|
)
|
|
167
212
|
rescue StandardError => e
|
|
168
213
|
failure(e.class.to_s, t0)
|
|
169
214
|
end
|
|
170
215
|
|
|
216
|
+
# Loads a case's `state:` into the conversation BEFORE its first turn —
|
|
217
|
+
# `POST /v1/conversations/:conv/seed`, the same Bearer as the turn, so the
|
|
218
|
+
# seeded session is the one the turn continues. The body is the state mapping
|
|
219
|
+
# (+ `customer` when the turns will carry one). A 403 is the deployment
|
|
220
|
+
# refusing to seed (`evals.seeding` off) -> SeedRefused, which the Runner
|
|
221
|
+
# turns into a skip; anything else that is not 2xx is an error that fails the
|
|
222
|
+
# case — a seed that did not land must never let the case run empty and pass.
|
|
223
|
+
def seed(conv, state, customer: nil)
|
|
224
|
+
uri = URI.join("#{@base}/", "v1/conversations/#{URI.encode_www_form_component(conv)}/seed")
|
|
225
|
+
req = Net::HTTP::Post.new(uri)
|
|
226
|
+
req["Authorization"] = "Bearer #{@token}"
|
|
227
|
+
req["Content-Type"] = "application/json"
|
|
228
|
+
body = Insika::Coercion.deep_stringify(state)
|
|
229
|
+
body["customer"] = customer if customer
|
|
230
|
+
req.body = JSON.generate(body)
|
|
231
|
+
|
|
232
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
233
|
+
http.use_ssl = uri.scheme == "https"
|
|
234
|
+
http.read_timeout = @timeout
|
|
235
|
+
http.open_timeout = 10
|
|
236
|
+
res = http.start { http.request(req) }
|
|
237
|
+
code = res.code.to_i
|
|
238
|
+
return true if code.between?(200, 299)
|
|
239
|
+
|
|
240
|
+
message = begin
|
|
241
|
+
JSON.parse(res.body.to_s).dig("error", "message")
|
|
242
|
+
rescue JSON::ParserError
|
|
243
|
+
nil
|
|
244
|
+
end
|
|
245
|
+
raise SeedRefused, "HTTP 403#{" #{message}" if message}" if code == 403
|
|
246
|
+
|
|
247
|
+
raise Insika::Error, "HTTP #{code}#{" #{message}" if message}"
|
|
248
|
+
rescue SystemCallError, IOError, Net::OpenTimeout, Net::ReadTimeout => e
|
|
249
|
+
raise Insika::Error, e.class.to_s
|
|
250
|
+
end
|
|
251
|
+
|
|
171
252
|
private
|
|
172
253
|
|
|
173
254
|
def failure(msg, t0)
|
|
@@ -199,16 +280,18 @@ module Insika
|
|
|
199
280
|
|
|
200
281
|
def turn(agent:, conv:, message:)
|
|
201
282
|
t0 = mono
|
|
202
|
-
sub = @event_stream&.subscribe(types: [
|
|
283
|
+
sub = @event_stream&.subscribe(types: %i[tool_call ui])
|
|
203
284
|
begin
|
|
204
285
|
text = @runtime.chat(message, session_id: conv, agent: agent)
|
|
286
|
+
tools, ui = drain(sub)
|
|
205
287
|
TurnOutcome.new(
|
|
206
|
-
result: TurnResult.new(output_text: text.to_s, tool_calls:
|
|
288
|
+
result: TurnResult.new(output_text: text.to_s, tool_calls: tools, ui: ui, error: nil),
|
|
207
289
|
ttfb: nil, total: (mono - t0) * 1000.0, usage: nil
|
|
208
290
|
)
|
|
209
291
|
rescue Insika::Error => e
|
|
292
|
+
tools, ui = drain(sub)
|
|
210
293
|
TurnOutcome.new(
|
|
211
|
-
result: TurnResult.new(output_text: "", tool_calls:
|
|
294
|
+
result: TurnResult.new(output_text: "", tool_calls: tools, ui: ui, error: e.message),
|
|
212
295
|
ttfb: nil, total: (mono - t0) * 1000.0, usage: nil
|
|
213
296
|
)
|
|
214
297
|
ensure
|
|
@@ -216,14 +299,233 @@ module Insika
|
|
|
216
299
|
end
|
|
217
300
|
end
|
|
218
301
|
|
|
302
|
+
# In-process seeding: the SAME `seed_session` command the HTTP route dispatches,
|
|
303
|
+
# on the graph's own bus — no route, no gate (there is no tenant token to
|
|
304
|
+
# protect here; whoever holds the graph already holds everything). A runtime
|
|
305
|
+
# that exposes no graph cannot seed, and says so as an error the Runner fails
|
|
306
|
+
# the case with.
|
|
307
|
+
def seed(conv, state, customer: nil)
|
|
308
|
+
bus = @runtime.graph.bus if @runtime.respond_to?(:graph) && @runtime.graph
|
|
309
|
+
raise Insika::Error, "transport cannot seed state: the runtime exposes no graph" if bus.nil?
|
|
310
|
+
|
|
311
|
+
payload = { id: conv, state: state }
|
|
312
|
+
payload[:customer] = customer if customer
|
|
313
|
+
bus.dispatch(Insika::Command.build(:seed_session, payload, transport: :internal))
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
private
|
|
317
|
+
|
|
318
|
+
# The same shapes an HTTP replay produces: tools as [{ "name" =>, "status" => nil }]
|
|
319
|
+
# (the stream carries no per-tool status — that lives in the trace store), ui as
|
|
320
|
+
# [{ "component" =>, "count" => }]. -> [tools, ui]
|
|
321
|
+
def drain(sub)
|
|
322
|
+
return [[], []] if sub.nil?
|
|
323
|
+
|
|
324
|
+
events = sub.drain_nonblocking
|
|
325
|
+
tools = events.select { |ev| ev.type == :tool_call }
|
|
326
|
+
.map { |ev| { "name" => ev.data[:name].to_s, "status" => nil } }
|
|
327
|
+
ui = events.select { |ev| ev.type == :ui }
|
|
328
|
+
.map { |ev| { "component" => ev.data[:component].to_s, "count" => ev.data[:count].to_i } }
|
|
329
|
+
[tools, ui]
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
# Drives a turn through an EXTERNAL agent runtime — a rival harness in a
|
|
334
|
+
# container — so the same corpus can be replayed against something that is not
|
|
335
|
+
# Insika and the answers land in the same TurnResult the graders already read.
|
|
336
|
+
#
|
|
337
|
+
# The protocol is one JSON object each way, per turn:
|
|
338
|
+
#
|
|
339
|
+
# in (stdin): {"agent": "...", "conv": "...", "message": "..."}
|
|
340
|
+
# out (stdout): {"output_text": "...", "tool_calls": [...], "error": null,
|
|
341
|
+
# "usage": {...}}
|
|
342
|
+
#
|
|
343
|
+
# `command` is an argv array, never a shell string: the message is customer text
|
|
344
|
+
# and must not be able to reach a shell. The adapter that speaks this protocol
|
|
345
|
+
# lives with the harness under test, not here — every rival has its own idea of
|
|
346
|
+
# what a turn is, and that translation is exactly what a bench entry costs.
|
|
347
|
+
#
|
|
348
|
+
# A harness whose adapter cannot report which tools were called says so ONCE, at
|
|
349
|
+
# construction (`reports_tool_calls: false`), and the Runner skips its tool-shaped
|
|
350
|
+
# graders with the reason. Otherwise a missing `tool_calls` is read as "this turn
|
|
351
|
+
# called nothing", which is a claim, not an absence.
|
|
352
|
+
#
|
|
353
|
+
# Timeout kills the child process; a container it started keeps running, which is
|
|
354
|
+
# the compose file's business (`--rm` and a per-run project name) rather than
|
|
355
|
+
# something to reimplement here.
|
|
356
|
+
class DriverTransport
|
|
357
|
+
def initialize(command:, timeout: 300, reports_tool_calls: true, env: {})
|
|
358
|
+
raise ArgumentError, "command must be an argv array" unless command.is_a?(Array) && !command.empty?
|
|
359
|
+
|
|
360
|
+
@command = command.map(&:to_s)
|
|
361
|
+
@timeout = timeout
|
|
362
|
+
@reports_tool_calls = reports_tool_calls
|
|
363
|
+
@env = env.transform_keys(&:to_s).transform_values(&:to_s)
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
def reports_tool_calls? = @reports_tool_calls
|
|
367
|
+
|
|
368
|
+
def mono = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
369
|
+
|
|
370
|
+
def turn(agent:, conv:, message:)
|
|
371
|
+
t0 = mono
|
|
372
|
+
out, err, status = run_driver(JSON.generate(agent: agent, conv: conv, message: message))
|
|
373
|
+
return failure(err, t0) if status.nil? || !status.success?
|
|
374
|
+
|
|
375
|
+
answer = last_json_object(out)
|
|
376
|
+
return failure("driver wrote no JSON object#{" (stderr: #{tail(err)})" if err}", t0) if answer.nil?
|
|
377
|
+
|
|
378
|
+
TurnOutcome.new(
|
|
379
|
+
result: TurnResult.new(output_text: answer["output_text"].to_s,
|
|
380
|
+
tool_calls: normalize_tool_calls(answer["tool_calls"]),
|
|
381
|
+
ui: [], error: presence(answer["error"])),
|
|
382
|
+
ttfb: nil, total: (mono - t0) * 1000.0, usage: answer["usage"]
|
|
383
|
+
)
|
|
384
|
+
rescue StandardError => e
|
|
385
|
+
failure("#{e.class}: #{e.message}", t0)
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
private
|
|
389
|
+
|
|
390
|
+
# -> [stdout, stderr-or-reason, status]. status nil = it never finished.
|
|
391
|
+
def run_driver(input)
|
|
392
|
+
Open3.popen3(@env, *@command) do |stdin, stdout, stderr, wait_thr|
|
|
393
|
+
stdin.write(input)
|
|
394
|
+
stdin.close
|
|
395
|
+
out = +""
|
|
396
|
+
err = +""
|
|
397
|
+
readers = [Thread.new { out << stdout.read }, Thread.new { err << stderr.read }]
|
|
398
|
+
|
|
399
|
+
if wait_thr.join(@timeout).nil?
|
|
400
|
+
kill(wait_thr.pid)
|
|
401
|
+
readers.each(&:kill)
|
|
402
|
+
return [out, "driver timed out after #{@timeout}s", nil]
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
readers.each(&:join)
|
|
406
|
+
status = wait_thr.value
|
|
407
|
+
[out, status.success? ? err : "exit #{status.exitstatus}#{" — #{tail(err)}" if presence(err)}", status]
|
|
408
|
+
end
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
def kill(pid)
|
|
412
|
+
Process.kill("KILL", pid)
|
|
413
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
414
|
+
nil
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
# The driver's answer is the LAST JSON object it printed: an adapter that logs
|
|
418
|
+
# to stdout is the normal case, and refusing to read past its chatter would
|
|
419
|
+
# turn every such harness into an entry-gate failure it does not deserve.
|
|
420
|
+
def last_json_object(out)
|
|
421
|
+
out.to_s.each_line.reverse_each do |line|
|
|
422
|
+
parsed = begin
|
|
423
|
+
JSON.parse(line)
|
|
424
|
+
rescue JSON::ParserError
|
|
425
|
+
nil
|
|
426
|
+
end
|
|
427
|
+
return parsed if parsed.is_a?(Hash)
|
|
428
|
+
end
|
|
429
|
+
nil
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
# Adapters report tool calls in whichever shape their harness has: a bare name,
|
|
433
|
+
# or an object. Both become the shape the graders read.
|
|
434
|
+
def normalize_tool_calls(raw)
|
|
435
|
+
Array(raw).filter_map do |call|
|
|
436
|
+
case call
|
|
437
|
+
when String then { "name" => call, "status" => nil }
|
|
438
|
+
when Hash
|
|
439
|
+
name = (call["name"] || call[:name]).to_s
|
|
440
|
+
next if name.empty?
|
|
441
|
+
|
|
442
|
+
entry = { "name" => name, "status" => presence(call["status"]) }
|
|
443
|
+
(args = call["arguments"]) && (entry["arguments"] = args)
|
|
444
|
+
(gate = presence(call["gate"])) && (entry["gate"] = gate)
|
|
445
|
+
entry
|
|
446
|
+
end
|
|
447
|
+
end
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
def presence(value)
|
|
451
|
+
s = value.to_s.strip
|
|
452
|
+
s.empty? ? nil : s
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
def tail(text) = text.to_s.strip.lines.last(3).join.strip
|
|
456
|
+
|
|
457
|
+
def failure(msg, t0)
|
|
458
|
+
TurnOutcome.new(result: TurnResult.new(output_text: "", tool_calls: [], ui: [], error: msg.to_s),
|
|
459
|
+
ttfb: nil, total: (mono - t0) * 1000.0, usage: nil)
|
|
460
|
+
end
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
# WHAT THE STORE HOLDS, for the `store_state:` graders. The bench dumps the
|
|
464
|
+
# store to one JSON file ({ collection => [rows] }) after the turn and points
|
|
465
|
+
# the runner at it; the file is re-read on every call, so the same reader serves
|
|
466
|
+
# a whole run as the dump is rewritten between tasks.
|
|
467
|
+
#
|
|
468
|
+
# Deliberately a file and not an MCP client: the harnesses under test reach the
|
|
469
|
+
# store over MCP and nothing else, but the GRADER is not a harness — reading the
|
|
470
|
+
# dumped truth is stricter than asking the same surface the agent could have
|
|
471
|
+
# confused, and it costs no per-store tool mapping.
|
|
472
|
+
class JsonStoreReader
|
|
473
|
+
def initialize(path)
|
|
474
|
+
@path = path
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
def read(collection)
|
|
478
|
+
snapshot = JSON.parse(File.read(@path))
|
|
479
|
+
raise Insika::Error, "#{@path}: expected a mapping of collection -> rows" unless snapshot.is_a?(Hash)
|
|
480
|
+
|
|
481
|
+
Array(snapshot[collection.to_s])
|
|
482
|
+
rescue Errno::ENOENT
|
|
483
|
+
raise Insika::Error, "#{@path}: no store snapshot on disk"
|
|
484
|
+
rescue JSON::ParserError => e
|
|
485
|
+
raise Insika::Error, "#{@path}: #{e.message}"
|
|
486
|
+
end
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
# The store, asked for its own state by running a command — `docker compose exec
|
|
490
|
+
# store dump-state`, or whatever a given store answers with. Preferred over the
|
|
491
|
+
# file reader for a benched harness: the dump then happens AFTER the turn, outside
|
|
492
|
+
# the window the runner is timing, so the store's own latency never lands in the
|
|
493
|
+
# harness's column.
|
|
494
|
+
#
|
|
495
|
+
# The dump is read ONCE per case and cached, because a case asks for several
|
|
496
|
+
# collections and dumping twice would report two different stores. `forget` is how
|
|
497
|
+
# the Runner opens a new case; a reader nobody resets would grade every case
|
|
498
|
+
# against the first one.
|
|
499
|
+
class CommandStoreReader
|
|
500
|
+
def initialize(command:, timeout: 60)
|
|
501
|
+
raise ArgumentError, "command must be an argv array" unless command.is_a?(Array) && !command.empty?
|
|
502
|
+
|
|
503
|
+
@command = command.map(&:to_s)
|
|
504
|
+
@timeout = timeout
|
|
505
|
+
@snapshot = nil
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
def forget = @snapshot = nil
|
|
509
|
+
|
|
510
|
+
def read(collection)
|
|
511
|
+
@snapshot ||= dump
|
|
512
|
+
Array(@snapshot[collection.to_s])
|
|
513
|
+
end
|
|
514
|
+
|
|
219
515
|
private
|
|
220
516
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
517
|
+
def dump
|
|
518
|
+
out, err, status = Open3.capture3(*@command)
|
|
519
|
+
raise Insika::Error, "store dump exited #{status.exitstatus}: #{err.to_s.strip.lines.last}" unless status.success?
|
|
520
|
+
|
|
521
|
+
parsed = JSON.parse(out)
|
|
522
|
+
raise Insika::Error, "store dump is not a mapping of collection -> rows" unless parsed.is_a?(Hash)
|
|
225
523
|
|
|
226
|
-
|
|
524
|
+
parsed
|
|
525
|
+
rescue JSON::ParserError => e
|
|
526
|
+
raise Insika::Error, "store dump is not JSON: #{e.message}"
|
|
527
|
+
rescue SystemCallError => e
|
|
528
|
+
raise Insika::Error, "store dump could not run: #{e.message}"
|
|
227
529
|
end
|
|
228
530
|
end
|
|
229
531
|
|
data/lib/insika/event_stream.rb
CHANGED
|
@@ -9,6 +9,7 @@ module Insika
|
|
|
9
9
|
# has its own queue and `emit` only enqueues. No mutex: one reactor, cooperative
|
|
10
10
|
# fibers — a plain Array is enough.
|
|
11
11
|
class EventStream
|
|
12
|
+
EMPTY = [].freeze
|
|
12
13
|
# One subscription = one queue. The consumer blocks on `each` (its own fiber),
|
|
13
14
|
# never the emitter.
|
|
14
15
|
class Subscription
|
|
@@ -20,6 +21,8 @@ module Insika
|
|
|
20
21
|
# local :error event — the turn never waits on transport.
|
|
21
22
|
MAX_QUEUED = 1000
|
|
22
23
|
|
|
24
|
+
attr_reader :session_id
|
|
25
|
+
|
|
23
26
|
def initialize(task_id: nil, session_id: nil, tenant: nil, types: nil, on_close: nil)
|
|
24
27
|
@task_id = task_id
|
|
25
28
|
@session_id = session_id
|
|
@@ -106,19 +109,26 @@ module Insika
|
|
|
106
109
|
end
|
|
107
110
|
end
|
|
108
111
|
|
|
112
|
+
# Subscriptions by the session they filter on (nil = any session). A session
|
|
113
|
+
# filter never changes after subscribe, so an event only needs the any-session
|
|
114
|
+
# list plus its own session's: emit does not walk every open conversation.
|
|
109
115
|
def initialize
|
|
110
|
-
@subscriptions =
|
|
116
|
+
@subscriptions = {}
|
|
117
|
+
@running = {} # session_id -> { task_id -> [Event] } of the turns still running
|
|
111
118
|
end
|
|
112
119
|
|
|
113
120
|
# NEVER raises: an observer's exception is isolated — a
|
|
114
121
|
# broken observer does not bring down the turn. Synchronous and cheap.
|
|
115
122
|
#
|
|
116
|
-
# Iterates over a SNAPSHOT (
|
|
123
|
+
# Iterates over a SNAPSHOT (a new array): a subscription's cap may close it
|
|
117
124
|
# DURING the push (overflow -> close -> on_close removes from the array);
|
|
118
125
|
# mutating the array in the middle of a plain Array#each would skip the next
|
|
119
126
|
# subscriber.
|
|
120
127
|
def emit(event)
|
|
121
|
-
|
|
128
|
+
session_id = event.meta&.[](:session_id)
|
|
129
|
+
candidates = @subscriptions.fetch(nil, EMPTY) + (session_id.nil? ? EMPTY : @subscriptions.fetch(session_id, EMPTY))
|
|
130
|
+
remember(event, session_id)
|
|
131
|
+
candidates.each do |sub|
|
|
122
132
|
sub.push(event) if sub.matches?(event)
|
|
123
133
|
rescue StandardError
|
|
124
134
|
# a broken observer does not bring down the turn; nothing to propagate
|
|
@@ -130,13 +140,59 @@ module Insika
|
|
|
130
140
|
# `#each` on its own fiber). `tenant:` scopes the stream to one tenant's
|
|
131
141
|
# events (WS1) — fail-closed, see Subscription#matches?. `types:` (nil =
|
|
132
142
|
# any) filters by event type so a subscriber's queue only ever holds what
|
|
133
|
-
# its consumer answers (WS6).
|
|
134
|
-
|
|
143
|
+
# its consumer answers (WS6). `replay: true` first hands over what the
|
|
144
|
+
# session's running turns already emitted: the transcript is only saved when
|
|
145
|
+
# a turn ends, so a viewer who arrives mid-turn would otherwise miss it.
|
|
146
|
+
def subscribe(task_id: nil, session_id: nil, tenant: nil, types: nil, replay: false)
|
|
135
147
|
sub = Subscription.new(task_id: task_id, session_id: session_id, tenant: tenant,
|
|
136
148
|
types: types,
|
|
137
|
-
on_close: ->(s) {
|
|
138
|
-
|
|
149
|
+
on_close: ->(s) { unsubscribe(s) })
|
|
150
|
+
running_events(session_id).each { |event| sub.push(event) if sub.matches?(event) } if replay && session_id
|
|
151
|
+
(@subscriptions[session_id] ||= []) << sub
|
|
139
152
|
sub
|
|
140
153
|
end
|
|
154
|
+
|
|
155
|
+
private
|
|
156
|
+
|
|
157
|
+
TERMINAL = %i[task_completed task_failed task_cancelled].freeze
|
|
158
|
+
DELTAS = %i[intermediate thinking].freeze
|
|
159
|
+
# Sessions whose running turn is kept. A turn that never reaches a terminal
|
|
160
|
+
# (a crash) would stay forever; past this, the oldest is dropped.
|
|
161
|
+
# ponytail: count cap, not a TTL — add one if stuck turns pile up.
|
|
162
|
+
MAX_RUNNING = 200
|
|
163
|
+
|
|
164
|
+
# Keeps the running turn of each session; consecutive deltas are merged into
|
|
165
|
+
# one event (carrying the newest meta/seq) so a long turn stays a short list.
|
|
166
|
+
def remember(event, session_id)
|
|
167
|
+
task_id = event.meta&.[](:task_id)
|
|
168
|
+
return if session_id.nil? || task_id.nil?
|
|
169
|
+
|
|
170
|
+
if TERMINAL.include?(event.type)
|
|
171
|
+
turns = @running[session_id] or return
|
|
172
|
+
turns.delete(task_id)
|
|
173
|
+
@running.delete(session_id) if turns.empty?
|
|
174
|
+
return
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
events = ((@running[session_id] ||= {})[task_id] ||= [])
|
|
178
|
+
last = events.last
|
|
179
|
+
if last && DELTAS.include?(event.type) && last.type == event.type
|
|
180
|
+
events[-1] = event.with(data: last.data.merge(delta: last.data[:delta].to_s + event.data[:delta].to_s))
|
|
181
|
+
else
|
|
182
|
+
events << event
|
|
183
|
+
end
|
|
184
|
+
@running.delete(@running.keys.first) if @running.size > MAX_RUNNING
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def running_events(session_id)
|
|
188
|
+
@running.fetch(session_id, {}).values.flatten
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def unsubscribe(sub)
|
|
193
|
+
list = @subscriptions[sub.session_id] or return
|
|
194
|
+
list.delete(sub)
|
|
195
|
+
@subscriptions.delete(sub.session_id) if list.empty?
|
|
196
|
+
end
|
|
141
197
|
end
|
|
142
198
|
end
|