insika 0.0.1 → 0.1.0

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.
Files changed (260) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +295 -0
  3. data/LICENSE +21 -0
  4. data/README.md +136 -2
  5. data/bin/insika +351 -0
  6. data/docs/AGENTS.md +494 -0
  7. data/docs/ARCHITECTURE.md +333 -0
  8. data/docs/BENCHMARK.md +114 -0
  9. data/docs/CHANNELS.md +453 -0
  10. data/docs/CONTEXT.md +100 -0
  11. data/docs/DEPLOY.md +334 -0
  12. data/docs/EMBEDDING.md +194 -0
  13. data/docs/EVALS.md +273 -0
  14. data/docs/LOADTEST.md +231 -0
  15. data/docs/OBSERVABILITY.md +365 -0
  16. data/docs/PLUGINS.md +211 -0
  17. data/docs/REFINEMENT.md +477 -0
  18. data/docs/RELEASING.md +70 -0
  19. data/docs/RUNNING-LOCAL.md +153 -0
  20. data/docs/SANDBOX.md +114 -0
  21. data/docs/SECURITY.md +362 -0
  22. data/docs/SKILLS.md +98 -0
  23. data/docs/TOOLS.md +302 -0
  24. data/docs/WHY.md +137 -0
  25. data/docs/WORKFLOWS.md +225 -0
  26. data/docs/build.md +14 -0
  27. data/docs/index.md +68 -0
  28. data/docs/onboarding/start.md +126 -0
  29. data/docs/operate.md +12 -0
  30. data/docs/ship.md +10 -0
  31. data/docs/understand.md +10 -0
  32. data/lib/insika/agent_file_store.rb +125 -0
  33. data/lib/insika/agent_profile.rb +188 -0
  34. data/lib/insika/allowlist.rb +28 -0
  35. data/lib/insika/baseline_store.rb +74 -0
  36. data/lib/insika/capability/resolved_tool.rb +34 -0
  37. data/lib/insika/capability_registry.rb +112 -0
  38. data/lib/insika/channel_delivery.rb +150 -0
  39. data/lib/insika/channel_registry.rb +30 -0
  40. data/lib/insika/channels/relay.rb +178 -0
  41. data/lib/insika/channels/web/widget.js +283 -0
  42. data/lib/insika/channels/web.rb +211 -0
  43. data/lib/insika/chat_builder.rb +254 -0
  44. data/lib/insika/checkpoint.rb +13 -0
  45. data/lib/insika/checkpoint_store.rb +153 -0
  46. data/lib/insika/coercion.rb +50 -0
  47. data/lib/insika/command.rb +32 -0
  48. data/lib/insika/command_bus.rb +39 -0
  49. data/lib/insika/commands/agent_payload.rb +41 -0
  50. data/lib/insika/commands/approve_action.rb +46 -0
  51. data/lib/insika/commands/cancel_task.rb +33 -0
  52. data/lib/insika/commands/create_agent.rb +54 -0
  53. data/lib/insika/commands/create_session.rb +67 -0
  54. data/lib/insika/commands/delete_agent.rb +33 -0
  55. data/lib/insika/commands/delete_agent_file.rb +50 -0
  56. data/lib/insika/commands/delete_data_tool.rb +33 -0
  57. data/lib/insika/commands/delete_llm_provider.rb +36 -0
  58. data/lib/insika/commands/delete_mcp.rb +30 -0
  59. data/lib/insika/commands/delete_system_file.rb +29 -0
  60. data/lib/insika/commands/gate_refinement.rb +245 -0
  61. data/lib/insika/commands/import_mcp_tools.rb +48 -0
  62. data/lib/insika/commands/import_tools.rb +81 -0
  63. data/lib/insika/commands/memory_add_note.rb +32 -0
  64. data/lib/insika/commands/memory_forget_fact.rb +32 -0
  65. data/lib/insika/commands/memory_put_fact.rb +35 -0
  66. data/lib/insika/commands/pause_task.rb +29 -0
  67. data/lib/insika/commands/resolve_refinement.rb +126 -0
  68. data/lib/insika/commands/restore_agent_file.rb +36 -0
  69. data/lib/insika/commands/restore_data_tool.rb +34 -0
  70. data/lib/insika/commands/restore_system_file.rb +31 -0
  71. data/lib/insika/commands/resume_task.rb +85 -0
  72. data/lib/insika/commands/run_refinement.rb +133 -0
  73. data/lib/insika/commands/send_message.rb +150 -0
  74. data/lib/insika/commands/set_agent_tools.rb +39 -0
  75. data/lib/insika/commands/set_skill_agents.rb +71 -0
  76. data/lib/insika/commands/trigger_workflow.rb +80 -0
  77. data/lib/insika/commands/update_agent.rb +49 -0
  78. data/lib/insika/commands/update_settings.rb +33 -0
  79. data/lib/insika/commands/upsert_llm_provider.rb +34 -0
  80. data/lib/insika/commands/upsert_mcp.rb +32 -0
  81. data/lib/insika/commands/write_agent_file.rb +57 -0
  82. data/lib/insika/commands/write_data_tool.rb +43 -0
  83. data/lib/insika/commands/write_golden.rb +58 -0
  84. data/lib/insika/commands/write_skill.rb +50 -0
  85. data/lib/insika/commands/write_system_file.rb +31 -0
  86. data/lib/insika/config_store.rb +85 -0
  87. data/lib/insika/context/builder.rb +166 -0
  88. data/lib/insika/context/catalog_provider.rb +23 -0
  89. data/lib/insika/context/fragment.rb +19 -0
  90. data/lib/insika/context/priority.rb +29 -0
  91. data/lib/insika/context/provider.rb +19 -0
  92. data/lib/insika/context/providers/memory.rb +60 -0
  93. data/lib/insika/context/providers/prompt.rb +105 -0
  94. data/lib/insika/context/providers/request.rb +32 -0
  95. data/lib/insika/context/providers/session.rb +108 -0
  96. data/lib/insika/context/providers/skill.rb +20 -0
  97. data/lib/insika/context/providers/tool_search.rb +20 -0
  98. data/lib/insika/delegation_store.rb +153 -0
  99. data/lib/insika/doctor.rb +294 -0
  100. data/lib/insika/dsl/definition.rb +55 -0
  101. data/lib/insika/dsl/runtime.rb +379 -0
  102. data/lib/insika/dsl/server_boot.rb +97 -0
  103. data/lib/insika/dsl/system.rb +93 -0
  104. data/lib/insika/dsl/workflow_adapter.rb +59 -0
  105. data/lib/insika/dsl.rb +307 -0
  106. data/lib/insika/edge_limiter.rb +130 -0
  107. data/lib/insika/egress_guard.rb +75 -0
  108. data/lib/insika/env_schema.rb +246 -0
  109. data/lib/insika/errors.rb +145 -0
  110. data/lib/insika/evals/assertions.rb +247 -0
  111. data/lib/insika/evals/baseline.rb +69 -0
  112. data/lib/insika/evals/golden.rb +172 -0
  113. data/lib/insika/evals/judge.rb +225 -0
  114. data/lib/insika/evals/pairwise.rb +178 -0
  115. data/lib/insika/evals/report.rb +115 -0
  116. data/lib/insika/evals/runner.rb +141 -0
  117. data/lib/insika/evals/transport.rb +178 -0
  118. data/lib/insika/event.rb +18 -0
  119. data/lib/insika/event_stream.rb +114 -0
  120. data/lib/insika/executor.rb +1680 -0
  121. data/lib/insika/frontmatter.rb +42 -0
  122. data/lib/insika/golden_store.rb +145 -0
  123. data/lib/insika/hooks.rb +48 -0
  124. data/lib/insika/http_client.rb +63 -0
  125. data/lib/insika/inbound_log.rb +84 -0
  126. data/lib/insika/llm_configurator.rb +99 -0
  127. data/lib/insika/llm_provider_store.rb +83 -0
  128. data/lib/insika/mcp_http_client.rb +67 -0
  129. data/lib/insika/mcp_store.rb +115 -0
  130. data/lib/insika/mcp_tool_ingestor.rb +143 -0
  131. data/lib/insika/memory_store.rb +93 -0
  132. data/lib/insika/message_origin.rb +76 -0
  133. data/lib/insika/middleware.rb +36 -0
  134. data/lib/insika/model_policy.rb +52 -0
  135. data/lib/insika/model_resolver.rb +176 -0
  136. data/lib/insika/model_selection.rb +114 -0
  137. data/lib/insika/onboarding.rb +208 -0
  138. data/lib/insika/outbox_store.rb +166 -0
  139. data/lib/insika/overlay_tool_registry.rb +103 -0
  140. data/lib/insika/pack.rb +102 -0
  141. data/lib/insika/pack_importer.rb +121 -0
  142. data/lib/insika/pending_action_store.rb +120 -0
  143. data/lib/insika/plugin/loader.rb +356 -0
  144. data/lib/insika/plugin.rb +35 -0
  145. data/lib/insika/policy/engine.rb +83 -0
  146. data/lib/insika/policy/policy.rb +120 -0
  147. data/lib/insika/policy_registry.rb +23 -0
  148. data/lib/insika/profile_source.rb +137 -0
  149. data/lib/insika/prompt_catalog.rb +61 -0
  150. data/lib/insika/queue_policy.rb +167 -0
  151. data/lib/insika/recovery.rb +127 -0
  152. data/lib/insika/refinement/candidate.rb +159 -0
  153. data/lib/insika/refinement/evidence_collector.rb +371 -0
  154. data/lib/insika/refinement/gate.rb +234 -0
  155. data/lib/insika/refinement/panel.rb +222 -0
  156. data/lib/insika/refinement/proposer.rb +262 -0
  157. data/lib/insika/refinement_store.rb +295 -0
  158. data/lib/insika/registry.rb +59 -0
  159. data/lib/insika/safety/config.rb +109 -0
  160. data/lib/insika/safety/detectors.rb +176 -0
  161. data/lib/insika/safety/factory.rb +102 -0
  162. data/lib/insika/safety/input_guardrail.rb +87 -0
  163. data/lib/insika/safety/moderator.rb +86 -0
  164. data/lib/insika/safety/output_filter.rb +79 -0
  165. data/lib/insika/safety/output_validator.rb +101 -0
  166. data/lib/insika/safety/safe_responses.rb +47 -0
  167. data/lib/insika/sandbox/boundary.rb +93 -0
  168. data/lib/insika/sandbox/docker.rb +74 -0
  169. data/lib/insika/sandbox/local.rb +33 -0
  170. data/lib/insika/sandbox/runner.rb +80 -0
  171. data/lib/insika/sandbox.rb +85 -0
  172. data/lib/insika/schema_guard.rb +147 -0
  173. data/lib/insika/secret_masking.rb +34 -0
  174. data/lib/insika/server/a2a/agent_card.rb +27 -0
  175. data/lib/insika/server/a2a/app.rb +112 -0
  176. data/lib/insika/server/a2a/client.rb +101 -0
  177. data/lib/insika/server/a2a/errors.rb +32 -0
  178. data/lib/insika/server/a2a/http.rb +42 -0
  179. data/lib/insika/server/a2a/message.rb +27 -0
  180. data/lib/insika/server/a2a/protocol.rb +45 -0
  181. data/lib/insika/server/a2a/remotes.rb +25 -0
  182. data/lib/insika/server/a2a/task_projection.rb +40 -0
  183. data/lib/insika/server/admin_auth.rb +29 -0
  184. data/lib/insika/server/app.rb +850 -0
  185. data/lib/insika/server/boot.rb +119 -0
  186. data/lib/insika/server/rack_app.rb +110 -0
  187. data/lib/insika/server/responses.rb +155 -0
  188. data/lib/insika/server/sse_body.rb +96 -0
  189. data/lib/insika/session_actor.rb +162 -0
  190. data/lib/insika/session_store.rb +143 -0
  191. data/lib/insika/settings_store.rb +154 -0
  192. data/lib/insika/shutdown.rb +125 -0
  193. data/lib/insika/skill_catalog.rb +113 -0
  194. data/lib/insika/skill_store.rb +79 -0
  195. data/lib/insika/steer_injector.rb +110 -0
  196. data/lib/insika/store.rb +52 -0
  197. data/lib/insika/stores/memory.rb +123 -0
  198. data/lib/insika/stores/sqlite.rb +183 -0
  199. data/lib/insika/studio/app.rb +1571 -0
  200. data/lib/insika/studio/assets/dist/application.css +1 -0
  201. data/lib/insika/studio/assets/dist/application.js +69 -0
  202. data/lib/insika/studio/forms.rb +340 -0
  203. data/lib/insika/studio/nav_icons.rb +31 -0
  204. data/lib/insika/studio/views/_message.erb +44 -0
  205. data/lib/insika/studio/views/agent_detail.erb +285 -0
  206. data/lib/insika/studio/views/agents.erb +63 -0
  207. data/lib/insika/studio/views/approvals.erb +41 -0
  208. data/lib/insika/studio/views/chats.erb +34 -0
  209. data/lib/insika/studio/views/evals.erb +83 -0
  210. data/lib/insika/studio/views/home.erb +72 -0
  211. data/lib/insika/studio/views/layout.erb +94 -0
  212. data/lib/insika/studio/views/login.erb +17 -0
  213. data/lib/insika/studio/views/mcp.erb +91 -0
  214. data/lib/insika/studio/views/not_found.erb +5 -0
  215. data/lib/insika/studio/views/playground.erb +47 -0
  216. data/lib/insika/studio/views/refinement.erb +234 -0
  217. data/lib/insika/studio/views/session.erb +62 -0
  218. data/lib/insika/studio/views/settings.erb +173 -0
  219. data/lib/insika/studio/views/skills.erb +86 -0
  220. data/lib/insika/studio/views/system_files.erb +65 -0
  221. data/lib/insika/studio/views/task.erb +105 -0
  222. data/lib/insika/studio/views/tasks.erb +33 -0
  223. data/lib/insika/studio/views/tool_edit.erb +107 -0
  224. data/lib/insika/studio/views/tools.erb +89 -0
  225. data/lib/insika/subagent_graph.rb +96 -0
  226. data/lib/insika/system_file_store.rb +96 -0
  227. data/lib/insika/task_actor.rb +128 -0
  228. data/lib/insika/task_store.rb +250 -0
  229. data/lib/insika/telemetry/pricing.rb +104 -0
  230. data/lib/insika/telemetry/recorder.rb +228 -0
  231. data/lib/insika/telemetry.rb +127 -0
  232. data/lib/insika/testing/store_contract.rb +270 -0
  233. data/lib/insika/token_estimator.rb +16 -0
  234. data/lib/insika/tool_assembly.rb +140 -0
  235. data/lib/insika/tool_catalog.rb +89 -0
  236. data/lib/insika/tool_definition.rb +518 -0
  237. data/lib/insika/tool_envelope.rb +140 -0
  238. data/lib/insika/tool_manifest.rb +218 -0
  239. data/lib/insika/tool_registry.rb +21 -0
  240. data/lib/insika/tool_store.rb +135 -0
  241. data/lib/insika/tool_trace_store.rb +92 -0
  242. data/lib/insika/tools/a2a_remote.rb +48 -0
  243. data/lib/insika/tools/agent_enum.rb +68 -0
  244. data/lib/insika/tools/concurrency.rb +54 -0
  245. data/lib/insika/tools/data_defined_tool.rb +220 -0
  246. data/lib/insika/tools/load_skill.rb +41 -0
  247. data/lib/insika/tools/remember.rb +53 -0
  248. data/lib/insika/tools/subagent.rb +75 -0
  249. data/lib/insika/tools/subagents.rb +77 -0
  250. data/lib/insika/tools/tool_search.rb +94 -0
  251. data/lib/insika/turn_output.rb +139 -0
  252. data/lib/insika/turn_state.rb +158 -0
  253. data/lib/insika/turn_timing.rb +56 -0
  254. data/lib/insika/usage_ledger.rb +47 -0
  255. data/lib/insika/version.rb +3 -1
  256. data/lib/insika/wiring/graph.rb +198 -0
  257. data/lib/insika/workflow.rb +185 -0
  258. data/lib/insika/workflow_registry.rb +33 -0
  259. data/lib/insika.rb +203 -4
  260. metadata +395 -8
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Insika
6
+ module Evals
7
+ # Renders a run's [CaseResult] as a machine-readable JSON blob (for the baseline
8
+ # + gating in Fase C) and a human-readable markdown summary.
9
+ # Pure over the results — takes a clock value in, never reads it (so callers stay
10
+ # deterministic/testable).
11
+ module Report
12
+ module_function
13
+
14
+ # -> Hash ready for JSON. `at` is an ISO-8601 string stamped by the caller.
15
+ def to_h(results, at:)
16
+ passed = results.count(&:pass?)
17
+ skipped = results.count(&:skipped?)
18
+ {
19
+ "at" => at,
20
+ "total" => results.size,
21
+ "passed" => passed,
22
+ # A skipped case is neither: counting it as failed is the lie this outcome
23
+ # exists to stop, and counting it as passed is worse.
24
+ "failed" => results.size - passed - skipped,
25
+ "skipped" => skipped,
26
+ "judge_pending" => results.count(&:judge_pending?),
27
+ # Absent (not an empty tally) when nothing was compared — a run with no
28
+ # pairwise is the normal one, and a zeroed block reads like every case tied.
29
+ "pairwise" => pairwise_summary(results),
30
+ "cases" => results.map do |r|
31
+ {
32
+ "id" => r.id, "agent" => r.agent, "pass" => r.pass?,
33
+ "skipped" => r.skipped,
34
+ "judge_pending" => r.judge_pending?, "error" => r.error,
35
+ "checks" => r.checks.map { |c| { "name" => c.name, "pass" => c.pass, "detail" => c.detail } },
36
+ "judge" => (r.judge && { "score" => r.judge.score, "pass" => r.judge.pass, "reason" => r.judge.reason }),
37
+ "pairwise" => (r.pairwise && { "outcome" => r.pairwise.outcome, "vs" => r.pairwise.vs,
38
+ "reason" => r.pairwise.reason, "judges" => r.pairwise.judges,
39
+ "order_dependent" => r.pairwise.order_dependent })
40
+ }
41
+ end
42
+ }.compact
43
+ end
44
+
45
+ # -> counts by outcome, or nil when no case carried a reference. `human_assisted`
46
+ # is counted separately and always printed: a "better" against a conversation a
47
+ # PERSON typed is a different claim from one against the incumbent's model, and
48
+ # the two must never be summed into one number somebody quotes.
49
+ def pairwise_summary(results)
50
+ compared = results.filter_map(&:pairwise)
51
+ return nil if compared.empty?
52
+
53
+ counts = compared.group_by(&:outcome).transform_values(&:size)
54
+ { "compared" => compared.size,
55
+ "human_assisted" => compared.count(&:human_assisted?),
56
+ "order_dependent" => compared.count(&:order_dependent),
57
+ "outcomes" => counts }
58
+ end
59
+
60
+ def to_json(results, at:)
61
+ JSON.pretty_generate(to_h(results, at: at))
62
+ end
63
+
64
+ PAIRWISE_MARK = { "better" => "🟢", "comparable" => "🟡", "worse" => "🔴",
65
+ "split" => "⚖️", "unknown" => "❔" }.freeze
66
+
67
+ # Never without `vs:` — see `pairwise_summary`.
68
+ def pairwise_line(v)
69
+ flip = v.order_dependent ? " (order-dependent)" : ""
70
+ "#{PAIRWISE_MARK.fetch(v.outcome, '·')} vs incumbent (#{v.vs}): #{v.outcome}#{flip} — #{v.reason}"
71
+ end
72
+
73
+ def pairwise_block(summary)
74
+ counts = summary["outcomes"].map { |k, n| "#{n} #{k}" }.join(" · ")
75
+ lines = ["", "**vs incumbent** (#{summary['compared']} compared): #{counts}"]
76
+ if summary["human_assisted"].positive?
77
+ lines << "- #{summary['human_assisted']} against a HUMAN-ASSISTED transcript (a person typed " \
78
+ "part of the reference — not a model-vs-model result)"
79
+ end
80
+ if summary["order_dependent"].positive?
81
+ lines << "- #{summary['order_dependent']} flipped when the transcripts were swapped, " \
82
+ "and were reported as comparable"
83
+ end
84
+ lines
85
+ end
86
+
87
+ # Human summary. One line per case; failing checks nested underneath.
88
+ def to_markdown(results, at:)
89
+ h = to_h(results, at: at)
90
+ lines = ["# Eval report — #{at}", "",
91
+ "**#{h['passed']}/#{h['total'] - h['skipped']} passed** · #{h['failed']} failed" \
92
+ "#{" · #{h['skipped']} skipped" if h['skipped'].positive?}" \
93
+ "#{" · #{h['judge_pending']} awaiting judge (Fase B)" if h['judge_pending'].positive?}", ""]
94
+ results.each do |r|
95
+ if r.skipped?
96
+ # WITH the reason, always: "12 skipped" alone is indistinguishable from a
97
+ # suite that quietly stopped testing anything.
98
+ lines << "- ⏭️ `#{r.id}` (#{r.agent}) — skipped: #{r.skipped}"
99
+ next
100
+ end
101
+
102
+ lines << "- #{r.pass? ? '✅' : '❌'} `#{r.id}` (#{r.agent})#{' ⏳ judge pending' if r.judge_pending?}"
103
+ r.failures.each { |c| lines << " - ❌ #{c.name}: #{c.detail}" }
104
+ if r.judge
105
+ v = r.judge
106
+ lines << " - #{v.pass ? '✅' : '❌'} judge: #{v.score} — #{v.reason}"
107
+ end
108
+ lines << " - #{pairwise_line(r.pairwise)}" if r.pairwise
109
+ end
110
+ lines.concat(pairwise_block(h["pairwise"])) if h["pairwise"]
111
+ "#{lines.join("\n")}\n"
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "golden"
4
+ require_relative "assertions"
5
+
6
+ module Insika
7
+ module Evals
8
+ # Replays golden cases through a Transport and evaluates each. Pure over the
9
+ # Transport (injected) — the fake makes the orchestration unit-testable offline;
10
+ # the HttpTransport makes a real run.
11
+ #
12
+ # Multi-turn: a case's turns replay IN ORDER under one conversation id
13
+ # (`eval-<id>`); earlier turns build context, the assertion runs on the LAST
14
+ # turn's result. A turn that errors aborts the rest of that conversation (there's
15
+ # nothing to continue from) and fails the case.
16
+ class Runner
17
+ # `tokens` is what the whole case cost, summed over its turns, or nil when no
18
+ # turn reported usage. `cached` is how much of that was served from the prompt
19
+ # cache, carried separately because it is the number that explains a total.
20
+ # Only the refinement gate reads them (RFC-0013 §3.9 records a run's cost); the
21
+ # report and the exit code are untouched.
22
+ RunCase = Struct.new(:result, :timings, :tokens, :cached, keyword_init: true)
23
+
24
+ # judge: an Evals::Judge (optional). When set, a case with a rubric whose turn
25
+ # ran cleanly gets a subjective verdict attached on top of the deterministic pass.
26
+ #
27
+ # capabilities: what the DEPLOYMENT has, per agent — anything answering
28
+ # `#for(agent_id)` with { "tools" =>, "capabilities" => } or nil. Used to skip a
29
+ # case the deployment cannot satisfy (RFC-0014 §3.2), BEFORE spending a turn on
30
+ # it. nil (or an unknown agent) = no resolution, and then a case with `requires`
31
+ # RUNS and says so in the report: "could not rule it out" is not a reason to
32
+ # stop testing something, and a suite that shrinks in silence is the failure
33
+ # this feature exists to avoid.
34
+ #
35
+ # pairwise: an Evals::Pairwise (optional, RFC-0014 §3.4). Only cases carrying a
36
+ # `reference:` are compared, and the verdict never touches pass/fail — it is the
37
+ # answer to "can we replace it", reported beside the suite's own verdict.
38
+ def initialize(transport:, judge: nil, conv_map: {}, capabilities: nil, pairwise: nil)
39
+ @transport = transport
40
+ @judge = judge
41
+ @conv_map = conv_map || {}
42
+ @capabilities = capabilities
43
+ @pairwise = pairwise
44
+ end
45
+
46
+ # [Golden] -> [RunCase]. Each RunCase carries the CaseResult (for the report) +
47
+ # per-turn timings (for `--mode perf`).
48
+ def run(goldens)
49
+ goldens.map { |g| run_case(g) }
50
+ end
51
+
52
+ def run_case(golden)
53
+ skip = skip_reason(golden)
54
+ return RunCase.new(result: Assertions.skip(golden, skip), timings: []) if skip
55
+
56
+ # A backend that resolves state from a pre-existing conversation (e.g. achei-b2b
57
+ # needs a real Chat UUID as X-Chat-Id) supplies it via conv_map; otherwise the
58
+ # synthetic "eval-<id>" keeps the adapter's own multi-turn continuation.
59
+ conv = @conv_map[golden.id] || "eval-#{golden.id}"
60
+ turns = []
61
+ timings = []
62
+ spent = []
63
+ cached = []
64
+ golden.user_turns.each do |message|
65
+ outcome = @transport.turn(agent: golden.agent, conv: conv, message: message)
66
+ timings << { ttfb: outcome.ttfb, total: outcome.total }
67
+ spent << billed_tokens(outcome.usage)
68
+ cached << cached_tokens(outcome.usage)
69
+ turns << outcome.result
70
+ break if outcome.result.error
71
+ end
72
+ last = turns.last
73
+ # Tool/content assertions read the last turn (unchanged); the policy checks
74
+ # read every turn — "one question per reply" is a rule about each of them.
75
+ result = Assertions.evaluate(golden, last, turns: turns)
76
+ # Subjective layer: only when a judge is configured, the case has a rubric, and
77
+ # the turn ran cleanly (nothing to judge on an errored turn).
78
+ result.judge = @judge.score(golden: golden, result: last) if @judge && result.rubric && result.error.nil?
79
+ # Against the incumbent (RFC-0014 §3.4). Same rule as the judge: nothing to
80
+ # compare on a turn that errored — half a conversation would lose the
81
+ # comparison for a reason that has nothing to do with the agent.
82
+ result.pairwise = @pairwise.compare(golden: golden, turns: turns) if @pairwise && result.error.nil?
83
+ RunCase.new(result: result, timings: timings, tokens: sum_tokens(spent),
84
+ cached: sum_tokens(cached))
85
+ end
86
+
87
+ private
88
+
89
+ # nil when NO turn reported usage; otherwise the sum of the ones that did. A
90
+ # partially-metered case is reported as what was actually measured, low rather
91
+ # than absent — a budget under-counting is a smaller lie than a budget that
92
+ # throws the number away because one leg was silent.
93
+ def sum_tokens(spent)
94
+ counted = spent.compact
95
+ counted.empty? ? nil : counted.sum
96
+ end
97
+
98
+ # What the turn actually SENT, cache included. The engine's `total_tokens` is
99
+ # input + output and DELIBERATELY excludes the cached prefix (`Executor#usage_of`
100
+ # reports `cached_tokens` alongside it), so reading it as the cost of a turn
101
+ # under-reads a cached identity by an order of magnitude.
102
+ #
103
+ # Measured on the pilot: one real turn reports `total_tokens: 88` with
104
+ # `cached_tokens: 26624`. A refinement budget built on the first number would
105
+ # have let a run send ~300× what its ceiling said. Cached tokens are cheaper
106
+ # than fresh ones; they are not free, and a ceiling has to see them.
107
+ def billed_tokens(usage)
108
+ return nil unless usage.is_a?(Hash)
109
+
110
+ total = read(usage, "total_tokens")
111
+ return nil if total.nil?
112
+
113
+ total + cached_tokens(usage).to_i
114
+ end
115
+
116
+ def cached_tokens(usage)
117
+ return nil unless usage.is_a?(Hash)
118
+
119
+ values = [read(usage, "cached_tokens"), read(usage, "cache_creation_tokens")].compact
120
+ values.empty? ? nil : values.sum
121
+ end
122
+
123
+ def read(usage, key)
124
+ value = usage[key] || usage[key.to_sym]
125
+ value&.to_i
126
+ end
127
+
128
+ # -> the reason to skip, or nil to run. A case with no `requires` always runs
129
+ # (and never even asks), which keeps the whole existing corpus untouched.
130
+ def skip_reason(golden)
131
+ return nil unless golden.requirements?
132
+
133
+ available = @capabilities&.for(golden.agent)
134
+ return nil if available.nil? # unresolved: run it, and the report says so
135
+
136
+ unmet = Assertions.unmet_requirements(golden, available)
137
+ unmet.empty? ? nil : unmet.join("; ")
138
+ end
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "uri"
6
+ require_relative "assertions"
7
+
8
+ module Insika
9
+ module Evals
10
+ # How the runner turns a (agent, conversation, message) into a TurnResult. The
11
+ # runner depends only on this seam, so it's unit-testable with a fake — the real
12
+ # HttpTransport is exercised against a running insika.
13
+ #
14
+ # One turn's outcome: the TurnResult the engine asserts on + perf timing (ttfb/
15
+ # total in ms) so `--mode perf` reports real latency over the real corpus (#6b).
16
+ #
17
+ # `usage` is the turn's token counts as the deployment reported them
18
+ # (`response.completed`), or nil when the provider sent none. It is carried, never
19
+ # asserted on: the consumer is RFC-0013's refinement budget, which has to bound the
20
+ # cost of a gate replay and cannot invent the number. nil is preserved as nil
21
+ # rather than zeroed — "the provider did not say" and "it cost nothing" are
22
+ # different facts, and a budget that confuses them stops being a budget.
23
+ TurnOutcome = Struct.new(:result, :ttfb, :total, :usage, keyword_init: true)
24
+
25
+ # Pure reduction of the /v1/responses SSE stream. Kept separate from the HTTP so
26
+ # it's testable offline with canned frames (server/responses.rb is the producer).
27
+ module Sse
28
+ module_function
29
+
30
+ # Raw SSE text -> [parsed JSON payload]. Skips `event:` lines, blanks, [DONE].
31
+ def payloads(text)
32
+ text.to_s.split("\n\n").flat_map do |frame|
33
+ frame.each_line.filter_map do |line|
34
+ next unless line.start_with?("data:")
35
+
36
+ p = line.sub(/^data:\s*/, "").strip
37
+ next if p.empty? || p == "[DONE]"
38
+
39
+ begin
40
+ JSON.parse(p)
41
+ rescue JSON::ParserError
42
+ nil
43
+ end
44
+ end
45
+ end
46
+ end
47
+
48
+ # [payload] -> { output_text:, tool_calls:, usage:, error: }. Maps the Responses
49
+ # frames (see server/responses.rb#frame_for): text deltas accumulate; each
50
+ # function_call item contributes a tool NAME (this stream carries no per-tool
51
+ # status — that lives in the ToolTraceStore, an in-process enrichment); a
52
+ # `response.failed` sets the turn error.
53
+ def reduce(payloads)
54
+ text = +""
55
+ tools = []
56
+ usage = nil
57
+ error = nil
58
+ payloads.each do |o|
59
+ case o["type"]
60
+ when "response.output_text.delta"
61
+ text << o["delta"].to_s
62
+ when "response.output_item.added"
63
+ item = o["item"] || {}
64
+ tools << { "name" => item["name"].to_s, "status" => nil } if item["type"] == "function_call"
65
+ when "response.completed"
66
+ usage = o.dig("response", "usage")
67
+ when "response.failed"
68
+ error = o.dig("response", "error", "message") || "response.failed"
69
+ end
70
+ end
71
+ { output_text: text, tool_calls: tools, usage: usage, error: error }
72
+ end
73
+ end
74
+
75
+ # What the deployment HAS, per agent (RFC-0014 §3.2), read over the same gated
76
+ # `/v1` the replay uses. The eval stays a client: it asks the engine instead of
77
+ # keeping its own idea of which tools exist.
78
+ #
79
+ # `#for(id)` -> { "tools" => [names] | nil, "capabilities" => [names] } | nil.
80
+ # nil means UNRESOLVED — the agent is not there, the route is not wired, the
81
+ # deployment is unreachable — and the Runner treats that as "run the case
82
+ # anyway". Silence must not shrink a suite. Answers are memoized: a corpus has
83
+ # many cases per agent and this is the same answer every time.
84
+ class HttpCapabilities
85
+ def initialize(base_url:, token:, timeout: 10)
86
+ @base = base_url
87
+ @token = token
88
+ @timeout = timeout
89
+ @cache = {}
90
+ end
91
+
92
+ def for(agent_id)
93
+ @cache.fetch(agent_id) { @cache[agent_id] = fetch(agent_id) }
94
+ end
95
+
96
+ private
97
+
98
+ def fetch(agent_id)
99
+ uri = URI.join("#{@base}/", "v1/agents/#{URI.encode_www_form_component(agent_id)}")
100
+ req = Net::HTTP::Get.new(uri)
101
+ req["Authorization"] = "Bearer #{@token}"
102
+
103
+ http = Net::HTTP.new(uri.host, uri.port)
104
+ http.use_ssl = uri.scheme == "https"
105
+ http.read_timeout = @timeout
106
+ http.open_timeout = @timeout
107
+ res = http.start { http.request(req) }
108
+ return nil unless res.code.to_i == 200
109
+
110
+ body = JSON.parse(res.body)
111
+ { "tools" => body["tools"], "capabilities" => Array(body["capabilities"]) }
112
+ rescue StandardError, JSON::ParserError
113
+ nil
114
+ end
115
+ end
116
+
117
+ # Drives real turns over POST /v1/responses — the SAME surface as
118
+ # scripts/loadtest.rb, so `--mode perf` is apples-to-apples with the loadtest and
119
+ # closes the real-traffic gap (#6b). A conversation is keyed by `user` (the
120
+ # adapter continues an existing chat on the same id), so multi-turn goldens replay
121
+ # in order under one `conv` id.
122
+ class HttpTransport
123
+ def initialize(base_url:, token:, timeout: 120)
124
+ @base = base_url
125
+ @token = token
126
+ @timeout = timeout
127
+ end
128
+
129
+ def mono = Process.clock_gettime(Process::CLOCK_MONOTONIC)
130
+
131
+ def turn(agent:, conv:, message:)
132
+ uri = URI.join("#{@base}/", "v1/responses")
133
+ req = Net::HTTP::Post.new(uri)
134
+ req["Authorization"] = "Bearer #{@token}"
135
+ req["Content-Type"] = "application/json"
136
+ req["Accept"] = "text/event-stream"
137
+ req.body = JSON.generate(model: "openclaw:#{agent}", user: conv, stream: true, input: message)
138
+
139
+ t0 = mono
140
+ ttfb = nil
141
+ buffer = +""
142
+ http = Net::HTTP.new(uri.host, uri.port)
143
+ http.use_ssl = uri.scheme == "https"
144
+ http.read_timeout = @timeout
145
+ http.open_timeout = 10
146
+
147
+ status = nil
148
+ http.start do
149
+ http.request(req) do |res|
150
+ status = res.code.to_i
151
+ return failure("HTTP #{status}", t0) unless status.between?(200, 299)
152
+
153
+ res.read_body do |chunk|
154
+ ttfb ||= (mono - t0) * 1000.0
155
+ buffer << chunk
156
+ end
157
+ end
158
+ end
159
+
160
+ reduced = Sse.reduce(Sse.payloads(buffer))
161
+ TurnOutcome.new(
162
+ result: TurnResult.new(output_text: reduced[:output_text],
163
+ tool_calls: reduced[:tool_calls], error: reduced[:error]),
164
+ ttfb: ttfb, total: (mono - t0) * 1000.0, usage: reduced[:usage]
165
+ )
166
+ rescue StandardError => e
167
+ failure(e.class.to_s, t0)
168
+ end
169
+
170
+ private
171
+
172
+ def failure(msg, t0)
173
+ TurnOutcome.new(result: TurnResult.new(output_text: "", tool_calls: [], error: msg),
174
+ ttfb: nil, total: (mono - t0) * 1000.0)
175
+ end
176
+ end
177
+ end
178
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # Everything the runtime emits is an Event: a closed canonical catalog — new
5
+ # types require updating the catalog.
6
+ #
7
+ # meta: { task_id:, session_id:, seq:, at: } (seq monotonic per task).
8
+ # Terminal turn events are :task_completed / :task_failed / :task_cancelled.
9
+ # :error is NOT a terminal twin — it survives only as the subscription-overflow
10
+ # signal (EventStream) and the post-terminal failure report (fail_task).
11
+ Event = Data.define(:type, :data, :meta) do
12
+ def initialize(type:, data:, meta: {})
13
+ super
14
+ end
15
+
16
+ def to_h = { type:, **data, meta: meta.compact }
17
+ end
18
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "async"
4
+ require "async/queue"
5
+
6
+ module Insika
7
+ # In-process pub/sub. The stream is concurrent with the
8
+ # turn: a slow observer NEVER delays execution — each subscriber
9
+ # has its own queue and `emit` only enqueues. No mutex: one reactor, cooperative
10
+ # fibers — a plain Array is enough.
11
+ class EventStream
12
+ # One subscription = one queue. The consumer blocks on `each` (its own fiber),
13
+ # never the emitter.
14
+ class Subscription
15
+ CLOSED = Object.new # internal end-of-stream sentinel
16
+ private_constant :CLOSED
17
+
18
+ # Cap on events queued per subscriber. A slow consumer
19
+ # piles up in its OWN queue; on overflow, the subscription closes with a
20
+ # local :error event — the turn never waits on transport.
21
+ MAX_QUEUED = 1000
22
+
23
+ def initialize(task_id: nil, session_id: nil, on_close: nil)
24
+ @task_id = task_id
25
+ @session_id = session_id
26
+ @on_close = on_close
27
+ @queue = Async::Queue.new
28
+ end
29
+
30
+ # Binds the subscription to a task_id AFTER creation: the
31
+ # transport subscribes before the dispatch — when the task_id does not yet
32
+ # exist — and binds it as soon as the handler returns. This keeps the
33
+ # per-task cap honest (only events for THIS task enter the queue) and makes
34
+ # the overflow :error carry the correct task_id. Returns self to chain.
35
+ def bind(task_id:)
36
+ @task_id = task_id
37
+ self
38
+ end
39
+
40
+ # Meta filter: nil = matches any value. Events with no task_id in
41
+ # meta (e.g. :session_created) reach only subscribers with no task filter.
42
+ def matches?(event)
43
+ meta = event.meta || {}
44
+ (@task_id.nil? || meta[:task_id] == @task_id) &&
45
+ (@session_id.nil? || meta[:session_id] == @session_id)
46
+ end
47
+
48
+ # Enqueues without EVER blocking. The real queue depth is `@queue.size`
49
+ # (not a separate counter — avoids drift). On reaching the cap, it enqueues
50
+ # a local :error and closes; pushes after close are ignored (`@closed`).
51
+ def push(event)
52
+ return if @closed
53
+
54
+ if @queue.size >= MAX_QUEUED
55
+ @queue.enqueue(Insika::Event.new(
56
+ type: :error,
57
+ data: { message: "subscription overflow" },
58
+ meta: { task_id: @task_id, session_id: @session_id }
59
+ ))
60
+ close
61
+ return
62
+ end
63
+
64
+ @queue.enqueue(event)
65
+ end
66
+
67
+ # Blocks the CONSUMER's fiber until #close.
68
+ def each
69
+ while (event = @queue.dequeue) != CLOSED
70
+ yield event
71
+ end
72
+ end
73
+
74
+ # Idempotent: a second CLOSED is harmless (the `each` stops at the first).
75
+ # `@on_close` fires only once (avoids removing the subscription twice).
76
+ def close
77
+ return if @closed
78
+
79
+ @closed = true
80
+ @queue.enqueue(CLOSED)
81
+ @on_close&.call(self)
82
+ end
83
+ end
84
+
85
+ def initialize
86
+ @subscriptions = []
87
+ end
88
+
89
+ # NEVER raises: an observer's exception is isolated — a
90
+ # broken observer does not bring down the turn. Synchronous and cheap.
91
+ #
92
+ # Iterates over a SNAPSHOT (`dup`): a subscription's cap may close it
93
+ # DURING the push (overflow -> close -> on_close removes from the array);
94
+ # mutating the array in the middle of a plain Array#each would skip the next
95
+ # subscriber.
96
+ def emit(event)
97
+ @subscriptions.dup.each do |sub|
98
+ sub.push(event) if sub.matches?(event)
99
+ rescue StandardError
100
+ # a broken observer does not bring down the turn; nothing to propagate
101
+ end
102
+ nil
103
+ end
104
+
105
+ # nil/nil = all events. Returns the Subscription (the caller iterates with
106
+ # `#each` on its own fiber).
107
+ def subscribe(task_id: nil, session_id: nil)
108
+ sub = Subscription.new(task_id: task_id, session_id: session_id,
109
+ on_close: ->(s) { @subscriptions.delete(s) })
110
+ @subscriptions << sub
111
+ sub
112
+ end
113
+ end
114
+ end