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.
Files changed (182) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +69 -3
  3. data/README.md +1 -1
  4. data/bin/insika +22 -7
  5. data/docs/AGENTS.md +129 -5
  6. data/docs/CHANNELS.md +1 -1
  7. data/docs/CONTEXT.md +22 -5
  8. data/docs/DEPLOY.md +30 -10
  9. data/docs/EMBEDDING.md +11 -7
  10. data/docs/EVALS.md +1 -1
  11. data/docs/LOADTEST.md +3 -2
  12. data/docs/OBSERVABILITY.md +11 -2
  13. data/docs/REFINEMENT.md +6 -6
  14. data/docs/RELEASING.md +7 -7
  15. data/docs/RUNNING-LOCAL.md +1 -1
  16. data/docs/SECURITY.md +24 -11
  17. data/docs/SKILLS.md +189 -3
  18. data/docs/WHY.md +1 -1
  19. data/docs/WORKFLOWS.md +2 -2
  20. data/docs/index.md +1 -1
  21. data/docs/onboarding/start.md +1 -1
  22. data/lib/insika/agent_profile.rb +89 -22
  23. data/lib/insika/alert_dispatcher.rb +139 -0
  24. data/lib/insika/baseline_store.rb +2 -2
  25. data/lib/insika/budget_ledger.rb +135 -0
  26. data/lib/insika/channel_delivery.rb +14 -11
  27. data/lib/insika/channel_registry.rb +1 -1
  28. data/lib/insika/channels/relay.rb +3 -3
  29. data/lib/insika/channels/web/widget.js +2 -2
  30. data/lib/insika/channels/web.rb +7 -7
  31. data/lib/insika/channels/webhook.rb +58 -0
  32. data/lib/insika/chat_builder.rb +62 -13
  33. data/lib/insika/circuit_state.rb +114 -0
  34. data/lib/insika/coercion.rb +8 -0
  35. data/lib/insika/commands/agent_payload.rb +5 -3
  36. data/lib/insika/commands/create_agent.rb +2 -2
  37. data/lib/insika/commands/create_session.rb +1 -1
  38. data/lib/insika/commands/delete_llm_provider.rb +1 -1
  39. data/lib/insika/commands/delete_skill.rb +43 -0
  40. data/lib/insika/commands/gate_refinement.rb +12 -12
  41. data/lib/insika/commands/import_mcp_tools.rb +1 -1
  42. data/lib/insika/commands/import_tools.rb +4 -4
  43. data/lib/insika/commands/issue_tenant_token.rb +41 -0
  44. data/lib/insika/commands/resolve_refinement.rb +1 -1
  45. data/lib/insika/commands/revoke_token.rb +39 -0
  46. data/lib/insika/commands/rotate_tenant_token.rb +43 -0
  47. data/lib/insika/commands/run_refinement.rb +5 -5
  48. data/lib/insika/commands/send_message.rb +9 -9
  49. data/lib/insika/commands/set_agent_tools.rb +1 -1
  50. data/lib/insika/commands/set_skill_agents.rb +60 -19
  51. data/lib/insika/commands/trigger_workflow.rb +1 -1
  52. data/lib/insika/commands/update_agent.rb +1 -1
  53. data/lib/insika/commands/write_data_tool.rb +1 -1
  54. data/lib/insika/commands/write_golden.rb +1 -1
  55. data/lib/insika/commands/write_skill.rb +19 -9
  56. data/lib/insika/config_store.rb +8 -4
  57. data/lib/insika/context/builder.rb +2 -2
  58. data/lib/insika/context/fragment.rb +27 -3
  59. data/lib/insika/context/priority.rb +3 -2
  60. data/lib/insika/context/providers/memory.rb +1 -1
  61. data/lib/insika/context/providers/request.rb +1 -1
  62. data/lib/insika/context/providers/session.rb +17 -2
  63. data/lib/insika/context/providers/skill.rb +5 -1
  64. data/lib/insika/context/providers/skill_trigger.rb +128 -0
  65. data/lib/insika/context_trace_store.rb +92 -0
  66. data/lib/insika/delegation_store.rb +2 -2
  67. data/lib/insika/doctor.rb +250 -5
  68. data/lib/insika/dsl/runtime.rb +12 -9
  69. data/lib/insika/dsl/server_boot.rb +4 -3
  70. data/lib/insika/dsl/system.rb +1 -1
  71. data/lib/insika/dsl.rb +72 -15
  72. data/lib/insika/edge_limiter.rb +144 -6
  73. data/lib/insika/egress_guard.rb +3 -3
  74. data/lib/insika/env_schema.rb +13 -10
  75. data/lib/insika/errors.rb +61 -5
  76. data/lib/insika/evals/assertions.rb +12 -12
  77. data/lib/insika/evals/baseline.rb +3 -3
  78. data/lib/insika/evals/golden.rb +8 -8
  79. data/lib/insika/evals/judge.rb +7 -7
  80. data/lib/insika/evals/pairwise.rb +3 -3
  81. data/lib/insika/evals/report.rb +2 -2
  82. data/lib/insika/evals/runner.rb +6 -6
  83. data/lib/insika/evals/transport.rb +2 -2
  84. data/lib/insika/event_stream.rb +23 -5
  85. data/lib/insika/executor.rb +423 -108
  86. data/lib/insika/frontmatter.rb +1 -1
  87. data/lib/insika/golden_store.rb +2 -2
  88. data/lib/insika/http_client.rb +3 -3
  89. data/lib/insika/inbound_log.rb +1 -1
  90. data/lib/insika/llm_configurator.rb +3 -3
  91. data/lib/insika/loop_detector.rb +143 -0
  92. data/lib/insika/mcp_http_client.rb +4 -4
  93. data/lib/insika/mcp_tool_ingestor.rb +6 -6
  94. data/lib/insika/message_origin.rb +2 -2
  95. data/lib/insika/model_resolver.rb +1 -1
  96. data/lib/insika/model_selection.rb +5 -4
  97. data/lib/insika/onboarding.rb +2 -2
  98. data/lib/insika/outbox_store.rb +2 -2
  99. data/lib/insika/overlay_tool_registry.rb +3 -4
  100. data/lib/insika/pack.rb +3 -3
  101. data/lib/insika/pack_importer.rb +17 -15
  102. data/lib/insika/pending_action_store.rb +1 -1
  103. data/lib/insika/plugin/loader.rb +2 -2
  104. data/lib/insika/policy/policy.rb +1 -1
  105. data/lib/insika/profile_source.rb +12 -6
  106. data/lib/insika/provider_error_classifier.rb +160 -0
  107. data/lib/insika/queue_policy.rb +2 -2
  108. data/lib/insika/recovery.rb +47 -6
  109. data/lib/insika/refinement/candidate.rb +4 -4
  110. data/lib/insika/refinement/evidence_collector.rb +6 -6
  111. data/lib/insika/refinement/gate.rb +7 -7
  112. data/lib/insika/refinement/panel.rb +7 -7
  113. data/lib/insika/refinement/proposer.rb +9 -9
  114. data/lib/insika/refinement_store.rb +12 -12
  115. data/lib/insika/reliability.rb +185 -0
  116. data/lib/insika/safety/config.rb +2 -2
  117. data/lib/insika/safety/detectors.rb +5 -5
  118. data/lib/insika/safety/factory.rb +3 -3
  119. data/lib/insika/safety/input_guardrail.rb +19 -4
  120. data/lib/insika/safety/moderator.rb +19 -11
  121. data/lib/insika/safety/output_filter.rb +2 -2
  122. data/lib/insika/safety/output_validator.rb +2 -2
  123. data/lib/insika/safety/safe_responses.rb +1 -1
  124. data/lib/insika/sandbox/boundary.rb +2 -2
  125. data/lib/insika/sandbox.rb +1 -1
  126. data/lib/insika/server/app.rb +223 -51
  127. data/lib/insika/server/boot.rb +4 -4
  128. data/lib/insika/server/rack_app.rb +15 -7
  129. data/lib/insika/server/responses.rb +18 -8
  130. data/lib/insika/server/tenant_auth.rb +61 -0
  131. data/lib/insika/session_actor.rb +3 -3
  132. data/lib/insika/session_store.rb +1 -1
  133. data/lib/insika/settings_store.rb +5 -5
  134. data/lib/insika/shutdown.rb +4 -4
  135. data/lib/insika/skill_catalog.rb +127 -20
  136. data/lib/insika/skill_store.rb +70 -22
  137. data/lib/insika/steer_injector.rb +1 -1
  138. data/lib/insika/store.rb +1 -1
  139. data/lib/insika/studio/app.rb +183 -61
  140. data/lib/insika/studio/assets/dist/application.js +25 -24
  141. data/lib/insika/studio/forms.rb +13 -18
  142. data/lib/insika/studio/nav_icons.rb +1 -1
  143. data/lib/insika/studio/views/_message.erb +2 -2
  144. data/lib/insika/studio/views/agent_detail.erb +2 -2
  145. data/lib/insika/studio/views/agents.erb +1 -1
  146. data/lib/insika/studio/views/refinement.erb +4 -4
  147. data/lib/insika/studio/views/session.erb +78 -3
  148. data/lib/insika/studio/views/settings.erb +7 -12
  149. data/lib/insika/studio/views/skills.erb +67 -12
  150. data/lib/insika/subagent_graph.rb +3 -3
  151. data/lib/insika/task_actor.rb +3 -3
  152. data/lib/insika/task_store.rb +1 -1
  153. data/lib/insika/telemetry/pricing.rb +3 -3
  154. data/lib/insika/telemetry/recorder.rb +1 -1
  155. data/lib/insika/telemetry.rb +2 -2
  156. data/lib/insika/testing/store_contract.rb +27 -27
  157. data/lib/insika/tick.rb +122 -0
  158. data/lib/insika/token_store.rb +168 -0
  159. data/lib/insika/tool_assembly.rb +5 -5
  160. data/lib/insika/tool_definition.rb +8 -8
  161. data/lib/insika/tool_envelope.rb +1 -1
  162. data/lib/insika/tool_manifest.rb +6 -6
  163. data/lib/insika/tool_output_compressor.rb +100 -0
  164. data/lib/insika/tool_store.rb +1 -1
  165. data/lib/insika/tool_trace_store.rb +1 -1
  166. data/lib/insika/tools/concurrency.rb +2 -2
  167. data/lib/insika/tools/data_defined_tool.rb +4 -5
  168. data/lib/insika/tools/load_skill.rb +61 -3
  169. data/lib/insika/tools/stuck_signal.rb +44 -0
  170. data/lib/insika/tools/subagent.rb +4 -4
  171. data/lib/insika/tools/subagents.rb +1 -1
  172. data/lib/insika/turn_output.rb +2 -2
  173. data/lib/insika/turn_state.rb +17 -13
  174. data/lib/insika/turn_timing.rb +2 -2
  175. data/lib/insika/usage_ledger.rb +1 -1
  176. data/lib/insika/version.rb +1 -1
  177. data/lib/insika/wiring/graph.rb +77 -26
  178. data/lib/insika/workflow.rb +1 -1
  179. data/lib/insika/workflow_registry.rb +1 -1
  180. data/lib/insika.rb +32 -15
  181. metadata +19 -2
  182. 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
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Insika
4
4
  module Safety
5
- # Per-agent guardrail configuration (RFC-0009 §3.3 / D6), read from
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 (RFC-0009 §7). The engine
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 (RFC-0009 D4).
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 (§3.1/§3.2) AND the eval's `must_not` detectors (RFC-0008): the eval
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 §6: a false positive blocks a
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 (Fase C), not regex.
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 §3.2 / D3). Covers the
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 (RFC-0009). Keeps the wiring
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, RFC-0017 A2): the graph's own RubyLLM context. nil = the
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 A2 and not a follow-up.
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 (RFC-0009 §3.1). It sits on the ONE seam that
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 (D2):
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 (Fase A/B parity).
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, §7).
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 (RFC-0009 Fase C / D2). The subtle tier the regex can't
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 — it degrades to `allow`. Blocking is the high-stakes
17
- # direction (RFC §6: a false positive turns away a real buyer).
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 -> allow/safe
32
- # (fail-open). `context` is optional free text (e.g. the agent's domain) woven
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: "allow", reason: "moderator error (fail-open)")
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 safe_verdict unless json
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 = "allow" unless ACTIONS.include?(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
- safe_verdict
62
+ unavailable_verdict
55
63
  end
56
64
 
57
- def safe_verdict = Verdict.new(category: "safe", action: "allow", reason: "unparseable (fail-open)")
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 (RFC-0009 §3.2 / D3). The turn
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 §6, "fronteira de chunk"): a CPF/secret can arrive split
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 (RFC-0009 §3.2 / D3). Runs as an `after_task` hook:
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
- # (D3): for a streaming agent the text is already out the door, so this is
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 (RFC-0009 D5/§7). A blocked turn NEVER returns a
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` (item 5)
14
- # and promoted to a core primitive (item 35). The two layers of defense:
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`).
@@ -7,7 +7,7 @@ require_relative "sandbox/local"
7
7
  require_relative "sandbox/docker"
8
8
 
9
9
  module Insika
10
- # Sandbox primitive (item 35 / COMPETITIVE-ANALYSIS §4.6): a single, pluggable
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
  #