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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +295 -0
- data/LICENSE +21 -0
- data/README.md +136 -2
- data/bin/insika +351 -0
- data/docs/AGENTS.md +494 -0
- data/docs/ARCHITECTURE.md +333 -0
- data/docs/BENCHMARK.md +114 -0
- data/docs/CHANNELS.md +453 -0
- data/docs/CONTEXT.md +100 -0
- data/docs/DEPLOY.md +334 -0
- data/docs/EMBEDDING.md +194 -0
- data/docs/EVALS.md +273 -0
- data/docs/LOADTEST.md +231 -0
- data/docs/OBSERVABILITY.md +365 -0
- data/docs/PLUGINS.md +211 -0
- data/docs/REFINEMENT.md +477 -0
- data/docs/RELEASING.md +70 -0
- data/docs/RUNNING-LOCAL.md +153 -0
- data/docs/SANDBOX.md +114 -0
- data/docs/SECURITY.md +362 -0
- data/docs/SKILLS.md +98 -0
- data/docs/TOOLS.md +302 -0
- data/docs/WHY.md +137 -0
- data/docs/WORKFLOWS.md +225 -0
- data/docs/build.md +14 -0
- data/docs/index.md +68 -0
- data/docs/onboarding/start.md +126 -0
- data/docs/operate.md +12 -0
- data/docs/ship.md +10 -0
- data/docs/understand.md +10 -0
- data/lib/insika/agent_file_store.rb +125 -0
- data/lib/insika/agent_profile.rb +188 -0
- data/lib/insika/allowlist.rb +28 -0
- data/lib/insika/baseline_store.rb +74 -0
- data/lib/insika/capability/resolved_tool.rb +34 -0
- data/lib/insika/capability_registry.rb +112 -0
- data/lib/insika/channel_delivery.rb +150 -0
- data/lib/insika/channel_registry.rb +30 -0
- data/lib/insika/channels/relay.rb +178 -0
- data/lib/insika/channels/web/widget.js +283 -0
- data/lib/insika/channels/web.rb +211 -0
- data/lib/insika/chat_builder.rb +254 -0
- data/lib/insika/checkpoint.rb +13 -0
- data/lib/insika/checkpoint_store.rb +153 -0
- data/lib/insika/coercion.rb +50 -0
- data/lib/insika/command.rb +32 -0
- data/lib/insika/command_bus.rb +39 -0
- data/lib/insika/commands/agent_payload.rb +41 -0
- data/lib/insika/commands/approve_action.rb +46 -0
- data/lib/insika/commands/cancel_task.rb +33 -0
- data/lib/insika/commands/create_agent.rb +54 -0
- data/lib/insika/commands/create_session.rb +67 -0
- data/lib/insika/commands/delete_agent.rb +33 -0
- data/lib/insika/commands/delete_agent_file.rb +50 -0
- data/lib/insika/commands/delete_data_tool.rb +33 -0
- data/lib/insika/commands/delete_llm_provider.rb +36 -0
- data/lib/insika/commands/delete_mcp.rb +30 -0
- data/lib/insika/commands/delete_system_file.rb +29 -0
- data/lib/insika/commands/gate_refinement.rb +245 -0
- data/lib/insika/commands/import_mcp_tools.rb +48 -0
- data/lib/insika/commands/import_tools.rb +81 -0
- data/lib/insika/commands/memory_add_note.rb +32 -0
- data/lib/insika/commands/memory_forget_fact.rb +32 -0
- data/lib/insika/commands/memory_put_fact.rb +35 -0
- data/lib/insika/commands/pause_task.rb +29 -0
- data/lib/insika/commands/resolve_refinement.rb +126 -0
- data/lib/insika/commands/restore_agent_file.rb +36 -0
- data/lib/insika/commands/restore_data_tool.rb +34 -0
- data/lib/insika/commands/restore_system_file.rb +31 -0
- data/lib/insika/commands/resume_task.rb +85 -0
- data/lib/insika/commands/run_refinement.rb +133 -0
- data/lib/insika/commands/send_message.rb +150 -0
- data/lib/insika/commands/set_agent_tools.rb +39 -0
- data/lib/insika/commands/set_skill_agents.rb +71 -0
- data/lib/insika/commands/trigger_workflow.rb +80 -0
- data/lib/insika/commands/update_agent.rb +49 -0
- data/lib/insika/commands/update_settings.rb +33 -0
- data/lib/insika/commands/upsert_llm_provider.rb +34 -0
- data/lib/insika/commands/upsert_mcp.rb +32 -0
- data/lib/insika/commands/write_agent_file.rb +57 -0
- data/lib/insika/commands/write_data_tool.rb +43 -0
- data/lib/insika/commands/write_golden.rb +58 -0
- data/lib/insika/commands/write_skill.rb +50 -0
- data/lib/insika/commands/write_system_file.rb +31 -0
- data/lib/insika/config_store.rb +85 -0
- data/lib/insika/context/builder.rb +166 -0
- data/lib/insika/context/catalog_provider.rb +23 -0
- data/lib/insika/context/fragment.rb +19 -0
- data/lib/insika/context/priority.rb +29 -0
- data/lib/insika/context/provider.rb +19 -0
- data/lib/insika/context/providers/memory.rb +60 -0
- data/lib/insika/context/providers/prompt.rb +105 -0
- data/lib/insika/context/providers/request.rb +32 -0
- data/lib/insika/context/providers/session.rb +108 -0
- data/lib/insika/context/providers/skill.rb +20 -0
- data/lib/insika/context/providers/tool_search.rb +20 -0
- data/lib/insika/delegation_store.rb +153 -0
- data/lib/insika/doctor.rb +294 -0
- data/lib/insika/dsl/definition.rb +55 -0
- data/lib/insika/dsl/runtime.rb +379 -0
- data/lib/insika/dsl/server_boot.rb +97 -0
- data/lib/insika/dsl/system.rb +93 -0
- data/lib/insika/dsl/workflow_adapter.rb +59 -0
- data/lib/insika/dsl.rb +307 -0
- data/lib/insika/edge_limiter.rb +130 -0
- data/lib/insika/egress_guard.rb +75 -0
- data/lib/insika/env_schema.rb +246 -0
- data/lib/insika/errors.rb +145 -0
- data/lib/insika/evals/assertions.rb +247 -0
- data/lib/insika/evals/baseline.rb +69 -0
- data/lib/insika/evals/golden.rb +172 -0
- data/lib/insika/evals/judge.rb +225 -0
- data/lib/insika/evals/pairwise.rb +178 -0
- data/lib/insika/evals/report.rb +115 -0
- data/lib/insika/evals/runner.rb +141 -0
- data/lib/insika/evals/transport.rb +178 -0
- data/lib/insika/event.rb +18 -0
- data/lib/insika/event_stream.rb +114 -0
- data/lib/insika/executor.rb +1680 -0
- data/lib/insika/frontmatter.rb +42 -0
- data/lib/insika/golden_store.rb +145 -0
- data/lib/insika/hooks.rb +48 -0
- data/lib/insika/http_client.rb +63 -0
- data/lib/insika/inbound_log.rb +84 -0
- data/lib/insika/llm_configurator.rb +99 -0
- data/lib/insika/llm_provider_store.rb +83 -0
- data/lib/insika/mcp_http_client.rb +67 -0
- data/lib/insika/mcp_store.rb +115 -0
- data/lib/insika/mcp_tool_ingestor.rb +143 -0
- data/lib/insika/memory_store.rb +93 -0
- data/lib/insika/message_origin.rb +76 -0
- data/lib/insika/middleware.rb +36 -0
- data/lib/insika/model_policy.rb +52 -0
- data/lib/insika/model_resolver.rb +176 -0
- data/lib/insika/model_selection.rb +114 -0
- data/lib/insika/onboarding.rb +208 -0
- data/lib/insika/outbox_store.rb +166 -0
- data/lib/insika/overlay_tool_registry.rb +103 -0
- data/lib/insika/pack.rb +102 -0
- data/lib/insika/pack_importer.rb +121 -0
- data/lib/insika/pending_action_store.rb +120 -0
- data/lib/insika/plugin/loader.rb +356 -0
- data/lib/insika/plugin.rb +35 -0
- data/lib/insika/policy/engine.rb +83 -0
- data/lib/insika/policy/policy.rb +120 -0
- data/lib/insika/policy_registry.rb +23 -0
- data/lib/insika/profile_source.rb +137 -0
- data/lib/insika/prompt_catalog.rb +61 -0
- data/lib/insika/queue_policy.rb +167 -0
- data/lib/insika/recovery.rb +127 -0
- data/lib/insika/refinement/candidate.rb +159 -0
- data/lib/insika/refinement/evidence_collector.rb +371 -0
- data/lib/insika/refinement/gate.rb +234 -0
- data/lib/insika/refinement/panel.rb +222 -0
- data/lib/insika/refinement/proposer.rb +262 -0
- data/lib/insika/refinement_store.rb +295 -0
- data/lib/insika/registry.rb +59 -0
- data/lib/insika/safety/config.rb +109 -0
- data/lib/insika/safety/detectors.rb +176 -0
- data/lib/insika/safety/factory.rb +102 -0
- data/lib/insika/safety/input_guardrail.rb +87 -0
- data/lib/insika/safety/moderator.rb +86 -0
- data/lib/insika/safety/output_filter.rb +79 -0
- data/lib/insika/safety/output_validator.rb +101 -0
- data/lib/insika/safety/safe_responses.rb +47 -0
- data/lib/insika/sandbox/boundary.rb +93 -0
- data/lib/insika/sandbox/docker.rb +74 -0
- data/lib/insika/sandbox/local.rb +33 -0
- data/lib/insika/sandbox/runner.rb +80 -0
- data/lib/insika/sandbox.rb +85 -0
- data/lib/insika/schema_guard.rb +147 -0
- data/lib/insika/secret_masking.rb +34 -0
- data/lib/insika/server/a2a/agent_card.rb +27 -0
- data/lib/insika/server/a2a/app.rb +112 -0
- data/lib/insika/server/a2a/client.rb +101 -0
- data/lib/insika/server/a2a/errors.rb +32 -0
- data/lib/insika/server/a2a/http.rb +42 -0
- data/lib/insika/server/a2a/message.rb +27 -0
- data/lib/insika/server/a2a/protocol.rb +45 -0
- data/lib/insika/server/a2a/remotes.rb +25 -0
- data/lib/insika/server/a2a/task_projection.rb +40 -0
- data/lib/insika/server/admin_auth.rb +29 -0
- data/lib/insika/server/app.rb +850 -0
- data/lib/insika/server/boot.rb +119 -0
- data/lib/insika/server/rack_app.rb +110 -0
- data/lib/insika/server/responses.rb +155 -0
- data/lib/insika/server/sse_body.rb +96 -0
- data/lib/insika/session_actor.rb +162 -0
- data/lib/insika/session_store.rb +143 -0
- data/lib/insika/settings_store.rb +154 -0
- data/lib/insika/shutdown.rb +125 -0
- data/lib/insika/skill_catalog.rb +113 -0
- data/lib/insika/skill_store.rb +79 -0
- data/lib/insika/steer_injector.rb +110 -0
- data/lib/insika/store.rb +52 -0
- data/lib/insika/stores/memory.rb +123 -0
- data/lib/insika/stores/sqlite.rb +183 -0
- data/lib/insika/studio/app.rb +1571 -0
- data/lib/insika/studio/assets/dist/application.css +1 -0
- data/lib/insika/studio/assets/dist/application.js +69 -0
- data/lib/insika/studio/forms.rb +340 -0
- data/lib/insika/studio/nav_icons.rb +31 -0
- data/lib/insika/studio/views/_message.erb +44 -0
- data/lib/insika/studio/views/agent_detail.erb +285 -0
- data/lib/insika/studio/views/agents.erb +63 -0
- data/lib/insika/studio/views/approvals.erb +41 -0
- data/lib/insika/studio/views/chats.erb +34 -0
- data/lib/insika/studio/views/evals.erb +83 -0
- data/lib/insika/studio/views/home.erb +72 -0
- data/lib/insika/studio/views/layout.erb +94 -0
- data/lib/insika/studio/views/login.erb +17 -0
- data/lib/insika/studio/views/mcp.erb +91 -0
- data/lib/insika/studio/views/not_found.erb +5 -0
- data/lib/insika/studio/views/playground.erb +47 -0
- data/lib/insika/studio/views/refinement.erb +234 -0
- data/lib/insika/studio/views/session.erb +62 -0
- data/lib/insika/studio/views/settings.erb +173 -0
- data/lib/insika/studio/views/skills.erb +86 -0
- data/lib/insika/studio/views/system_files.erb +65 -0
- data/lib/insika/studio/views/task.erb +105 -0
- data/lib/insika/studio/views/tasks.erb +33 -0
- data/lib/insika/studio/views/tool_edit.erb +107 -0
- data/lib/insika/studio/views/tools.erb +89 -0
- data/lib/insika/subagent_graph.rb +96 -0
- data/lib/insika/system_file_store.rb +96 -0
- data/lib/insika/task_actor.rb +128 -0
- data/lib/insika/task_store.rb +250 -0
- data/lib/insika/telemetry/pricing.rb +104 -0
- data/lib/insika/telemetry/recorder.rb +228 -0
- data/lib/insika/telemetry.rb +127 -0
- data/lib/insika/testing/store_contract.rb +270 -0
- data/lib/insika/token_estimator.rb +16 -0
- data/lib/insika/tool_assembly.rb +140 -0
- data/lib/insika/tool_catalog.rb +89 -0
- data/lib/insika/tool_definition.rb +518 -0
- data/lib/insika/tool_envelope.rb +140 -0
- data/lib/insika/tool_manifest.rb +218 -0
- data/lib/insika/tool_registry.rb +21 -0
- data/lib/insika/tool_store.rb +135 -0
- data/lib/insika/tool_trace_store.rb +92 -0
- data/lib/insika/tools/a2a_remote.rb +48 -0
- data/lib/insika/tools/agent_enum.rb +68 -0
- data/lib/insika/tools/concurrency.rb +54 -0
- data/lib/insika/tools/data_defined_tool.rb +220 -0
- data/lib/insika/tools/load_skill.rb +41 -0
- data/lib/insika/tools/remember.rb +53 -0
- data/lib/insika/tools/subagent.rb +75 -0
- data/lib/insika/tools/subagents.rb +77 -0
- data/lib/insika/tools/tool_search.rb +94 -0
- data/lib/insika/turn_output.rb +139 -0
- data/lib/insika/turn_state.rb +158 -0
- data/lib/insika/turn_timing.rb +56 -0
- data/lib/insika/usage_ledger.rb +47 -0
- data/lib/insika/version.rb +3 -1
- data/lib/insika/wiring/graph.rb +198 -0
- data/lib/insika/workflow.rb +185 -0
- data/lib/insika/workflow_registry.rb +33 -0
- data/lib/insika.rb +203 -4
- 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
|
data/lib/insika/event.rb
ADDED
|
@@ -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
|