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
@@ -9,47 +9,52 @@ module Insika
9
9
  # (name + description + parameters.inspect, executor.rb:957). Outputs are
10
10
  # PII-free digests: it never sees message text, only hashes leave this class.
11
11
  # Pure stdlib (digest/sha2), no gem, no IO.
12
+ #
13
+ # The chain follows the cache boundary: the cumulative "prefix" is over the
14
+ # IDENTITY categories + tool_schemas only — the bytes the cache breakpoint
15
+ # actually covers. Volatile categories (memory, knowledge, briefing, request)
16
+ # render below the breakpoint, so a change there is not a prefix invalidation;
17
+ # they still get their own digest (the trace shows them) but sit AFTER the
18
+ # "prefix" key, outside the chain.
12
19
  class PrefixFingerprint
13
20
  # -> { "prompt" => "sha256…", …, "tool_schemas" => "sha256…",
14
- # "prefix" => "sha256…" }
15
- # Keys are the demodulized, downcased provider ids, in render order.
21
+ # "prefix" => "sha256…", "memory" => "sha256…", … }
22
+ # Keys are the demodulized, downcased provider ids. Order: identity
23
+ # categories (render order), "tool_schemas", "prefix", then the volatile
24
+ # categories — everything before "prefix" is the chain.
16
25
  # A category with no fragments is absent (not an empty hash).
17
26
  # The category digest = SHA256 of the category's fragments joined "\n\n"
18
27
  # (the Builder's separator — the digest matches rendered bytes).
19
- # "prefix" = SHA256 of the category digests concatenated IN CHAIN ORDER
20
- # (identity categories, then volatile, then tool_schemas) — any divergence
21
- # anywhere above the boundary changes it.
28
+ # "prefix" = SHA256 of the chain digests concatenated in order — any
29
+ # divergence anywhere above the boundary changes it. A fragment with no
30
+ # layer stamp reads as volatile, like everywhere else.
22
31
  def self.compute(system_fragments, tool_serial:)
23
- digests = {} # category name -> digest, insertion = render order
24
- grouped = system_fragments.group_by { |f| category(f.source) }
25
- grouped.each do |name, frags|
26
- digests[name] = digest(frags.map(&:content).join("\n\n"))
27
- end
32
+ identity, volatile = system_fragments.partition { |f| (f.layer || :volatile) == :identity }
33
+ digests = category_digests(identity)
28
34
  digests["tool_schemas"] = digest(tool_serial.to_s) unless tool_serial.nil?
29
35
  digests["prefix"] = digest(digests.values.join) unless digests.empty?
30
- digests
36
+ digests.merge(category_digests(volatile))
31
37
  end
32
38
 
33
- # -> String | nil. The first category (in CURRENT chain order) whose digest
34
- # differs or is absent from `previous`; else the first PREVIOUS key now
35
- # absent from the current chain (a vanished block is a divergence too);
36
- # else nil. nil `previous` (first turn) -> nil. The returned name is a
37
- # category id — PII-free by construction.
38
- #
39
- # The cumulative "prefix" key is deliberately SKIPPED in the scan: its
40
- # digest changes whenever ANY category moves, so scanning it would shadow a
41
- # vanished block (every surviving category matches, "prefix" differs, and
42
- # the vanished-fallback below becomes unreachable — reporting `broke:
43
- # prefix` instead of the category that actually left).
39
+ # -> String | nil. nil when the cumulative "prefix" did not move (a
40
+ # volatile change is not an invalidation). Else the first chain category
41
+ # (in CURRENT chain order) whose digest differs or is absent from
42
+ # `previous`; else the first PREVIOUS chain key now absent from the current
43
+ # chain (a vanished block is a divergence too). nil `previous` (first turn)
44
+ # -> nil. The returned name is a category id — PII-free by construction.
44
45
  def self.invalidation_reason(current, previous)
45
46
  return nil unless previous.is_a?(Hash)
47
+ return nil if current["prefix"] == previous["prefix"]
46
48
 
47
- current.each_key do |name|
48
- next if name == "prefix"
49
+ chain(current).find { |name| previous[name] != current[name] } ||
50
+ (chain(previous) - current.keys).first
51
+ end
52
+
53
+ def self.chain(map) = map.keys.take_while { |k| k != "prefix" }
49
54
 
50
- return name unless previous[name] == current[name]
51
- end
52
- (previous.keys - current.keys).first
55
+ def self.category_digests(fragments)
56
+ fragments.group_by { |f| category(f.source) }
57
+ .transform_values { |frags| digest(frags.map(&:content).join("\n\n")) }
53
58
  end
54
59
 
55
60
  def self.category(source) = source.to_s.split("::").last.to_s.downcase
@@ -115,6 +115,7 @@ module Insika
115
115
  # only a stored explicit false turns the discipline block off.
116
116
  tool_persistence: h[:tool_persistence],
117
117
  tool_output_compression: h[:tool_output_compression],
118
+ fencing: h[:fencing],
118
119
  # params/model_policy: the resolver tolerates string keys from
119
120
  # the JSON round-trip (ModelResolver#normalize_params / ModelPolicy), so no
120
121
  # re-symbolization needed here.
@@ -56,8 +56,13 @@ module Insika
56
56
  config:, pending_action_store: nil, a2a: nil, provisioner: nil,
57
57
  workflow_registry: nil, onboarding: nil, profiles: nil,
58
58
  channels: nil, logger: nil, token_store: nil, outcome_store: nil,
59
- executor: nil, db_path: nil, tool_registry: nil, mcp_store: nil)
59
+ executor: nil, db_path: nil, tool_registry: nil, mcp_store: nil,
60
+ settings_store: nil)
60
61
  @command_bus = command_bus
62
+ # READ for one gate: `POST /v1/conversations/:id/seed` answers only while
63
+ # the platform setting `evals.seeding` is on. nil = no settings = seeding
64
+ # off (fail-closed — the base wiring has no SettingsStore and cannot seed).
65
+ @settings_store = settings_store
61
66
  @event_stream = event_stream
62
67
  @session_store = session_store
63
68
  @task_store = task_store
@@ -132,6 +137,8 @@ module Insika
132
137
  error_response(422, e)
133
138
  rescue Insika::NotFoundError => e
134
139
  error_response(404, e)
140
+ rescue Insika::ConflictError => e
141
+ error_response(409, e) # the write contradicts existing state (a seed on a used conversation)
135
142
  rescue Async::TimeoutError => e
136
143
  error_response(504, e) # synchronous control request exceeded the ceiling
137
144
  rescue StandardError => e
@@ -186,6 +193,8 @@ module Insika
186
193
  handle_trigger_workflow(req, name)
187
194
  in ["POST", ["v1", "responses"]]
188
195
  handle_responses(req)
196
+ in ["POST", ["v1", "conversations", id, "seed"]]
197
+ handle_seed(req, id)
189
198
  in ["POST", ["v1", "outcomes"]] if @outcome_store
190
199
  handle_record_outcome(req)
191
200
  in ["GET", ["v1", "outcomes"]] if @outcome_store
@@ -427,6 +436,38 @@ module Insika
427
436
  tenant: tenant)
428
437
  end
429
438
 
439
+ # POST /v1/conversations/:id/seed — loads the snapshot an eval case starts from
440
+ # (its `state:` — evidence ids, memory facts/notes, history, briefing fields)
441
+ # into the conversation BEFORE its first turn. Body = the state mapping, plus an
442
+ # optional `customer` (the memory scope the turns will carry). Same Bearer as
443
+ # /v1/responses and the same id namespacing for a tenant, so the seeded session
444
+ # IS the one the turn continues. 200 {session}; 409 when the conversation
445
+ # already has messages (seeding a used one is a test bug, not a merge).
446
+ #
447
+ # Refused (auth error) unless the platform setting `evals.seeding` is on. A seeded
448
+ # conversation is a fabricated precondition written under the tenant token:
449
+ # right on the machine running snapshot evals, wrong in production — so the
450
+ # default is off and the doctor warns while it is on.
451
+ def handle_seed(req, id)
452
+ return auth_error(403, "seeding is off (settings evals.seeding)") unless seeding_enabled?
453
+
454
+ body = parse_body(req)
455
+ tenant = req_tenant(req)
456
+ # the conversation id as the TURN will send it in `user` — the path
457
+ # segment arrives percent-encoded (the eval transport encodes "loja:c1").
458
+ conv = URI.decode_www_form_component(id)
459
+ payload = { id: scoped_session_id(tenant, conv), state: body.except(:customer) }
460
+ (customer = Insika::Coercion.presence(body[:customer])) && (payload[:customer] = customer)
461
+ command = Insika::Command.build(:seed_session, payload, transport: :http, tenant: tenant)
462
+ session = dispatch_with_timeout(command)
463
+ json_response(200, { session: session.to_h })
464
+ end
465
+
466
+ def seeding_enabled?
467
+ settings = @settings_store&.get
468
+ settings.is_a?(Hash) && (settings["evals"] || {})["seeding"] == true
469
+ end
470
+
430
471
  # POST /v1/agents — provisions (upserts) an agent from a standardized
431
472
  # PACK. Same Bearer as /v1/responses (gateway_token,
432
473
  # fail-closed). The consumer (GatewayClient/ProvisionStore) sends the pack as
@@ -625,6 +666,7 @@ TENANT_SURFACES = [
625
666
  ["POST", ["v1", "sessions"]],
626
667
  ["POST", ["v1", "messages"]],
627
668
  ["POST", ["v1", "responses"]],
669
+ ["POST", ["v1", "conversations", nil, "seed"]],
628
670
  ["POST", ["v1", "outcomes"]],
629
671
  ["GET", ["v1", "outcomes"]],
630
672
  ["POST", ["v1", "workflows", nil]],
@@ -80,6 +80,8 @@ module Insika
80
80
  outcome_store: @graph.outcome_store,
81
81
  # GET/PUT/DELETE /v1/mcp[/:name] — the config surface.
82
82
  mcp_store: @rt.component(:mcp_store),
83
+ # POST /v1/conversations/:id/seed answers only while `evals.seeding` is on.
84
+ settings_store: @rt.component(:settings_store),
83
85
  # a 500's error_ref must be findable in the process log.
84
86
  logger: $stdout
85
87
  )
@@ -95,7 +95,7 @@ module Insika
95
95
  end
96
96
 
97
97
  # Turn Event -> OpenAI Responses SSE frame | nil (event with no
98
- # counterpart: :task_started, :tool_result, :skill_activated, ...).
98
+ # counterpart: :task_started, :skill_activated, ...).
99
99
  # Terminal events emit the final frame + `[DONE]` (close the stream).
100
100
  def frame_for(event)
101
101
  case event.type
@@ -103,9 +103,27 @@ module Insika
103
103
  sse("response.output_text.delta",
104
104
  { type: "response.output_text.delta", delta: event.data[:delta].to_s })
105
105
  when :tool_call
106
- sse("response.output_item.added",
107
- { type: "response.output_item.added",
108
- item: { type: "function_call", name: event.data[:name].to_s } })
106
+ item = { type: "function_call", name: event.data[:name].to_s }
107
+ # The provider's call id, as the OpenAI item carries it: `added` and `done`
108
+ # are TWO frames of ONE call, and a consumer pairs them by this. Absent when
109
+ # the emitter had none.
110
+ (id = event.data[:call_id]) && (item[:call_id] = id.to_s)
111
+ # The call's arguments, as the OpenAI item carries them (a JSON string).
112
+ # Absent when the emitter had none to report.
113
+ (args = event.data[:arguments]) && (item[:arguments] = args.is_a?(String) ? args : JSON.generate(args))
114
+ sse("response.output_item.added", { type: "response.output_item.added", item: item })
115
+ when :tool_result
116
+ # How the call ENDED — ok / error / blocked (+ the gate that held it). Until
117
+ # this frame the stream carried tool NAMES only, so nothing outside the
118
+ # process could tell "the tool ran" from "a gate refused it" or "it errored".
119
+ # The result body itself stays inside: it is the model's input, not the
120
+ # consumer's answer. Same `call_id` as the `added` frame — a consumer that
121
+ # counts calls counts `added`, not both.
122
+ item = { type: "function_call", name: event.data[:name].to_s,
123
+ status: (event.data[:status] || "ok").to_s }
124
+ (id = event.data[:call_id]) && (item[:call_id] = id.to_s)
125
+ (gate = event.data[:gate]) && (item[:gate] = gate.to_s)
126
+ sse("response.output_item.done", { type: "response.output_item.done", item: item })
109
127
  when :task_completed
110
128
  completed(event) + done
111
129
  when :task_failed
@@ -149,6 +167,15 @@ module Insika
149
167
  # Studio + the trace. Explicit (not a fall-through) to keep the closed
150
168
  # catalog honest.
151
169
  nil
170
+ when :ui
171
+ # A presentation tool's selection: what the customer should SEE alongside
172
+ # the answer. Namespaced like `insika.intermediate` — no OpenAI Responses
173
+ # counterpart, unknown to strict clients, safely ignored. `items` are the
174
+ # cards the engine validated and joined (id/url/caption); `dropped` is what
175
+ # the model asked for and could not be shown, with the reason.
176
+ sse("insika.ui", { type: "insika.ui", component: event.data[:component].to_s,
177
+ title: event.data[:title], items: Array(event.data[:items]),
178
+ count: event.data[:count].to_i, dropped: Array(event.data[:dropped]) })
152
179
  when :ttft
153
180
  # the live TTFB signal (WS6, INSIKA_TURN_TIMING opt-in): the provider's
154
181
  # ms-to-first-token, emitted when the first content chunk arrives.
@@ -101,12 +101,15 @@ module Insika
101
101
  # appends this turn's evidence (ids + ungrounded delta) to the
102
102
  # session record. RMW like append_messages — the SessionActor serializes
103
103
  # same-session turns; the copy is in the method comment.
104
- def append_evidence(id, ids:, ungrounded:)
104
+ def append_evidence(id, ids:, ungrounded:, cards: [])
105
105
  record = fetch!(id)
106
106
  ev = record["evidence"] ||= { "ids" => [], "ungrounded" => 0 }
107
107
  fresh = (ev["ids"] + Array(ids).map(&:to_s).reject(&:empty?)).uniq.last(EvidenceLedger::MAX_IDS)
108
108
  ev["ids"] = fresh
109
109
  ev["ungrounded"] = ev["ungrounded"].to_i + ungrounded.to_i
110
+ # the cards those ids came with (one per id, newest wins) — what a
111
+ # presentation tool joins on in a LATER turn. Absent until a card arrives.
112
+ ev["cards"] = EvidenceLedger.merge_cards(ev["cards"], cards) unless Array(cards).empty?
110
113
  record["updated_at"] = timestamp
111
114
  @store.set(SCOPE, key_for(id), record)
112
115
  to_session(record)
@@ -38,6 +38,10 @@ module Insika
38
38
  # = parity (nothing runs). Additive keys — reads overlay DEFAULTS.
39
39
  "compaction" => { "enabled" => false, "keep_last" => 20,
40
40
  "compact_after" => 40, "model" => nil },
41
+ # Fencing (per-agent `fencing` flag): the cap on ONE string leaf of a tool
42
+ # result after sanitizing. Platform-wide — the leaf size is a context-budget
43
+ # concern, not a persona one. Additive key — reads overlay DEFAULTS.
44
+ "fencing" => { "max_chars" => 12_000 },
41
45
  # Data lifecycle (WS8, phase 2): the RETENTION window in days. The
42
46
  # tick's Retention sweep purges sessions (+traces), terminal tasks
43
47
  # (+checkpoints), memory cells and outcomes older than this. nil/0 =
@@ -81,7 +85,12 @@ module Insika
81
85
  "aggregate" => "median",
82
86
  "min_agreement" => 0.5,
83
87
  "quorum" => 1,
84
- "tolerance" => 0.05
88
+ "tolerance" => 0.05,
89
+ # seeding -> opens POST /v1/conversations/:id/seed, the route a snapshot eval
90
+ # loads a case's `state:` through. OFF by default: a seeded
91
+ # conversation is a fabricated precondition, and production must
92
+ # not accept one under the tenant token. The doctor warns when on.
93
+ "seeding" => false
85
94
  },
86
95
  # Edge limits — the platform layer of the EdgeLimiter.
87
96
  # nil/0 = off (opt-in). chat_rate_limit = turn attempts per chat per
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # The transcript slice an EXTRACTOR reads — memory distillation, knowledge
5
+ # extraction. Only what people said: `user` and `assistant` prose. A
6
+ # `role: tool` message is a product description, a search result, an FAQ body
7
+ # — third-party text; a customer fact or a learned concept distilled from it
8
+ # is a defect, not a behaviour to preserve. Tool-call payloads never render
9
+ # either (only `content` does). Indices are the ORIGINAL message positions,
10
+ # so a proposal's `turns` still point at the right message.
11
+ module SpokenTranscript
12
+ module_function
13
+
14
+ ROLES = %w[user assistant].freeze
15
+
16
+ # -> String, PII-redacted (what reaches the utility model follows the same
17
+ # redaction rule as what gets persisted). Messages are store records: string
18
+ # keys, like everything at the persistence boundary.
19
+ def render(messages)
20
+ lines = Array(messages).each_with_index.filter_map do |m, i|
21
+ role = m["role"].to_s
22
+ content = m["content"].to_s
23
+ next unless ROLES.include?(role) && !content.strip.empty?
24
+
25
+ "[#{i}] #{role}: #{content}"
26
+ end
27
+ redacted, = Insika::Safety::Detectors.redact(lines.join("\n"))
28
+ redacted
29
+ end
30
+ end
31
+ end
@@ -45,8 +45,11 @@ module Studio
45
45
  }.freeze
46
46
 
47
47
  # Plugins that do NOT depend on the secret (loaded at class definition).
48
+ # The templates are UTF-8 ("Signing in…"). Tilt reads them in the PROCESS
49
+ # locale, and a host with no LANG (a bare container, a systemd unit) reads
50
+ # US-ASCII and 500s on the login page — found by the release install proof.
48
51
  plugin :render, views: File.expand_path("views", __dir__), engine: "erb",
49
- layout: "layout", escape: true
52
+ layout: "layout", escape: true, template_opts: { default_encoding: "UTF-8" }
50
53
  plugin :hash_branches
51
54
  plugin :h
52
55
 
@@ -1976,11 +1979,12 @@ end
1976
1979
  name: t["name"].to_s, description: t["description"].to_s,
1977
1980
  method: req["method"] || "GET", url: req["url"].to_s,
1978
1981
  parameters: params_text(t["parameters"]),
1982
+ requires_evidence: Array(t.dig("requires_evidence", "params")).join(", "),
1979
1983
  query: env_lines(req["query"]), headers: env_lines(req["headers"]),
1980
1984
  secret_headers: Array(t["secret_headers"]).join(", "),
1981
1985
  body: req["body"].to_s,
1982
1986
  extract: resp["extract"] || "body_raw", path: resp["path"].to_s,
1983
- timeout: t["timeout"]
1987
+ timeout: t["timeout"], presentation: t["presentation"]
1984
1988
  }
1985
1989
  end
1986
1990
 
@@ -77,6 +77,7 @@ module Studio
77
77
  # unchecked box saves an explicit false — both spellings of ON collapse.
78
78
  tool_persistence: r.params["tool_persistence"] == "1",
79
79
  tool_output_compression: r.params["tool_output_compression"] == "1",
80
+ fencing: r.params["fencing"] == "1",
80
81
  subagents: list_patch(r, "subagents"),
81
82
  capabilities: list_patch(r, "capabilities"),
82
83
  tools_deferred: list_patch(r, "tools_deferred"),
@@ -386,7 +387,7 @@ module Studio
386
387
  # record on write, so anything missing here is erased — a save that only fixed a
387
388
  # typo in the description would silently drop them (the class of bug already
388
389
  # paid for once). `stored` carries them through untouched.
389
- UNEDITED_TOOL_FIELDS = %w[group tags halt_when].freeze
390
+ UNEDITED_TOOL_FIELDS = %w[group tags halt_when evidence presentation side_effect].freeze
390
391
 
391
392
  # :write_data_tool payload from the form. nested request/response;
392
393
  # headers/query as "key=value" per line (same idiom as the MCP env —
@@ -394,12 +395,17 @@ module Studio
394
395
  # `stored` = the definition being edited (nil when creating).
395
396
  def tool_patch(r, stored = nil)
396
397
  preserved = (stored || {}).slice(*UNEDITED_TOOL_FIELDS).compact
397
- preserved.merge(
398
+ method = presence(r.params["method"]) || "GET"
399
+ # A stored side_effect describes the stored method. When the method changes
400
+ # (a GET turned POST) it is dropped so the store re-derives it — the write
401
+ # gate and the resume skip both read it.
402
+ preserved.delete("side_effect") unless method == (stored || {}).dig("request", "method")
403
+ patch = preserved.merge(
398
404
  name: presence(r.params["name"]),
399
405
  description: r.params["description"].to_s,
400
406
  parameters: parse_parameters(r.params["parameters"]),
401
407
  request: {
402
- method: presence(r.params["method"]) || "GET",
408
+ method: method,
403
409
  url: r.params["url"].to_s,
404
410
  headers: parse_kv_lines(r.params["headers"]),
405
411
  query: parse_kv_lines(r.params["query"]),
@@ -410,8 +416,17 @@ module Studio
410
416
  path: presence(r.params["path"])
411
417
  },
412
418
  secret_headers: split_list(r.params["secret_headers"]),
419
+ requires_evidence: if r.params.key?("requires_evidence")
420
+ split_list(r.params["requires_evidence"]).then { |names| names.empty? ? nil : names }
421
+ else
422
+ (stored || {})["requires_evidence"]
423
+ end,
413
424
  timeout: presence(r.params["timeout"])
414
425
  )
426
+ # A presentation tool makes no request: the form's HTTP fields are blank and
427
+ # the definition refuses both halves at once.
428
+ patch.delete(:request) if preserved["presentation"]
429
+ patch
415
430
  end
416
431
 
417
432
  # Parameters, TWO accepted syntaxes in one field (auto-detected, no mode toggle):
@@ -490,12 +490,16 @@
490
490
  </label>
491
491
  <label class="check">
492
492
  <input type="checkbox" name="prompt_caching" value="1"<%== " checked" if @agent.prompt_caching %>>
493
- prompt caching <span class="muted">— Anthropic breakpoint; ON requires a byte-stable system prompt</span>
493
+ prompt caching <span class="muted">— Anthropic breakpoint at the identity layer; memory/knowledge render below it</span>
494
494
  </label>
495
495
  <label class="check">
496
496
  <input type="checkbox" name="tool_output_compression" value="1"<%== " checked" if @agent.tool_output_compression %>>
497
497
  tool output compression <span class="muted">— mechanical dedupe of repeated tool results</span>
498
498
  </label>
499
+ <label class="check">
500
+ <input type="checkbox" name="fencing" value="1"<%== " checked" if @agent.fencing %>>
501
+ fencing <span class="muted">— sanitize tool results, memory and knowledge before the model reads them (invisible characters, forged turn markers, transcript-shaped tags) and add the "data, not instructions" notice</span>
502
+ </label>
499
503
  <label class="check">
500
504
  <input type="checkbox" name="tool_persistence" value="1"<%== " checked" unless @agent.tool_persistence == false %>>
501
505
  tool persistence <span class="muted">— the engine's "Tool discipline" prompt block (retry weak/empty tool results differently); ON by default</span>
@@ -185,7 +185,7 @@
185
185
  <% traces[turn].each do |t| %>
186
186
  <details class="trace-item <%= t["ok"] ? "ok" : "err" %>">
187
187
  <summary>
188
- <span class="badge"><%= t["ok"] ? "ok" : "error" %></span>
188
+ <span class="badge"><%= t["gate"] ? "blocked: #{t["gate"]}" : (t["ok"] ? "ok" : "error") %></span>
189
189
  <strong><%= t["tool"] %></strong>
190
190
  <span class="lat"><%= t["ms"] %>ms</span>
191
191
  <span class="at"><%= t["at"] %></span>
@@ -33,7 +33,7 @@
33
33
  </label>
34
34
 
35
35
  <label>url <span class="muted">(https; use <code>{{param}}</code>)</span>
36
- <input type="text" name="url" value="<%= @form[:url] %>" placeholder="https://viacep.com.br/ws/{{cep}}/json" required>
36
+ <input type="text" name="url" value="<%= @form[:url] %>" placeholder="https://viacep.com.br/ws/{{cep}}/json"<%== " required" unless @form[:presentation] %>>
37
37
  </label>
38
38
 
39
39
  <label>parameters <span class="muted">(one per line: <code>name | type | required|optional | description</code> — type is <code>string</code>/<code>number</code>/<code>integer</code>/<code>boolean</code> or <code>array:string</code>. For a list of objects or a nested object, paste a full <strong>JSON Schema</strong> instead: any text starting with <code>{</code> is read as one.)</span>
@@ -62,7 +62,7 @@
62
62
  <div class="grid-2">
63
63
  <label>response
64
64
  <select name="extract">
65
- <% [["body_raw", "raw body"], ["json_path", "JSON field"], ["status", "status only"]].each do |v, lbl| %>
65
+ <% [["body_raw", "raw body"], ["json_path", "JSON field"], ["status", "status only"], ["evidence_envelope", "evidence envelope"]].each do |v, lbl| %>
66
66
  <option value="<%= v %>"<%== " selected" if @form[:extract] == v %>><%= lbl %></option>
67
67
  <% end %>
68
68
  </select>
@@ -72,6 +72,10 @@
72
72
  </label>
73
73
  </div>
74
74
 
75
+ <label>requires_evidence <span class="muted">(parameter names, comma-separated; each ID must first be returned by a tool in this conversation)</span>
76
+ <input type="text" name="requires_evidence" value="<%= @form[:requires_evidence] %>" placeholder="product_id">
77
+ </label>
78
+
75
79
  <label>timeout <span class="muted">(seconds, optional)</span>
76
80
  <input type="number" name="timeout" value="<%= @form[:timeout] %>" min="1" placeholder="30">
77
81
  </label>
@@ -41,7 +41,7 @@ module Insika
41
41
  # renaming one breaks every dashboard built on it.
42
42
  class Instruments
43
43
  attr_reader :turns, :turn_duration, :tokens, :cost, :tool_calls, :tool_duration,
44
- :cache_hit_rate, :loop_intervened, :context_compacted
44
+ :cache_hit_rate, :loop_intervened, :context_compacted, :tool_blocked
45
45
 
46
46
  def initialize(meter)
47
47
  @turns = meter.create_counter("insika.turns", unit: "{turn}",
@@ -60,6 +60,8 @@ module Insika
60
60
  description: "Prompt-cache hit rate of a turn (cached / billed prompt tokens)")
61
61
  @loop_intervened = meter.create_counter("insika.tool.loop_intervened", unit: "{intervention}",
62
62
  description: "Loop-detector warnings delivered to the model")
63
+ @tool_blocked = meter.create_counter("insika.tool.blocked", unit: "{call}",
64
+ description: "Tool calls blocked before execution")
63
65
  @context_compacted = meter.create_counter("insika.context.compacted", unit: "{compaction}",
64
66
  description: "In-session compactions persisted (RFC-0044)")
65
67
  end
@@ -81,6 +83,7 @@ module Insika
81
83
  when :tool_result then finish_tool(meta)
82
84
  when :data_tool_call then point_tool(meta, data)
83
85
  when :tool_loop_intervened then count_loop(meta, data)
86
+ when :tool_blocked then count_blocked(meta, data)
84
87
  when :context_compacted then count_compaction(data)
85
88
  when :task_completed then finish_turn(meta, data, :ok)
86
89
  when :task_failed then finish_turn(meta, data, :error)
@@ -206,6 +209,15 @@ module Insika
206
209
  @instruments.loop_intervened.add(1, attributes: labels)
207
210
  end
208
211
 
212
+ def count_blocked(meta, data)
213
+ return unless @instruments
214
+
215
+ turn = @turns[meta[:task_id]] or return
216
+ labels = turn.labels.merge(attrs("insika.tool" => data[:name]&.to_s,
217
+ "insika.gate" => data[:gate]&.to_s))
218
+ @instruments.tool_blocked.add(1, attributes: labels)
219
+ end
220
+
209
221
  # A compaction persisted (`:context_compacted`, RFC-0044) — counted by
210
222
  # agent/model, INDEPENDENT of any open turn: it fires post-turn, usually
211
223
  # after task_completed already closed the span.
@@ -58,14 +58,13 @@ module Insika
58
58
  # capability alias.
59
59
  def assemble_tool_instances(allowed, state)
60
60
  names = state.respond_to?(:capability_names) ? (state.capability_names || {}) : {}
61
- ctx = state.respond_to?(:turn_context) ? state.turn_context : nil
62
- return instantiate_tools(allowed, ctx) if names.empty?
61
+ return instantiate_tools(allowed, state) if names.empty?
63
62
 
64
63
  # Dedup by the ENTRY NAME (registry key = impl_name) BEFORE
65
64
  # instantiating — the INSTANCE's `.name` (RubyLLM) is not the registration
66
65
  # name.
67
66
  direct = Array(allowed).reject { |e| e.respond_to?(:name) && names.key?(e.name.to_s) }
68
- instantiate_tools(direct, ctx) + capability_tool_instances(names, ctx)
67
+ instantiate_tools(direct, state) + capability_tool_instances(names, state)
69
68
  end
70
69
 
71
70
  # Envelopes each allowed tool (per-call timeout + side-effect recording).
@@ -78,7 +77,7 @@ module Insika
78
77
  ToolEnvelope.new(tool, state: state, checkpoint_store: @checkpoint_store,
79
78
  tool_registry: @tool_registry, timeout: timeout,
80
79
  skip_side_effects: skip_side_effects,
81
- trace_recorder: @tool_trace_store)
80
+ trace_recorder: @tool_trace_store, event_stream: @event_stream)
82
81
  end
83
82
  end
84
83
 
@@ -99,16 +98,19 @@ module Insika
99
98
  return unless state.respond_to?(:tool_gate) && state.respond_to?(:tool_concurrency)
100
99
 
101
100
  cap = state.tool_concurrency
102
- state.tool_gate = cap ? Async::Semaphore.new(cap) : nil
101
+ state.tool_gate = cap && cap > 1 ? Async::Semaphore.new(cap) : nil
102
+ if state.respond_to?(:side_effect_gate=)
103
+ state.side_effect_gate = state.tool_gate ? Async::Semaphore.new(1) : nil
104
+ end
103
105
  end
104
106
 
105
107
  # Real Engine -> Entries (respond to factory); fakes -> ready instances.
106
108
  # `turn_context` is deposited into the instances that expose it
107
109
  # (data-tools); the rest ignore it (parity).
108
- def instantiate_tools(allowed, turn_context = nil)
110
+ def instantiate_tools(allowed, state = nil)
109
111
  Array(allowed).map do |t|
110
112
  tool = t.respond_to?(:factory) ? t.factory.call : t
111
- inject_turn_context(tool, turn_context)
113
+ inject_turn_context(tool, state)
112
114
  tool
113
115
  end
114
116
  end
@@ -116,22 +118,28 @@ module Insika
116
118
  # seam: deposits the turn context into the freshly created instance
117
119
  # (same idea as `remember`, which receives tenant/state) BEFORE the
118
120
  # ToolEnvelope. Duck-typed: only what exposes `turn_context=` (DataDefinedTool)
119
- # receives it. nil (a state with no turn_context, e.g. a test stub) -> no-op.
120
- def inject_turn_context(tool, turn_context)
121
- return if turn_context.nil?
121
+ # receives it; a tool that exposes `turn_state=` (a presentation tool, which
122
+ # reads the ledger and the hoarded cards) receives the state itself. nil (a
123
+ # state with no turn_context, e.g. a test stub) -> no-op.
124
+ def inject_turn_context(tool, state)
125
+ return if state.nil?
126
+
127
+ tool.turn_state = state if tool.respond_to?(:turn_state=)
128
+ ctx = state.respond_to?(:turn_context) ? state.turn_context : nil
129
+ return if ctx.nil?
122
130
 
123
- tool.turn_context = turn_context if tool.respond_to?(:turn_context=)
131
+ tool.turn_context = ctx if tool.respond_to?(:turn_context=)
124
132
  end
125
133
 
126
134
  # impl_name -> Capability::ResolvedTool(capability_name:), STILL without
127
135
  # ToolEnvelope (the call site's wrap_tools wraps the whole set — same
128
136
  # order impl -> ResolvedTool -> ToolEnvelope). entry already validated in
129
137
  # resolve_capabilities.
130
- def capability_tool_instances(names, turn_context = nil)
138
+ def capability_tool_instances(names, state = nil)
131
139
  names.map do |impl_name, capability_name|
132
140
  entry = @tool_registry.entries.find { |e| e.name == impl_name }
133
141
  tool = entry.factory.call
134
- inject_turn_context(tool, turn_context)
142
+ inject_turn_context(tool, state)
135
143
  Capability::ResolvedTool.new(tool, capability_name: capability_name,
136
144
  impl_name: impl_name)
137
145
  end