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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +75 -0
- data/README.md +5 -3
- data/bin/insika +1 -1
- data/docs/AGENTS.md +52 -11
- data/docs/API.md +73 -0
- data/docs/ARCHITECTURE.md +45 -44
- data/docs/CHANNELS.md +19 -2
- data/docs/CONTEXT.md +33 -27
- data/docs/DEPLOY.md +13 -2
- data/docs/EVALS.md +98 -8
- data/docs/FACTS.md +4 -0
- data/docs/KNOWLEDGE.md +7 -0
- data/docs/OBSERVABILITY.md +21 -6
- data/docs/POLICY.md +4 -1
- data/docs/RELEASING.md +4 -0
- data/docs/SECURITY.md +27 -1
- data/docs/TOOLS.md +127 -33
- data/docs/prompts/ADD-TOOL.md +12 -2
- data/docs/prompts/DIAGNOSE-TURN.md +3 -0
- data/docs/prompts/GO-LIVE.md +3 -1
- data/lib/insika/agent_profile.rb +21 -9
- data/lib/insika/channels/web/widget.js +33 -0
- data/lib/insika/channels/web.rb +5 -2
- data/lib/insika/chat_builder.rb +62 -20
- 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/context/builder.rb +29 -9
- data/lib/insika/context/priority.rb +2 -0
- data/lib/insika/context/provider.rb +5 -0
- data/lib/insika/context/providers/briefing.rb +11 -8
- 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 +7 -3
- data/lib/insika/doctor.rb +109 -1
- data/lib/insika/dsl/runtime.rb +1 -0
- data/lib/insika/dsl.rb +6 -0
- data/lib/insika/edge_limiter.rb +4 -1
- 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 +117 -15
- data/lib/insika/evidence.rb +79 -12
- data/lib/insika/executor.rb +30 -23
- data/lib/insika/fence.rb +96 -0
- data/lib/insika/golden_store.rb +3 -0
- 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 +1 -0
- data/lib/insika/server/app.rb +43 -1
- data/lib/insika/server/rack_app.rb +2 -0
- data/lib/insika/server/responses.rb +31 -4
- data/lib/insika/session_store.rb +4 -1
- data/lib/insika/settings_store.rb +10 -1
- data/lib/insika/spoken_transcript.rb +31 -0
- data/lib/insika/studio/app.rb +6 -2
- data/lib/insika/studio/forms.rb +18 -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/tool_edit.erb +6 -2
- data/lib/insika/telemetry/recorder.rb +13 -1
- data/lib/insika/tool_assembly.rb +21 -13
- 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 +12 -2
- 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_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 +4 -0
- 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
|
|
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
|
|
20
|
-
#
|
|
21
|
-
#
|
|
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
|
-
|
|
24
|
-
|
|
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.
|
|
34
|
-
#
|
|
35
|
-
#
|
|
36
|
-
# else
|
|
37
|
-
#
|
|
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.
|
|
48
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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.
|
data/lib/insika/server/app.rb
CHANGED
|
@@ -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, :
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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.
|
data/lib/insika/session_store.rb
CHANGED
|
@@ -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
|
data/lib/insika/studio/app.rb
CHANGED
|
@@ -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
|
|
data/lib/insika/studio/forms.rb
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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
|
|
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.
|
data/lib/insika/tool_assembly.rb
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
|
120
|
-
|
|
121
|
-
|
|
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 =
|
|
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,
|
|
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,
|
|
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
|