insika 0.1.0 → 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 +69 -3
- data/README.md +1 -1
- data/bin/insika +22 -7
- data/docs/AGENTS.md +129 -5
- data/docs/CHANNELS.md +1 -1
- data/docs/CONTEXT.md +22 -5
- data/docs/DEPLOY.md +30 -10
- data/docs/EMBEDDING.md +11 -7
- data/docs/EVALS.md +1 -1
- data/docs/LOADTEST.md +3 -2
- data/docs/OBSERVABILITY.md +11 -2
- data/docs/REFINEMENT.md +6 -6
- data/docs/RELEASING.md +7 -7
- data/docs/RUNNING-LOCAL.md +1 -1
- data/docs/SECURITY.md +24 -11
- data/docs/SKILLS.md +189 -3
- data/docs/WHY.md +1 -1
- data/docs/WORKFLOWS.md +2 -2
- data/docs/index.md +1 -1
- data/docs/onboarding/start.md +1 -1
- data/lib/insika/agent_profile.rb +89 -22
- data/lib/insika/alert_dispatcher.rb +139 -0
- data/lib/insika/baseline_store.rb +2 -2
- data/lib/insika/budget_ledger.rb +135 -0
- data/lib/insika/channel_delivery.rb +14 -11
- data/lib/insika/channel_registry.rb +1 -1
- data/lib/insika/channels/relay.rb +3 -3
- data/lib/insika/channels/web/widget.js +2 -2
- data/lib/insika/channels/web.rb +7 -7
- data/lib/insika/channels/webhook.rb +58 -0
- data/lib/insika/chat_builder.rb +62 -13
- data/lib/insika/circuit_state.rb +114 -0
- data/lib/insika/coercion.rb +8 -0
- data/lib/insika/commands/agent_payload.rb +5 -3
- data/lib/insika/commands/create_agent.rb +2 -2
- data/lib/insika/commands/create_session.rb +1 -1
- data/lib/insika/commands/delete_llm_provider.rb +1 -1
- data/lib/insika/commands/delete_skill.rb +43 -0
- data/lib/insika/commands/gate_refinement.rb +12 -12
- data/lib/insika/commands/import_mcp_tools.rb +1 -1
- data/lib/insika/commands/import_tools.rb +4 -4
- data/lib/insika/commands/issue_tenant_token.rb +41 -0
- data/lib/insika/commands/resolve_refinement.rb +1 -1
- 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 +5 -5
- data/lib/insika/commands/send_message.rb +9 -9
- data/lib/insika/commands/set_agent_tools.rb +1 -1
- data/lib/insika/commands/set_skill_agents.rb +60 -19
- data/lib/insika/commands/trigger_workflow.rb +1 -1
- data/lib/insika/commands/update_agent.rb +1 -1
- data/lib/insika/commands/write_data_tool.rb +1 -1
- data/lib/insika/commands/write_golden.rb +1 -1
- data/lib/insika/commands/write_skill.rb +19 -9
- data/lib/insika/config_store.rb +8 -4
- data/lib/insika/context/builder.rb +2 -2
- data/lib/insika/context/fragment.rb +27 -3
- data/lib/insika/context/priority.rb +3 -2
- data/lib/insika/context/providers/memory.rb +1 -1
- data/lib/insika/context/providers/request.rb +1 -1
- data/lib/insika/context/providers/session.rb +17 -2
- data/lib/insika/context/providers/skill.rb +5 -1
- data/lib/insika/context/providers/skill_trigger.rb +128 -0
- data/lib/insika/context_trace_store.rb +92 -0
- data/lib/insika/delegation_store.rb +2 -2
- data/lib/insika/doctor.rb +250 -5
- data/lib/insika/dsl/runtime.rb +12 -9
- data/lib/insika/dsl/server_boot.rb +4 -3
- data/lib/insika/dsl/system.rb +1 -1
- data/lib/insika/dsl.rb +72 -15
- data/lib/insika/edge_limiter.rb +144 -6
- data/lib/insika/egress_guard.rb +3 -3
- data/lib/insika/env_schema.rb +13 -10
- data/lib/insika/errors.rb +61 -5
- data/lib/insika/evals/assertions.rb +12 -12
- data/lib/insika/evals/baseline.rb +3 -3
- data/lib/insika/evals/golden.rb +8 -8
- data/lib/insika/evals/judge.rb +7 -7
- data/lib/insika/evals/pairwise.rb +3 -3
- data/lib/insika/evals/report.rb +2 -2
- data/lib/insika/evals/runner.rb +6 -6
- data/lib/insika/evals/transport.rb +2 -2
- data/lib/insika/event_stream.rb +23 -5
- data/lib/insika/executor.rb +423 -108
- data/lib/insika/frontmatter.rb +1 -1
- data/lib/insika/golden_store.rb +2 -2
- data/lib/insika/http_client.rb +3 -3
- data/lib/insika/inbound_log.rb +1 -1
- data/lib/insika/llm_configurator.rb +3 -3
- data/lib/insika/loop_detector.rb +143 -0
- data/lib/insika/mcp_http_client.rb +4 -4
- data/lib/insika/mcp_tool_ingestor.rb +6 -6
- data/lib/insika/message_origin.rb +2 -2
- data/lib/insika/model_resolver.rb +1 -1
- data/lib/insika/model_selection.rb +5 -4
- data/lib/insika/onboarding.rb +2 -2
- data/lib/insika/outbox_store.rb +2 -2
- data/lib/insika/overlay_tool_registry.rb +3 -4
- data/lib/insika/pack.rb +3 -3
- data/lib/insika/pack_importer.rb +17 -15
- data/lib/insika/pending_action_store.rb +1 -1
- data/lib/insika/plugin/loader.rb +2 -2
- data/lib/insika/policy/policy.rb +1 -1
- data/lib/insika/profile_source.rb +12 -6
- data/lib/insika/provider_error_classifier.rb +160 -0
- data/lib/insika/queue_policy.rb +2 -2
- data/lib/insika/recovery.rb +47 -6
- data/lib/insika/refinement/candidate.rb +4 -4
- data/lib/insika/refinement/evidence_collector.rb +6 -6
- data/lib/insika/refinement/gate.rb +7 -7
- data/lib/insika/refinement/panel.rb +7 -7
- data/lib/insika/refinement/proposer.rb +9 -9
- data/lib/insika/refinement_store.rb +12 -12
- data/lib/insika/reliability.rb +185 -0
- data/lib/insika/safety/config.rb +2 -2
- data/lib/insika/safety/detectors.rb +5 -5
- data/lib/insika/safety/factory.rb +3 -3
- data/lib/insika/safety/input_guardrail.rb +19 -4
- data/lib/insika/safety/moderator.rb +19 -11
- data/lib/insika/safety/output_filter.rb +2 -2
- data/lib/insika/safety/output_validator.rb +2 -2
- data/lib/insika/safety/safe_responses.rb +1 -1
- data/lib/insika/sandbox/boundary.rb +2 -2
- data/lib/insika/sandbox.rb +1 -1
- data/lib/insika/server/app.rb +223 -51
- data/lib/insika/server/boot.rb +4 -4
- data/lib/insika/server/rack_app.rb +15 -7
- data/lib/insika/server/responses.rb +18 -8
- data/lib/insika/server/tenant_auth.rb +61 -0
- data/lib/insika/session_actor.rb +3 -3
- data/lib/insika/session_store.rb +1 -1
- data/lib/insika/settings_store.rb +5 -5
- data/lib/insika/shutdown.rb +4 -4
- data/lib/insika/skill_catalog.rb +127 -20
- data/lib/insika/skill_store.rb +70 -22
- data/lib/insika/steer_injector.rb +1 -1
- data/lib/insika/store.rb +1 -1
- data/lib/insika/studio/app.rb +183 -61
- data/lib/insika/studio/assets/dist/application.js +25 -24
- data/lib/insika/studio/forms.rb +13 -18
- data/lib/insika/studio/nav_icons.rb +1 -1
- data/lib/insika/studio/views/_message.erb +2 -2
- data/lib/insika/studio/views/agent_detail.erb +2 -2
- data/lib/insika/studio/views/agents.erb +1 -1
- data/lib/insika/studio/views/refinement.erb +4 -4
- data/lib/insika/studio/views/session.erb +78 -3
- data/lib/insika/studio/views/settings.erb +7 -12
- data/lib/insika/studio/views/skills.erb +67 -12
- data/lib/insika/subagent_graph.rb +3 -3
- data/lib/insika/task_actor.rb +3 -3
- data/lib/insika/task_store.rb +1 -1
- data/lib/insika/telemetry/pricing.rb +3 -3
- data/lib/insika/telemetry/recorder.rb +1 -1
- data/lib/insika/telemetry.rb +2 -2
- data/lib/insika/testing/store_contract.rb +27 -27
- data/lib/insika/tick.rb +122 -0
- data/lib/insika/token_store.rb +168 -0
- data/lib/insika/tool_assembly.rb +5 -5
- data/lib/insika/tool_definition.rb +8 -8
- data/lib/insika/tool_envelope.rb +1 -1
- data/lib/insika/tool_manifest.rb +6 -6
- data/lib/insika/tool_output_compressor.rb +100 -0
- data/lib/insika/tool_store.rb +1 -1
- data/lib/insika/tool_trace_store.rb +1 -1
- data/lib/insika/tools/concurrency.rb +2 -2
- data/lib/insika/tools/data_defined_tool.rb +4 -5
- data/lib/insika/tools/load_skill.rb +61 -3
- data/lib/insika/tools/stuck_signal.rb +44 -0
- data/lib/insika/tools/subagent.rb +4 -4
- data/lib/insika/tools/subagents.rb +1 -1
- data/lib/insika/turn_output.rb +2 -2
- data/lib/insika/turn_state.rb +17 -13
- data/lib/insika/turn_timing.rb +2 -2
- data/lib/insika/usage_ledger.rb +1 -1
- data/lib/insika/version.rb +1 -1
- data/lib/insika/wiring/graph.rb +77 -26
- data/lib/insika/workflow.rb +1 -1
- data/lib/insika/workflow_registry.rb +1 -1
- data/lib/insika.rb +32 -15
- metadata +19 -2
- data/lib/insika/server/admin_auth.rb +0 -29
|
@@ -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
|
data/lib/insika/safety/config.rb
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
module Insika
|
|
4
4
|
module Safety
|
|
5
|
-
# Per-agent guardrail configuration
|
|
5
|
+
# Per-agent guardrail configuration, read from
|
|
6
6
|
# `profile.guardrails`. OPT-IN like `capabilities`: an agent that says nothing
|
|
7
7
|
# gets the CONSERVATIVE default — deterministic detectors ON, LLM moderator OFF.
|
|
8
8
|
#
|
|
@@ -18,7 +18,7 @@ module Insika
|
|
|
18
18
|
# responses: { <category> => "<safe reply>", ... } # per-agent override, see below
|
|
19
19
|
# }
|
|
20
20
|
#
|
|
21
|
-
# `responses` is the CONFIGURATION-OVER-CONVENTION knob
|
|
21
|
+
# `responses` is the CONFIGURATION-OVER-CONVENTION knob. The engine
|
|
22
22
|
# ships neutral built-in refusals (Safety::SafeResponses::DEFAULTS), but this is
|
|
23
23
|
# OSS across arbitrary businesses/languages, so we never hard-bake tone: an agent
|
|
24
24
|
# overrides the safe reply per category (`injection`/`sexual`/`abuse`/`escalate`/
|
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
module Insika
|
|
4
4
|
module Safety
|
|
5
|
-
# SINGLE SOURCE of truth for content-safety pattern matching
|
|
5
|
+
# SINGLE SOURCE of truth for content-safety pattern matching.
|
|
6
6
|
#
|
|
7
7
|
# Two families live here on purpose — the same lists back BOTH the runtime
|
|
8
|
-
# guardrail
|
|
8
|
+
# guardrail AND the eval's `must_not` detectors: the eval
|
|
9
9
|
# is a CLIENT of the runtime by design, so `evals/lib/evals/assertions.rb`
|
|
10
10
|
# requires THIS file rather than keeping a divergent copy. The runtime must never
|
|
11
11
|
# depend on `evals/`, so the file is deliberately self-contained (pure Ruby +
|
|
@@ -16,10 +16,10 @@ module Insika
|
|
|
16
16
|
# · INPUT side (injection/abuse/sexual) — high-confidence heuristics that
|
|
17
17
|
# short-circuit the turn with a safe refusal BEFORE the LLM runs.
|
|
18
18
|
#
|
|
19
|
-
# Everything here is CONSERVATIVE by design (RFC
|
|
19
|
+
# Everything here is CONSERVATIVE by design (RFC: a false positive blocks a
|
|
20
20
|
# legitimate customer). The deterministic layer catches only the gross,
|
|
21
21
|
# unambiguous cases; the subtler judgment (social engineering, tone) is the LLM
|
|
22
|
-
# moderator's job
|
|
22
|
+
# moderator's job, not regex.
|
|
23
23
|
#
|
|
24
24
|
# LANGUAGE: the input heuristics are inherently language-specific. We ship pt-BR
|
|
25
25
|
# + EN (the pilot + the OSS lingua franca) as a BEST-EFFORT net; other languages
|
|
@@ -40,7 +40,7 @@ module Insika
|
|
|
40
40
|
# A run of the output stream that MIGHT still be growing into a PII/secret
|
|
41
41
|
# match if more chunks arrive — anchored at the buffer tail. The OutputFilter
|
|
42
42
|
# holds back from the start of such a run so a value split across chunk
|
|
43
|
-
# boundaries is never emitted in the clear (RFC
|
|
43
|
+
# boundaries is never emitted in the clear (RFC). Covers the
|
|
44
44
|
# unbounded `sk-…`/`Bearer …` case that a fixed window cannot.
|
|
45
45
|
#
|
|
46
46
|
# Crucially it also matches a PARTIAL literal PREFIX at the tail — a lone "s"
|
|
@@ -9,7 +9,7 @@ require_relative "output_validator"
|
|
|
9
9
|
|
|
10
10
|
module Insika
|
|
11
11
|
module Safety
|
|
12
|
-
# Composition helper for the guardrail subsystem
|
|
12
|
+
# Composition helper for the guardrail subsystem. Keeps the wiring
|
|
13
13
|
# (config.ru / deployment.rb) a one-liner and the Executor decoupled from Safety:
|
|
14
14
|
# the Executor only ever sees a duck-typed `content_filter_factory` callable and
|
|
15
15
|
# reads plain Hash fields (`guardrail_block`/`guardrail_flags`) off the state.
|
|
@@ -21,10 +21,10 @@ module Insika
|
|
|
21
21
|
class Factory
|
|
22
22
|
# `settings_store` (optional): source of the platform `utility_model` fallback.
|
|
23
23
|
# nil = no fallback (moderator only when the agent pins its own model ref).
|
|
24
|
-
# `llm` (optional
|
|
24
|
+
# `llm` (optional): the graph's own RubyLLM context. nil = the
|
|
25
25
|
# process-wide RubyLLM constant. A guardrail asking on the global while the
|
|
26
26
|
# turn asks on the graph's key is a leak with a false sense of isolation, so
|
|
27
|
-
# this seam is part of
|
|
27
|
+
# this seam is part of and not a follow-up.
|
|
28
28
|
def initialize(settings_store: nil, llm: nil)
|
|
29
29
|
@settings_store = settings_store
|
|
30
30
|
@llm = llm
|
|
@@ -7,14 +7,14 @@ require_relative "config"
|
|
|
7
7
|
|
|
8
8
|
module Insika
|
|
9
9
|
module Safety
|
|
10
|
-
# Input guardrail as a Middleware
|
|
10
|
+
# Input guardrail as a Middleware. It sits on the ONE seam that
|
|
11
11
|
# already short-circuits structurally (stage 4: a link that does not call `nxt`),
|
|
12
12
|
# and uses the NEW graceful-halt contract: instead of `halt_reason` (which the
|
|
13
13
|
# Executor maps to a turn FAILURE), it sets `halt_response` (a safe reply) +
|
|
14
14
|
# `guardrail_block` (audit metadata) and returns without calling `nxt`. The
|
|
15
15
|
# Executor completes the turn with that safe reply, never touching the LLM.
|
|
16
16
|
#
|
|
17
|
-
# Two tiers
|
|
17
|
+
# Two tiers:
|
|
18
18
|
# 1. deterministic scan (always, cheap, zero-token) — Detectors#scan_input;
|
|
19
19
|
# 2. LLM moderator (opt-in per agent) — only when the deterministic tier let
|
|
20
20
|
# the message through, so the cheap layer short-circuits the expensive one.
|
|
@@ -23,7 +23,7 @@ module Insika
|
|
|
23
23
|
# link lives ONCE in the global MiddlewareStack — there is no per-agent stack.
|
|
24
24
|
#
|
|
25
25
|
# `moderator_factory` (optional): ->(config) { Moderator | nil }, built by the
|
|
26
|
-
# Safety::Factory. nil = deterministic only (
|
|
26
|
+
# Safety::Factory. nil = deterministic only (B parity).
|
|
27
27
|
class InputGuardrail < Insika::Middleware
|
|
28
28
|
def initialize(moderator_factory: nil)
|
|
29
29
|
@moderator_factory = moderator_factory
|
|
@@ -43,6 +43,11 @@ module Insika
|
|
|
43
43
|
return block(state, config, category: category, source: :moderator,
|
|
44
44
|
detail: verdict.reason, action: verdict.action)
|
|
45
45
|
end
|
|
46
|
+
# an UNAVAILABLE moderator fails open — the turn proceeds —
|
|
47
|
+
# but silence is not a negative: record the degradation on the state so the
|
|
48
|
+
# Executor (single emitter) emits :guardrail_flagged and a degraded tier
|
|
49
|
+
# never looks identical to a healthy one in the audit stream.
|
|
50
|
+
flag_unavailable(state, verdict) if verdict.unavailable?
|
|
46
51
|
end
|
|
47
52
|
|
|
48
53
|
nxt.call(state)
|
|
@@ -54,11 +59,21 @@ module Insika
|
|
|
54
59
|
@moderator_factory&.call(config)
|
|
55
60
|
end
|
|
56
61
|
|
|
62
|
+
# Appends the audit flag for a degraded moderator tier. Same
|
|
63
|
+
# state-carried channel the OutputValidator uses; detail is the moderator's
|
|
64
|
+
# own reason string ("moderator error (fail-open)" / "unparseable
|
|
65
|
+
# (fail-open)"), never message content.
|
|
66
|
+
def flag_unavailable(state, verdict)
|
|
67
|
+
state.guardrail_flags = Array(state.guardrail_flags) + [{
|
|
68
|
+
category: "moderator_unavailable", source: "moderator", detail: verdict.reason.to_s[0, 200]
|
|
69
|
+
}]
|
|
70
|
+
end
|
|
71
|
+
|
|
57
72
|
# The block category. `escalate` (an ACTION) becomes the `escalate` category so
|
|
58
73
|
# the agent can address it; otherwise the moderator's own category flows
|
|
59
74
|
# through UNCOLLAPSED — the safe-reply lookup (SafeResponses.for) resolves an
|
|
60
75
|
# unknown one to the agent's `default` / the neutral built-in, so we don't need
|
|
61
|
-
# to force it into a fixed bucket here (configuration over convention
|
|
76
|
+
# to force it into a fixed bucket here (configuration over convention).
|
|
62
77
|
def moderator_category(verdict)
|
|
63
78
|
return :escalate if verdict.action.to_s == "escalate"
|
|
64
79
|
|
|
@@ -4,7 +4,7 @@ require "json"
|
|
|
4
4
|
|
|
5
5
|
module Insika
|
|
6
6
|
module Safety
|
|
7
|
-
# LLM content moderator
|
|
7
|
+
# LLM content moderator. The subtle tier the regex can't
|
|
8
8
|
# reach: social engineering (a fabricated prior promise), veiled hostility, tone.
|
|
9
9
|
#
|
|
10
10
|
# Pure over an injected `ask` callable (prompt -> raw model text), exactly like
|
|
@@ -13,48 +13,56 @@ module Insika
|
|
|
13
13
|
#
|
|
14
14
|
# FAIL-OPEN by construction: the deterministic layer already ran and caught the
|
|
15
15
|
# gross cases, so an unparseable/failed moderator reply must NOT block a
|
|
16
|
-
# legitimate customer
|
|
17
|
-
#
|
|
16
|
+
# legitimate customer. Blocking is the high-stakes direction (RFC: a false
|
|
17
|
+
# positive turns away a real buyer). But fail-open is not a fake negative
|
|
18
|
+
# a moderator that could not answer returns the third state
|
|
19
|
+
# `unavailable` — it does not block, and it is NOT recorded as a clean `allow`,
|
|
20
|
+
# so a degraded tier is distinguishable from a healthy one in the audit stream.
|
|
18
21
|
class Moderator
|
|
19
22
|
Verdict = Struct.new(:category, :action, :reason, keyword_init: true) do
|
|
20
23
|
def block? = %w[refuse escalate].include?(action.to_s)
|
|
24
|
+
def unavailable? = action.to_s == "unavailable"
|
|
21
25
|
end
|
|
22
26
|
|
|
23
27
|
CATEGORIES = %w[injection abuse sexual self_harm off_topic safe].freeze
|
|
24
|
-
ACTIONS = %w[allow refuse escalate].freeze
|
|
28
|
+
ACTIONS = %w[allow refuse escalate unavailable].freeze
|
|
25
29
|
|
|
26
30
|
# ask: ->(prompt) { "<raw model text>" }.
|
|
27
31
|
def initialize(ask:)
|
|
28
32
|
@ask = ask
|
|
29
33
|
end
|
|
30
34
|
|
|
31
|
-
# Classifies a user message. Returns a Verdict; on ANY failure ->
|
|
32
|
-
# (fail-open
|
|
35
|
+
# Classifies a user message. Returns a Verdict; on ANY failure -> unavailable
|
|
36
|
+
# (fail-open: never blocks, but never masquerades as a real `allow` either —
|
|
37
|
+
# `context` is optional free text (e.g. the agent's domain) woven
|
|
33
38
|
# into the prompt.
|
|
34
39
|
def classify(message, context: nil)
|
|
35
40
|
raw = @ask.call(build_prompt(message.to_s, context)).to_s
|
|
36
41
|
parse(raw)
|
|
37
42
|
rescue StandardError
|
|
38
|
-
Verdict.new(category: "safe", action: "
|
|
43
|
+
Verdict.new(category: "safe", action: "unavailable", reason: "moderator error (fail-open)")
|
|
39
44
|
end
|
|
40
45
|
|
|
41
46
|
private
|
|
42
47
|
|
|
43
48
|
def parse(raw)
|
|
44
49
|
json = raw[/\{.*\}/m]
|
|
45
|
-
return
|
|
50
|
+
return unavailable_verdict unless json
|
|
46
51
|
|
|
47
52
|
data = JSON.parse(json)
|
|
48
53
|
action = data["action"].to_s.strip.downcase
|
|
49
|
-
action
|
|
54
|
+
# An out-of-enum action is a reply we cannot honor — normalize to
|
|
55
|
+
# unavailable, not allow: inventing a negative the model never gave is the
|
|
56
|
+
# same ambiguity removes.
|
|
57
|
+
action = "unavailable" unless ACTIONS.include?(action)
|
|
50
58
|
category = data["category"].to_s.strip.downcase
|
|
51
59
|
category = "safe" unless CATEGORIES.include?(category)
|
|
52
60
|
Verdict.new(category: category, action: action, reason: data["reason"].to_s)
|
|
53
61
|
rescue JSON::ParserError
|
|
54
|
-
|
|
62
|
+
unavailable_verdict
|
|
55
63
|
end
|
|
56
64
|
|
|
57
|
-
def
|
|
65
|
+
def unavailable_verdict = Verdict.new(category: "safe", action: "unavailable", reason: "unparseable (fail-open)")
|
|
58
66
|
|
|
59
67
|
def build_prompt(message, context)
|
|
60
68
|
<<~PROMPT
|
|
@@ -4,12 +4,12 @@ require_relative "detectors"
|
|
|
4
4
|
|
|
5
5
|
module Insika
|
|
6
6
|
module Safety
|
|
7
|
-
# Deterministic output redaction on the STREAM
|
|
7
|
+
# Deterministic output redaction on the STREAM. The turn
|
|
8
8
|
# streams `content` deltas, so we cannot "unsay" text already sent — the filter
|
|
9
9
|
# sits between the provider chunks and the `:content` event and only ever emits
|
|
10
10
|
# text it has PROVEN clean.
|
|
11
11
|
#
|
|
12
|
-
# The hard part (RFC
|
|
12
|
+
# The hard part (RFC, "fronteira de chunk"): a CPF/secret can arrive split
|
|
13
13
|
# across two chunks and neither half matches on its own. A naive delta-by-delta
|
|
14
14
|
# regex leaks "by construction". So the filter keeps a SLIDING BUFFER: it retains
|
|
15
15
|
# the tail that might still be growing into a match (Detectors::OPEN_TAIL, which
|
|
@@ -6,11 +6,11 @@ require_relative "config"
|
|
|
6
6
|
|
|
7
7
|
module Insika
|
|
8
8
|
module Safety
|
|
9
|
-
# Post-turn output validator
|
|
9
|
+
# Post-turn output validator. Runs as an `after_task` hook:
|
|
10
10
|
# with the FINAL assistant text assembled, it does a richer check than the
|
|
11
11
|
# stream filter can — an unverified discount/price promise, a tone slip — and
|
|
12
12
|
# FLAGS it (audit) rather than blocking. It is honest about the streaming limit
|
|
13
|
-
#
|
|
13
|
+
# for a streaming agent the text is already out the door, so this is
|
|
14
14
|
# detection, not prevention; true pre-emission blocking needs `streaming:false`
|
|
15
15
|
# (a per-profile override left for a later slice).
|
|
16
16
|
#
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
module Insika
|
|
4
4
|
module Safety
|
|
5
|
-
# Safe reply for a blocked turn
|
|
5
|
+
# Safe reply for a blocked turn. A blocked turn NEVER returns a
|
|
6
6
|
# raw error nor silence — it completes gracefully with one of these.
|
|
7
7
|
#
|
|
8
8
|
# CONFIGURATION OVER CONVENTION: this is OSS across arbitrary businesses and
|
|
@@ -10,8 +10,8 @@ module Insika
|
|
|
10
10
|
# sandbox primitive — always on, independent of the exec provider (local or
|
|
11
11
|
# docker) and of the engine's approval layer.
|
|
12
12
|
#
|
|
13
|
-
# Extracted VERBATIM from the insika-code prototype's `Workspace`
|
|
14
|
-
# and promoted to a core primitive
|
|
13
|
+
# Extracted VERBATIM from the insika-code prototype's `Workspace`
|
|
14
|
+
# and promoted to a core primitive. The two layers of defense:
|
|
15
15
|
# 1. `File.expand_path` normalizes `..` traversal; the expanded path must be
|
|
16
16
|
# the root itself or a descendant of it (string containment on a
|
|
17
17
|
# separator boundary, so `/ws-evil` does not pass for root `/ws`).
|
data/lib/insika/sandbox.rb
CHANGED
|
@@ -7,7 +7,7 @@ require_relative "sandbox/local"
|
|
|
7
7
|
require_relative "sandbox/docker"
|
|
8
8
|
|
|
9
9
|
module Insika
|
|
10
|
-
# Sandbox primitive (
|
|
10
|
+
# Sandbox primitive (COMPETITIVE-ANALYSIS): a single, pluggable
|
|
11
11
|
# interface for confined execution, promoted to the core from the insika-code
|
|
12
12
|
# prototype. Two halves:
|
|
13
13
|
#
|