insika 0.0.1 → 0.2.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 +361 -0
- data/LICENSE +21 -0
- data/README.md +136 -2
- data/bin/insika +366 -0
- data/docs/AGENTS.md +618 -0
- data/docs/ARCHITECTURE.md +333 -0
- data/docs/BENCHMARK.md +114 -0
- data/docs/CHANNELS.md +453 -0
- data/docs/CONTEXT.md +117 -0
- data/docs/DEPLOY.md +354 -0
- data/docs/EMBEDDING.md +198 -0
- data/docs/EVALS.md +273 -0
- data/docs/LOADTEST.md +232 -0
- data/docs/OBSERVABILITY.md +374 -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 +375 -0
- data/docs/SKILLS.md +284 -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 +255 -0
- data/lib/insika/alert_dispatcher.rb +139 -0
- data/lib/insika/allowlist.rb +28 -0
- data/lib/insika/baseline_store.rb +74 -0
- data/lib/insika/budget_ledger.rb +135 -0
- data/lib/insika/capability/resolved_tool.rb +34 -0
- data/lib/insika/capability_registry.rb +112 -0
- data/lib/insika/channel_delivery.rb +153 -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/channels/webhook.rb +58 -0
- data/lib/insika/chat_builder.rb +303 -0
- data/lib/insika/checkpoint.rb +13 -0
- data/lib/insika/checkpoint_store.rb +153 -0
- data/lib/insika/circuit_state.rb +114 -0
- data/lib/insika/coercion.rb +58 -0
- data/lib/insika/command.rb +32 -0
- data/lib/insika/command_bus.rb +39 -0
- data/lib/insika/commands/agent_payload.rb +43 -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_skill.rb +43 -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/issue_tenant_token.rb +41 -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/revoke_token.rb +39 -0
- data/lib/insika/commands/rotate_tenant_token.rb +43 -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 +112 -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 +60 -0
- data/lib/insika/commands/write_system_file.rb +31 -0
- data/lib/insika/config_store.rb +89 -0
- data/lib/insika/context/builder.rb +166 -0
- data/lib/insika/context/catalog_provider.rb +23 -0
- data/lib/insika/context/fragment.rb +43 -0
- data/lib/insika/context/priority.rb +30 -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 +123 -0
- data/lib/insika/context/providers/skill.rb +24 -0
- data/lib/insika/context/providers/skill_trigger.rb +128 -0
- data/lib/insika/context/providers/tool_search.rb +20 -0
- data/lib/insika/context_trace_store.rb +92 -0
- data/lib/insika/delegation_store.rb +153 -0
- data/lib/insika/doctor.rb +539 -0
- data/lib/insika/dsl/definition.rb +55 -0
- data/lib/insika/dsl/runtime.rb +382 -0
- data/lib/insika/dsl/server_boot.rb +98 -0
- data/lib/insika/dsl/system.rb +93 -0
- data/lib/insika/dsl/workflow_adapter.rb +59 -0
- data/lib/insika/dsl.rb +364 -0
- data/lib/insika/edge_limiter.rb +268 -0
- data/lib/insika/egress_guard.rb +75 -0
- data/lib/insika/env_schema.rb +249 -0
- data/lib/insika/errors.rb +201 -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 +132 -0
- data/lib/insika/executor.rb +1995 -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/loop_detector.rb +143 -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 +115 -0
- data/lib/insika/onboarding.rb +208 -0
- data/lib/insika/outbox_store.rb +166 -0
- data/lib/insika/overlay_tool_registry.rb +102 -0
- data/lib/insika/pack.rb +102 -0
- data/lib/insika/pack_importer.rb +123 -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 +143 -0
- data/lib/insika/prompt_catalog.rb +61 -0
- data/lib/insika/provider_error_classifier.rb +160 -0
- data/lib/insika/queue_policy.rb +167 -0
- data/lib/insika/recovery.rb +168 -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/reliability.rb +185 -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 +102 -0
- data/lib/insika/safety/moderator.rb +94 -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/app.rb +1022 -0
- data/lib/insika/server/boot.rb +119 -0
- data/lib/insika/server/rack_app.rb +118 -0
- data/lib/insika/server/responses.rb +165 -0
- data/lib/insika/server/sse_body.rb +96 -0
- data/lib/insika/server/tenant_auth.rb +61 -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 +220 -0
- data/lib/insika/skill_store.rb +127 -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 +1693 -0
- data/lib/insika/studio/assets/dist/application.css +1 -0
- data/lib/insika/studio/assets/dist/application.js +70 -0
- data/lib/insika/studio/forms.rb +335 -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 +137 -0
- data/lib/insika/studio/views/settings.erb +168 -0
- data/lib/insika/studio/views/skills.erb +141 -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/tick.rb +122 -0
- data/lib/insika/token_estimator.rb +16 -0
- data/lib/insika/token_store.rb +168 -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_output_compressor.rb +100 -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 +219 -0
- data/lib/insika/tools/load_skill.rb +99 -0
- data/lib/insika/tools/remember.rb +53 -0
- data/lib/insika/tools/stuck_signal.rb +44 -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 +162 -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 +249 -0
- data/lib/insika/workflow.rb +185 -0
- data/lib/insika/workflow_registry.rb +33 -0
- data/lib/insika.rb +220 -4
- metadata +412 -8
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
module Refinement
|
|
5
|
+
# What a refinement run is allowed to SPEND.
|
|
6
|
+
#
|
|
7
|
+
# A panel of 3 proposers over a 7-case golden set is 3 model calls plus 21
|
|
8
|
+
# replayed conversations, each a real turn with real tools. That is the honest
|
|
9
|
+
# objection to the whole feature, and this is the answer to it: a ceiling the
|
|
10
|
+
# operator sets, checked before each expensive step and never in the middle of
|
|
11
|
+
# one. Nothing is aborted mid-flight — a half-scored candidate is worse than an
|
|
12
|
+
# unscored one, because it looks like a verdict.
|
|
13
|
+
#
|
|
14
|
+
# **Unmetered legs are counted, not guessed.** A provider that reports no token
|
|
15
|
+
# usage makes a leg invisible to the ceiling; recording it as 0 would let a budget
|
|
16
|
+
# sit at "0 spent" forever while real money went out, so those legs are tallied
|
|
17
|
+
# separately and shown to the operator. The structural bounds — the fan-out cap on
|
|
18
|
+
# the panel, `max_edits`, the gate's own refusals — are what bound a run whose
|
|
19
|
+
# provider says nothing.
|
|
20
|
+
class Budget
|
|
21
|
+
def initialize(tokens: nil)
|
|
22
|
+
limit = tokens.to_i
|
|
23
|
+
@limit = limit.positive? ? limit : nil
|
|
24
|
+
@spent = 0
|
|
25
|
+
@cached = 0
|
|
26
|
+
@unmetered = 0
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def limited? = !@limit.nil?
|
|
30
|
+
|
|
31
|
+
# `tokens` is what the leg SENT, prompt cache included — see
|
|
32
|
+
# `Evals::Runner#billed_tokens` for why the engine's `total_tokens` alone is not
|
|
33
|
+
# that number. `cached` is the part of it that was cached, carried so the record
|
|
34
|
+
# can explain a total rather than just state one. nil/0 tokens = the leg
|
|
35
|
+
# happened and reported nothing.
|
|
36
|
+
def spend(tokens, cached: nil)
|
|
37
|
+
count = tokens.to_i
|
|
38
|
+
if count.positive?
|
|
39
|
+
@spent += count
|
|
40
|
+
@cached += cached.to_i
|
|
41
|
+
else
|
|
42
|
+
@unmetered += 1
|
|
43
|
+
end
|
|
44
|
+
self
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def exhausted? = limited? && @spent >= @limit
|
|
48
|
+
|
|
49
|
+
def remaining = limited? ? [@limit - @spent, 0].max : nil
|
|
50
|
+
|
|
51
|
+
def to_h = { "tokens" => @limit, "spent" => @spent, "cached" => @cached,
|
|
52
|
+
"unmetered" => @unmetered }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# The proposer PANEL: N models write N independent
|
|
56
|
+
# candidates, the gate scores each one by replaying the golden set, and the best
|
|
57
|
+
# SURVIVOR becomes the proposal a human is asked about.
|
|
58
|
+
#
|
|
59
|
+
# Independent, not consensus-seeking. Two models agreeing on wording is weak
|
|
60
|
+
# evidence and a golden case passing is strong evidence, so convergence only
|
|
61
|
+
# ever breaks a tie between candidates the gate already ranked equal.
|
|
62
|
+
#
|
|
63
|
+
# A panel of one is unchanged, which is why there is no second code path:
|
|
64
|
+
# `refinement.proposer` (a single ref) resolves to a one-element panel.
|
|
65
|
+
class Panel
|
|
66
|
+
# One member of the panel: the candidate, WHO wrote it (more than one model when
|
|
67
|
+
# they converged on the identical edit set), and how it scored.
|
|
68
|
+
Entry = Data.define(:candidate, :proposers, :report) do
|
|
69
|
+
def converged = proposers.size
|
|
70
|
+
def passed? = report&.passed == true
|
|
71
|
+
|
|
72
|
+
def to_h = { "candidate" => candidate.to_h, "proposers" => proposers,
|
|
73
|
+
"gate" => report&.to_h }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
Result = Data.define(:entries, :winner, :budget, :failed) do
|
|
77
|
+
def winner_report = winner&.report
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# gate: a Refinement::Gate (anything answering #score).
|
|
81
|
+
# proposers: [Refinement::Proposer], already resolved by ProposerFactory.panel.
|
|
82
|
+
# budget: a Budget. The default is unlimited — a deployment that configured
|
|
83
|
+
# none gets behaviour, which had no ceiling either.
|
|
84
|
+
def initialize(gate:, proposers: [], budget: Budget.new, fan_out: nil)
|
|
85
|
+
@gate = gate
|
|
86
|
+
@proposers = Array(proposers)
|
|
87
|
+
@budget = budget
|
|
88
|
+
@fan_out = fan_out || Insika::SubagentGraph.fan_out_cap
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Proposes (unless `raw` is given), builds, gates and ranks. Yields the built
|
|
92
|
+
# entries BEFORE any of them is scored, so the caller can record the panel and
|
|
93
|
+
# move the run to :gating — the gate is the slow part and a run that says
|
|
94
|
+
# nothing until it finishes looks hung.
|
|
95
|
+
#
|
|
96
|
+
# -> Result. Raises ValidationError when there is nothing gateable at all,
|
|
97
|
+
# because that is an operator-facing refusal ("every edit was dropped, here is
|
|
98
|
+
# why"), not a verdict about the agent.
|
|
99
|
+
def run(agent_id:, run_id:, findings:, files:, allowlist:, contents:, limits: {},
|
|
100
|
+
raw: nil, tolerance: nil)
|
|
101
|
+
proposals, failed = raw ? [[raw], []] : propose(agent_id, findings, files, limits)
|
|
102
|
+
# A candidate that ARRIVED (Studio form, API client) cost this run nothing —
|
|
103
|
+
# counting it as an unmetered leg would make the cost record read as if a model
|
|
104
|
+
# had been asked and stayed quiet.
|
|
105
|
+
entries = build(proposals, allowlist, contents, limits, metered: raw.nil?)
|
|
106
|
+
yield entries if block_given?
|
|
107
|
+
|
|
108
|
+
scored = entries.map { |entry| score(entry, agent_id, run_id, tolerance) }
|
|
109
|
+
Result.new(entries: scored, winner: self.class.rank(scored), budget: @budget, failed: failed)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# -> the best SURVIVOR, or nil when none passed.
|
|
113
|
+
#
|
|
114
|
+
# Highest graded score first; ties broken by the fewest edits (a smaller diff is
|
|
115
|
+
# a smaller bet), then by how many proposers converged on it. `min_by`
|
|
116
|
+
# over a negated tuple keeps the comparison in one place and stays stable, so
|
|
117
|
+
# two genuinely indistinguishable candidates resolve to the first proposer the
|
|
118
|
+
# operator listed rather than to whichever fiber finished first.
|
|
119
|
+
def self.rank(entries)
|
|
120
|
+
entries.select(&:passed?)
|
|
121
|
+
.min_by { |e| [-e.report.passed_cases, e.candidate.edits.size, -e.converged] }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# The refusal to RECORD when nothing survived: the entry that got furthest,
|
|
125
|
+
# because "1 regression on `quotes`" tells an operator something and "budget
|
|
126
|
+
# exhausted" tells them only that the run stopped.
|
|
127
|
+
def self.best_refusal(entries)
|
|
128
|
+
entries.find { |e| e.report&.cases.to_i.positive? } ||
|
|
129
|
+
entries.find { |e| e.report } || entries.first
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
private
|
|
133
|
+
|
|
134
|
+
# All proposers at once, bounded by the fan-out cap (says the
|
|
135
|
+
# panel reuses it). Each is one blocking HTTP call to a provider, so the
|
|
136
|
+
# wall-clock is the slowest model rather than their sum.
|
|
137
|
+
#
|
|
138
|
+
# A proposer that answers prose, times out or 500s takes ITSELF out of the panel
|
|
139
|
+
# and nothing else: the whole point of asking several models is that one of them
|
|
140
|
+
# being useless is a survivable event. All of them failing is not, and raises.
|
|
141
|
+
def propose(agent_id, findings, files, limits)
|
|
142
|
+
raise Insika::ValidationError, "no proposer is configured" if @proposers.empty?
|
|
143
|
+
|
|
144
|
+
require_relative "../tools/concurrency"
|
|
145
|
+
blocks = @proposers.map do |proposer|
|
|
146
|
+
lambda do
|
|
147
|
+
proposer.propose(agent_id: agent_id, findings: findings, files: files, limits: limits)
|
|
148
|
+
rescue StandardError => e
|
|
149
|
+
{ "failed" => "#{proposer.model}: #{e.class}: #{e.message}" }
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
outcomes = Insika::Tools::Concurrency.gather(blocks, max: @fan_out)
|
|
154
|
+
proposals, failures = outcomes.partition { |o| o["failed"].nil? }
|
|
155
|
+
failed = failures.map { |o| o["failed"] }
|
|
156
|
+
if proposals.empty?
|
|
157
|
+
raise Insika::ValidationError,
|
|
158
|
+
"every proposer failed — #{failed.join('; ')}"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
[proposals, failed]
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Raw candidates -> Entries, DEDUPED by their edit set. Two models that wrote
|
|
165
|
+
# the identical edit are one candidate with two proposers: gating it twice would
|
|
166
|
+
# spend a whole golden replay to learn the same number, and the fact that they
|
|
167
|
+
# agreed is worth more as a tie-break than as a second row.
|
|
168
|
+
def build(proposals, allowlist, contents, limits, metered: true)
|
|
169
|
+
entries = {}
|
|
170
|
+
dropped = []
|
|
171
|
+
|
|
172
|
+
proposals.each do |proposal|
|
|
173
|
+
candidate = CandidateBuilder.build(proposal, allowlist: allowlist,
|
|
174
|
+
contents: contents, limits: limits)
|
|
175
|
+
if metered
|
|
176
|
+
raw = proposal.is_a?(Hash) ? proposal : {}
|
|
177
|
+
@budget.spend(raw["tokens"], cached: raw["cached"])
|
|
178
|
+
end
|
|
179
|
+
if candidate.empty?
|
|
180
|
+
dropped.concat(candidate.dropped)
|
|
181
|
+
next
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
signature = candidate.edits.map(&:to_h)
|
|
185
|
+
if (seen = entries[signature])
|
|
186
|
+
entries[signature] = seen.with(proposers: seen.proposers + [candidate.proposer])
|
|
187
|
+
else
|
|
188
|
+
entries[signature] = Entry.new(candidate: candidate, proposers: [candidate.proposer],
|
|
189
|
+
report: nil)
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
return entries.values unless entries.empty?
|
|
194
|
+
|
|
195
|
+
# Every edit of every proposal fell off. Say which and why: an operator who
|
|
196
|
+
# gets "invalid candidate" back learns nothing, and a stale `before` is the
|
|
197
|
+
# common one.
|
|
198
|
+
raise Insika::ValidationError,
|
|
199
|
+
"every edit was dropped — #{dropped.map { |d| "#{d.file}: #{d.reason}" }.join('; ')}"
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# One gate run, unless the budget is already spent. An unscored candidate is
|
|
203
|
+
# recorded with a refusal that names the ceiling — never dropped in silence,
|
|
204
|
+
# which would read as "the panel only produced one idea".
|
|
205
|
+
def score(entry, agent_id, run_id, tolerance)
|
|
206
|
+
if @budget.exhausted?
|
|
207
|
+
return entry.with(report: Gate::Report.new(
|
|
208
|
+
candidate_id: entry.candidate.id, passed: false,
|
|
209
|
+
reason: "not gated — the run's token budget (#{@budget.to_h['tokens']}) was spent",
|
|
210
|
+
cases: 0, passed_cases: 0, baseline_cases: 0, regressions: [], report: nil,
|
|
211
|
+
tokens: nil, cached: nil
|
|
212
|
+
))
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
report = @gate.score(agent_id: agent_id, candidate: entry.candidate,
|
|
216
|
+
run_id: run_id, tolerance: tolerance)
|
|
217
|
+
@budget.spend(report.tokens, cached: report.cached)
|
|
218
|
+
entry.with(report: report)
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
module Refinement
|
|
7
|
+
# Writes a CANDIDATE from a run's findings — the
|
|
8
|
+
# one place in refinement where a model is asked for anything.
|
|
9
|
+
#
|
|
10
|
+
# It is deliberately the WEAKEST link and it is built that way: everything this
|
|
11
|
+
# class produces is data that the CandidateBuilder bounds (allowlist, size,
|
|
12
|
+
# growth, a `before` that must still match) and the Gate then scores by REPLAYING
|
|
13
|
+
# the golden set. A hallucinated rationale, a misread finding, an invented anchor
|
|
14
|
+
# — the worst outcome of each is a candidate that gets dropped or fails to improve
|
|
15
|
+
# a score, and never reaches a customer. Nothing here is trusted; it is measured.
|
|
16
|
+
#
|
|
17
|
+
# Pure over an injected `ask` (prompt -> raw model text), like `Evals::Judge` and
|
|
18
|
+
# `Safety::Factory`: unit-testable without a provider, and the real ask is one
|
|
19
|
+
# lambda built by `ProposerFactory`.
|
|
20
|
+
#
|
|
21
|
+
# ## What it is shown
|
|
22
|
+
#
|
|
23
|
+
# The findings (already redacted at collection — a snippet went through the same
|
|
24
|
+
# output filter a customer-facing turn does) and the CURRENT CONTENT of the
|
|
25
|
+
# allowlisted files, verbatim and unmasked. That is not a leak: those files are
|
|
26
|
+
# the agent's own instructions, which are sent to a model on every single turn.
|
|
27
|
+
# Masking them here would buy nothing and would break anchoring — a `before`
|
|
28
|
+
# copied from a masked view never matches the real file, so every edit near a
|
|
29
|
+
# secret would drop as stale.
|
|
30
|
+
#
|
|
31
|
+
# Files OUTSIDE the allowlist are not shown at all. A model that can read them
|
|
32
|
+
# proposes edits to them, which drop, which spends the operator's attention on
|
|
33
|
+
# rejects.
|
|
34
|
+
class Proposer
|
|
35
|
+
# A model that answers with prose instead of JSON produces NOTHING, loudly.
|
|
36
|
+
# Silently returning an empty candidate would read as "the traffic is fine".
|
|
37
|
+
class Unusable < Insika::ValidationError; end
|
|
38
|
+
|
|
39
|
+
MAX_FINDINGS = 10
|
|
40
|
+
|
|
41
|
+
# The model ref, so a panel can name WHICH proposer failed without guessing.
|
|
42
|
+
attr_reader :model
|
|
43
|
+
|
|
44
|
+
# ask: ->(prompt) { "<raw model text>" }, or something answering `#content`
|
|
45
|
+
# plus `#input_tokens`/`#output_tokens` (a RubyLLM message). The second
|
|
46
|
+
# shape is what lets the panel's budget count what a proposal cost; a
|
|
47
|
+
# plain String stays valid and simply reports no cost, which is what
|
|
48
|
+
# every existing caller and every fake does.
|
|
49
|
+
# model: what to record as the candidate's `proposer` — the ref an operator
|
|
50
|
+
# reads on the review card and in `:refinement_proposed`.
|
|
51
|
+
def initialize(ask:, model: "unknown")
|
|
52
|
+
@ask = ask
|
|
53
|
+
@model = model.to_s
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# -> a RAW candidate hash (string keys) for `CandidateBuilder.build`. It is not
|
|
57
|
+
# a Candidate: this class does not get to decide what is in bounds.
|
|
58
|
+
#
|
|
59
|
+
# findings: the run's findings as stored (string keys).
|
|
60
|
+
# files: name => current content, allowlist only.
|
|
61
|
+
def propose(agent_id:, findings:, files:, limits: {})
|
|
62
|
+
raise Unusable, "there is nothing to propose from — the run found no findings" if Array(findings).empty?
|
|
63
|
+
raise Unusable, "no writable file has any content to anchor an edit in" if files.empty?
|
|
64
|
+
|
|
65
|
+
answer = @ask.call(build_prompt(agent_id, Array(findings).first(MAX_FINDINGS), files, limits))
|
|
66
|
+
parsed = parse(text_of(answer))
|
|
67
|
+
parsed["proposer"] = @model
|
|
68
|
+
parsed["tokens"] = tokens_of(answer)
|
|
69
|
+
parsed["cached"] = cached_of(answer)
|
|
70
|
+
parsed
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def text_of(answer) = (answer.respond_to?(:content) ? answer.content : answer).to_s
|
|
76
|
+
|
|
77
|
+
# nil when the provider said nothing — never 0. `Budget` distinguishes the two
|
|
78
|
+
# and an operator reading "0 tokens" for a real model call would be reading a
|
|
79
|
+
# lie the record cannot correct. The cached prefix is INCLUDED, for the same
|
|
80
|
+
# reason `Evals::Runner#billed_tokens` includes it: a ceiling that cannot see
|
|
81
|
+
# what the cache served is not a ceiling on what was sent.
|
|
82
|
+
def tokens_of(answer)
|
|
83
|
+
return nil unless answer.respond_to?(:input_tokens) && answer.respond_to?(:output_tokens)
|
|
84
|
+
|
|
85
|
+
total = answer.input_tokens.to_i + answer.output_tokens.to_i + cached_of(answer).to_i
|
|
86
|
+
total.positive? ? total : nil
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def cached_of(answer)
|
|
90
|
+
return nil unless answer.respond_to?(:cached_tokens)
|
|
91
|
+
|
|
92
|
+
cached = answer.cached_tokens.to_i
|
|
93
|
+
cached.positive? ? cached : nil
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# JSON or nothing. ````json` fences are the common wrapper and stripping them is
|
|
97
|
+
# not leniency — the payload inside is still parsed strictly, so a model that
|
|
98
|
+
# improvises a schema fails here instead of producing half a candidate.
|
|
99
|
+
def parse(raw)
|
|
100
|
+
body = raw.strip.gsub(/\A```(?:json)?\s*|\s*```\z/, "")
|
|
101
|
+
first = body.index("{")
|
|
102
|
+
last = body.rindex("}")
|
|
103
|
+
raise Unusable, "the proposer answered with no JSON object" if first.nil? || last.nil? || last < first
|
|
104
|
+
|
|
105
|
+
parsed = JSON.parse(body[first..last])
|
|
106
|
+
raise Unusable, "the proposer's JSON is not an object" unless parsed.is_a?(Hash)
|
|
107
|
+
raise Unusable, "the proposer's JSON carries no `edits`" unless parsed["edits"].is_a?(Array)
|
|
108
|
+
|
|
109
|
+
parsed
|
|
110
|
+
rescue JSON::ParserError => e
|
|
111
|
+
raise Unusable, "the proposer's answer is not valid JSON: #{e.message}"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def build_prompt(agent_id, findings, files, limits)
|
|
115
|
+
bounds = DEFAULT_LIMITS.merge(Coercion.deep_stringify(limits.is_a?(Hash) ? limits : {}))
|
|
116
|
+
<<~PROMPT
|
|
117
|
+
You are improving the written instructions of a production customer-service agent
|
|
118
|
+
called "#{agent_id}". You are given (1) what actually broke in its recent real
|
|
119
|
+
traffic and (2) the current text of the only files you may edit.
|
|
120
|
+
|
|
121
|
+
Propose the SMALLEST set of anchored edits that would plausibly fix the findings.
|
|
122
|
+
Your proposal will be scored by replaying the agent's test cases with your edits
|
|
123
|
+
applied — plausible prose that changes no behaviour is worth nothing here.
|
|
124
|
+
|
|
125
|
+
## What broke
|
|
126
|
+
|
|
127
|
+
#{render_findings(findings)}
|
|
128
|
+
|
|
129
|
+
## Files you may edit (current content, verbatim)
|
|
130
|
+
|
|
131
|
+
#{render_files(files)}
|
|
132
|
+
|
|
133
|
+
## Rules
|
|
134
|
+
|
|
135
|
+
- Answer with a single JSON object and NOTHING else. No prose, no fences.
|
|
136
|
+
- At most #{bounds['max_edits']} edits. Each `after` at most #{bounds['max_bytes']} bytes.
|
|
137
|
+
- `op` is "replace" or "append". Never rewrite a whole file.
|
|
138
|
+
- For "replace": `before` MUST be copied character-for-character from the file
|
|
139
|
+
above and must appear there EXACTLY ONCE. If you cannot find a unique anchor,
|
|
140
|
+
use "append" instead. An edit whose `before` does not match is discarded.
|
|
141
|
+
- For "append": `before` is ignored; the text is added at the end of the file.
|
|
142
|
+
- `file` must be one of the file names listed above.
|
|
143
|
+
- `addresses` lists the findings the edit is meant to fix, by their `kind` and
|
|
144
|
+
subject (e.g. "tool_error:shipping_quote").
|
|
145
|
+
- Do not propose an edit you cannot justify from a finding above. Fewer, better
|
|
146
|
+
edits beat filling the quota.
|
|
147
|
+
- You cannot remove a tool, change a guardrail or a model with these edits —
|
|
148
|
+
those are configuration, not text. Do not write instructions that pretend to.
|
|
149
|
+
- Some findings are INFRASTRUCTURE, not behaviour: a tool that failed on the
|
|
150
|
+
network, on TLS, on a refused connection, on a timeout, on a blocked
|
|
151
|
+
destination or on an HTTP status. The assistant does not choose URLs,
|
|
152
|
+
schemes, hosts or credentials and cannot fix any of that by being told to.
|
|
153
|
+
You may say what to DO when a tool fails; never say how to call it correctly.
|
|
154
|
+
|
|
155
|
+
## Answer with exactly this shape
|
|
156
|
+
|
|
157
|
+
{"rationale": "one or two sentences on the cause you are addressing",
|
|
158
|
+
"edits": [{"file": "TOOLS.md", "op": "replace", "anchor": "## shipping_quote",
|
|
159
|
+
"before": "<text copied from the file>",
|
|
160
|
+
"after": "<the replacement>",
|
|
161
|
+
"addresses": ["tool_error:shipping_quote"]}]}
|
|
162
|
+
PROMPT
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Counts included: "this happened 24 times" is the difference between a defect
|
|
166
|
+
# worth a prompt edit and a one-off the operator should ignore.
|
|
167
|
+
def render_findings(findings)
|
|
168
|
+
findings.map do |f|
|
|
169
|
+
f = Coercion.deep_stringify(f.respond_to?(:to_h) ? f.to_h : f)
|
|
170
|
+
line = "- #{f['kind']} (×#{f['count']}): #{f['title']}"
|
|
171
|
+
line += "\n #{f['detail']}" if Coercion.present?(f["detail"])
|
|
172
|
+
line
|
|
173
|
+
end.join("\n")
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def render_files(files)
|
|
177
|
+
files.map { |name, body| "### #{name}\n\n```\n#{body}\n```" }.join("\n\n")
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Resolves WHICH model(s) write the candidate, and builds the ask.
|
|
182
|
+
#
|
|
183
|
+
# refinement.proposers on the agent (a PANEL)
|
|
184
|
+
# -> refinement.proposer ("deepseek/deepseek-v4-flash" | "deepseek-v4-flash")
|
|
185
|
+
# -> the platform utility_model
|
|
186
|
+
# -> nothing, and the caller refuses. There is no default model here on purpose:
|
|
187
|
+
# guessing one spends an operator's provider budget without being asked.
|
|
188
|
+
module ProposerFactory
|
|
189
|
+
module_function
|
|
190
|
+
|
|
191
|
+
# config: the agent's `refinement` hash. -> Proposer | nil (the FIRST of the
|
|
192
|
+
# panel — single-proposer entry point, kept because a deployment that
|
|
193
|
+
# never configured a panel is a panel of one).
|
|
194
|
+
def build(config, utility_model: nil, ask_factory: nil, llm: nil)
|
|
195
|
+
panel(config, utility_model: utility_model, ask_factory: ask_factory, llm: llm).first
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# -> [Proposer], in configured order, DEDUPED by model ref and capped at the
|
|
199
|
+
# fan-out (says the panel reuses it). Two entries naming the same
|
|
200
|
+
# model are one proposer: asking the same model twice at temperature 0 measures
|
|
201
|
+
# its variance, which is exactly what rejected for the judges.
|
|
202
|
+
# `llm`: the graph's own RubyLLM context; nil = the global
|
|
203
|
+
# constant. Today only the deployment root builds a panel, and a deployment
|
|
204
|
+
# is one graph per process — the seam exists so an embedded graph that ever
|
|
205
|
+
# gains the refinement commands proposes on its own credentials.
|
|
206
|
+
def panel(config, utility_model: nil, ask_factory: nil, max: nil, llm: nil)
|
|
207
|
+
refs = refs_for(Coercion.deep_stringify(config || {}), utility_model)
|
|
208
|
+
cap = max || Insika::SubagentGraph.fan_out_cap
|
|
209
|
+
factory = ask_factory || ->(model, provider) { ruby_llm_ask(model, provider, llm: llm) }
|
|
210
|
+
refs.first(cap).map do |ref|
|
|
211
|
+
provider, model = split_ref(ref)
|
|
212
|
+
Proposer.new(ask: factory.call(model, provider), model: ref)
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# `proposers` accepts either syntax — a bare ref ("deepseek/deepseek-v4-flash") or
|
|
217
|
+
# the RFC's `{ "model" =>, "provider"? => }` — because the two already coexist in
|
|
218
|
+
# this config (`proposer` is a bare ref, `judges` are hashes) and refusing one of
|
|
219
|
+
# them would only teach operators which page they were reading.
|
|
220
|
+
def refs_for(config, utility_model)
|
|
221
|
+
listed = Array(config["proposers"]).filter_map { |entry| normalize_ref(entry) }
|
|
222
|
+
return listed.uniq unless listed.empty?
|
|
223
|
+
|
|
224
|
+
[Coercion.presence(config["proposer"]) || Coercion.presence(utility_model)].compact
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def normalize_ref(entry)
|
|
228
|
+
return Coercion.presence(entry) unless entry.is_a?(Hash)
|
|
229
|
+
|
|
230
|
+
e = Coercion.deep_stringify(entry)
|
|
231
|
+
model = Coercion.presence(e["model"])
|
|
232
|
+
return nil if model.nil?
|
|
233
|
+
|
|
234
|
+
(provider = Coercion.presence(e["provider"])) ? "#{provider}/#{model}" : model
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# "provider/model" -> [provider, model]; "model" -> [nil, model]. Same reading
|
|
238
|
+
# `Safety::Factory` uses for the moderator — one syntax for "which model", not
|
|
239
|
+
# one per feature.
|
|
240
|
+
def split_ref(ref)
|
|
241
|
+
prov, name = ref.to_s.split("/", 2)
|
|
242
|
+
name ? [prov, name] : [nil, prov]
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Temperature 0: a proposal an operator rejects should be re-proposable, and a
|
|
246
|
+
# gate result should be attributable to the edit rather than to a sampling seed.
|
|
247
|
+
# `ruby_llm` is required lazily so nothing loads a provider gem until a proposer
|
|
248
|
+
# is actually configured.
|
|
249
|
+
#
|
|
250
|
+
# Returns the MESSAGE, not `.content`: the token counts ride on it and the
|
|
251
|
+
# budget is what spends them. `Proposer` reads either shape.
|
|
252
|
+
def ruby_llm_ask(model, provider, llm: nil)
|
|
253
|
+
require "ruby_llm"
|
|
254
|
+
llm ||= RubyLLM
|
|
255
|
+
lambda do |prompt|
|
|
256
|
+
llm.chat(model: model, provider: provider, assume_model_exists: true)
|
|
257
|
+
.with_temperature(0).ask(prompt)
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
end
|