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
@@ -35,7 +35,9 @@ module Insika
35
35
  ToolDefinition = Data.define(
36
36
  :name, :description, :parameters, :request, :response,
37
37
  :secret_headers, :side_effect, :timeout, :group, :tags, :halt_when,
38
- :evidence # Insika::Evidence::Spec | nil
38
+ :evidence, # Insika::Evidence::Spec | nil
39
+ :requires_evidence, # { "params" => [String] } | nil
40
+ :presentation # { component:, ids:, max: } | nil — a UI tool, no HTTP
39
41
  )
40
42
 
41
43
  class ToolDefinition
@@ -73,9 +75,9 @@ module Insika
73
75
  # Builds + validates. Raises Insika::ValidationError. Accepts keyword args
74
76
  # (already-normalized symbol keys); use from_h for a raw Hash from the store/UI.
75
77
  # `parameters` accepts JSON Schema (Hash) OR the legacy flat array.
76
- def self.build(name:, description:, request:, parameters: nil, response: nil,
78
+ def self.build(name:, description:, request: nil, parameters: nil, response: nil,
77
79
  secret_headers: nil, side_effect: nil, timeout: nil, group: nil, tags: nil,
78
- halt_when: nil, evidence: nil)
80
+ halt_when: nil, evidence: nil, presentation: nil, requires_evidence: nil)
79
81
  name = name.to_s
80
82
  raise Insika::ValidationError, "name must match #{NAME_RE.inspect}" unless NAME_RE.match?(name)
81
83
 
@@ -83,15 +85,21 @@ module Insika
83
85
  raise Insika::ValidationError, "description is required" if desc.empty?
84
86
 
85
87
  schema = normalize_params(parameters)
86
- req = normalize_request(request, top_level_names(schema))
88
+ # A tool is EITHER an HTTP call or a presentation — never both, never neither.
89
+ if request && presentation
90
+ raise Insika::ValidationError, "a tool declares either 'request' or 'presentation', not both"
91
+ end
92
+
93
+ pres = normalize_presentation(presentation, schema)
94
+ req = pres ? nil : normalize_request(request || {}, top_level_names(schema))
87
95
  resp = normalize_response(response)
88
96
  if resp[:extract] == "evidence_envelope" && evidence.nil?
89
97
  raise Insika::ValidationError,
90
98
  "extract 'evidence_envelope' requires an 'evidence' declaration"
91
99
  end
92
100
 
93
- method = req[:method]
94
- effect = side_effect.nil? ? !IDEMPOTENT.include?(method) : (side_effect ? true : false)
101
+ method = req && req[:method]
102
+ effect = side_effect.nil? ? !(method.nil? || IDEMPOTENT.include?(method)) : (side_effect ? true : false)
95
103
 
96
104
  new(
97
105
  name: name, description: desc, parameters: schema, request: req, response: resp,
@@ -99,7 +107,9 @@ module Insika
99
107
  timeout: timeout.nil? ? nil : Integer(timeout),
100
108
  group: normalize_group(group), tags: normalize_tags(tags),
101
109
  halt_when: normalize_halt_when(halt_when),
102
- evidence: Insika::Evidence::Spec.parse(evidence)
110
+ evidence: Insika::Evidence::Spec.parse(evidence),
111
+ presentation: pres,
112
+ requires_evidence: normalize_requires_evidence(requires_evidence, top_level_names(schema))
103
113
  )
104
114
  end
105
115
 
@@ -108,12 +118,63 @@ module Insika
108
118
  h = deep_symbolize(hash)
109
119
  build(
110
120
  name: h[:name], description: h[:description], parameters: h[:parameters],
111
- request: h[:request] || {}, response: h[:response],
121
+ request: h[:request], response: h[:response],
112
122
  secret_headers: h[:secret_headers], side_effect: h[:side_effect], timeout: h[:timeout],
113
- group: h[:group], tags: h[:tags], halt_when: h[:halt_when], evidence: h[:evidence]
123
+ group: h[:group], tags: h[:tags], halt_when: h[:halt_when], evidence: h[:evidence],
124
+ presentation: h[:presentation], requires_evidence: h[:requires_evidence]
114
125
  )
115
126
  end
116
127
 
128
+ # PRESENTATION: a tool whose job is to SHOW something, not to fetch it. The model
129
+ # picks ids, the engine validates them against the session's evidence ledger and
130
+ # joins the cards an evidence tool already returned (Tools::Present). No HTTP.
131
+ #
132
+ # "presentation" => { "component" => "product_cards", # what the channel renders
133
+ # "ids" => "product_ids", # the array:string param
134
+ # "max" => 8 } # 1..16, the attachment cap
135
+ #
136
+ # -> { component:, ids:, max: } | nil
137
+ def self.normalize_presentation(raw, schema)
138
+ return nil if raw.nil?
139
+
140
+ h = deep_symbolize(raw)
141
+ raise Insika::ValidationError, "presentation must be an object" unless h.is_a?(Hash)
142
+
143
+ component = h[:component].to_s
144
+ unless NAME_RE.match?(component)
145
+ raise Insika::ValidationError, "presentation.component must match #{NAME_RE.inspect}"
146
+ end
147
+
148
+ ids = h[:ids].to_s
149
+ prop = (schema["properties"] || {})[ids]
150
+ unless prop.is_a?(Hash) && prop["type"] == "array" && prop.dig("items", "type") == "string"
151
+ raise Insika::ValidationError,
152
+ "presentation.ids must name a declared array:string parameter (got #{ids.inspect})"
153
+ end
154
+
155
+ max = h[:max].nil? ? Insika::Evidence::MAX_ATTACHMENTS : Integer(h[:max], exception: false)
156
+ unless max.is_a?(Integer) && max.between?(1, Insika::Evidence::MAX_ATTACHMENTS)
157
+ raise Insika::ValidationError, "presentation.max must be 1..#{Insika::Evidence::MAX_ATTACHMENTS}"
158
+ end
159
+
160
+ { component: component, ids: ids, max: max }
161
+ end
162
+ private_class_method :normalize_presentation
163
+
164
+ def presentation? = !presentation.nil?
165
+
166
+ def self.normalize_requires_evidence(value, param_names)
167
+ return nil if value.nil?
168
+
169
+ params = value.is_a?(Hash) ? deep_symbolize(value)[:params] : value
170
+ unless params.is_a?(Array) && !params.empty? && params.all? { |p| param_names.include?(p.to_s) }
171
+ raise Insika::ValidationError, "requires_evidence needs a non-empty list of declared top-level parameters"
172
+ end
173
+
174
+ { "params" => params.map(&:to_s).uniq }
175
+ end
176
+ private_class_method :normalize_requires_evidence
177
+
117
178
  # Group: enablement label by DATA (not name convention),
118
179
  # target of AgentProfile's `tools_allow_groups`. Trimmed; empty/nil -> nil.
119
180
  def self.normalize_group(group)
@@ -414,7 +475,7 @@ module Insika
414
475
  h = {
415
476
  "name" => name, "description" => description,
416
477
  "parameters" => parameters,
417
- "request" => request.transform_keys(&:to_s),
478
+ "request" => request&.transform_keys(&:to_s),
418
479
  "response" => response.transform_keys(&:to_s),
419
480
  "secret_headers" => secret_headers,
420
481
  "side_effect" => side_effect, "timeout" => timeout,
@@ -424,6 +485,8 @@ module Insika
424
485
  # present only when declared — a tool without evidence is byte-identical
425
486
  # to today (no declaration, no envelope processing).
426
487
  h["evidence"] = evidence.to_h if evidence
488
+ h["requires_evidence"] = requires_evidence if requires_evidence
489
+ h["presentation"] = presentation.transform_keys(&:to_s) if presentation
427
490
  h
428
491
  end
429
492
 
@@ -12,6 +12,10 @@ module Insika
12
12
  # The tool loop belongs to RubyLLM; this is a decorator over the instances —
13
13
  # the Executor never drives roundtrips.
14
14
  class ToolEnvelope < SimpleDelegator
15
+ PROVENANCE_INSTRUCTION = "This value was not returned by any tool in this conversation. " \
16
+ "Find it with a tool that returns it — a search or a lookup by id — " \
17
+ "then call this tool again with an id from that result."
18
+
15
19
  # The tool timeout's OWN class: distinct from Async::TimeoutError so that
16
20
  # the rescue below NEVER swallows the TURN timeout (which uses the default of
17
21
  # with_timeout). Without this, a turn overflowing while the fiber is inside a
@@ -20,13 +24,21 @@ module Insika
20
24
  ToolTimeout = Class.new(StandardError)
21
25
  private_constant :ToolTimeout
22
26
 
27
+ # A gate's refusal. A plain Hash subclass: it reaches the model exactly as the
28
+ # `{status:, gate:, ...}` it always was, and the engine's own readers (the
29
+ # :tool_result outcome, the trace) recognize a refusal by CLASS — a data tool
30
+ # answering `{"status":"blocked","gate":"fraud_review"}` for a held order is
31
+ # not one, whatever keys it happens to use.
32
+ class Blocked < Hash; end
33
+
23
34
  def initialize(tool, state:, checkpoint_store:, tool_registry:, timeout:,
24
- skip_side_effects: [], trace_recorder: nil)
35
+ skip_side_effects: [], trace_recorder: nil, event_stream: nil)
25
36
  super(tool)
26
37
  @state = state
27
38
  @checkpoint_store = checkpoint_store
28
39
  @tool_registry = tool_registry
29
40
  @timeout = timeout
41
+ @event_stream = event_stream
30
42
  @skip_side_effects = Array(skip_side_effects) # ids already completed in the interrupted turn
31
43
  @trace_recorder = trace_recorder # duck-type: #record(session_id:, entry:). nil = no trace.
32
44
  end
@@ -41,6 +53,13 @@ module Insika
41
53
  call_id = correlation_id
42
54
  return { "skipped" => "already_executed" } if call_id && @skip_side_effects.include?(call_id)
43
55
 
56
+ started = monotonic
57
+ if (blocked = provenance_block(args))
58
+ trace(call_id, args, blocked, started)
59
+ emit_blocked(blocked)
60
+ return blocked
61
+ end
62
+
44
63
  # Approval gate: a tool marked `approval` suspends the turn in
45
64
  # :waiting until the operator resolves it. Delegates to the coordinator (the
46
65
  # Executor), which creates/queries the PendingAction and blocks via the
@@ -61,6 +80,7 @@ module Insika
61
80
  # on the ledger, hoard the attachments. No evidence = the result passes
62
81
  # through untouched (one nil-check — parity).
63
82
  result = process_evidence(result)
83
+ result = fence(result)
64
84
  record_side_effect!(call_id) if side_effect?
65
85
  trace(call_id, args, result, started)
66
86
  result
@@ -83,7 +103,59 @@ module Insika
83
103
  # wall-clock the model waited. No gate (the default, serial) = straight through.
84
104
  def with_gate(&)
85
105
  gate = @state.respond_to?(:tool_gate) ? @state.tool_gate : nil
86
- gate ? gate.acquire(&) : yield
106
+ return yield unless gate
107
+
108
+ serial = @state.respond_to?(:side_effect_gate) ? @state.side_effect_gate : nil
109
+ return gate.acquire(&) unless side_effect? && serial
110
+
111
+ # Backends may read-modify-write. Serialize writes before taking a slot,
112
+ # so queued writes cannot keep independent reads from running.
113
+ serial.acquire { gate.acquire(&) }
114
+ end
115
+
116
+ def provenance_block(args)
117
+ tool = __getobj__
118
+ requirement = tool.respond_to?(:requires_evidence) ? tool.requires_evidence : nil
119
+ return unless requirement
120
+
121
+ ledger = @state.respond_to?(:evidence_ledger) ? @state.evidence_ledger : nil
122
+ known = ledger ? ledger.ids : []
123
+ optional = optional_params(tool)
124
+ requirement.fetch("params").each do |param|
125
+ value = args.key?(param) ? args[param] : args[param.to_sym]
126
+ # A parameter the schema marks optional and the model left out carries no
127
+ # id to ground — nothing to check (a REQUIRED one left out is still a
128
+ # block: the write would run without the id the gate exists for).
129
+ next if value.nil? && ledger && optional.include?(param)
130
+
131
+ values = value.is_a?(Array) ? value : [value]
132
+ values = [nil] if values.empty? && !ledger
133
+ values.each do |id|
134
+ next if ledger && known.include?(id.to_s)
135
+
136
+ return Blocked[{ "status" => "blocked", "gate" => "provenance", "param" => param,
137
+ "value" => id.to_s, "instruction" => PROVENANCE_INSTRUCTION }]
138
+ end
139
+ end
140
+ nil
141
+ end
142
+
143
+ # The wrapped tool's top-level parameters NOT in the schema's `required`
144
+ # (DataDefinedTool exposes its definition's schema; a code tool exposes none
145
+ # -> every declared parameter is treated as required).
146
+ def optional_params(tool)
147
+ return [] unless tool.respond_to?(:params_schema) && (schema = tool.params_schema).is_a?(Hash)
148
+
149
+ (schema["properties"] || {}).keys.map(&:to_s) - Array(schema["required"]).map(&:to_s)
150
+ end
151
+
152
+ def emit_blocked(result)
153
+ task = @state.task # nil on a one-shot turn, like `trace` already assumes
154
+ @event_stream&.emit(Insika::Event.new(
155
+ type: :tool_blocked,
156
+ data: { name: real_name, gate: result["gate"], param: result["param"] },
157
+ meta: task ? { task_id: task.id, session_id: task.session_id } : {}
158
+ ))
87
159
  end
88
160
 
89
161
  # Records the call for debugging in the Studio (name + model args + result +
@@ -96,6 +168,7 @@ module Insika
96
168
  session_id: @state.task.session_id,
97
169
  entry: { "turn" => @state.turn, "tool" => real_name, "call_id" => call_id.to_s,
98
170
  "args" => args, "result" => result,
171
+ "gate" => result.is_a?(Blocked) ? result["gate"] : nil,
99
172
  "ms" => started ? ((monotonic - started) * 1000).round : nil,
100
173
  "at" => Time.now.utc.iso8601 }
101
174
  )
@@ -142,6 +215,31 @@ module Insika
142
215
  tool_call_id: call_id)
143
216
  end
144
217
 
218
+ # ---- fencing ----------------------------------------------
219
+
220
+ # After the evidence reshape (the lean envelope is already the shape the
221
+ # model reads): every String leaf sanitized, keys and non-strings untouched,
222
+ # each leaf capped at the platform's `fencing.max_chars`. Off = bytes
223
+ # identical to today. An error hash is engine-authored — never touched.
224
+ def fence(result)
225
+ return result unless Insika::Fence.enabled?(@state.profile)
226
+ return result if result.is_a?(Hash) && (result[:error] || result["error"])
227
+
228
+ max = (@state.respond_to?(:fence_max_chars) && @state.fence_max_chars) || Insika::Fence::DEFAULT_MAX_CHARS
229
+ return Insika::Fence.sanitize_value(result, max_chars: max) unless lean_evidence?(result)
230
+
231
+ # A lean evidence result: the LINES are third-party text, the IDS are keys.
232
+ # The ledger recorded the ids byte-exact and a presentation or a write joins
233
+ # on them, so NFKC must not touch them (a fullwidth digit in a SKU would
234
+ # stop matching the moment the model repeated it).
235
+ items = result["items"].map { |i| i.merge("line" => Insika::Fence.sanitize_text(i["line"].to_s, max_chars: max)) }
236
+ result.merge("items" => items)
237
+ end
238
+
239
+ def lean_evidence?(result)
240
+ evidence_spec && result.is_a?(Hash) && result["items"].is_a?(Array)
241
+ end
242
+
145
243
  # ---- evidence ---------------------------------------------
146
244
 
147
245
  # The evidence spec for the wrapped tool (D4). Resolution order:
@@ -204,6 +302,8 @@ module Insika
204
302
 
205
303
  @state.evidence_attachments ||= []
206
304
  @state.evidence_attachments.concat(attachments)
305
+ ledger = @state.respond_to?(:evidence_ledger) ? @state.evidence_ledger : nil
306
+ ledger.record_cards(attachments) if ledger.respond_to?(:record_cards)
207
307
  end
208
308
  end
209
309
  end
@@ -55,10 +55,13 @@ module Insika
55
55
  raise Insika::ValidationError, "tool '#{name}' already exists" if create_only && existing
56
56
 
57
57
  final = definition.to_h
58
- final["request"]["headers"] = reconcile_secret_headers(
59
- final["request"]["headers"], definition.secret_headers,
60
- existing&.dig("definition", "request", "headers")
61
- )
58
+ # A presentation tool makes no request — nothing to reconcile.
59
+ if final["request"]
60
+ final["request"]["headers"] = reconcile_secret_headers(
61
+ final["request"]["headers"], definition.secret_headers,
62
+ existing&.dig("definition", "request", "headers")
63
+ )
64
+ end
62
65
 
63
66
  rec = build_record(final, existing)
64
67
  @cs.put(SCOPE, name, rec)
@@ -110,6 +113,8 @@ module Insika
110
113
  secret = definition["secret_headers"] || []
111
114
  return definition if secret.empty?
112
115
 
116
+ return definition unless definition["request"]
117
+
113
118
  headers = (definition.dig("request", "headers") || {}).each_with_object({}) do |(k, v), acc|
114
119
  acc[k] = secret.include?(k) ? SecretMasking.mask(v) : v
115
120
  end
@@ -52,7 +52,7 @@ module Insika
52
52
  "ok" => ok?(e["result"]),
53
53
  "args" => clip(mask(e["args"])), "result" => clip(mask(e["result"])),
54
54
  "ms" => e["ms"], "at" => e["at"].to_s
55
- }
55
+ }.tap { |trace| trace["gate"] = e["gate"].to_s if e["gate"] }
56
56
  end
57
57
 
58
58
  # Conventional tool error = Hash with key "error"/:error (everything else is ok).
@@ -72,7 +72,7 @@ module Insika
72
72
  stats = tool_stats(sessions[id] || [], cutoff)
73
73
  never_called_rows(id, record, stats) +
74
74
  error_rate_rows(id, stats, days) +
75
- stale_rows(id, stats)
75
+ stale_rows(id, stats) + blocked_rows(id, stats, days)
76
76
  end
77
77
 
78
78
  Report.new(generated_at: now.iso8601, days: days,
@@ -100,7 +100,7 @@ module Insika
100
100
  # tool name -> { calls:, errors:, window_calls:, window_errors:, last_at: }
101
101
  # over every stored trace entry of the agent's sessions.
102
102
  def tool_stats(session_ids, cutoff)
103
- stats = Hash.new { |h, k| h[k] = { calls: 0, errors: 0, window_calls: 0, window_errors: 0, last_at: nil } }
103
+ stats = Hash.new { |h, k| h[k] = { calls: 0, errors: 0, window_calls: 0, window_errors: 0, blocked: Hash.new(0), last_at: nil } }
104
104
  session_ids.each do |sid|
105
105
  @tool_trace_store.for_session(sid).each do |entry|
106
106
  s = stats[entry["tool"].to_s]
@@ -113,6 +113,7 @@ module Insika
113
113
 
114
114
  s[:window_calls] += 1
115
115
  s[:window_errors] += 1 if error
116
+ s[:blocked][entry["gate"]] += 1 if entry["gate"]
116
117
  end
117
118
  end
118
119
  stats
@@ -142,6 +143,15 @@ module Insika
142
143
  end
143
144
  end
144
145
 
146
+ def blocked_rows(agent, stats, days)
147
+ stats.flat_map do |tool, s|
148
+ s[:blocked].map do |gate, count|
149
+ Row.new(agent: agent, tool: tool, kind: "blocked",
150
+ detail: "#{count} call(s) blocked by #{gate} in the last #{days} day(s)")
151
+ end
152
+ end
153
+ end
154
+
145
155
  def stale_rows(agent, stats)
146
156
  stats.filter_map do |tool, s|
147
157
  next if s[:window_calls].positive? || s[:last_at].nil?
@@ -47,6 +47,7 @@ module Insika
47
47
  # envelope's duck-typed resolution checks this FIRST — a data-tool declares
48
48
  # its evidence on its definition, never in the registry metadata.
49
49
  def evidence = @definition.evidence
50
+ def requires_evidence = @definition.requires_evidence
50
51
 
51
52
  # FULL (nested) JSON Schema straight into RubyLLM's params_schema — it is what
52
53
  # the providers serialize (OpenAI/Anthropic/Gemini/Bedrock prefer
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_llm"
4
+
5
+ module Insika
6
+ module Tools
7
+ # PRESENTATION tool: the model says WHICH products to show, the engine decides
8
+ # WHAT is shown. Built by the overlay registry from a ToolDefinition that
9
+ # declares `presentation` (one class, N instances — like DataDefinedTool).
10
+ #
11
+ # Cards used to be a side effect of evidence: every attachment a search tool
12
+ # returned rode the channel delivery, all of them, and the model had no way to
13
+ # say "these three, not the ten I searched". The deployments compensated with
14
+ # inline markers in the text, parsed by regex on their side — where most of
15
+ # the hallucinated ids came from. Here text never carries product data: the
16
+ # model picks ids, and the engine
17
+ # 1. keeps only ids the session's evidence ledger has seen (provenance),
18
+ # 2. joins each to the card the evidence tool already hoarded this turn,
19
+ # 3. caps at the declared `max`,
20
+ # 4. records the selection on the turn (the delivery sends THAT),
21
+ # 5. emits `:ui` on the stream (the edge publishes it as `insika.ui`),
22
+ # 6. tells the model what was shown and what was dropped, and why.
23
+ # No HTTP, no model call, deterministic. `execute` never raises.
24
+ class Present < RubyLLM::Tool
25
+ # The one sentence for the model when NOTHING could be shown. Engine-generic:
26
+ # the pack tunes vocabulary through the tool's `description`.
27
+ NOTHING_SHOWN = "no card could be shown; name the products in text or search again"
28
+
29
+ def initialize(definition:, event_stream: nil)
30
+ @definition = definition
31
+ @event_stream = event_stream
32
+ @state = nil
33
+ super()
34
+ end
35
+
36
+ # Deposited by ToolAssembly before the envelope wraps the instance — the
37
+ # ledger and the hoarded cards live on the turn, never on the registry.
38
+ def turn_state=(state)
39
+ @state = state
40
+ end
41
+
42
+ def name = @definition.name
43
+ def description = @definition.description
44
+ def params_schema = @definition.parameters
45
+
46
+ def parameters
47
+ @parameters ||= @definition.top_level_params.each_with_object({}) do |p, acc|
48
+ sym = p[:name].to_sym
49
+ acc[sym] = RubyLLM::Parameter.new(sym, type: p[:type], desc: p[:description], required: p[:required])
50
+ end
51
+ end
52
+
53
+ def execute(**kwargs)
54
+ spec = @definition.presentation
55
+ ids = Array(kwargs[spec[:ids].to_sym]).map(&:to_s).reject(&:empty?).uniq
56
+ title = Coercion.presence(kwargs[:title])
57
+
58
+ shown, dropped = select(ids, spec[:max])
59
+ record(spec[:component], title, shown)
60
+ emit(spec[:component], title, shown, dropped)
61
+
62
+ out = { "shown" => shown.map { |c| c["id"] }, "dropped" => dropped }
63
+ out["instruction"] = NOTHING_SHOWN if shown.empty?
64
+ out
65
+ rescue StandardError => e
66
+ { error: "presentation failed: #{e.message}" }
67
+ end
68
+
69
+ private
70
+
71
+ # -> [shown cards, dropped [{id, reason}]]. Order preserved. Reasons:
72
+ # unknown — the ledger never saw the id (the model made it up, or the
73
+ # customer typed it); no ledger on the state reads as unknown too —
74
+ # a check that cannot verify does not pass.
75
+ # no_card — the id is known but no evidence tool returned a card for it
76
+ # this session (a text-only search result, or a seed of ids alone).
77
+ # max — beyond the declared cap.
78
+ # Cards come from this turn first, then from the ledger (the last few
79
+ # searches of the session) — "show me the second one" a turn later works.
80
+ def select(ids, max)
81
+ ledger = @state.respond_to?(:evidence_ledger) ? @state.evidence_ledger : nil
82
+ known = Array(ledger&.ids)
83
+ turn_cards = @state.respond_to?(:evidence_attachments) ? Array(@state.evidence_attachments) : []
84
+ session_cards = ledger.respond_to?(:cards) ? Array(ledger.cards) : []
85
+ by_id = (turn_cards + session_cards).each_with_object({}) { |c, h| h[c["id"]] ||= c if c["id"] }
86
+
87
+ shown = []
88
+ dropped = []
89
+ ids.each do |id|
90
+ if !known.include?(id) then dropped << { "id" => id, "reason" => "unknown" }
91
+ elsif (card = by_id[id]).nil? then dropped << { "id" => id, "reason" => "no_card" }
92
+ elsif shown.size >= max then dropped << { "id" => id, "reason" => "max" }
93
+ else shown << card
94
+ end
95
+ end
96
+ [shown, dropped]
97
+ end
98
+
99
+ def record(component, title, shown)
100
+ return unless @state.respond_to?(:presentations)
101
+
102
+ @state.presentations ||= []
103
+ @state.presentations << { "component" => component, "title" => title,
104
+ "items" => shown.map { |c| c.slice("id", "type", "url", "caption") } }
105
+ end
106
+
107
+ def emit(component, title, shown, dropped)
108
+ return unless @event_stream
109
+
110
+ task = @state.respond_to?(:task) ? @state.task : nil
111
+ meta = task ? { task_id: task.id, session_id: task.session_id } : {}
112
+ @event_stream.emit(Insika::Event.new(
113
+ type: :ui,
114
+ data: { component: component, title: title,
115
+ items: shown.map { |c| c.slice("id", "type", "url", "caption") },
116
+ count: shown.size, dropped: dropped },
117
+ meta: meta
118
+ ))
119
+ end
120
+ end
121
+ end
122
+ end
@@ -161,7 +161,7 @@ module Insika
161
161
  simulator = Insika::Evals::Simulator.new(transport: transport, ask: persona_ask, safety: safety)
162
162
 
163
163
  conv = "eval-#{golden.id}-#{SecureRandom.hex(4)}" # a fresh session every run (never reused)
164
- run = simulator.run(persona: golden.persona, agent: golden.agent, conv: conv)
164
+ run = simulator.run(persona: golden.persona, agent: golden.agent, conv: conv, state: golden.state)
165
165
  verdict = judge.score_conversation(
166
166
  rubric: golden.rubric, transcript: run.transcript, policy: golden.policy,
167
167
  min_score: golden.min_score || Insika::Evals::Judge::DEFAULT_MIN_SCORE
@@ -199,6 +199,11 @@ module Insika
199
199
  llm: llm_context
200
200
  )
201
201
  bus = Insika::CommandBus.new
202
+ # a golden with `state:` seeds through the same bus the turn runs on
203
+ bus.register(:seed_session, Insika::Commands::SeedSession.new(
204
+ session_store: @graph.session_store, memory_store: @graph.memory_store,
205
+ event_stream: @graph.event_stream
206
+ ))
202
207
  bus.register(:send_message, Insika::Commands::SendMessage.new(
203
208
  profiles: @graph.profiles, session_store: @graph.session_store,
204
209
  task_store: @graph.task_store, executor: executor,
@@ -21,7 +21,7 @@ module Insika
21
21
  def name = "tool_search"
22
22
 
23
23
  def initialize(catalog, deferred_allowed, chat, tool_registry:, event_stream:,
24
- checkpoint_store:, state:)
24
+ checkpoint_store:, state:, trace_recorder: nil)
25
25
  @catalog = catalog
26
26
  @deferred_allowed = Array(deferred_allowed).map(&:to_s)
27
27
  @chat = chat
@@ -29,6 +29,7 @@ module Insika
29
29
  @event_stream = event_stream
30
30
  @checkpoint_store = checkpoint_store
31
31
  @state = state
32
+ @trace_recorder = trace_recorder
32
33
  @promoted = [] # names already promoted IN THIS chat — idempotency
33
34
  super()
34
35
  end
@@ -59,7 +60,8 @@ module Insika
59
60
  @promoted << entry.name
60
61
  ToolEnvelope.new(tool, state: @state, checkpoint_store: @checkpoint_store,
61
62
  tool_registry: @tool_registry, timeout: timeout,
62
- skip_side_effects: Array(@state.skip_side_effects))
63
+ skip_side_effects: Array(@state.skip_side_effects),
64
+ event_stream: @event_stream, trace_recorder: @trace_recorder)
63
65
  rescue Insika::NotFoundError
64
66
  nil
65
67
  end
@@ -61,9 +61,16 @@ module Insika
61
61
  # context via an evidence-declared tool (the envelope
62
62
  # appends; the validator/enforcer read it). Built per
63
63
  # turn by the Executor; nil = no session/no evidence.
64
- :evidence_attachments, # [ {type, url, caption} ] hoarded by the
64
+ :evidence_attachments, # [ {type, url, caption, id} ] hoarded by the
65
65
  # envelope this turn; read by the Executor at stage 8
66
66
  # for the channel delivery. Reset per turn.
67
+ :presentations, # [ {component, title, items: [{id,url,caption}]} ] —
68
+ # what a presentation tool SELECTED this turn, in call
69
+ # order. Non-empty = the channel delivery sends these
70
+ # instead of every hoarded card. Reset per turn.
71
+ :fence_max_chars, # per-leaf cap the ToolEnvelope applies when the
72
+ # agent has `fencing` on — the platform's
73
+ # `fencing.max_chars`, set per turn by the Executor.
67
74
  :context_trace_entry # the sanitized trace entry parked at
68
75
  # prepare_turn (fingerprints + invalidation_reason);
69
76
  # the stage-8 stamp merges the cache-hit fields into
@@ -167,6 +174,10 @@ module Insika
167
174
  # nil = concurrency off: no gate, no overhead, serial execution unchanged.
168
175
  attr_accessor :tool_gate
169
176
 
177
+ # Turns of one session already run serially through SessionActor. This gate
178
+ # prevents overlapping writes within a parallel batch of the current turn.
179
+ attr_accessor :side_effect_gate
180
+
170
181
  # parallel tool calls, resolved PER TURN and read by ChatBuilder
171
182
  # (whether to hand the gem `concurrency:`) and ToolAssembly (the gate's size).
172
183
  #
@@ -199,6 +210,7 @@ module Insika
199
210
  @output_parts = []
200
211
  @channel_capabilities = []
201
212
  @evidence_attachments = []
213
+ @presentations = []
202
214
  # Fiber storage is INHERITED by fibers created later, so a turn spawned from
203
215
  # inside a tool call (a subagent child) would start out carrying its
204
216
  # parent's correlation. Clearing at turn start keeps a child from keying its
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Insika
4
- VERSION = "0.8.0"
4
+ VERSION = "0.9.0"
5
5
  end
@@ -422,6 +422,13 @@ module Insika
422
422
  bus = Insika::CommandBus.new
423
423
  bus.register(:create_session,
424
424
  Insika::Commands::CreateSession.new(session_store: spine.session_store, event_stream: spine.event_stream))
425
+ # A snapshot loaded into a conversation before its first turn — what an eval
426
+ # case declares as `state:`. The HTTP route and the in-process eval transport
427
+ # both dispatch this one command.
428
+ bus.register(:seed_session,
429
+ Insika::Commands::SeedSession.new(session_store: spine.session_store,
430
+ memory_store: spine.memory_store,
431
+ event_stream: spine.event_stream))
425
432
  bus.register(:cancel_task,
426
433
  Insika::Commands::CancelTask.new(task_store: spine.task_store, executor: executor))
427
434
  bus.register(:pause_task,
@@ -18,6 +18,10 @@ module Insika
18
18
  @graph = graph
19
19
  end
20
20
 
21
+ # The graph this seam speaks for — an eval transport reaches its bus and
22
+ # event stream through here.
23
+ attr_reader :graph
24
+
21
25
  # One turn, in-process -> the assistant's text. `agent:` is REQUIRED —
22
26
  # this seam has no notion of "the default agent" (that is a DSL::Runtime
23
27
  # concept, filled in by its own caller before delegating here). Raises
data/lib/insika.rb CHANGED
@@ -99,6 +99,7 @@ require_relative "insika/context/catalog_provider"
99
99
  require_relative "insika/context/builder"
100
100
  require_relative "insika/context/providers/request"
101
101
  require_relative "insika/context/providers/prompt"
102
+ require_relative "insika/context/providers/fence_notice"
102
103
  require_relative "insika/context/providers/skill"
103
104
  require_relative "insika/context/providers/skill_trigger"
104
105
  require_relative "insika/context/providers/tool_search"
@@ -230,6 +231,7 @@ require_relative "insika/system_file_store"
230
231
  require_relative "insika/recovery"
231
232
  require_relative "insika/command_bus"
232
233
  require_relative "insika/commands/create_session"
234
+ require_relative "insika/commands/seed_session"
233
235
  require_relative "insika/commands/cancel_task"
234
236
  require_relative "insika/commands/pause_task"
235
237
  require_relative "insika/commands/approve_action"
@@ -345,6 +347,8 @@ require_relative "insika/turn_output"
345
347
  require_relative "insika/turn_state"
346
348
  require_relative "insika/turn_timing"
347
349
  require_relative "insika/capability/resolved_tool"
350
+ require_relative "insika/fence"
351
+ require_relative "insika/spoken_transcript"
348
352
  require_relative "insika/tool_envelope"
349
353
  require_relative "insika/tool_assembly"
350
354
  require_relative "insika/chat_builder"