insika 0.8.0 → 0.9.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 (84) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +75 -0
  3. data/README.md +5 -3
  4. data/bin/insika +1 -1
  5. data/docs/AGENTS.md +52 -11
  6. data/docs/API.md +73 -0
  7. data/docs/ARCHITECTURE.md +45 -44
  8. data/docs/CHANNELS.md +19 -2
  9. data/docs/CONTEXT.md +33 -27
  10. data/docs/DEPLOY.md +13 -2
  11. data/docs/EVALS.md +98 -8
  12. data/docs/FACTS.md +4 -0
  13. data/docs/KNOWLEDGE.md +7 -0
  14. data/docs/OBSERVABILITY.md +21 -6
  15. data/docs/POLICY.md +4 -1
  16. data/docs/RELEASING.md +4 -0
  17. data/docs/SECURITY.md +27 -1
  18. data/docs/TOOLS.md +127 -33
  19. data/docs/prompts/ADD-TOOL.md +12 -2
  20. data/docs/prompts/DIAGNOSE-TURN.md +3 -0
  21. data/docs/prompts/GO-LIVE.md +3 -1
  22. data/lib/insika/agent_profile.rb +21 -9
  23. data/lib/insika/channels/web/widget.js +33 -0
  24. data/lib/insika/channels/web.rb +5 -2
  25. data/lib/insika/chat_builder.rb +62 -20
  26. data/lib/insika/commands/agent_payload.rb +1 -1
  27. data/lib/insika/commands/run_distillation.rb +5 -8
  28. data/lib/insika/commands/seed_session.rb +118 -0
  29. data/lib/insika/context/builder.rb +29 -9
  30. data/lib/insika/context/priority.rb +2 -0
  31. data/lib/insika/context/provider.rb +5 -0
  32. data/lib/insika/context/providers/briefing.rb +11 -8
  33. data/lib/insika/context/providers/fence_notice.rb +27 -0
  34. data/lib/insika/context/providers/knowledge.rb +7 -4
  35. data/lib/insika/context/providers/memory.rb +8 -4
  36. data/lib/insika/context/providers/session.rb +7 -3
  37. data/lib/insika/doctor.rb +109 -1
  38. data/lib/insika/dsl/runtime.rb +1 -0
  39. data/lib/insika/dsl.rb +6 -0
  40. data/lib/insika/edge_limiter.rb +4 -1
  41. data/lib/insika/errors.rb +1 -0
  42. data/lib/insika/evals/assertions.rb +92 -6
  43. data/lib/insika/evals/golden.rb +91 -2
  44. data/lib/insika/evals/runner.rb +20 -0
  45. data/lib/insika/evals/simulator.rb +11 -2
  46. data/lib/insika/evals/transport.rb +117 -15
  47. data/lib/insika/evidence.rb +79 -12
  48. data/lib/insika/executor.rb +30 -23
  49. data/lib/insika/fence.rb +96 -0
  50. data/lib/insika/golden_store.rb +3 -0
  51. data/lib/insika/mcp_store.rb +5 -2
  52. data/lib/insika/mcp_tool_registry.rb +8 -1
  53. data/lib/insika/memory_store.rb +12 -0
  54. data/lib/insika/overlay_tool_registry.rb +5 -0
  55. data/lib/insika/prefix_fingerprint.rb +32 -27
  56. data/lib/insika/profile_source.rb +1 -0
  57. data/lib/insika/server/app.rb +43 -1
  58. data/lib/insika/server/rack_app.rb +2 -0
  59. data/lib/insika/server/responses.rb +31 -4
  60. data/lib/insika/session_store.rb +4 -1
  61. data/lib/insika/settings_store.rb +10 -1
  62. data/lib/insika/spoken_transcript.rb +31 -0
  63. data/lib/insika/studio/app.rb +6 -2
  64. data/lib/insika/studio/forms.rb +18 -3
  65. data/lib/insika/studio/views/_agent_tab_config.erb +5 -1
  66. data/lib/insika/studio/views/session.erb +1 -1
  67. data/lib/insika/studio/views/tool_edit.erb +6 -2
  68. data/lib/insika/telemetry/recorder.rb +13 -1
  69. data/lib/insika/tool_assembly.rb +21 -13
  70. data/lib/insika/tool_definition.rb +73 -10
  71. data/lib/insika/tool_envelope.rb +102 -2
  72. data/lib/insika/tool_store.rb +9 -4
  73. data/lib/insika/tool_trace_store.rb +1 -1
  74. data/lib/insika/tool_usage_report.rb +12 -2
  75. data/lib/insika/tools/data_defined_tool.rb +1 -0
  76. data/lib/insika/tools/present.rb +122 -0
  77. data/lib/insika/tools/run_persona_eval.rb +6 -1
  78. data/lib/insika/tools/tool_search.rb +4 -2
  79. data/lib/insika/turn_state.rb +13 -1
  80. data/lib/insika/version.rb +1 -1
  81. data/lib/insika/wiring/graph.rb +7 -0
  82. data/lib/insika/wiring/graph_chat.rb +4 -0
  83. data/lib/insika.rb +4 -0
  84. metadata +6 -1
@@ -22,6 +22,11 @@ module Insika
22
22
  # different facts, and a budget that confuses them stops being a budget.
23
23
  TurnOutcome = Struct.new(:result, :ttfb, :total, :usage, keyword_init: true)
24
24
 
25
+ # The deployment answered the seed with a refusal (403: `evals.seeding` is off
26
+ # there). Its own class so the Runner can SKIP the case with the reason — a
27
+ # different fact from a seed that failed (409, 5xx, network), which fails it.
28
+ class SeedRefused < Insika::Error; end
29
+
25
30
  # Pure reduction of the /v1/responses SSE stream. Kept separate from the HTTP so
26
31
  # it's testable offline with canned frames (server/responses.rb is the producer).
27
32
  module Sse
@@ -47,28 +52,67 @@ module Insika
47
52
 
48
53
  # [payload] -> { output_text:, tool_calls:, usage:, error: }. Maps the Responses
49
54
  # frames (see server/responses.rb#frame_for): text deltas accumulate; each
50
- # function_call item contributes a tool NAME (this stream carries no per-tool
51
- # status — that lives in the ToolTraceStore, an in-process enrichment); a
52
- # `response.failed` sets the turn error.
55
+ # function_call `added` item opens a tool call (name + arguments); its `done`
56
+ # item closes it with how the call ended (status ok/error/blocked + the gate
57
+ # that held it). An older deployment sends `added` only, and the entries keep
58
+ # `status: nil` — the shape widened, nothing moved. A `response.failed` sets
59
+ # the turn error.
53
60
  def reduce(payloads)
54
61
  text = +""
55
62
  tools = []
63
+ by_id = {}
64
+ ui = []
56
65
  usage = nil
57
66
  error = nil
58
67
  payloads.each do |o|
59
68
  case o["type"]
60
69
  when "response.output_text.delta"
61
70
  text << o["delta"].to_s
71
+ when "insika.ui"
72
+ # A presentation tool's selection — the component and how many cards it
73
+ # showed. The `ui_components`/`no_ui` graders read this.
74
+ ui << { "component" => o["component"].to_s, "count" => o["count"].to_i }
62
75
  when "response.output_item.added"
63
76
  item = o["item"] || {}
64
- tools << { "name" => item["name"].to_s, "status" => nil } if item["type"] == "function_call"
77
+ next unless item["type"] == "function_call"
78
+
79
+ entry = { "name" => item["name"].to_s, "status" => nil }
80
+ (args = arguments_of(item["arguments"])) && (entry["arguments"] = args)
81
+ tools << entry
82
+ (id = item["call_id"]) && (by_id[id] = entry)
83
+ when "response.output_item.done"
84
+ item = o["item"] || {}
85
+ next unless item["type"] == "function_call"
86
+
87
+ # Paired by call_id. An older engine's frames carry none: then the FIRST
88
+ # still-open call of that name (a batch runs concurrently and its `done`
89
+ # frames arrive in completion order). No `added` at all = a system tool
90
+ # the engine reports the end of but never announced (load_skill): not a
91
+ # tool call here, exactly as it is not one in the in-process transport.
92
+ entry = by_id[item["call_id"]] || tools.find { |t| t["name"] == item["name"].to_s && t["status"].nil? }
93
+ next unless entry
94
+
95
+ entry["status"] = item["status"].to_s
96
+ entry["gate"] = item["gate"].to_s if item["gate"]
65
97
  when "response.completed"
66
98
  usage = o.dig("response", "usage")
67
99
  when "response.failed"
68
100
  error = o.dig("response", "error", "message") || "response.failed"
69
101
  end
70
102
  end
71
- { output_text: text, tool_calls: tools, usage: usage, error: error }
103
+ { output_text: text, tool_calls: tools, ui: ui, usage: usage, error: error }
104
+ end
105
+
106
+ # The item's `arguments` is a JSON string on the wire (the OpenAI shape); the
107
+ # graders want the Hash. Anything unparseable stays the raw string — the call
108
+ # happened, and a report should still show what it saw. nil when absent.
109
+ def arguments_of(raw)
110
+ return nil if raw.nil?
111
+ return raw unless raw.is_a?(String)
112
+
113
+ JSON.parse(raw)
114
+ rescue JSON::ParserError
115
+ raw
72
116
  end
73
117
  end
74
118
 
@@ -160,14 +204,50 @@ module Insika
160
204
 
161
205
  reduced = Sse.reduce(Sse.payloads(buffer))
162
206
  TurnOutcome.new(
163
- result: TurnResult.new(output_text: reduced[:output_text],
164
- tool_calls: reduced[:tool_calls], error: reduced[:error]),
207
+ result: TurnResult.new(output_text: reduced[:output_text], tool_calls: reduced[:tool_calls],
208
+ ui: reduced[:ui], error: reduced[:error]),
165
209
  ttfb: ttfb, total: (mono - t0) * 1000.0, usage: reduced[:usage]
166
210
  )
167
211
  rescue StandardError => e
168
212
  failure(e.class.to_s, t0)
169
213
  end
170
214
 
215
+ # Loads a case's `state:` into the conversation BEFORE its first turn —
216
+ # `POST /v1/conversations/:conv/seed`, the same Bearer as the turn, so the
217
+ # seeded session is the one the turn continues. The body is the state mapping
218
+ # (+ `customer` when the turns will carry one). A 403 is the deployment
219
+ # refusing to seed (`evals.seeding` off) -> SeedRefused, which the Runner
220
+ # turns into a skip; anything else that is not 2xx is an error that fails the
221
+ # case — a seed that did not land must never let the case run empty and pass.
222
+ def seed(conv, state, customer: nil)
223
+ uri = URI.join("#{@base}/", "v1/conversations/#{URI.encode_www_form_component(conv)}/seed")
224
+ req = Net::HTTP::Post.new(uri)
225
+ req["Authorization"] = "Bearer #{@token}"
226
+ req["Content-Type"] = "application/json"
227
+ body = Insika::Coercion.deep_stringify(state)
228
+ body["customer"] = customer if customer
229
+ req.body = JSON.generate(body)
230
+
231
+ http = Net::HTTP.new(uri.host, uri.port)
232
+ http.use_ssl = uri.scheme == "https"
233
+ http.read_timeout = @timeout
234
+ http.open_timeout = 10
235
+ res = http.start { http.request(req) }
236
+ code = res.code.to_i
237
+ return true if code.between?(200, 299)
238
+
239
+ message = begin
240
+ JSON.parse(res.body.to_s).dig("error", "message")
241
+ rescue JSON::ParserError
242
+ nil
243
+ end
244
+ raise SeedRefused, "HTTP 403#{" #{message}" if message}" if code == 403
245
+
246
+ raise Insika::Error, "HTTP #{code}#{" #{message}" if message}"
247
+ rescue SystemCallError, IOError, Net::OpenTimeout, Net::ReadTimeout => e
248
+ raise Insika::Error, e.class.to_s
249
+ end
250
+
171
251
  private
172
252
 
173
253
  def failure(msg, t0)
@@ -199,16 +279,18 @@ module Insika
199
279
 
200
280
  def turn(agent:, conv:, message:)
201
281
  t0 = mono
202
- sub = @event_stream&.subscribe(types: [:tool_call])
282
+ sub = @event_stream&.subscribe(types: %i[tool_call ui])
203
283
  begin
204
284
  text = @runtime.chat(message, session_id: conv, agent: agent)
285
+ tools, ui = drain(sub)
205
286
  TurnOutcome.new(
206
- result: TurnResult.new(output_text: text.to_s, tool_calls: drain_tools(sub), error: nil),
287
+ result: TurnResult.new(output_text: text.to_s, tool_calls: tools, ui: ui, error: nil),
207
288
  ttfb: nil, total: (mono - t0) * 1000.0, usage: nil
208
289
  )
209
290
  rescue Insika::Error => e
291
+ tools, ui = drain(sub)
210
292
  TurnOutcome.new(
211
- result: TurnResult.new(output_text: "", tool_calls: drain_tools(sub), error: e.message),
293
+ result: TurnResult.new(output_text: "", tool_calls: tools, ui: ui, error: e.message),
212
294
  ttfb: nil, total: (mono - t0) * 1000.0, usage: nil
213
295
  )
214
296
  ensure
@@ -216,14 +298,34 @@ module Insika
216
298
  end
217
299
  end
218
300
 
301
+ # In-process seeding: the SAME `seed_session` command the HTTP route dispatches,
302
+ # on the graph's own bus — no route, no gate (there is no tenant token to
303
+ # protect here; whoever holds the graph already holds everything). A runtime
304
+ # that exposes no graph cannot seed, and says so as an error the Runner fails
305
+ # the case with.
306
+ def seed(conv, state, customer: nil)
307
+ bus = @runtime.graph.bus if @runtime.respond_to?(:graph) && @runtime.graph
308
+ raise Insika::Error, "transport cannot seed state: the runtime exposes no graph" if bus.nil?
309
+
310
+ payload = { id: conv, state: state }
311
+ payload[:customer] = customer if customer
312
+ bus.dispatch(Insika::Command.build(:seed_session, payload, transport: :internal))
313
+ end
314
+
219
315
  private
220
316
 
221
- # The same shape an HTTP replay produces: [{ "name" =>, "status" => nil }]
222
- # (the stream carries no per-tool status — that lives in the trace store).
223
- def drain_tools(sub)
224
- return [] if sub.nil?
317
+ # The same shapes an HTTP replay produces: tools as [{ "name" =>, "status" => nil }]
318
+ # (the stream carries no per-tool status — that lives in the trace store), ui as
319
+ # [{ "component" =>, "count" => }]. -> [tools, ui]
320
+ def drain(sub)
321
+ return [[], []] if sub.nil?
225
322
 
226
- sub.drain_nonblocking.map { |ev| { "name" => ev.data[:name].to_s, "status" => nil } }
323
+ events = sub.drain_nonblocking
324
+ tools = events.select { |ev| ev.type == :tool_call }
325
+ .map { |ev| { "name" => ev.data[:name].to_s, "status" => nil } }
326
+ ui = events.select { |ev| ev.type == :ui }
327
+ .map { |ev| { "component" => ev.data[:component].to_s, "count" => ev.data[:count].to_i } }
328
+ [tools, ui]
227
329
  end
228
330
  end
229
331
 
@@ -29,10 +29,12 @@ module Insika
29
29
  Spec = Data.define(:kind, :items_path, :attachments_path) do
30
30
  PATH_RE = /\A[a-zA-Z0-9_]+(?:\.[a-zA-Z0-9_]+)*\z/
31
31
 
32
- # String | Hash | nil -> Spec | nil. Raises ValidationError on a blank kind
32
+ # String | Hash | Spec | nil -> Spec | nil. Raises ValidationError on a blank kind
33
33
  # or an empty/ill-formed path. All at ingestion, never at the turn.
34
34
  def self.parse(raw)
35
35
  return nil if raw.nil? || raw == false
36
+ # A data tool hands the envelope its definition's evidence — already a Spec.
37
+ return raw if raw.is_a?(Spec)
36
38
 
37
39
  h = raw.is_a?(String) ? { "kind" => raw } : Coercion.deep_stringify(raw)
38
40
  h = h.is_a?(Hash) ? h : {}
@@ -70,6 +72,13 @@ module Insika
70
72
  # model): [{ "type" => "card"|"image", "url" => String, "caption" => String|nil }].
71
73
  # Entries without a String url, or beyond MAX_ATTACHMENTS, are DROPPED — never
72
74
  # a turn failure (the card is a channel nicety, not the answer).
75
+ #
76
+ # Three OPTIONAL keys survive when present: `id` (the product the card stands
77
+ # for — what a presentation tool joins on), and `component`/`title` (stamped
78
+ # by a presentation call so the channel knows what it is rendering). Absent =
79
+ # absent; a card with none of them is byte-identical to before.
80
+ OPTIONAL_KEYS = %w[id component title].freeze
81
+
73
82
  def self.valid_attachments(list)
74
83
  Array(list).filter_map do |entry|
75
84
  next unless entry.is_a?(Hash)
@@ -78,9 +87,15 @@ module Insika
78
87
  next if url.empty?
79
88
 
80
89
  caption = Coercion.presence(entry["caption"] || entry[:caption])
81
- { "type" => (entry["type"] || entry[:type]).to_s,
82
- "url" => url[0, URL_MAX],
83
- "caption" => caption }
90
+ caption &&= Coercion.utf8(caption)
91
+ card = { "type" => (entry["type"] || entry[:type]).to_s,
92
+ "url" => url[0, URL_MAX],
93
+ "caption" => caption }
94
+ OPTIONAL_KEYS.each do |k|
95
+ v = Coercion.presence(entry[k] || entry[k.to_sym])
96
+ card[k] = v.to_s if v
97
+ end
98
+ card
84
99
  end.first(MAX_ATTACHMENTS)
85
100
  end
86
101
 
@@ -106,11 +121,28 @@ module Insika
106
121
  items = SchemaGuard.dig(raw, spec.items_path) || []
107
122
  lean_items = items.first(MAX_ITEMS).map do |item|
108
123
  { "id" => (item["id"] || item[:id]).to_s,
124
+ # the line is what the model reads: truncated here; sanitized by the
125
+ # envelope's fence when the agent has `fencing` on (bytes as-is when off).
109
126
  "line" => Coercion.utf8((item["line"] || item[:line]).to_s)[0, LINE_MAX] }
110
127
  end
111
128
  lean = { "items" => lean_items }
112
- attachments = Insika::Evidence.valid_attachments(SchemaGuard.dig(raw, spec.attachments_path))
113
- [lean, attachments]
129
+ cards = stamp_ids(items, SchemaGuard.dig(raw, spec.attachments_path))
130
+ [lean, Insika::Evidence.valid_attachments(cards)]
131
+ end
132
+
133
+ # Items and attachments come from the SAME payload, one card per item in
134
+ # order — so a card that names no id of its own takes the id of the item at
135
+ # its position. That id is what a presentation tool later joins on. Stamped
136
+ # on the RAW list, before malformed cards are dropped: pairing after the
137
+ # drop would shift every later card onto the wrong product.
138
+ def stamp_ids(items, cards)
139
+ Array(cards).each_with_index.map do |card, i|
140
+ next card unless card.is_a?(Hash) && (card["id"] || card[:id]).nil?
141
+
142
+ item = items[i]
143
+ id = item.is_a?(Hash) ? (item["id"] || item[:id]).to_s : ""
144
+ id.empty? ? card : card.merge("id" => id)
145
+ end
114
146
  end
115
147
  end
116
148
  end
@@ -125,14 +157,26 @@ module Insika
125
157
  # Oldest-evicted cap: a session that outlives it needs a real cap or the row
126
158
  # grows forever.
127
159
  MAX_IDS = 1_000
160
+ # Cards kept per session, one per id, newest wins — enough for the last few
161
+ # searches, so "show me the second one" a turn later still has a card to show.
162
+ MAX_CARDS = 64
128
163
 
129
164
  def initialize(store: nil, session_id: nil)
130
165
  @store = store
131
166
  @session_id = session_id
132
167
  @ids = []
168
+ @cards = []
133
169
  @ungrounded = 0
134
170
  end
135
171
 
172
+ # -> one card per id (newest wins), capped. Shared by the ledger and the
173
+ # session store so both keep the same set.
174
+ def self.merge_cards(*lists)
175
+ lists.flatten.compact
176
+ .each_with_object({}) { |c, h| h[c["id"]] = c if c.is_a?(Hash) && c["id"] }
177
+ .values.last(MAX_CARDS)
178
+ end
179
+
136
180
  # The in-memory accumulator (the envelope appends, the validator/enforcer
137
181
  # read the union). The PERSISTED list is appended on flush (Executor, stage 8)
138
182
  # — the envelope never blocks on the store.
@@ -147,6 +191,19 @@ module Insika
147
191
  (session_ids + @ids).uniq.last(MAX_IDS)
148
192
  end
149
193
 
194
+ # The cards an evidence tool returned alongside its items, hoarded for the
195
+ # presentation tool. Only cards with an id are kept (nothing to join on
196
+ # otherwise). Persisted with the ids on flush.
197
+ def record_cards(cards)
198
+ @cards.concat(Array(cards).select { |c| c.is_a?(Hash) && c["id"] })
199
+ self
200
+ end
201
+
202
+ # -> the session's cards (persisted) + this turn's, one per id, newest wins.
203
+ def cards
204
+ self.class.merge_cards(session_evidence["cards"], @cards)
205
+ end
206
+
150
207
  attr_reader :ungrounded
151
208
 
152
209
  def ungrounded_count(claim)
@@ -161,9 +218,11 @@ module Insika
161
218
  def flush!
162
219
  return self unless @store && @session_id
163
220
 
164
- @store.append_evidence(@session_id, ids: @ids, ungrounded: @ungrounded)
221
+ @store.append_evidence(@session_id, ids: @ids, ungrounded: @ungrounded, cards: @cards)
165
222
  @ids = []
223
+ @cards = []
166
224
  @ungrounded = 0
225
+ @session_evidence = nil
167
226
  self
168
227
  rescue Insika::Error
169
228
  self
@@ -172,12 +231,20 @@ module Insika
172
231
  private
173
232
 
174
233
  def session_ids
175
- return [] unless @store && @session_id
234
+ Array(session_evidence["ids"]).map(&:to_s)
235
+ end
176
236
 
177
- session = @store.find(@session_id)
178
- Array(session&.evidence&.fetch("ids", [])).map(&:to_s)
179
- rescue Insika::NotFoundError
180
- []
237
+ # Read ONCE per ledger (= per turn): a batch of gated calls must not re-read
238
+ # the session row for each. `flush!` forgets it, so the next read sees what
239
+ # it just appended.
240
+ def session_evidence
241
+ return {} unless @store && @session_id
242
+
243
+ @session_evidence ||= begin
244
+ @store.find(@session_id)&.evidence || {}
245
+ rescue Insika::NotFoundError
246
+ {}
247
+ end
181
248
  end
182
249
  end
183
250
  end
@@ -913,6 +913,7 @@ module Insika
913
913
  stamp_customer_session(task, profile)
914
914
  state.turn_context = build_turn_context(task, profile, state) # data-tools' ctx.*
915
915
  state.resumed = !resume_from.nil? # EdgeLimiter: an admitted turn is never re-counted
916
+ state.fence_max_chars = fence_max_chars if Insika::Fence.enabled?(profile)
916
917
  # resolved for the RUN, not per message, so what the turn accepts cannot
917
918
  # change under it. Same cost as the EdgeLimiter's per-turn resolution. Only for a
918
919
  # SESSION turn: steering needs a session to arrive through, and resolving here for a
@@ -1969,19 +1970,8 @@ module Insika
1969
1970
  # indistinguishable from a single-tenant customer ref, and the Studio drill
1970
1971
  # must not list conversations as customers with a Forget button.
1971
1972
  def memory_tenant(task)
1972
- customer = command_customer(task)
1973
- return command_tenant(task) || session_scope(task.session_id) if customer.nil?
1974
-
1975
- [command_tenant(task), customer].compact.join(":")
1976
- end
1977
-
1978
- # The marked per-session scope : "chat:<session id>" -> cell
1979
- # "memory:chat:<session id>". nil for a one-shot turn (no session) — the
1980
- # MemoryStore applies _default.
1981
- def session_scope(session_id)
1982
- return nil if session_id.nil?
1983
-
1984
- "#{MemoryStore::SESSION_TAG}:#{session_id}"
1973
+ MemoryStore.scope_for(tenant: command_tenant(task), customer: command_customer(task),
1974
+ session_id: task.session_id)
1985
1975
  end
1986
1976
 
1987
1977
  # WS8 + : stamp the customer (WS8 — the `forget_customer`
@@ -2515,7 +2505,11 @@ module Insika
2515
2505
 
2516
2506
  # the hoarded evidence attachments ride the channel delivery
2517
2507
  # (additive outbox payload key — the channel contract widens, nothing breaks).
2518
- attachments = state.respond_to?(:evidence_attachments) ? state.evidence_attachments : nil
2508
+ # When the model SELECTED cards with a presentation tool this turn, the
2509
+ # selection is what rides — in call order, each card stamped with the
2510
+ # component and title of the call that picked it. No selection = every
2511
+ # hoarded card, as before.
2512
+ attachments = delivery_attachments(state)
2519
2513
  deliveries = @channel_delivery.record_balloons(
2520
2514
  task: task, channel_id: channel_id, content: content,
2521
2515
  progressive: @channel_delivery.progressive?(channel_id),
@@ -2529,6 +2523,17 @@ module Insika
2529
2523
  nil
2530
2524
  end
2531
2525
 
2526
+ def delivery_attachments(state)
2527
+ presentations = state.respond_to?(:presentations) ? Array(state.presentations) : []
2528
+ if presentations.empty?
2529
+ return state.respond_to?(:evidence_attachments) ? state.evidence_attachments : nil
2530
+ end
2531
+
2532
+ presentations.flat_map do |p|
2533
+ Array(p["items"]).map { |item| item.merge("component" => p["component"], "title" => p["title"]).compact }
2534
+ end
2535
+ end
2536
+
2532
2537
  # Turns whose combined transcript slice is this trivially short skip
2533
2538
  # extraction entirely ("ok thanks" exchanges) — no new config surface,
2534
2539
  # just avoids a wasted utility-model call.
@@ -2602,15 +2607,9 @@ module Insika
2602
2607
  PROMPT
2603
2608
  end
2604
2609
 
2605
- # Redacted (RFC's PII rule applies to what reaches the model too, not
2606
- # just what gets persisted).
2607
- def knowledge_transcript(new_messages)
2608
- redacted, = Insika::Safety::Detectors.redact(
2609
- new_messages.each_with_index.map { |m, i| "[#{i}] #{m['role'] || m[:role]}: #{m['content'] || m[:content]}" }
2610
- .join("\n")
2611
- )
2612
- redacted
2613
- end
2610
+ # Only what people said, PII-redacted — a `role: tool` message is
2611
+ # third-party text and never a source of a learned concept.
2612
+ def knowledge_transcript(new_messages) = Insika::SpokenTranscript.render(new_messages)
2614
2613
 
2615
2614
  def utility_model
2616
2615
  return nil unless @settings_store
@@ -2618,6 +2617,14 @@ module Insika
2618
2617
  @settings_store.get["utility_model"]
2619
2618
  end
2620
2619
 
2620
+ # `Settings fencing.max_chars`, the per-leaf cap the envelope applies for a
2621
+ # fenced agent. One settings read per fenced turn, like the queue policy.
2622
+ def fence_max_chars
2623
+ config = @settings_store && Coercion.deep_stringify(@settings_store.get["fencing"])
2624
+ n = config && config["max_chars"].to_i
2625
+ n && n.positive? ? n : Insika::Fence::DEFAULT_MAX_CHARS
2626
+ end
2627
+
2621
2628
  # in-session compaction (RFC-0044). Platform-gated
2622
2629
  # (Settings compaction.enabled — parity when off), planned over the
2623
2630
  # POST-append transcript (persist_turn already wrote this turn's messages),
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # Third-party text — a product description, a review, an FAQ body, a fact a
5
+ # customer dictated — reaches the model as bytes, and the model reads every
6
+ # one of them. Those bytes can carry zero-width characters, bidi overrides, a
7
+ # forged "\n\nassistant:" turn marker or a tag shaped like the engine's own
8
+ # transcript markup. This is the ONE sanitizer for that class of input:
9
+ # pure Ruby, stdlib only, no IO, every pattern bounded so a hostile megabyte
10
+ # finishes in linear time on the reactor.
11
+ #
12
+ # It never re-wraps. The engine already renders data inside fixed labels
13
+ # (<memory>, <knowledge>, a tool result) and the FenceNotice provider names
14
+ # them to the model; what this module does is make sure the DATA cannot
15
+ # reproduce those labels or a turn boundary.
16
+ module Fence
17
+ module_function
18
+
19
+ # Zero-width, bidi and format controls: the usual carriers of hidden text.
20
+ INVISIBLE = /[\u00AD\u061C\u180E\u200B-\u200F\u2028-\u202E\u2060-\u2064\u2066-\u2069\u206A-\u206F\uFE00-\uFE0F\uFEFF\uFFF9-\uFFFB\u{E0000}-\u{E007F}\u{E0100}-\u{E01EF}]/
21
+
22
+ # C0/C1 controls except tab, newline and carriage return.
23
+ CONTROL = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/
24
+
25
+ # Tag names that read as transcript or tool-call markup to a model, plus the
26
+ # labels the engine itself renders around data. A copy of any of them inside
27
+ # DATA is a forgery — `<fact>` inside a fact value would end the fact early.
28
+ TAG_NAMES = %w[
29
+ system human user assistant transcript conversation
30
+ tool_result tool_use function_calls function_results invoke
31
+ memory knowledge fact note concept briefing conversation_summary request_context
32
+ ].freeze
33
+
34
+ # Only tag-SHAPED text: bare (`<system>`), closing (`</memory>`), self-closing,
35
+ # optionally namespaced (`<ns:invoke>`), with at most eight `name="value"`
36
+ # attributes. Bare words are not attributes, so `<system requirements>`
37
+ # passes. Quantifiers are bounded and non-adjacent — linear on unclosed input.
38
+ TAG_ATTRS = /(?:[ \t]+[\w:.-]{1,40}[ \t]*=[ \t]*(?:"[^"]{0,200}"|'[^']{0,200}'|[^\s"'>]{1,200})){0,8}/
39
+ TAG = /<[ \t]*\/?[ \t]*(?:[a-z][\w.-]{0,30}:)?(?:#{TAG_NAMES.join('|')})\b#{TAG_ATTRS.source}[ \t]*\/?>/i
40
+
41
+ # Provider special tokens (`<|im_start|>`, `<|endoftext|>`).
42
+ SPECIAL_TOKEN = /<\|[^|<>\r\n]{1,64}\|>/
43
+
44
+ # A forged turn boundary: a blank line (or the very start), a full role word,
45
+ # a colon. A mid-sentence "user:" and a one-letter list marker ("A:") do not
46
+ # match. The colon becomes " -", so the words survive as prose.
47
+ TURN_MARKER = /(\A[ \t]*|(?:\r\n|\r|\n)[ \t]*(?:\r\n|\r|\n)[ \t]*)(human|assistant|system|user)[ \t]*:/i
48
+
49
+ REMOVED = "[removed]"
50
+ TRUNCATED = " …[truncated]"
51
+ # One string leaf after the envelope; `Settings fencing.max_chars` overrides.
52
+ DEFAULT_MAX_CHARS = 12_000
53
+
54
+ # -> String. `max_chars` bounds the result INCLUDING the suffix.
55
+ def sanitize_text(text, max_chars: nil)
56
+ s = Coercion.utf8(text).unicode_normalize(:nfkc)
57
+ s = s.gsub(INVISIBLE, "").gsub(CONTROL, " ")
58
+ # To a fixpoint: a tag nested inside another (`</memory</memory>>`) must
59
+ # not reassemble once the inner one goes. Each round shrinks the string or
60
+ # stops, so the loop is bounded by the number of tags.
61
+ loop do
62
+ stripped = s.gsub(TAG, REMOVED).gsub(SPECIAL_TOKEN, REMOVED)
63
+ break if stripped == s
64
+
65
+ s = stripped
66
+ end
67
+ s = s.gsub(TURN_MARKER) { "#{Regexp.last_match(1)}#{Regexp.last_match(2)} -" }
68
+ cap(s, max_chars)
69
+ end
70
+
71
+ # Walks Hash/Array leaves; String leaves are sanitized, keys and every other
72
+ # type pass through untouched (numbers, booleans, nil are not text).
73
+ def sanitize_value(obj, max_chars: nil)
74
+ case obj
75
+ when String then sanitize_text(obj, max_chars: max_chars)
76
+ when Hash then obj.transform_values { |v| sanitize_value(v, max_chars: max_chars) }
77
+ when Array then obj.map { |v| sanitize_value(v, max_chars: max_chars) }
78
+ else obj
79
+ end
80
+ end
81
+
82
+ # The per-agent switch, read the same way everywhere (envelope, providers,
83
+ # doctor): an operator's `fencing true`, a form's "1" or a JSON `true`.
84
+ def enabled?(profile)
85
+ profile.respond_to?(:fencing) && Coercion.truthy?(profile.fencing)
86
+ end
87
+
88
+ def cap(text, max_chars)
89
+ return text if max_chars.nil? || text.length <= max_chars
90
+ return text[0, max_chars] if max_chars <= TRUNCATED.length
91
+
92
+ text[0, max_chars - TRUNCATED.length] + TRUNCATED
93
+ end
94
+ private_class_method :cap
95
+ end
96
+ end
@@ -148,6 +148,9 @@ module Insika
148
148
  # when present. A case that lost its reference in a round-trip would stop being
149
149
  # compared against the incumbent and the report would look identical.
150
150
  h["reference"] = golden.reference unless golden.reference.empty?
151
+ # And `state`: a snapshot case that lost its snapshot would replay from an
152
+ # empty conversation and fail for a reason that has nothing to do with the agent.
153
+ h["state"] = golden.state unless golden.state.empty?
151
154
  # `tenant` follows the same omit-when-default rule: "platform" is the
152
155
  # loader's own default, so leaving it out reproduces the same case; an
153
156
  # explicit tenant is a case's isolation boundary and must never round-trip away.
@@ -135,7 +135,7 @@ module Insika
135
135
 
136
136
  def http_like?(transport) = %w[http sse].include?(transport.to_s)
137
137
 
138
- # -> [{"name","description","inputSchema"}] string-keyed, dropping any
138
+ # -> [{"name","description","inputSchema"[,"annotations"]}] string-keyed, dropping any
139
139
  # entry without a name (nothing to register a Registry::Entry under).
140
140
  def normalize_tools_cache(tools)
141
141
  Array(tools).filter_map do |t|
@@ -143,7 +143,10 @@ module Insika
143
143
  name = presence(h["name"])
144
144
  next nil if name.nil?
145
145
 
146
- { "name" => name, "description" => h["description"].to_s, "inputSchema" => h["inputSchema"] || {} }
146
+ cached = { "name" => name, "description" => h["description"].to_s, "inputSchema" => h["inputSchema"] || {} }
147
+ # the server's own hints (readOnlyHint decides side_effect at registration)
148
+ cached["annotations"] = h["annotations"] if h["annotations"].is_a?(Hash)
149
+ cached
147
150
  end
148
151
  end
149
152
 
@@ -71,11 +71,18 @@ module Insika
71
71
  instance = record["name"]
72
72
  Insika::Registry::Entry.new(
73
73
  name: tool["name"], plugin: "mcp:#{instance}",
74
- metadata: { optional: false, side_effect: true, group: "mcp:#{instance}", tags: [] },
74
+ metadata: { optional: false, side_effect: !read_only?(tool), group: "mcp:#{instance}", tags: [] },
75
75
  factory: -> { build_tool(record, tool) }
76
76
  )
77
77
  end
78
78
 
79
+ # An MCP tool is a side effect unless its server says otherwise
80
+ # (`annotations.readOnlyHint`): a write is never re-run on resume and runs
81
+ # serially within the session; a declared read keeps `tool_concurrency`.
82
+ def read_only?(tool)
83
+ Coercion.truthy?(tool.dig("annotations", "readOnlyHint"))
84
+ end
85
+
79
86
  # Lazy require (McpLiveTool < RubyLLM::Tool pulls in ruby_llm) — kept out
80
87
  # of insika.rb load-time, loaded on the 1st instance (turn time), same
81
88
  # discipline as OverlayToolRegistry#build_tool for data-tools.
@@ -42,6 +42,18 @@ module Insika
42
42
  # ids live in a different namespace in practice.
43
43
  SESSION_TAG = "chat"
44
44
 
45
+ # The ONE memory-cell rule, read by the turn (Executor) and by whoever
46
+ # writes a cell the turn must find (SeedSession): a customer -> the
47
+ # "[tenant:]customer" cell; otherwise the tenant's cell; otherwise the marked
48
+ # per-session cell "chat:<session id>" (nil for a one-shot turn with no
49
+ # session — the store applies _default).
50
+ def self.scope_for(tenant:, customer:, session_id:)
51
+ return [tenant, customer].compact.join(":") if customer
52
+ return tenant if tenant
53
+
54
+ "#{SESSION_TAG}:#{session_id}" if session_id
55
+ end
56
+
45
57
  Fact = Data.define(:key, :value, :origin, :created_at, :updated_at, :expires_at)
46
58
  Note = Data.define(:id, :text, :created_at)
47
59
 
@@ -112,6 +112,11 @@ module Insika
112
112
  # Lazy require: DataDefinedTool inherits from RubyLLM::Tool (pulls in the gem) -> kept
113
113
  # out of insika.rb load-time, loaded on the 1st instance (turn time).
114
114
  def build_tool(definition)
115
+ if definition.presentation?
116
+ require_relative "tools/present"
117
+ return Insika::Tools::Present.new(definition: definition, event_stream: @event_stream)
118
+ end
119
+
115
120
  require_relative "tools/data_defined_tool"
116
121
  Insika::Tools::DataDefinedTool.new(
117
122
  definition: definition, http: @http, egress: @egress,