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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +191 -0
- data/README.md +9 -6
- data/bin/insika +44 -3
- data/docs/AGENTS.md +74 -14
- data/docs/API.md +73 -0
- data/docs/ARCHITECTURE.md +45 -44
- data/docs/ARTIFACTS.md +42 -0
- data/docs/CHANNELS.md +19 -2
- data/docs/CONTEXT.md +86 -38
- data/docs/DEPLOY.md +27 -7
- data/docs/EVALS.md +98 -8
- data/docs/FACTS.md +4 -0
- data/docs/KNOWLEDGE.md +7 -0
- data/docs/LOADTEST.md +15 -27
- data/docs/MEDIA.md +1 -1
- data/docs/OBSERVABILITY.md +48 -4
- data/docs/POLICY.md +14 -5
- data/docs/RELEASING.md +4 -0
- data/docs/RUNNING-LOCAL.md +2 -2
- data/docs/SECURITY.md +28 -2
- data/docs/SOAK.md +1 -1
- data/docs/TOOLS.md +150 -32
- data/docs/prompts/ADD-TOOL.md +12 -2
- data/docs/prompts/DIAGNOSE-TURN.md +3 -0
- data/docs/prompts/GO-LIVE.md +6 -4
- data/lib/insika/agent_profile.rb +47 -10
- data/lib/insika/channels/web/widget.js +33 -0
- data/lib/insika/channels/web.rb +5 -2
- data/lib/insika/chat_builder.rb +90 -37
- data/lib/insika/commands/agent_payload.rb +1 -1
- data/lib/insika/commands/run_distillation.rb +5 -8
- data/lib/insika/commands/seed_session.rb +118 -0
- data/lib/insika/compaction.rb +196 -0
- data/lib/insika/context/builder.rb +35 -11
- data/lib/insika/context/fragment.rb +4 -1
- data/lib/insika/context/priority.rb +8 -0
- data/lib/insika/context/provider.rb +5 -0
- data/lib/insika/context/providers/briefing.rb +61 -29
- data/lib/insika/context/providers/fence_notice.rb +27 -0
- data/lib/insika/context/providers/knowledge.rb +7 -4
- data/lib/insika/context/providers/memory.rb +8 -4
- data/lib/insika/context/providers/session.rb +50 -10
- data/lib/insika/context_trace_store.rb +11 -1
- data/lib/insika/doctor.rb +213 -10
- data/lib/insika/dsl/runtime.rb +5 -0
- data/lib/insika/dsl.rb +6 -0
- data/lib/insika/edge_limiter.rb +4 -1
- data/lib/insika/env_schema.rb +5 -6
- data/lib/insika/errors.rb +1 -0
- data/lib/insika/evals/assertions.rb +92 -6
- data/lib/insika/evals/golden.rb +91 -2
- data/lib/insika/evals/runner.rb +20 -0
- data/lib/insika/evals/simulator.rb +11 -2
- data/lib/insika/evals/transport.rb +118 -16
- data/lib/insika/evidence.rb +79 -12
- data/lib/insika/executor.rb +94 -23
- data/lib/insika/fence.rb +96 -0
- data/lib/insika/golden_store.rb +3 -0
- data/lib/insika/loop_detector.rb +5 -34
- data/lib/insika/mcp_store.rb +5 -2
- data/lib/insika/mcp_tool_registry.rb +8 -1
- data/lib/insika/memory_store.rb +12 -0
- data/lib/insika/overlay_tool_registry.rb +5 -0
- data/lib/insika/prefix_fingerprint.rb +32 -27
- data/lib/insika/profile_source.rb +8 -0
- data/lib/insika/server/app.rb +43 -1
- data/lib/insika/server/rack_app.rb +2 -0
- data/lib/insika/server/responses.rb +35 -8
- data/lib/insika/session_store.rb +38 -5
- data/lib/insika/settings_store.rb +18 -2
- data/lib/insika/soak/runner.rb +4 -4
- data/lib/insika/spoken_transcript.rb +31 -0
- data/lib/insika/studio/app.rb +34 -8
- data/lib/insika/studio/forms.rb +29 -3
- data/lib/insika/studio/views/_agent_tab_config.erb +5 -1
- data/lib/insika/studio/views/session.erb +1 -1
- data/lib/insika/studio/views/settings.erb +11 -0
- data/lib/insika/studio/views/tool_edit.erb +6 -2
- data/lib/insika/telemetry/recorder.rb +61 -1
- data/lib/insika/templates/daily-digest/README.md +9 -0
- data/lib/insika/templates/research-analyst/agent.rb +10 -0
- data/lib/insika/tool_assembly.rb +21 -13
- data/lib/insika/tool_batch.rb +67 -0
- data/lib/insika/tool_definition.rb +73 -10
- data/lib/insika/tool_envelope.rb +102 -2
- data/lib/insika/tool_store.rb +9 -4
- data/lib/insika/tool_trace_store.rb +1 -1
- data/lib/insika/tool_usage_report.rb +172 -0
- data/lib/insika/tools/data_defined_tool.rb +1 -0
- data/lib/insika/tools/present.rb +122 -0
- data/lib/insika/tools/run_persona_eval.rb +6 -1
- data/lib/insika/tools/tool_search.rb +4 -2
- data/lib/insika/turn_budget.rb +91 -0
- data/lib/insika/turn_state.rb +13 -1
- data/lib/insika/version.rb +1 -1
- data/lib/insika/wiring/graph.rb +7 -0
- data/lib/insika/wiring/graph_chat.rb +4 -0
- data/lib/insika.rb +11 -0
- 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
|
-
|
|
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
|
|
51
|
-
#
|
|
52
|
-
#
|
|
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
|
-
|
|
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: "
|
|
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
|
-
|
|
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: [
|
|
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:
|
|
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:
|
|
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
|
|
222
|
-
# (the stream carries no per-tool status — that lives in the trace store)
|
|
223
|
-
|
|
224
|
-
|
|
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
|
|
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
|
|
data/lib/insika/evidence.rb
CHANGED
|
@@ -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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
113
|
-
[lean,
|
|
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
|
-
|
|
234
|
+
Array(session_evidence["ids"]).map(&:to_s)
|
|
235
|
+
end
|
|
176
236
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
data/lib/insika/executor.rb
CHANGED
|
@@ -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
|
|
1968
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
2595
|
-
#
|
|
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
|
data/lib/insika/fence.rb
ADDED
|
@@ -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
|
data/lib/insika/golden_store.rb
CHANGED
|
@@ -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.
|