insika 0.9.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +237 -2
- data/README.md +5 -3
- data/bin/insika +3 -0
- data/bin/insika-router +0 -1
- data/docs/AGENTS.md +90 -7
- data/docs/ARCHITECTURE.md +12 -7
- data/docs/ARTIFACTS.md +33 -3
- data/docs/BENCHMARK.md +50 -7
- data/docs/CONTEXT.md +63 -0
- data/docs/CROSS-HARNESS-BENCH.md +157 -0
- data/docs/DEPLOY.md +57 -11
- data/docs/EMBEDDING.md +10 -0
- data/docs/EVALS.md +33 -4
- data/docs/FACTS.md +8 -6
- data/docs/HARVEST.md +8 -3
- data/docs/KNOWLEDGE.md +7 -0
- data/docs/MEDIA.md +6 -2
- data/docs/OBSERVABILITY.md +143 -22
- data/docs/PERFORMANCE.md +110 -0
- data/docs/PLUGINS.md +4 -4
- data/docs/POLICY.md +22 -13
- data/docs/RELEASING.md +5 -0
- data/docs/ROUTER.md +7 -0
- data/docs/RUBYLLM_2_MIGRATION.md +307 -0
- data/docs/SECURITY.md +7 -0
- data/docs/SHARED-CONVERSATIONS.md +40 -0
- data/docs/SKILLS.md +5 -3
- data/docs/STUDIO.md +122 -0
- data/docs/TOOLS.md +111 -9
- data/docs/operate.md +3 -0
- data/lib/insika/agent_defaults.rb +140 -0
- data/lib/insika/agent_file_store.rb +12 -0
- data/lib/insika/agent_profile.rb +78 -8
- data/lib/insika/artifact_signing.rb +11 -0
- data/lib/insika/chat_builder.rb +79 -25
- data/lib/insika/checkpoint.rb +6 -1
- data/lib/insika/checkpoint_store.rb +59 -7
- data/lib/insika/commands/agent_payload.rb +3 -3
- data/lib/insika/commands/delete_kit.rb +26 -0
- data/lib/insika/commands/gate_harvest.rb +3 -0
- data/lib/insika/commands/review_guardrail_flag.rb +38 -0
- data/lib/insika/commands/run_harvest.rb +17 -1
- data/lib/insika/commands/seed_session.rb +11 -1
- data/lib/insika/commands/send_message.rb +9 -3
- data/lib/insika/commands/update_settings.rb +23 -1
- data/lib/insika/commands/write_kit.rb +27 -0
- data/lib/insika/compaction.rb +33 -7
- data/lib/insika/config_store.rb +87 -7
- data/lib/insika/context/priority.rb +3 -0
- data/lib/insika/context/provider.rb +2 -2
- data/lib/insika/context/providers/knowledge.rb +25 -2
- data/lib/insika/context/providers/memory.rb +53 -4
- data/lib/insika/context/providers/pending_confirmation.rb +52 -0
- data/lib/insika/context/providers/prompt.rb +17 -4
- data/lib/insika/context/providers/session.rb +39 -3
- data/lib/insika/context/reranker.rb +75 -0
- data/lib/insika/distill.rb +22 -13
- data/lib/insika/doctor.rb +34 -4
- data/lib/insika/dsl/runtime.rb +36 -4
- data/lib/insika/dsl/server_boot.rb +4 -12
- data/lib/insika/dsl.rb +13 -5
- data/lib/insika/edge_limiter.rb +23 -15
- data/lib/insika/egress_guard.rb +11 -3
- data/lib/insika/env_schema.rb +33 -73
- data/lib/insika/evals/assertions.rb +171 -16
- data/lib/insika/evals/golden.rb +141 -4
- data/lib/insika/evals/judge.rb +19 -10
- data/lib/insika/evals/moderator_compare.rb +47 -0
- data/lib/insika/evals/report.rb +61 -9
- data/lib/insika/evals/runner.rb +44 -4
- data/lib/insika/evals/simulator.rb +10 -1
- data/lib/insika/evals/transport.rb +200 -0
- data/lib/insika/event_stream.rb +63 -7
- data/lib/insika/evidence.rb +29 -8
- data/lib/insika/executor.rb +668 -141
- data/lib/insika/harvest.rb +26 -14
- data/lib/insika/harvest_engine.rb +1 -0
- data/lib/insika/harvest_store.rb +12 -0
- data/lib/insika/http_client.rb +87 -4
- data/lib/insika/kits.rb +54 -0
- data/lib/insika/knowledge.rb +34 -20
- data/lib/insika/llm_configurator.rb +46 -4
- data/lib/insika/llm_provider_store.rb +1 -3
- data/lib/insika/llm_trace_store.rb +54 -0
- data/lib/insika/mcp_client.rb +27 -63
- data/lib/insika/mcp_live_tool.rb +55 -9
- data/lib/insika/mcp_store.rb +32 -1
- data/lib/insika/mcp_tool_registry.rb +69 -26
- data/lib/insika/media.rb +38 -25
- data/lib/insika/memory_store.rb +4 -3
- data/lib/insika/memory_trim.rb +60 -0
- data/lib/insika/middleware.rb +12 -3
- data/lib/insika/model_metrics_store.rb +285 -0
- data/lib/insika/model_registry.rb +110 -0
- data/lib/insika/model_resolver.rb +1 -1
- data/lib/insika/model_selection.rb +14 -21
- data/lib/insika/model_visible.rb +17 -6
- data/lib/insika/onboarding.rb +1 -0
- data/lib/insika/outcome_store.rb +1 -1
- data/lib/insika/overlay_tool_registry.rb +25 -2
- data/lib/insika/pack_importer.rb +54 -9
- data/lib/insika/packaging.rb +14 -5
- data/lib/insika/pending_action_store.rb +45 -18
- data/lib/insika/plugin/loader.rb +4 -2
- data/lib/insika/profile_source.rb +4 -0
- data/lib/insika/provider_error_classifier.rb +18 -3
- data/lib/insika/queue_policy.rb +7 -2
- data/lib/insika/refinement/proposer.rb +42 -17
- data/lib/insika/reliability.rb +12 -3
- data/lib/insika/router/app.rb +14 -5
- data/lib/insika/router/session_key.rb +35 -5
- data/lib/insika/safety/config.rb +20 -3
- data/lib/insika/safety/corpus.rb +10 -3
- data/lib/insika/safety/factory.rb +39 -1
- data/lib/insika/safety/input_guardrail.rb +21 -1
- data/lib/insika/safety/judge_moderator.rb +70 -0
- data/lib/insika/safety/output_validator.rb +29 -5
- data/lib/insika/safety/prompt_echo.rb +89 -0
- data/lib/insika/schema_guard.rb +53 -76
- data/lib/insika/server/app.rb +17 -8
- data/lib/insika/server/responses.rb +19 -1
- data/lib/insika/session_actor.rb +58 -28
- data/lib/insika/session_store.rb +159 -10
- data/lib/insika/settings_store.rb +47 -1
- data/lib/insika/shared_conversations.rb +305 -0
- data/lib/insika/skill_catalog.rb +28 -2
- data/lib/insika/skill_store.rb +6 -0
- data/lib/insika/soak/runner.rb +1 -1
- data/lib/insika/steer_injector.rb +7 -48
- data/lib/insika/store.rb +29 -1
- data/lib/insika/stores/json_value.rb +37 -0
- data/lib/insika/stores/memory.rb +22 -35
- data/lib/insika/stores/postgres.rb +295 -0
- data/lib/insika/stores/sqlite.rb +257 -43
- data/lib/insika/stores/turn_counter.rb +25 -0
- data/lib/insika/studio/app.rb +1453 -161
- data/lib/insika/studio/assets/dist/application.css +1 -1
- data/lib/insika/studio/assets/dist/application.js +25 -25
- data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-400-normal.woff2 +0 -0
- data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-500-normal.woff2 +0 -0
- data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-600-normal.woff2 +0 -0
- data/lib/insika/studio/assets/dist/fonts/ibm-plex-sans-latin-wght-normal.woff2 +0 -0
- data/lib/insika/studio/forms.rb +171 -9
- data/lib/insika/studio/nav_icons.rb +3 -0
- data/lib/insika/studio/views/_agent_config_retrieval.erb +73 -0
- data/lib/insika/studio/views/_agent_picker.erb +15 -0
- data/lib/insika/studio/views/_agent_tab_config.erb +78 -242
- data/lib/insika/studio/views/_agent_tab_history.erb +56 -1
- data/lib/insika/studio/views/_agent_tab_performance.erb +34 -0
- data/lib/insika/studio/views/_agent_tab_prompts.erb +7 -9
- data/lib/insika/studio/views/_agent_tab_skills.erb +16 -7
- data/lib/insika/studio/views/_agent_tab_tools.erb +90 -0
- data/lib/insika/studio/views/_agents_master.erb +8 -12
- data/lib/insika/studio/views/_cfg_alerts.erb +4 -0
- data/lib/insika/studio/views/_cfg_budget.erb +20 -0
- data/lib/insika/studio/views/_cfg_distill.erb +26 -0
- data/lib/insika/studio/views/_cfg_followup.erb +30 -0
- data/lib/insika/studio/views/_cfg_grounding.erb +21 -0
- data/lib/insika/studio/views/_cfg_guardrails.erb +47 -0
- data/lib/insika/studio/views/_cfg_harvest.erb +35 -0
- data/lib/insika/studio/views/_cfg_reliability.erb +32 -0
- data/lib/insika/studio/views/_chats_master.erb +66 -0
- data/lib/insika/studio/views/_customers_master.erb +37 -0
- data/lib/insika/studio/views/_empty.erb +15 -0
- data/lib/insika/studio/views/_facts_master.erb +42 -0
- data/lib/insika/studio/views/_harvest_candidate_detail.erb +14 -0
- data/lib/insika/studio/views/_identity.erb +16 -0
- data/lib/insika/studio/views/_kpi.erb +17 -0
- data/lib/insika/studio/views/_message.erb +40 -2
- data/lib/insika/studio/views/_model_chart.erb +82 -0
- data/lib/insika/studio/views/_refinement_master.erb +38 -0
- data/lib/insika/studio/views/{tool_edit.erb → _tool_edit.erb} +32 -9
- data/lib/insika/studio/views/_tool_users.erb +17 -0
- data/lib/insika/studio/views/_why.erb +53 -0
- data/lib/insika/studio/views/agent_detail.erb +17 -10
- data/lib/insika/studio/views/agents.erb +28 -16
- data/lib/insika/studio/views/approvals.erb +16 -11
- data/lib/insika/studio/views/artifact.erb +20 -7
- data/lib/insika/studio/views/artifacts.erb +19 -14
- data/lib/insika/studio/views/chats.erb +22 -29
- data/lib/insika/studio/views/customer.erb +88 -43
- data/lib/insika/studio/views/customers.erb +16 -26
- data/lib/insika/studio/views/evals.erb +49 -31
- data/lib/insika/studio/views/fact_detail.erb +114 -0
- data/lib/insika/studio/views/facts.erb +15 -116
- data/lib/insika/studio/views/followups.erb +63 -52
- data/lib/insika/studio/views/funnel.erb +104 -43
- data/lib/insika/studio/views/harvest.erb +85 -59
- data/lib/insika/studio/views/home.erb +138 -23
- data/lib/insika/studio/views/inbox.erb +36 -0
- data/lib/insika/studio/views/kits.erb +176 -0
- data/lib/insika/studio/views/knowledge.erb +92 -80
- data/lib/insika/studio/views/layout.erb +42 -11
- data/lib/insika/studio/views/live.erb +45 -0
- data/lib/insika/studio/views/mcp.erb +43 -13
- data/lib/insika/studio/views/models.erb +187 -0
- data/lib/insika/studio/views/parity.erb +38 -32
- data/lib/insika/studio/views/playground.erb +13 -9
- data/lib/insika/studio/views/refinement.erb +15 -224
- data/lib/insika/studio/views/refinement_detail.erb +243 -0
- data/lib/insika/studio/views/session.erb +152 -117
- data/lib/insika/studio/views/settings.erb +86 -22
- data/lib/insika/studio/views/skills.erb +53 -19
- data/lib/insika/studio/views/system_files.erb +7 -7
- data/lib/insika/studio/views/task.erb +45 -15
- data/lib/insika/studio/views/tasks.erb +35 -18
- data/lib/insika/studio/views/tools.erb +62 -73
- data/lib/insika/task_actor.rb +4 -1
- data/lib/insika/task_store.rb +67 -1
- data/lib/insika/telemetry/pricing.rb +16 -14
- data/lib/insika/telemetry/recorder.rb +103 -9
- data/lib/insika/telemetry/ruby_llm_instrumenter.rb +110 -0
- data/lib/insika/telemetry.rb +12 -2
- data/lib/insika/templates/daily-digest/README.md +13 -0
- data/lib/insika/templates/daily-digest/agent.rb +22 -11
- data/lib/insika/testing/store_contract.rb +65 -0
- data/lib/insika/text_tool_call.rb +66 -0
- data/lib/insika/tick.rb +7 -1
- data/lib/insika/token_store.rb +5 -0
- data/lib/insika/tool_batch.rb +1 -1
- data/lib/insika/tool_catalog.rb +12 -2
- data/lib/insika/tool_definition.rb +10 -4
- data/lib/insika/tool_envelope.rb +165 -28
- data/lib/insika/tool_manifest.rb +1 -0
- data/lib/insika/tool_store.rb +3 -0
- data/lib/insika/tool_trace_store.rb +15 -8
- data/lib/insika/tool_usage_report.rb +2 -2
- data/lib/insika/tools/a2a_remote.rb +1 -1
- data/lib/insika/tools/agent_enum.rb +1 -1
- data/lib/insika/tools/confirm_pending.rb +112 -0
- data/lib/insika/tools/data_defined_tool.rb +23 -15
- data/lib/insika/tools/generate_image.rb +2 -2
- data/lib/insika/tools/load_knowledge.rb +7 -3
- data/lib/insika/tools/load_skill.rb +3 -3
- data/lib/insika/tools/present.rb +1 -8
- data/lib/insika/tools/remember.rb +7 -2
- data/lib/insika/tools/run_persona_eval.rb +3 -5
- data/lib/insika/tools/save_artifact.rb +16 -6
- data/lib/insika/tools/schedule_followup.rb +3 -3
- data/lib/insika/tools/stuck_signal.rb +3 -3
- data/lib/insika/tools/subagent.rb +5 -5
- data/lib/insika/tools/subagents.rb +3 -3
- data/lib/insika/tools/tool_search.rb +7 -5
- data/lib/insika/tools/tts.rb +2 -2
- data/lib/insika/tools/update_briefing.rb +4 -4
- data/lib/insika/turn_output.rb +2 -2
- data/lib/insika/turn_state.rb +18 -0
- data/lib/insika/turn_timing.rb +51 -14
- data/lib/insika/version.rb +1 -1
- data/lib/insika/wiring/graph.rb +81 -16
- data/lib/insika/workflow.rb +25 -64
- data/lib/insika.rb +19 -3
- metadata +69 -13
- data/lib/insika/commands/import_mcp_tools.rb +0 -48
- data/lib/insika/mcp_http_client.rb +0 -67
- data/lib/insika/mcp_tool_ingestor.rb +0 -165
data/lib/insika/evals/report.rb
CHANGED
|
@@ -12,7 +12,15 @@ module Insika
|
|
|
12
12
|
module_function
|
|
13
13
|
|
|
14
14
|
# -> Hash ready for JSON. `at` is an ISO-8601 string stamped by the caller.
|
|
15
|
-
|
|
15
|
+
#
|
|
16
|
+
# `perf` is what each case COST, keyed by case id: `{"ms" =>, "turns" =>,
|
|
17
|
+
# "tokens" =>, "cached" =>}`. Optional, because the answer to "did it pass" must
|
|
18
|
+
# not depend on anyone having measured the clock — but a cross-harness table
|
|
19
|
+
# without it is a table about correctness only, and a merchant asks how long the
|
|
20
|
+
# customer waited before asking anything else. Wall clock is measured by the
|
|
21
|
+
# RUNNER around the turn, never self-reported: it is the one column every
|
|
22
|
+
# entrant can be held to.
|
|
23
|
+
def to_h(results, at:, perf: nil)
|
|
16
24
|
passed = results.count(&:pass?)
|
|
17
25
|
skipped = results.count(&:skipped?)
|
|
18
26
|
{
|
|
@@ -27,17 +35,22 @@ module Insika
|
|
|
27
35
|
# Absent (not an empty tally) when nothing was compared — a run with no
|
|
28
36
|
# pairwise is the normal one, and a zeroed block reads like every case tied.
|
|
29
37
|
"pairwise" => pairwise_summary(results),
|
|
38
|
+
"perf" => perf_summary(perf),
|
|
30
39
|
"cases" => results.map do |r|
|
|
31
40
|
{
|
|
32
41
|
"id" => r.id, "agent" => r.agent, "pass" => r.pass?,
|
|
33
42
|
"skipped" => r.skipped,
|
|
34
43
|
"judge_pending" => r.judge_pending?, "error" => r.error,
|
|
35
|
-
"checks" => r.checks.map
|
|
44
|
+
"checks" => r.checks.map do |c|
|
|
45
|
+
{ "name" => c.name, "pass" => c.pass, "detail" => c.detail,
|
|
46
|
+
"skipped" => (true if c.skipped?) }.compact
|
|
47
|
+
end,
|
|
36
48
|
"judge" => (r.judge && { "score" => r.judge.score, "pass" => r.judge.pass, "reason" => r.judge.reason }),
|
|
37
49
|
"pairwise" => (r.pairwise && { "outcome" => r.pairwise.outcome, "vs" => r.pairwise.vs,
|
|
38
50
|
"reason" => r.pairwise.reason, "judges" => r.pairwise.judges,
|
|
39
|
-
"order_dependent" => r.pairwise.order_dependent })
|
|
40
|
-
|
|
51
|
+
"order_dependent" => r.pairwise.order_dependent }),
|
|
52
|
+
"perf" => perf&.[](r.id)
|
|
53
|
+
}.compact
|
|
41
54
|
end
|
|
42
55
|
}.compact
|
|
43
56
|
end
|
|
@@ -57,8 +70,36 @@ module Insika
|
|
|
57
70
|
"outcomes" => counts }
|
|
58
71
|
end
|
|
59
72
|
|
|
60
|
-
|
|
61
|
-
|
|
73
|
+
# nil when nothing was timed — an absent block, never a zeroed one: "nobody
|
|
74
|
+
# measured" and "it took no time" are different facts, and a table that confuses
|
|
75
|
+
# them publishes a harness as instant.
|
|
76
|
+
def perf_summary(perf)
|
|
77
|
+
measured = Array(perf&.values).select { |v| v["ms"] }
|
|
78
|
+
return nil if measured.empty?
|
|
79
|
+
|
|
80
|
+
case_ms = measured.map { |v| v["ms"].to_f }.sort
|
|
81
|
+
turn_ms = measured.flat_map { |v| Array(v["turn_ms"]) }.compact.sort
|
|
82
|
+
tokens = measured.filter_map { |v| v["tokens"] }
|
|
83
|
+
{ "cases_measured" => measured.size,
|
|
84
|
+
"case_ms_p50" => percentile(case_ms, 50), "case_ms_p95" => percentile(case_ms, 95),
|
|
85
|
+
"turn_ms_p50" => percentile(turn_ms, 50), "turn_ms_p95" => percentile(turn_ms, 95),
|
|
86
|
+
# Absent rather than zero when no provider reported usage — most rival
|
|
87
|
+
# harnesses will not, and a zero there reads as free.
|
|
88
|
+
"tokens_total" => (tokens.sum unless tokens.empty?),
|
|
89
|
+
"tokens_measured" => tokens.size }.compact
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def percentile(sorted, p)
|
|
93
|
+
return nil if sorted.empty?
|
|
94
|
+
|
|
95
|
+
r = (p / 100.0) * (sorted.length - 1)
|
|
96
|
+
lo = sorted[r.floor]
|
|
97
|
+
hi = sorted[r.ceil]
|
|
98
|
+
(lo + ((hi - lo) * (r - r.floor))).round
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def to_json(results, at:, perf: nil)
|
|
102
|
+
JSON.pretty_generate(to_h(results, at: at, perf: perf))
|
|
62
103
|
end
|
|
63
104
|
|
|
64
105
|
PAIRWISE_MARK = { "better" => "🟢", "comparable" => "🟡", "worse" => "🔴",
|
|
@@ -85,8 +126,8 @@ module Insika
|
|
|
85
126
|
end
|
|
86
127
|
|
|
87
128
|
# Human summary. One line per case; failing checks nested underneath.
|
|
88
|
-
def to_markdown(results, at:)
|
|
89
|
-
h = to_h(results, at: at)
|
|
129
|
+
def to_markdown(results, at:, perf: nil)
|
|
130
|
+
h = to_h(results, at: at, perf: perf)
|
|
90
131
|
lines = ["# Eval report — #{at}", "",
|
|
91
132
|
"**#{h['passed']}/#{h['total'] - h['skipped']} passed** · #{h['failed']} failed" \
|
|
92
133
|
"#{" · #{h['skipped']} skipped" if h['skipped'].positive?}" \
|
|
@@ -99,14 +140,25 @@ module Insika
|
|
|
99
140
|
next
|
|
100
141
|
end
|
|
101
142
|
|
|
102
|
-
|
|
143
|
+
took = perf&.dig(r.id, "ms")
|
|
144
|
+
lines << "- #{r.pass? ? '✅' : '❌'} `#{r.id}` (#{r.agent})" \
|
|
145
|
+
"#{" · #{(took / 1000.0).round(1)}s" if took}" \
|
|
146
|
+
"#{' ⏳ judge pending' if r.judge_pending?}"
|
|
103
147
|
r.failures.each { |c| lines << " - ❌ #{c.name}: #{c.detail}" }
|
|
148
|
+
# A grader the transport could not feed. Printed even on a passing case:
|
|
149
|
+
# "passed" over checks nobody could run is the claim this line prevents.
|
|
150
|
+
r.skipped_checks.each { |c| lines << " - ⏭️ #{c.name}: #{c.detail}" }
|
|
104
151
|
if r.judge
|
|
105
152
|
v = r.judge
|
|
106
153
|
lines << " - #{v.pass ? '✅' : '❌'} judge: #{v.score} — #{v.reason}"
|
|
107
154
|
end
|
|
108
155
|
lines << " - #{pairwise_line(r.pairwise)}" if r.pairwise
|
|
109
156
|
end
|
|
157
|
+
if (summary = h["perf"])
|
|
158
|
+
lines << "" << "**time** (#{summary['cases_measured']} case(s)): " \
|
|
159
|
+
"case p50/p95 #{summary['case_ms_p50']} / #{summary['case_ms_p95']} ms · " \
|
|
160
|
+
"turn p50/p95 #{summary['turn_ms_p50']} / #{summary['turn_ms_p95']} ms"
|
|
161
|
+
end
|
|
110
162
|
lines.concat(pairwise_block(h["pairwise"])) if h["pairwise"]
|
|
111
163
|
"#{lines.join("\n")}\n"
|
|
112
164
|
end
|
data/lib/insika/evals/runner.rb
CHANGED
|
@@ -19,7 +19,11 @@ module Insika
|
|
|
19
19
|
# cache, carried separately because it is the number that explains a total.
|
|
20
20
|
# Only the refinement gate reads them (records a run's cost); the
|
|
21
21
|
# report and the exit code are untouched.
|
|
22
|
-
|
|
22
|
+
# `turns` and `store` are what the case actually SAW: every turn's reply and
|
|
23
|
+
# tool calls, and the store as it stood afterwards. The verdict alone says a
|
|
24
|
+
# cell failed; only these say what the harness answered, and a cross-harness
|
|
25
|
+
# table is unreadable without being able to go and look.
|
|
26
|
+
RunCase = Struct.new(:result, :timings, :tokens, :cached, :turns, :store, keyword_init: true)
|
|
23
27
|
|
|
24
28
|
# judge: an Evals::Judge (optional). When set, a case with a rubric whose turn
|
|
25
29
|
# ran cleanly gets a subjective verdict attached on top of the deterministic pass.
|
|
@@ -35,12 +39,18 @@ module Insika
|
|
|
35
39
|
# pairwise: an Evals::Pairwise (optional). Only cases carrying a
|
|
36
40
|
# `reference:` are compared, and the verdict never touches pass/fail — it is the
|
|
37
41
|
# answer to "can we replace it", reported beside the suite's own verdict.
|
|
38
|
-
|
|
42
|
+
#
|
|
43
|
+
# store_reader: how a case's `store_state:` is graded — anything answering
|
|
44
|
+
# `#read(collection)` with the collection's rows. Only the cases that declare a
|
|
45
|
+
# `store_state:` ask for it; a case that declares one with no reader configured
|
|
46
|
+
# is SKIPPED with the reason, never run and passed on its reply alone.
|
|
47
|
+
def initialize(transport:, judge: nil, conv_map: {}, capabilities: nil, pairwise: nil, store_reader: nil)
|
|
39
48
|
@transport = transport
|
|
40
49
|
@judge = judge
|
|
41
50
|
@conv_map = conv_map || {}
|
|
42
51
|
@capabilities = capabilities
|
|
43
52
|
@pairwise = pairwise
|
|
53
|
+
@store_reader = store_reader
|
|
44
54
|
end
|
|
45
55
|
|
|
46
56
|
# [Golden] -> [RunCase]. Each RunCase carries the CaseResult (for the report) +
|
|
@@ -62,9 +72,16 @@ module Insika
|
|
|
62
72
|
skip = skip_reason(golden)
|
|
63
73
|
return RunCase.new(result: Assertions.skip(golden, skip), timings: []) if skip
|
|
64
74
|
|
|
75
|
+
if golden.store_state? && @store_reader.nil?
|
|
76
|
+
return RunCase.new(result: Assertions.skip(golden, "no store reader configured for `store_state`"),
|
|
77
|
+
timings: [])
|
|
78
|
+
end
|
|
79
|
+
|
|
65
80
|
# A backend that resolves state from a pre-existing conversation (e.g. a
|
|
66
81
|
# consumer needing a real Chat UUID as X-Chat-Id) supplies it via conv_map; otherwise the
|
|
67
82
|
# synthetic "eval-<id>" keeps the adapter's own multi-turn continuation.
|
|
83
|
+
# A reader that caches its snapshot is opening a new case now.
|
|
84
|
+
@store_reader.forget if @store_reader.respond_to?(:forget)
|
|
68
85
|
conv = @conv_map[golden.id] || "eval-#{golden.id}"
|
|
69
86
|
if golden.seeded?
|
|
70
87
|
# The snapshot goes in BEFORE turn 1 — on the same conversation id the turns
|
|
@@ -101,7 +118,10 @@ module Insika
|
|
|
101
118
|
last = turns.last
|
|
102
119
|
# Tool/content assertions read the last turn (unchanged); the policy checks
|
|
103
120
|
# read every turn — "one question per reply" is a rule about each of them.
|
|
104
|
-
|
|
121
|
+
# The store is read AFTER the last turn: what the conversation left behind.
|
|
122
|
+
store, store_error = read_store(golden) if golden.store_state? && last.error.nil?
|
|
123
|
+
result = Assertions.evaluate(golden, last, turns: turns, store: store, store_error: store_error,
|
|
124
|
+
tools_reported: tools_reported?)
|
|
105
125
|
# Subjective layer: only when a judge is configured, the case has a rubric, and
|
|
106
126
|
# the turn ran cleanly (nothing to judge on an errored turn).
|
|
107
127
|
result.judge = @judge.score(golden: golden, result: last) if @judge && result.rubric && result.error.nil?
|
|
@@ -110,11 +130,31 @@ module Insika
|
|
|
110
130
|
# comparison for a reason that has nothing to do with the agent.
|
|
111
131
|
result.pairwise = @pairwise.compare(golden: golden, turns: turns) if @pairwise && result.error.nil?
|
|
112
132
|
RunCase.new(result: result, timings: timings, tokens: sum_tokens(spent),
|
|
113
|
-
cached: sum_tokens(cached))
|
|
133
|
+
cached: sum_tokens(cached), turns: turns, store: store)
|
|
114
134
|
end
|
|
115
135
|
|
|
116
136
|
private
|
|
117
137
|
|
|
138
|
+
# Does the transport see tool calls at all? Only a benched harness answers no
|
|
139
|
+
# (an adapter that can report the text and nothing else). Anything that does
|
|
140
|
+
# not answer the question is assumed to report them, which is what every
|
|
141
|
+
# transport in the engine does.
|
|
142
|
+
def tools_reported?
|
|
143
|
+
return true unless @transport.respond_to?(:reports_tool_calls?)
|
|
144
|
+
|
|
145
|
+
@transport.reports_tool_calls?
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# -> [{ collection => [rows] }, nil] or [nil, why]. A reader that raises fails
|
|
149
|
+
# the store graders WITH the reason rather than killing the run: one unreadable
|
|
150
|
+
# cell of a bench table must not throw away the other thirty-nine, and a store
|
|
151
|
+
# nobody could read is never a store that matched.
|
|
152
|
+
def read_store(golden)
|
|
153
|
+
[golden.store_collections.to_h { |c| [c, Array(@store_reader.read(c))] }, nil]
|
|
154
|
+
rescue StandardError => e
|
|
155
|
+
[nil, "#{e.class}: #{e.message}"]
|
|
156
|
+
end
|
|
157
|
+
|
|
118
158
|
# nil when NO turn reported usage; otherwise the sum of the ones that did. A
|
|
119
159
|
# partially-metered case is reported as what was actually measured, low rather
|
|
120
160
|
# than absent — a budget under-counting is a smaller lie than a budget that
|
|
@@ -166,12 +166,21 @@ module Insika
|
|
|
166
166
|
# [tool names] the agent can reach that are marked side-effect, sorted.
|
|
167
167
|
def side_effect_tools(profile, registry)
|
|
168
168
|
allowed = if profile.tools_allow.nil? then Array(registry.names)
|
|
169
|
-
else Array(profile.tools_allow).map(&:to_s)
|
|
169
|
+
else Array(profile.tools_allow).map(&:to_s) | group_tools(profile, registry)
|
|
170
170
|
end
|
|
171
171
|
denied = Array(profile.tools_deny).map(&:to_s)
|
|
172
172
|
(allowed - denied).select { |name| registry.side_effect?(name) }.sort
|
|
173
173
|
end
|
|
174
174
|
|
|
175
|
+
# Tools reachable through tools_allow_groups (an MCP server, a kit's groups):
|
|
176
|
+
# the registry entries whose metadata group the agent allows.
|
|
177
|
+
def group_tools(profile, registry)
|
|
178
|
+
groups = Array(profile.tools_allow_groups).map(&:to_s)
|
|
179
|
+
return [] if groups.empty? || !registry.respond_to?(:entries)
|
|
180
|
+
|
|
181
|
+
registry.entries.select { |e| groups.include?(e.metadata[:group].to_s) }.map { |e| e.name.to_s }
|
|
182
|
+
end
|
|
183
|
+
|
|
175
184
|
# -> bool: can a simulated run touch this agent without a staging
|
|
176
185
|
# declaration (no reachable side-effect tool)?
|
|
177
186
|
def safe?(profile, registry)
|
|
@@ -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
|
|
@@ -329,6 +330,205 @@ module Insika
|
|
|
329
330
|
end
|
|
330
331
|
end
|
|
331
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
|
+
|
|
515
|
+
private
|
|
516
|
+
|
|
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)
|
|
523
|
+
|
|
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}"
|
|
529
|
+
end
|
|
530
|
+
end
|
|
531
|
+
|
|
332
532
|
# Thin A2A transport: drives a REMOTE A2A agent (an agent that only speaks
|
|
333
533
|
# A2A) through the same `turn` seam the Simulator uses. The outbound A2A
|
|
334
534
|
# client already does the send+poll; this is the wrapper that makes it a
|
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
|
data/lib/insika/evidence.rb
CHANGED
|
@@ -26,7 +26,11 @@ module Insika
|
|
|
26
26
|
#
|
|
27
27
|
# `kind` is the PILOT PACK's value, never a gem constant (the
|
|
28
28
|
# engine owns nothing about product shape).
|
|
29
|
-
|
|
29
|
+
# `id`/`line` name the FIELDS inside an item, because a store's own payload
|
|
30
|
+
# calls them what it calls them (`product_id`, `name`) and a server nobody
|
|
31
|
+
# controls — an MCP server in particular — will not rename them for us.
|
|
32
|
+
# Defaults keep every existing spec byte-identical.
|
|
33
|
+
Spec = Data.define(:kind, :items_path, :attachments_path, :id_field, :line_field) do
|
|
30
34
|
PATH_RE = /\A[a-zA-Z0-9_]+(?:\.[a-zA-Z0-9_]+)*\z/
|
|
31
35
|
|
|
32
36
|
# String | Hash | Spec | nil -> Spec | nil. Raises ValidationError on a blank kind
|
|
@@ -43,13 +47,28 @@ module Insika
|
|
|
43
47
|
|
|
44
48
|
items = presence_path(h["items"], "items")
|
|
45
49
|
attachments = presence_path(h["attachments"], "attachments")
|
|
46
|
-
new(kind: kind, items_path: items, attachments_path: attachments
|
|
50
|
+
new(kind: kind, items_path: items, attachments_path: attachments,
|
|
51
|
+
id_field: presence_field(h["id"], "id"), line_field: presence_field(h["line"], "line"))
|
|
47
52
|
end
|
|
48
53
|
|
|
54
|
+
# A default field is left OUT: a spec that names nothing new serializes
|
|
55
|
+
# exactly as it did before this existed.
|
|
49
56
|
def to_h
|
|
50
|
-
{ "kind" => kind, "items" => items_path, "attachments" => attachments_path
|
|
57
|
+
{ "kind" => kind, "items" => items_path, "attachments" => attachments_path,
|
|
58
|
+
"id" => (id_field unless id_field == "id"),
|
|
59
|
+
"line" => (line_field unless line_field == "line") }.compact
|
|
51
60
|
end
|
|
52
61
|
|
|
62
|
+
# A field name, not a path: the item is one flat object and a dotted dig
|
|
63
|
+
# inside it would be a different feature.
|
|
64
|
+
def self.presence_field(value, default)
|
|
65
|
+
s = Coercion.presence(value) || default
|
|
66
|
+
raise Insika::ValidationError, "evidence.#{default}: not a field name" unless /\A[a-zA-Z0-9_]+\z/.match?(s)
|
|
67
|
+
|
|
68
|
+
s
|
|
69
|
+
end
|
|
70
|
+
private_class_method :presence_field
|
|
71
|
+
|
|
53
72
|
def self.presence_path(value, default)
|
|
54
73
|
s = Coercion.presence(value)
|
|
55
74
|
s = default if s.nil?
|
|
@@ -119,14 +138,15 @@ module Insika
|
|
|
119
138
|
# model must never see a null where the contract says items.
|
|
120
139
|
def build(spec, raw)
|
|
121
140
|
items = SchemaGuard.dig(raw, spec.items_path) || []
|
|
141
|
+
id_of = ->(item) { item[spec.id_field] || item[spec.id_field.to_sym] }
|
|
122
142
|
lean_items = items.first(MAX_ITEMS).map do |item|
|
|
123
|
-
{ "id" => (item
|
|
143
|
+
{ "id" => id_of.call(item).to_s,
|
|
124
144
|
# the line is what the model reads: truncated here; sanitized by the
|
|
125
145
|
# envelope's fence when the agent has `fencing` on (bytes as-is when off).
|
|
126
|
-
"line" => Coercion.utf8((item[
|
|
146
|
+
"line" => Coercion.utf8((item[spec.line_field] || item[spec.line_field.to_sym]).to_s)[0, LINE_MAX] }
|
|
127
147
|
end
|
|
128
148
|
lean = { "items" => lean_items }
|
|
129
|
-
cards = stamp_ids(items, SchemaGuard.dig(raw, spec.attachments_path))
|
|
149
|
+
cards = stamp_ids(items, SchemaGuard.dig(raw, spec.attachments_path), id_of)
|
|
130
150
|
[lean, Insika::Evidence.valid_attachments(cards)]
|
|
131
151
|
end
|
|
132
152
|
|
|
@@ -135,12 +155,12 @@ module Insika
|
|
|
135
155
|
# its position. That id is what a presentation tool later joins on. Stamped
|
|
136
156
|
# on the RAW list, before malformed cards are dropped: pairing after the
|
|
137
157
|
# drop would shift every later card onto the wrong product.
|
|
138
|
-
def stamp_ids(items, cards)
|
|
158
|
+
def stamp_ids(items, cards, id_of = ->(item) { item["id"] || item[:id] })
|
|
139
159
|
Array(cards).each_with_index.map do |card, i|
|
|
140
160
|
next card unless card.is_a?(Hash) && (card["id"] || card[:id]).nil?
|
|
141
161
|
|
|
142
162
|
item = items[i]
|
|
143
|
-
id = item.is_a?(Hash) ? (item
|
|
163
|
+
id = item.is_a?(Hash) ? id_of.call(item).to_s : ""
|
|
144
164
|
id.empty? ? card : card.merge("id" => id)
|
|
145
165
|
end
|
|
146
166
|
end
|
|
@@ -217,6 +237,7 @@ module Insika
|
|
|
217
237
|
# "nothing to append", never an explosion).
|
|
218
238
|
def flush!
|
|
219
239
|
return self unless @store && @session_id
|
|
240
|
+
return self if @ids.empty? && @cards.empty? && @ungrounded.zero? # nothing new: no session rewrite
|
|
220
241
|
|
|
221
242
|
@store.append_evidence(@session_id, ids: @ids, ungrounded: @ungrounded, cards: @cards)
|
|
222
243
|
@ids = []
|