insika 0.7.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 (100) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +191 -0
  3. data/README.md +9 -6
  4. data/bin/insika +44 -3
  5. data/docs/AGENTS.md +74 -14
  6. data/docs/API.md +73 -0
  7. data/docs/ARCHITECTURE.md +45 -44
  8. data/docs/ARTIFACTS.md +42 -0
  9. data/docs/CHANNELS.md +19 -2
  10. data/docs/CONTEXT.md +86 -38
  11. data/docs/DEPLOY.md +27 -7
  12. data/docs/EVALS.md +98 -8
  13. data/docs/FACTS.md +4 -0
  14. data/docs/KNOWLEDGE.md +7 -0
  15. data/docs/LOADTEST.md +15 -27
  16. data/docs/MEDIA.md +1 -1
  17. data/docs/OBSERVABILITY.md +48 -4
  18. data/docs/POLICY.md +14 -5
  19. data/docs/RELEASING.md +4 -0
  20. data/docs/RUNNING-LOCAL.md +2 -2
  21. data/docs/SECURITY.md +28 -2
  22. data/docs/SOAK.md +1 -1
  23. data/docs/TOOLS.md +150 -32
  24. data/docs/prompts/ADD-TOOL.md +12 -2
  25. data/docs/prompts/DIAGNOSE-TURN.md +3 -0
  26. data/docs/prompts/GO-LIVE.md +6 -4
  27. data/lib/insika/agent_profile.rb +47 -10
  28. data/lib/insika/channels/web/widget.js +33 -0
  29. data/lib/insika/channels/web.rb +5 -2
  30. data/lib/insika/chat_builder.rb +90 -37
  31. data/lib/insika/commands/agent_payload.rb +1 -1
  32. data/lib/insika/commands/run_distillation.rb +5 -8
  33. data/lib/insika/commands/seed_session.rb +118 -0
  34. data/lib/insika/compaction.rb +196 -0
  35. data/lib/insika/context/builder.rb +35 -11
  36. data/lib/insika/context/fragment.rb +4 -1
  37. data/lib/insika/context/priority.rb +8 -0
  38. data/lib/insika/context/provider.rb +5 -0
  39. data/lib/insika/context/providers/briefing.rb +61 -29
  40. data/lib/insika/context/providers/fence_notice.rb +27 -0
  41. data/lib/insika/context/providers/knowledge.rb +7 -4
  42. data/lib/insika/context/providers/memory.rb +8 -4
  43. data/lib/insika/context/providers/session.rb +50 -10
  44. data/lib/insika/context_trace_store.rb +11 -1
  45. data/lib/insika/doctor.rb +213 -10
  46. data/lib/insika/dsl/runtime.rb +5 -0
  47. data/lib/insika/dsl.rb +6 -0
  48. data/lib/insika/edge_limiter.rb +4 -1
  49. data/lib/insika/env_schema.rb +5 -6
  50. data/lib/insika/errors.rb +1 -0
  51. data/lib/insika/evals/assertions.rb +92 -6
  52. data/lib/insika/evals/golden.rb +91 -2
  53. data/lib/insika/evals/runner.rb +20 -0
  54. data/lib/insika/evals/simulator.rb +11 -2
  55. data/lib/insika/evals/transport.rb +118 -16
  56. data/lib/insika/evidence.rb +79 -12
  57. data/lib/insika/executor.rb +94 -23
  58. data/lib/insika/fence.rb +96 -0
  59. data/lib/insika/golden_store.rb +3 -0
  60. data/lib/insika/loop_detector.rb +5 -34
  61. data/lib/insika/mcp_store.rb +5 -2
  62. data/lib/insika/mcp_tool_registry.rb +8 -1
  63. data/lib/insika/memory_store.rb +12 -0
  64. data/lib/insika/overlay_tool_registry.rb +5 -0
  65. data/lib/insika/prefix_fingerprint.rb +32 -27
  66. data/lib/insika/profile_source.rb +8 -0
  67. data/lib/insika/server/app.rb +43 -1
  68. data/lib/insika/server/rack_app.rb +2 -0
  69. data/lib/insika/server/responses.rb +35 -8
  70. data/lib/insika/session_store.rb +38 -5
  71. data/lib/insika/settings_store.rb +18 -2
  72. data/lib/insika/soak/runner.rb +4 -4
  73. data/lib/insika/spoken_transcript.rb +31 -0
  74. data/lib/insika/studio/app.rb +34 -8
  75. data/lib/insika/studio/forms.rb +29 -3
  76. data/lib/insika/studio/views/_agent_tab_config.erb +5 -1
  77. data/lib/insika/studio/views/session.erb +1 -1
  78. data/lib/insika/studio/views/settings.erb +11 -0
  79. data/lib/insika/studio/views/tool_edit.erb +6 -2
  80. data/lib/insika/telemetry/recorder.rb +61 -1
  81. data/lib/insika/templates/daily-digest/README.md +9 -0
  82. data/lib/insika/templates/research-analyst/agent.rb +10 -0
  83. data/lib/insika/tool_assembly.rb +21 -13
  84. data/lib/insika/tool_batch.rb +67 -0
  85. data/lib/insika/tool_definition.rb +73 -10
  86. data/lib/insika/tool_envelope.rb +102 -2
  87. data/lib/insika/tool_store.rb +9 -4
  88. data/lib/insika/tool_trace_store.rb +1 -1
  89. data/lib/insika/tool_usage_report.rb +172 -0
  90. data/lib/insika/tools/data_defined_tool.rb +1 -0
  91. data/lib/insika/tools/present.rb +122 -0
  92. data/lib/insika/tools/run_persona_eval.rb +6 -1
  93. data/lib/insika/tools/tool_search.rb +4 -2
  94. data/lib/insika/turn_budget.rb +91 -0
  95. data/lib/insika/turn_state.rb +13 -1
  96. data/lib/insika/version.rb +1 -1
  97. data/lib/insika/wiring/graph.rb +7 -0
  98. data/lib/insika/wiring/graph_chat.rb +4 -0
  99. data/lib/insika.rb +11 -0
  100. metadata +10 -1
@@ -95,11 +95,20 @@ module Insika
95
95
  @safety = safety
96
96
  end
97
97
 
98
- # Runs one simulated conversation. -> SimulatedRun.
99
- def run(persona:, agent:, conv:)
98
+ # Runs one simulated conversation. -> SimulatedRun. `state` is the case's
99
+ # snapshot (Golden#state), loaded into the conversation before the persona's
100
+ # opening line — a simulated customer can start from "already saw three
101
+ # products" too. Empty/nil = the conversation starts empty, as before.
102
+ def run(persona:, agent:, conv:, state: nil)
100
103
  reason = @safety.refusal
101
104
  raise UnsafeTarget, reason if reason
102
105
 
106
+ if state && !state.empty?
107
+ raise Insika::Error, "transport cannot seed state" unless @transport.respond_to?(:seed)
108
+
109
+ @transport.seed(conv, state)
110
+ end
111
+
103
112
  transcript = []
104
113
  message = persona.opens_with
105
114
  stop = :max_turns
@@ -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
 
@@ -135,7 +179,7 @@ module Insika
135
179
  req["Authorization"] = "Bearer #{@token}"
136
180
  req["Content-Type"] = "application/json"
137
181
  req["Accept"] = "text/event-stream"
138
- req.body = JSON.generate(model: "openclaw:#{agent}", user: conv, stream: true, input: message)
182
+ req.body = JSON.generate(model: "insika:#{agent}", user: conv, stream: true, input: message)
139
183
 
140
184
  t0 = mono
141
185
  ttfb = nil
@@ -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
@@ -1038,6 +1039,11 @@ module Insika
1038
1039
  tokens: estimate_tools_tokens(state.allowed_tools) },
1039
1040
  fingerprints: fingerprints,
1040
1041
  cache: { invalidation_reason: reason } }
1042
+ # the compaction state this turn was BUILT over (RFC-0044) —
1043
+ # {upto, runs}, counts only; the summary fragment itself already shows
1044
+ # as the trace's own "compaction" category via its fragment source.
1045
+ compaction = state.session.respond_to?(:compaction) ? state.session.compaction : nil
1046
+ entry[:compaction] = { upto: compaction["upto"], runs: compaction["runs"] } if compaction
1041
1047
  # Park the SANITIZED entry (string keys) — the stage-8 stamp merges into
1042
1048
  # it and re-records the same key; a raw entry would add a SECOND "cache"
1043
1049
  # key that sanitize would then ignore (the symbol one wins).
@@ -1964,19 +1970,8 @@ module Insika
1964
1970
  # indistinguishable from a single-tenant customer ref, and the Studio drill
1965
1971
  # must not list conversations as customers with a Forget button.
1966
1972
  def memory_tenant(task)
1967
- customer = command_customer(task)
1968
- return command_tenant(task) || session_scope(task.session_id) if customer.nil?
1969
-
1970
- [command_tenant(task), customer].compact.join(":")
1971
- end
1972
-
1973
- # The marked per-session scope : "chat:<session id>" -> cell
1974
- # "memory:chat:<session id>". nil for a one-shot turn (no session) — the
1975
- # MemoryStore applies _default.
1976
- def session_scope(session_id)
1977
- return nil if session_id.nil?
1978
-
1979
- "#{MemoryStore::SESSION_TAG}:#{session_id}"
1973
+ MemoryStore.scope_for(tenant: command_tenant(task), customer: command_customer(task),
1974
+ session_id: task.session_id)
1980
1975
  end
1981
1976
 
1982
1977
  # WS8 + : stamp the customer (WS8 — the `forget_customer`
@@ -2476,6 +2471,12 @@ module Insika
2476
2471
  # next door to the other two, for the same reason: it fires for a fresh
2477
2472
  # turn and a recovered one.
2478
2473
  finalize_knowledge_extraction(task, profile, new_messages)
2474
+
2475
+ # in-session compaction (RFC-0044): when the uncompacted
2476
+ # transcript crossed the threshold this turn, summarize the old prefix
2477
+ # with the cheap model and move the boundary — off the critical path,
2478
+ # same terminal hook, same best-effort discipline as the other three.
2479
+ finalize_compaction(task, profile)
2479
2480
  end
2480
2481
 
2481
2482
  # Records the answer in the outbox and dispatches it. The discriminator is the
@@ -2504,7 +2505,11 @@ module Insika
2504
2505
 
2505
2506
  # the hoarded evidence attachments ride the channel delivery
2506
2507
  # (additive outbox payload key — the channel contract widens, nothing breaks).
2507
- 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)
2508
2513
  deliveries = @channel_delivery.record_balloons(
2509
2514
  task: task, channel_id: channel_id, content: content,
2510
2515
  progressive: @channel_delivery.progressive?(channel_id),
@@ -2518,6 +2523,17 @@ module Insika
2518
2523
  nil
2519
2524
  end
2520
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
+
2521
2537
  # Turns whose combined transcript slice is this trivially short skip
2522
2538
  # extraction entirely ("ok thanks" exchanges) — no new config surface,
2523
2539
  # just avoids a wasted utility-model call.
@@ -2591,15 +2607,9 @@ module Insika
2591
2607
  PROMPT
2592
2608
  end
2593
2609
 
2594
- # Redacted (RFC's PII rule applies to what reaches the model too, not
2595
- # just what gets persisted).
2596
- def knowledge_transcript(new_messages)
2597
- redacted, = Insika::Safety::Detectors.redact(
2598
- new_messages.each_with_index.map { |m, i| "[#{i}] #{m['role'] || m[:role]}: #{m['content'] || m[:content]}" }
2599
- .join("\n")
2600
- )
2601
- redacted
2602
- 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)
2603
2613
 
2604
2614
  def utility_model
2605
2615
  return nil unless @settings_store
@@ -2607,6 +2617,67 @@ module Insika
2607
2617
  @settings_store.get["utility_model"]
2608
2618
  end
2609
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
+
2628
+ # in-session compaction (RFC-0044). Platform-gated
2629
+ # (Settings compaction.enabled — parity when off), planned over the
2630
+ # POST-append transcript (persist_turn already wrote this turn's messages),
2631
+ # dispatched off the critical path — the SAME shape as
2632
+ # finalize_knowledge_extraction: inline when non-supervised, a child of the
2633
+ # turn supervisor when serving. Best-effort: any failure leaves the session
2634
+ # record untouched and the next turn re-plans.
2635
+ def finalize_compaction(task, profile)
2636
+ return unless task.session_id && @settings_store
2637
+
2638
+ config = Coercion.deep_stringify(@settings_store.get["compaction"])
2639
+ return unless config && Coercion.truthy?(config["enabled"])
2640
+
2641
+ session = @session_store.find(task.session_id)
2642
+ return unless session
2643
+
2644
+ state = session.respond_to?(:compaction) ? session.compaction : nil
2645
+ plan = Insika::Compaction.plan(messages: session.messages, state: state, config: config)
2646
+ return unless plan
2647
+
2648
+ # compaction.model -> platform utility_model -> inert (never a guess);
2649
+ # `insika doctor` warns on enabled-with-no-model. @llm rides along so a
2650
+ # deployment (or spec) that injects its LLM seam covers this call too.
2651
+ summarizer = Compaction::SummarizerFactory.build(config, utility_model: utility_model, llm: @llm)
2652
+ return unless summarizer
2653
+
2654
+ run = lambda { run_compaction(task, profile, session, state, plan, config, summarizer) }
2655
+ return run.call unless @supervised
2656
+
2657
+ turn_parent.async do |t|
2658
+ t.annotate("compaction:#{task.id}")
2659
+ run.call
2660
+ end
2661
+ end
2662
+
2663
+ def run_compaction(task, profile, session, state, plan, config, summarizer)
2664
+ prompt = Insika::Compaction.prompt(messages: session.messages, plan: plan,
2665
+ previous: state && state["summary"],
2666
+ base: config["prompt"])
2667
+ result = summarizer.summarize(prompt: prompt)
2668
+ updated = @session_store.set_compaction(task.session_id, summary: result[:summary],
2669
+ upto: plan.upto, model: summarizer.model)
2670
+ # counts and ids only — the summary text never enters the stream. Feeds
2671
+ # the insika.context.compacted counter (Telemetry::Recorder).
2672
+ emit(:context_compacted,
2673
+ { task_id: task.id, agent: profile.id, from: plan.from, upto: plan.upto,
2674
+ messages: plan.count, runs: updated.compaction && updated.compaction["runs"],
2675
+ model: summarizer.model, cost: result[:cost] },
2676
+ task: task)
2677
+ rescue StandardError
2678
+ nil # best-effort: compaction never re-fails an already-committed turn.
2679
+ end
2680
+
2610
2681
  # ONE supervisor fiber for the whole chain. Sequential deliver
2611
2682
  # calls, so balloon N+1 cannot overtake balloon N on the wire. Still off the
2612
2683
  # session's FIFO — the customer's next message does not wait on this turn's
@@ -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.