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,295 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Insika
|
|
7
|
+
# REFINEMENT DOMAIN store. One record per refinement RUN:
|
|
8
|
+
# the window that was read, the ranked findings the EvidenceCollector produced,
|
|
9
|
+
# and the run's outcome. RUNTIME data (it is derived from sessions/tasks/traces),
|
|
10
|
+
# so it takes the raw `store:` like SessionStore/TaskStore — not the ConfigStore.
|
|
11
|
+
#
|
|
12
|
+
# The key embeds the agent and the start timestamp:
|
|
13
|
+
# "run:<agent_id>:<started_at>:<id>"
|
|
14
|
+
# so `list(SCOPE, "run:<agent>:")` comes back CHRONOLOGICAL for that agent (the
|
|
15
|
+
# Store contract orders lexicographically) and `latest_for` is its last element.
|
|
16
|
+
# An agent id containing ":" would break that split, so it is rejected on write.
|
|
17
|
+
#
|
|
18
|
+
# wrote no edits anywhere — a Run was a REPORT and every non-collecting
|
|
19
|
+
# status was terminal. adds the rest of the lifecycle on
|
|
20
|
+
# the SAME record, additively: a gated candidate and the operator's decision.
|
|
21
|
+
#
|
|
22
|
+
# collecting ─▶ completed ─▶ gating ─▶ awaiting_approval ─▶ applied
|
|
23
|
+
# ╰─▶ no_findings ╰─▶ rejected (the gate failed it,
|
|
24
|
+
# ╰─▶ failed or the operator did)
|
|
25
|
+
#
|
|
26
|
+
# **The approval lives here, not in `PendingActionStore`** — a deliberate deviation
|
|
27
|
+
# from That store is coupled to a suspended TURN: `ApproveAction` resolves
|
|
28
|
+
# the record and then calls `executor.approve(task_id)` to wake a fiber. A
|
|
29
|
+
# refinement proposal has no turn and no fiber, so reusing it would mean inventing
|
|
30
|
+
# a task id, a fake `tool` name, and a wake-up that must never do anything — and
|
|
31
|
+
# the operator's approvals inbox would fill with rows that are not tool calls. The
|
|
32
|
+
# property actually wanted is durability across a `kill -9`, and this store
|
|
33
|
+
# has had it since.
|
|
34
|
+
class RefinementStore
|
|
35
|
+
include Coercion
|
|
36
|
+
|
|
37
|
+
SCOPE = "refinements"
|
|
38
|
+
KEY_PREFIX = "run:"
|
|
39
|
+
|
|
40
|
+
STATUSES = %i[collecting completed gating awaiting_approval applied rejected
|
|
41
|
+
no_findings failed].freeze
|
|
42
|
+
|
|
43
|
+
# OPEN means "this run will change without anyone asking": work is in flight
|
|
44
|
+
# (`collecting`, `gating`) or a human owes it an answer (`awaiting_approval`).
|
|
45
|
+
# Everything else is terminal — INCLUDING `completed`, which already
|
|
46
|
+
# treated that way and which stays true: a report is finished, and gating one is
|
|
47
|
+
# a new deliberate action, not a continuation. (Widening `terminal?` here is what
|
|
48
|
+
# the Studio's "latest report" lookup reads, so getting it wrong hides the report.)
|
|
49
|
+
OPEN = %i[collecting gating awaiting_approval].freeze
|
|
50
|
+
|
|
51
|
+
# `candidate`/`gate` are the WINNER — the one proposal a human is asked about, and
|
|
52
|
+
# the only thing `ResolveRefinement` ever applies. `candidates` is the whole panel
|
|
53
|
+
# every candidate that was built, who wrote it, and how
|
|
54
|
+
# it scored, including the ones that lost and the ones the budget never gated. A
|
|
55
|
+
# phase-C run recorded one candidate and no panel; it still reads back correctly,
|
|
56
|
+
# because a panel of one is the same shape.
|
|
57
|
+
Run = Data.define(:id, :agent_id, :status, :window, :findings, :excluded,
|
|
58
|
+
:started_at, :finished_at, :error,
|
|
59
|
+
:candidate, :candidates, :gate, :cost, :decision) do
|
|
60
|
+
def terminal? = !OPEN.include?(status)
|
|
61
|
+
def findings_count = findings.size
|
|
62
|
+
|
|
63
|
+
# Is there a gated proposal waiting for a human? The one question the Studio
|
|
64
|
+
# and the apply command both ask.
|
|
65
|
+
def awaiting_approval? = status == :awaiting_approval
|
|
66
|
+
def gate_passed? = gate.is_a?(Hash) && gate["passed"] == true
|
|
67
|
+
def edits = (candidate || {})["edits"] || []
|
|
68
|
+
def panel = Array(candidates)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def initialize(store:)
|
|
72
|
+
@store = store
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Opens a run (:collecting). `window` is the collector's window as data
|
|
76
|
+
# ({ "last_sessions" => N } | { "since" => iso8601 }) — recorded so a report can
|
|
77
|
+
# be read months later and still say what it looked at. -> Run.
|
|
78
|
+
def create(agent_id:, window: {}, id: SecureRandom.uuid, at: nil)
|
|
79
|
+
agent = agent_id.to_s
|
|
80
|
+
raise Insika::ValidationError, "agent_id is required" if agent.empty?
|
|
81
|
+
raise Insika::ValidationError, "agent_id must not contain ':'" if agent.include?(":")
|
|
82
|
+
|
|
83
|
+
started = at || timestamp
|
|
84
|
+
record = {
|
|
85
|
+
"id" => id.to_s, "agent_id" => agent, "status" => "collecting",
|
|
86
|
+
"window" => deep_stringify(window || {}), "findings" => [], "excluded" => 0,
|
|
87
|
+
"started_at" => started, "finished_at" => nil, "error" => nil,
|
|
88
|
+
"candidate" => nil, "candidates" => [], "gate" => nil, "cost" => nil,
|
|
89
|
+
"decision" => nil
|
|
90
|
+
}
|
|
91
|
+
@store.set(SCOPE, key_for(agent, started, id), record)
|
|
92
|
+
to_run(record)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Closes a run with its findings. Empty findings -> :no_findings (a distinct
|
|
96
|
+
# outcome from :completed — "we looked and it was clean" is a real answer, not a
|
|
97
|
+
# failure). -> Run. ArgumentError if the run is already terminal.
|
|
98
|
+
# `excluded` is how many turns the window dropped on purpose (synthetic
|
|
99
|
+
# traffic) — recorded so a report never reads cleaner than the data was.
|
|
100
|
+
def complete(id, findings:, excluded: 0)
|
|
101
|
+
update(id) do |record|
|
|
102
|
+
guard_open!(record)
|
|
103
|
+
list = Array(findings).map { |f| deep_stringify(f.respond_to?(:to_h) ? f.to_h : f) }
|
|
104
|
+
record["findings"] = list
|
|
105
|
+
record["excluded"] = Integer(excluded)
|
|
106
|
+
record["status"] = list.empty? ? "no_findings" : "completed"
|
|
107
|
+
record["finished_at"] = timestamp
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Closes a run as :failed, recording the error. -> Run.
|
|
112
|
+
def fail(id, error:)
|
|
113
|
+
update(id) do |record|
|
|
114
|
+
guard_open!(record)
|
|
115
|
+
record["status"] = "failed"
|
|
116
|
+
record["error"] = error.to_s
|
|
117
|
+
record["finished_at"] = timestamp
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# the proposal's lifecycle -------------------------------
|
|
122
|
+
|
|
123
|
+
# Attaches the candidate(s) under gate and moves the run to :gating. Only a
|
|
124
|
+
# `completed` run can be gated: a report with no findings has nothing to propose
|
|
125
|
+
# from, and a failed one never finished looking.
|
|
126
|
+
#
|
|
127
|
+
# `candidate:` (one) and `candidates:` (a panel) are the same call — the panel is
|
|
128
|
+
# recorded before the gate runs so the Studio can show WHAT is being scored while
|
|
129
|
+
# it is being scored, which is minutes of real replay. No winner is claimed yet:
|
|
130
|
+
# `candidate` stays nil until `gated` says which one it is. -> Run.
|
|
131
|
+
def gating(id, candidate: nil, candidates: nil)
|
|
132
|
+
panel = Array(candidates || [candidate].compact)
|
|
133
|
+
raise Insika::ValidationError, "a candidate is required to gate" if panel.empty?
|
|
134
|
+
|
|
135
|
+
update(id) do |record|
|
|
136
|
+
unless record["status"] == "completed"
|
|
137
|
+
raise ArgumentError, "run #{record['id']} is #{record['status']}, expected completed"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
record["candidates"] = panel.map { |c| entry_for(c) }
|
|
141
|
+
record["candidate"] = nil
|
|
142
|
+
record["gate"] = nil
|
|
143
|
+
record["status"] = "gating"
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Records the gate's verdict. A PASS parks the run at :awaiting_approval — a
|
|
148
|
+
# human still has to say yes, which is the product and not a formality. A
|
|
149
|
+
# FAIL is terminal as :rejected, with the gate report as the stated reason: the
|
|
150
|
+
# same finding must re-surface with new evidence before anything is proposed
|
|
151
|
+
# again, so there is no silent retry loop.
|
|
152
|
+
#
|
|
153
|
+
# `report` is the WINNER's (or, when nothing survived, the most informative
|
|
154
|
+
# refusal). `panel` is every scored entry — the store attaches it as-is and picks
|
|
155
|
+
# the winning candidate out of it by id. Which candidate WON is the caller's
|
|
156
|
+
# ranking decision; this store does not rank, it records. -> Run.
|
|
157
|
+
def gated(id, report:, panel: nil, cost: nil)
|
|
158
|
+
update(id) do |record|
|
|
159
|
+
unless record["status"] == "gating"
|
|
160
|
+
raise ArgumentError, "run #{record['id']} is #{record['status']}, expected gating"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
gate = deep_stringify(report.respond_to?(:to_h) ? report.to_h : report)
|
|
164
|
+
record["candidates"] = panel.map { |e| entry_for(e) } if panel
|
|
165
|
+
record["candidate"] = winning_candidate(record, gate)
|
|
166
|
+
record["gate"] = gate
|
|
167
|
+
record["cost"] = deep_stringify(cost.respond_to?(:to_h) ? cost.to_h : cost) if cost
|
|
168
|
+
if gate["passed"]
|
|
169
|
+
record["status"] = "awaiting_approval"
|
|
170
|
+
else
|
|
171
|
+
record["status"] = "rejected"
|
|
172
|
+
record["decision"] = { "by" => "gate", "at" => timestamp, "note" => gate["reason"] }
|
|
173
|
+
record["finished_at"] = timestamp
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# The operator's answer to a gated proposal. `applied` is recorded only after the
|
|
179
|
+
# writes land, so a crash between the two leaves the run awaiting approval and
|
|
180
|
+
# the operator re-approves — replaying a write that is already versioned and
|
|
181
|
+
# idempotent-ish beats recording a lie. -> Run.
|
|
182
|
+
def resolve(id, decision:, operator: nil, note: nil)
|
|
183
|
+
target = decision.to_sym
|
|
184
|
+
unless %i[applied rejected].include?(target)
|
|
185
|
+
raise Insika::ValidationError, "invalid decision: #{decision} (applied|rejected)"
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
update(id) do |record|
|
|
189
|
+
unless record["status"] == "awaiting_approval"
|
|
190
|
+
raise ArgumentError, "run #{record['id']} is #{record['status']}, expected awaiting_approval"
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
record["status"] = target.to_s
|
|
194
|
+
record["decision"] = { "by" => (Coercion.presence(operator) || "operator").to_s,
|
|
195
|
+
"at" => timestamp, "note" => Coercion.presence(note) }.compact
|
|
196
|
+
record["finished_at"] = timestamp
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# -> [Run] every run parked on a human, most recent first. What the Studio badges.
|
|
201
|
+
def awaiting_approval(limit: 20)
|
|
202
|
+
recent(limit: 200).select(&:awaiting_approval?).first(limit)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# -> Run | nil. O(n) scan over the scope (the key carries agent+timestamp, so
|
|
206
|
+
# there is no index by id): one node, local SQLite, runs are operator-paced.
|
|
207
|
+
def find(id)
|
|
208
|
+
key = key_for_id(id)
|
|
209
|
+
key && to_run(@store.get(SCOPE, key))
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# -> [Run] for one agent, MOST RECENT FIRST, capped by `limit`.
|
|
213
|
+
def for_agent(agent_id, limit: nil)
|
|
214
|
+
keys = @store.list(SCOPE, "#{KEY_PREFIX}#{agent_id}:").reverse
|
|
215
|
+
keys = keys.first(limit) if limit
|
|
216
|
+
keys.filter_map { |k| to_run(@store.get(SCOPE, k)) }
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# -> Run | nil (the agent's most recent run, whatever its status).
|
|
220
|
+
def latest_for(agent_id) = for_agent(agent_id, limit: 1).first
|
|
221
|
+
|
|
222
|
+
# -> [Run] across every agent, most recent first, capped.
|
|
223
|
+
def recent(limit: 20)
|
|
224
|
+
@store.list(SCOPE, KEY_PREFIX)
|
|
225
|
+
.filter_map { |k| to_run(@store.get(SCOPE, k)) }
|
|
226
|
+
.sort_by { |r| r.started_at.to_s }.reverse.first(limit)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
private
|
|
230
|
+
|
|
231
|
+
# One panel row: `{ candidate:, proposers: [], gate: }`. A bare Candidate (phase
|
|
232
|
+
# C, an operator's own payload, an older record) is wrapped into the same shape,
|
|
233
|
+
# so every reader has ONE thing to read and the two eras of this record do not
|
|
234
|
+
# each need a branch in the Studio.
|
|
235
|
+
def entry_for(value)
|
|
236
|
+
raw = deep_stringify(value.respond_to?(:to_h) ? value.to_h : value)
|
|
237
|
+
return raw if raw.key?("candidate")
|
|
238
|
+
|
|
239
|
+
{ "candidate" => raw, "proposers" => [raw["proposer"]].compact, "gate" => nil }
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# The candidate the winning report belongs to, matched by id. A report whose
|
|
243
|
+
# candidate is not in the panel (an older record, a hand-built payload) leaves
|
|
244
|
+
# whatever was already there rather than inventing a winner.
|
|
245
|
+
def winning_candidate(record, gate)
|
|
246
|
+
match = Array(record["candidates"]).find do |entry|
|
|
247
|
+
(entry["candidate"] || {})["id"] == gate["candidate_id"]
|
|
248
|
+
end
|
|
249
|
+
match ? match["candidate"] : record["candidate"]
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def key_for(agent, started_at, id) = "#{KEY_PREFIX}#{agent}:#{started_at}:#{id}"
|
|
253
|
+
|
|
254
|
+
# The id is the key's last segment; scanning is the price of keeping the key
|
|
255
|
+
# chronological (which is what every read except `find` actually wants).
|
|
256
|
+
def key_for_id(id)
|
|
257
|
+
suffix = ":#{id}"
|
|
258
|
+
@store.list(SCOPE, KEY_PREFIX).find { |k| k.end_with?(suffix) }
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def update(id)
|
|
262
|
+
key = key_for_id(id)
|
|
263
|
+
raise Insika::NotFoundError, "refinement run not found: #{id}" if key.nil?
|
|
264
|
+
|
|
265
|
+
record = @store.get(SCOPE, key)
|
|
266
|
+
raise Insika::NotFoundError, "refinement run not found: #{id}" if record.nil?
|
|
267
|
+
|
|
268
|
+
yield record
|
|
269
|
+
@store.set(SCOPE, key, record)
|
|
270
|
+
to_run(record)
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def guard_open!(record)
|
|
274
|
+
return if record["status"] == "collecting"
|
|
275
|
+
|
|
276
|
+
raise ArgumentError, "run #{record['id']} is already #{record['status']}"
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def to_run(record)
|
|
280
|
+
return nil if record.nil?
|
|
281
|
+
|
|
282
|
+
Run.new(
|
|
283
|
+
id: record["id"], agent_id: record["agent_id"],
|
|
284
|
+
status: record["status"].to_sym, window: record["window"] || {},
|
|
285
|
+
findings: record["findings"] || [], excluded: record["excluded"] || 0,
|
|
286
|
+
started_at: record["started_at"], finished_at: record["finished_at"],
|
|
287
|
+
error: record["error"],
|
|
288
|
+
candidate: record["candidate"], candidates: record["candidates"] || [],
|
|
289
|
+
gate: record["gate"], cost: record["cost"], decision: record["decision"]
|
|
290
|
+
)
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def timestamp = Time.now.utc.iso8601
|
|
294
|
+
end
|
|
295
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
# Generic executable base: Registry =
|
|
5
|
+
# EXECUTABLE content (tools/workflows/policies). Catalog (skills/prompts) is
|
|
6
|
+
# non-executable and does not inherit from here.
|
|
7
|
+
#
|
|
8
|
+
# Immutable post-boot by CONSTRUCTION (only boot registers): there
|
|
9
|
+
# is no `freeze!`; immutability is not enforced at runtime.
|
|
10
|
+
class Registry
|
|
11
|
+
Entry = Data.define(:name, :plugin, :metadata, :factory)
|
|
12
|
+
|
|
13
|
+
def initialize
|
|
14
|
+
@entries = {}
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# factory = block OR the positional callable. metadata captured by **kw
|
|
18
|
+
# (Symbol keys, stored as-is). Duplicate: FIRST wins (plugin precedence)
|
|
19
|
+
# — the second is discarded with a warn, never overwritten.
|
|
20
|
+
def register(name, callable = nil, plugin: nil, **metadata, &block)
|
|
21
|
+
name = name.to_s
|
|
22
|
+
factory = block || (callable.nil? ? nil : -> { callable })
|
|
23
|
+
raise ArgumentError, "registration without factory: #{name}" if factory.nil?
|
|
24
|
+
|
|
25
|
+
if @entries.key?(name)
|
|
26
|
+
existing = @entries[name]
|
|
27
|
+
warn "[registry] '#{name}' already registered by #{existing.plugin.inspect}; " \
|
|
28
|
+
"descartando registro de #{plugin.inspect} (primeiro vence)"
|
|
29
|
+
return self
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
@entries[name] = Entry.new(name: name, plugin: plugin&.to_s, metadata: metadata, factory: factory)
|
|
33
|
+
self
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# -> instance (factory.call) | raise NotFoundError.
|
|
37
|
+
def resolve(name)
|
|
38
|
+
entry(name).factory.call
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# -> the raw Entry (name/plugin/metadata/factory) | raise NotFoundError. For
|
|
42
|
+
# readers that need the metadata WITHOUT resolving the factory (discovery,
|
|
43
|
+
# WorkflowRegistry#definition).
|
|
44
|
+
def entry(name)
|
|
45
|
+
@entries[name.to_s] ||
|
|
46
|
+
(raise Insika::NotFoundError, "'#{name}' not registered in #{self.class}")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def entries = @entries.values
|
|
50
|
+
def names = @entries.keys
|
|
51
|
+
|
|
52
|
+
# Loader rollback support: removes the entries of a
|
|
53
|
+
# plugin. NOT a runtime API (registries are immutable post-boot).
|
|
54
|
+
def deregister_plugin(plugin_id)
|
|
55
|
+
@entries.delete_if { |_name, entry| entry.plugin == plugin_id.to_s }
|
|
56
|
+
nil
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
# The reliability policy for the turn's provider interaction (WS3): retries
|
|
5
|
+
# with exponential backoff, mid-turn ROTATION across the fallback chain, and
|
|
6
|
+
# the circuit breaker — all as DATA on `AgentProfile#reliability`, never a
|
|
7
|
+
# parallel code path. The failure classification is B9's
|
|
8
|
+
# (ProviderErrorClassifier): ONLY :retryable / :rate_limited_* ever retry or
|
|
9
|
+
# rotate; a :fatal is re-raised immediately (a poisoned credential must not
|
|
10
|
+
# hammer N models).
|
|
11
|
+
#
|
|
12
|
+
# Each ATTEMPT is a fresh chat built by the caller (the coordinator yields the
|
|
13
|
+
# selection): a failed `ask` leaves its user message in the chat, so re-asking
|
|
14
|
+
# the same chat would double the input. The customer-visible answer comes only
|
|
15
|
+
# from the attempt that returns; the fragments of failed attempts ride
|
|
16
|
+
# :intermediate (operator-side) and die with the turn.
|
|
17
|
+
#
|
|
18
|
+
# The breaker is per (tenant, provider/model): a node whose circuit is open is
|
|
19
|
+
# skipped (fail-fast with CircuitOpenError when the PRIMARY is open — the turn
|
|
20
|
+
# dies in ms, no provider call); a node that tripped mid-turn moves on.
|
|
21
|
+
class Reliability
|
|
22
|
+
DEFAULT_TIMEOUT = 30 # seconds per attempt (reliability["timeout"])
|
|
23
|
+
|
|
24
|
+
# circuit_store: CircuitState (the breaker's durable cells).
|
|
25
|
+
# event_stream: where the reliability events go (:provider_fallback, ...).
|
|
26
|
+
# sleeper: ->(seconds) — the backoff wait; injectable for specs.
|
|
27
|
+
def initialize(circuit_store:, event_stream:, sleeper: nil)
|
|
28
|
+
@circuit_store = circuit_store
|
|
29
|
+
@event_stream = event_stream
|
|
30
|
+
@sleeper = sleeper || method(:backoff_wait)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# policy: the profile's reliability data (string keys) — the caller skips
|
|
34
|
+
# this coordinator entirely when nil (parity).
|
|
35
|
+
# tenant: the command tenant (breaker scoping; nil = platform).
|
|
36
|
+
# agent: the agent id (event attribution — WS6 alerts read it).
|
|
37
|
+
# selection: the resolved primary ModelSelection.
|
|
38
|
+
# chain: [{ model:, provider: }] fallback candidates (profile's first,
|
|
39
|
+
# then the platform's resolved fallbacks).
|
|
40
|
+
# attempt: ->(selection, attempt_index) { response } — build the chat for
|
|
41
|
+
# that selection and ask. May RAISE a provider-family error.
|
|
42
|
+
#
|
|
43
|
+
# -> the successful response. Raises CircuitOpenError (primary open),
|
|
44
|
+
# or the last retryable error when every node exhausted its retries.
|
|
45
|
+
def call(policy:, tenant:, agent: nil, selection:, chain:, &attempt)
|
|
46
|
+
@agent = agent # event attribution (WF6 alerts) for THIS run
|
|
47
|
+
nodes = ([selection] + Array(chain)).map { |node| { selection: node, tries: 0 } }
|
|
48
|
+
retries = [policy["retries"].to_i, 0].max
|
|
49
|
+
breaker = breaker_config(policy)
|
|
50
|
+
# The per-attempt ceiling. A policy WITHOUT a timeout is DEFAULT_TIMEOUT —
|
|
51
|
+
# nothing config overrides here (the old [.., 1].max silently made every
|
|
52
|
+
# unset profile die in ~1s, WS3).
|
|
53
|
+
configured_timeout = policy["timeout"].to_i
|
|
54
|
+
timeout = configured_timeout.positive? ? configured_timeout : DEFAULT_TIMEOUT
|
|
55
|
+
# declaraed HERE (not inside a block) so the post-loop `raise` sees the
|
|
56
|
+
# method-local binding — a first assignment inside a block would not leak.
|
|
57
|
+
last_error = nil
|
|
58
|
+
|
|
59
|
+
# Fail-fast BEFORE any provider call: the PRIMARY's circuit open means
|
|
60
|
+
# this provider family is known-dead — the turn dies in ms.
|
|
61
|
+
if breaker && breaker_open?(tenant, selection, breaker)
|
|
62
|
+
raise circuit_open(tenant, selection, breaker)
|
|
63
|
+
end
|
|
64
|
+
nodes.each do |node|
|
|
65
|
+
selection = node[:selection]
|
|
66
|
+
next if breaker && breaker_open?(tenant, selection, breaker)
|
|
67
|
+
|
|
68
|
+
attempts = retries + 1
|
|
69
|
+
attempts.times do |index|
|
|
70
|
+
node[:tries] += 1
|
|
71
|
+
begin
|
|
72
|
+
response = with_attempt_timeout(timeout) { yield selection, node[:tries] }
|
|
73
|
+
@circuit_store.record_success(tenant: tenant, ref: ref_of(selection)) if breaker
|
|
74
|
+
return response
|
|
75
|
+
rescue StandardError => e
|
|
76
|
+
last_error = e
|
|
77
|
+
# A :fatal provider error — or ANYTHING that is not a provider
|
|
78
|
+
# failure at all (a bug, a domain error, a guardrail raise) — is
|
|
79
|
+
# never retried, never rotated (B9's structural rule). Only
|
|
80
|
+
# retryable/rate-limited (and the per-attempt timeout we raised)
|
|
81
|
+
# spend the retry budget. The B9 classifier is class-name based, so
|
|
82
|
+
# OUR TimeoutError reads as :fatal — the retryable_failure? check
|
|
83
|
+
# (which owns the reliability-stage timeout) must decide FIRST, or
|
|
84
|
+
# the :fatal guard would swallow it (WS3: a timeout never retried,
|
|
85
|
+
# never rotated).
|
|
86
|
+
retryable = retryable_failure?(e)
|
|
87
|
+
raise unless retryable
|
|
88
|
+
raise if kind_of(e) == :fatal && !e.is_a?(Insika::TimeoutError)
|
|
89
|
+
|
|
90
|
+
record_failure(tenant, selection, breaker, e)
|
|
91
|
+
# the last attempt of the last node re-raises; otherwise back off
|
|
92
|
+
# and give the next attempt/node a turn.
|
|
93
|
+
if index < attempts - 1 || node != nodes.last
|
|
94
|
+
@sleeper.call(backoff_seconds(policy, index))
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
raise last_error if last_error
|
|
100
|
+
|
|
101
|
+
raise Insika::Error, "reliability loop exhausted without a result"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
def breaker_config(policy)
|
|
107
|
+
b = policy["circuit_breaker"]
|
|
108
|
+
return nil unless b.is_a?(Hash) && b["after"].to_i.positive?
|
|
109
|
+
|
|
110
|
+
{ after: b["after"].to_i, within: b["within"].to_i, cooldown: b["cooldown"].to_i }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# The breaker cell reads need the POLICY's numbers; they ride as args. Only
|
|
114
|
+
# :open FAIL-FASTS; :half_open (cooldown elapsed) is the TRIAL — allowed.
|
|
115
|
+
def breaker_open?(tenant, selection, breaker)
|
|
116
|
+
@circuit_store.state(tenant: tenant, ref: ref_of(selection),
|
|
117
|
+
after: breaker[:after], within: breaker[:within],
|
|
118
|
+
cooldown: breaker[:cooldown]) == :open
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def circuit_open(tenant, selection, breaker)
|
|
122
|
+
Insika::CircuitOpenError.new(
|
|
123
|
+
"circuit open for #{ref_of(selection)}",
|
|
124
|
+
ref: ref_of(selection),
|
|
125
|
+
retry_after: @circuit_store.retry_after(tenant: tenant, ref: ref_of(selection),
|
|
126
|
+
cooldown: breaker[:cooldown])
|
|
127
|
+
)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def record_failure(tenant, selection, breaker, error)
|
|
131
|
+
return unless breaker
|
|
132
|
+
|
|
133
|
+
tripped = @circuit_store.record_failure(
|
|
134
|
+
tenant: tenant, ref: ref_of(selection),
|
|
135
|
+
after: breaker[:after], within: breaker[:within]
|
|
136
|
+
)
|
|
137
|
+
emit(:provider_failure,
|
|
138
|
+
{ agent: @agent, ref: ref_of(selection), error: error.class.name, kind: kind_of(error) })
|
|
139
|
+
# the failure that TRIPPED the circuit is itself an alert (WS6).
|
|
140
|
+
emit(:breaker_open, { agent: @agent, ref: ref_of(selection), tenant: tenant }) if tripped == :open
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def kind_of(error) = ProviderErrorClassifier.classify(error).kind
|
|
144
|
+
|
|
145
|
+
# A provider-family error OR the per-attempt timeout: both are transient
|
|
146
|
+
# transport-class failures that spend the retry budget.
|
|
147
|
+
def retryable_failure?(error)
|
|
148
|
+
ProviderErrorClassifier.provider_error?(error) ||
|
|
149
|
+
(error.is_a?(Insika::TimeoutError) && error.stage.to_s == "reliability")
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def with_attempt_timeout(timeout, &blk)
|
|
153
|
+
return yield unless Async::Task.current?
|
|
154
|
+
Async::Task.current.with_timeout(timeout) { yield }
|
|
155
|
+
rescue Async::TimeoutError
|
|
156
|
+
# a per-attempt timeout is a TRANSPORT-class failure: counted, retried.
|
|
157
|
+
raise Insika::TimeoutError.new("provider attempt exceeded #{timeout}s", stage: :reliability)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# The breaker cell id: "provider/model" for the ref'd node — a ModelSelection
|
|
161
|
+
# (primary) or a { model:, provider: } hash (fallback node).
|
|
162
|
+
def ref_of(selection)
|
|
163
|
+
model = selection.respond_to?(:model) ? selection.model.to_s : selection[:model].to_s
|
|
164
|
+
provider = selection.respond_to?(:provider) ? selection.provider : selection[:provider]
|
|
165
|
+
provider ? "#{provider}/#{model}" : model
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def backoff_seconds(policy, index)
|
|
169
|
+
case policy["backoff"].to_s
|
|
170
|
+
when "exponential" then 2**index
|
|
171
|
+
else index + 1
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def backoff_wait(seconds)
|
|
176
|
+
Async::Task.current? ? Async::Task.current.sleep(seconds) : Kernel.sleep(seconds)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def emit(type, data)
|
|
180
|
+
@event_stream.emit(Insika::Event.new(
|
|
181
|
+
type: type, data: data, meta: { at: Time.now.utc.iso8601 }
|
|
182
|
+
))
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
module Safety
|
|
5
|
+
# Per-agent guardrail configuration, read from
|
|
6
|
+
# `profile.guardrails`. OPT-IN like `capabilities`: an agent that says nothing
|
|
7
|
+
# gets the CONSERVATIVE default — deterministic detectors ON, LLM moderator OFF.
|
|
8
|
+
#
|
|
9
|
+
# Tolerant of the JSON round-trip (string OR symbol keys / values), same
|
|
10
|
+
# discipline as ModelPolicy — the StoredProfileSource persists this as a plain
|
|
11
|
+
# Hash and it comes back stringified.
|
|
12
|
+
#
|
|
13
|
+
# guardrails: {
|
|
14
|
+
# input: true|false, # run the input guardrail middleware (default true)
|
|
15
|
+
# output: true|false, # run the output filter + validator (default true)
|
|
16
|
+
# moderator: "provider/model"|nil, # LLM moderator model; nil = deterministic only
|
|
17
|
+
# strictness: "low"|"medium"|"high", # which input categories fire (default medium)
|
|
18
|
+
# responses: { <category> => "<safe reply>", ... } # per-agent override, see below
|
|
19
|
+
# }
|
|
20
|
+
#
|
|
21
|
+
# `responses` is the CONFIGURATION-OVER-CONVENTION knob. The engine
|
|
22
|
+
# ships neutral built-in refusals (Safety::SafeResponses::DEFAULTS), but this is
|
|
23
|
+
# OSS across arbitrary businesses/languages, so we never hard-bake tone: an agent
|
|
24
|
+
# overrides the safe reply per category (`injection`/`sexual`/`abuse`/`escalate`/
|
|
25
|
+
# …) or sets a single catch-all `default`. Resolution order (SafeResponses.for):
|
|
26
|
+
# agent[category] → agent["default"] → built-in[category] → built-in[:default].
|
|
27
|
+
class Config
|
|
28
|
+
# strictness -> input categories that the deterministic scan runs.
|
|
29
|
+
# low = only injection (+ output redaction) — highest confidence, fewest FPs
|
|
30
|
+
# medium = injection + sexual + abuse (the default)
|
|
31
|
+
# high = same families, reserved for future broader lists
|
|
32
|
+
STRICTNESS_CATEGORIES = {
|
|
33
|
+
low: %i[injection],
|
|
34
|
+
medium: %i[injection sexual abuse],
|
|
35
|
+
high: %i[injection sexual abuse]
|
|
36
|
+
}.freeze
|
|
37
|
+
|
|
38
|
+
DEFAULT_STRICTNESS = :medium
|
|
39
|
+
|
|
40
|
+
attr_reader :input, :output, :moderator, :strictness, :responses
|
|
41
|
+
|
|
42
|
+
def initialize(input:, output:, moderator:, strictness:, responses: {})
|
|
43
|
+
@input = input
|
|
44
|
+
@output = output
|
|
45
|
+
@moderator = moderator
|
|
46
|
+
@strictness = strictness
|
|
47
|
+
@responses = responses # { "category" => "safe reply" }, agent override map
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Builds a Config from a profile. A nil/empty `guardrails` -> the
|
|
51
|
+
# conservative default (see from_hash).
|
|
52
|
+
def self.from_profile(profile) = from_hash(profile.guardrails)
|
|
53
|
+
|
|
54
|
+
def self.from_hash(raw)
|
|
55
|
+
h = symbolize(raw)
|
|
56
|
+
new(
|
|
57
|
+
input: bool(h.fetch(:input, true)),
|
|
58
|
+
output: bool(h.fetch(:output, true)),
|
|
59
|
+
moderator: presence(h[:moderator]),
|
|
60
|
+
strictness: normalize_strictness(h[:strictness]),
|
|
61
|
+
responses: normalize_responses(h[:responses])
|
|
62
|
+
)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Input categories the deterministic scan should run, per strictness.
|
|
66
|
+
def input_categories = STRICTNESS_CATEGORIES.fetch(@strictness, STRICTNESS_CATEGORIES[DEFAULT_STRICTNESS])
|
|
67
|
+
|
|
68
|
+
# A guardrail is fully off only when BOTH sides are disabled — cheap early-out.
|
|
69
|
+
def enabled? = @input || @output
|
|
70
|
+
|
|
71
|
+
def moderator? = !@moderator.nil?
|
|
72
|
+
|
|
73
|
+
def self.symbolize(raw)
|
|
74
|
+
return {} unless raw.is_a?(Hash)
|
|
75
|
+
|
|
76
|
+
raw.each_with_object({}) { |(k, v), acc| acc[k.to_sym] = v }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def self.bool(v)
|
|
80
|
+
return v if v == true || v == false
|
|
81
|
+
return false if v.nil?
|
|
82
|
+
|
|
83
|
+
s = v.to_s.strip.downcase
|
|
84
|
+
!s.empty? && !%w[false 0 off no].include?(s)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def self.presence(v) = Insika::Coercion.presence(v)
|
|
88
|
+
|
|
89
|
+
def self.normalize_strictness(v)
|
|
90
|
+
sym = v.to_s.strip.downcase.to_sym
|
|
91
|
+
STRICTNESS_CATEGORIES.key?(sym) ? sym : DEFAULT_STRICTNESS
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Per-agent safe-reply overrides -> { "category" => "text" } with STRING keys
|
|
95
|
+
# (SafeResponses looks up by string), blanks dropped. Tolerant of the JSON
|
|
96
|
+
# round-trip and of a non-Hash (ignored -> {}).
|
|
97
|
+
def self.normalize_responses(v)
|
|
98
|
+
return {} unless v.is_a?(Hash)
|
|
99
|
+
|
|
100
|
+
v.each_with_object({}) do |(k, val), acc|
|
|
101
|
+
text = val.to_s.strip
|
|
102
|
+
acc[k.to_s] = text unless text.empty?
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private_class_method :symbolize, :bool, :presence, :normalize_strictness, :normalize_responses
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|