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
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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]
|
|
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
|
|
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
|
|
data/lib/insika/tool_envelope.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
data/lib/insika/tool_store.rb
CHANGED
|
@@ -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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
data/lib/insika/turn_state.rb
CHANGED
|
@@ -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
|
data/lib/insika/version.rb
CHANGED
data/lib/insika/wiring/graph.rb
CHANGED
|
@@ -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"
|