insika 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +191 -0
- data/README.md +9 -6
- data/bin/insika +44 -3
- data/docs/AGENTS.md +74 -14
- data/docs/API.md +73 -0
- data/docs/ARCHITECTURE.md +45 -44
- data/docs/ARTIFACTS.md +42 -0
- data/docs/CHANNELS.md +19 -2
- data/docs/CONTEXT.md +86 -38
- data/docs/DEPLOY.md +27 -7
- data/docs/EVALS.md +98 -8
- data/docs/FACTS.md +4 -0
- data/docs/KNOWLEDGE.md +7 -0
- data/docs/LOADTEST.md +15 -27
- data/docs/MEDIA.md +1 -1
- data/docs/OBSERVABILITY.md +48 -4
- data/docs/POLICY.md +14 -5
- data/docs/RELEASING.md +4 -0
- data/docs/RUNNING-LOCAL.md +2 -2
- data/docs/SECURITY.md +28 -2
- data/docs/SOAK.md +1 -1
- data/docs/TOOLS.md +150 -32
- data/docs/prompts/ADD-TOOL.md +12 -2
- data/docs/prompts/DIAGNOSE-TURN.md +3 -0
- data/docs/prompts/GO-LIVE.md +6 -4
- data/lib/insika/agent_profile.rb +47 -10
- data/lib/insika/channels/web/widget.js +33 -0
- data/lib/insika/channels/web.rb +5 -2
- data/lib/insika/chat_builder.rb +90 -37
- data/lib/insika/commands/agent_payload.rb +1 -1
- data/lib/insika/commands/run_distillation.rb +5 -8
- data/lib/insika/commands/seed_session.rb +118 -0
- data/lib/insika/compaction.rb +196 -0
- data/lib/insika/context/builder.rb +35 -11
- data/lib/insika/context/fragment.rb +4 -1
- data/lib/insika/context/priority.rb +8 -0
- data/lib/insika/context/provider.rb +5 -0
- data/lib/insika/context/providers/briefing.rb +61 -29
- data/lib/insika/context/providers/fence_notice.rb +27 -0
- data/lib/insika/context/providers/knowledge.rb +7 -4
- data/lib/insika/context/providers/memory.rb +8 -4
- data/lib/insika/context/providers/session.rb +50 -10
- data/lib/insika/context_trace_store.rb +11 -1
- data/lib/insika/doctor.rb +213 -10
- data/lib/insika/dsl/runtime.rb +5 -0
- data/lib/insika/dsl.rb +6 -0
- data/lib/insika/edge_limiter.rb +4 -1
- data/lib/insika/env_schema.rb +5 -6
- data/lib/insika/errors.rb +1 -0
- data/lib/insika/evals/assertions.rb +92 -6
- data/lib/insika/evals/golden.rb +91 -2
- data/lib/insika/evals/runner.rb +20 -0
- data/lib/insika/evals/simulator.rb +11 -2
- data/lib/insika/evals/transport.rb +118 -16
- data/lib/insika/evidence.rb +79 -12
- data/lib/insika/executor.rb +94 -23
- data/lib/insika/fence.rb +96 -0
- data/lib/insika/golden_store.rb +3 -0
- data/lib/insika/loop_detector.rb +5 -34
- data/lib/insika/mcp_store.rb +5 -2
- data/lib/insika/mcp_tool_registry.rb +8 -1
- data/lib/insika/memory_store.rb +12 -0
- data/lib/insika/overlay_tool_registry.rb +5 -0
- data/lib/insika/prefix_fingerprint.rb +32 -27
- data/lib/insika/profile_source.rb +8 -0
- data/lib/insika/server/app.rb +43 -1
- data/lib/insika/server/rack_app.rb +2 -0
- data/lib/insika/server/responses.rb +35 -8
- data/lib/insika/session_store.rb +38 -5
- data/lib/insika/settings_store.rb +18 -2
- data/lib/insika/soak/runner.rb +4 -4
- data/lib/insika/spoken_transcript.rb +31 -0
- data/lib/insika/studio/app.rb +34 -8
- data/lib/insika/studio/forms.rb +29 -3
- data/lib/insika/studio/views/_agent_tab_config.erb +5 -1
- data/lib/insika/studio/views/session.erb +1 -1
- data/lib/insika/studio/views/settings.erb +11 -0
- data/lib/insika/studio/views/tool_edit.erb +6 -2
- data/lib/insika/telemetry/recorder.rb +61 -1
- data/lib/insika/templates/daily-digest/README.md +9 -0
- data/lib/insika/templates/research-analyst/agent.rb +10 -0
- data/lib/insika/tool_assembly.rb +21 -13
- data/lib/insika/tool_batch.rb +67 -0
- data/lib/insika/tool_definition.rb +73 -10
- data/lib/insika/tool_envelope.rb +102 -2
- data/lib/insika/tool_store.rb +9 -4
- data/lib/insika/tool_trace_store.rb +1 -1
- data/lib/insika/tool_usage_report.rb +172 -0
- data/lib/insika/tools/data_defined_tool.rb +1 -0
- data/lib/insika/tools/present.rb +122 -0
- data/lib/insika/tools/run_persona_eval.rb +6 -1
- data/lib/insika/tools/tool_search.rb +4 -2
- data/lib/insika/turn_budget.rb +91 -0
- data/lib/insika/turn_state.rb +13 -1
- data/lib/insika/version.rb +1 -1
- data/lib/insika/wiring/graph.rb +7 -0
- data/lib/insika/wiring/graph_chat.rb +4 -0
- data/lib/insika.rb +11 -0
- metadata +10 -1
data/lib/insika/loop_detector.rb
CHANGED
|
@@ -50,9 +50,7 @@ module Insika
|
|
|
50
50
|
@streak = 0
|
|
51
51
|
@intervened = false # the ONE warning of this turn has been delivered
|
|
52
52
|
@pending = false # detection fired; waiting for the batch boundary
|
|
53
|
-
@
|
|
54
|
-
@seen = 0
|
|
55
|
-
@halted = false
|
|
53
|
+
@batch = ToolBatch.new
|
|
56
54
|
end
|
|
57
55
|
|
|
58
56
|
# From ChatBuilder's before_tool_call. Raises BEFORE the call executes once
|
|
@@ -80,41 +78,21 @@ module Insika
|
|
|
80
78
|
|
|
81
79
|
# From ChatBuilder's after_tool_result, with the RAW result — the only place
|
|
82
80
|
# a Tool::Halt is still recognizable (SteerInjector's comment applies here).
|
|
83
|
-
def tool_result(result)
|
|
84
|
-
@halted = true if defined?(RubyLLM::Tool::Halt) && result.is_a?(RubyLLM::Tool::Halt)
|
|
85
|
-
end
|
|
81
|
+
def tool_result(result) = @batch.halt!(result)
|
|
86
82
|
|
|
87
83
|
# RubyLLM after_message. An assistant message carrying tool calls OPENS a
|
|
88
84
|
# batch; the Nth tool result CLOSES it — the one boundary where appending
|
|
89
|
-
# is valid.
|
|
85
|
+
# is valid (ToolBatch owns that arithmetic; TurnBudget follows the same rule).
|
|
90
86
|
def message_ended(message)
|
|
91
|
-
|
|
92
|
-
return open_batch(message) if role == "assistant"
|
|
93
|
-
return unless role == "tool" && @expected
|
|
94
|
-
|
|
95
|
-
@seen += 1
|
|
96
|
-
intervene! if @seen >= @expected
|
|
87
|
+
intervene! if @batch.closed?(message)
|
|
97
88
|
end
|
|
98
89
|
|
|
99
90
|
private
|
|
100
91
|
|
|
101
|
-
def open_batch(message)
|
|
102
|
-
calls = field(message, :tool_calls)
|
|
103
|
-
size = calls.respond_to?(:size) ? calls.size : 0
|
|
104
|
-
# No tool call = the model talking; the turn is ending and a pending
|
|
105
|
-
# warning is moot — the loop resolved itself.
|
|
106
|
-
return @expected = nil if size.zero?
|
|
107
|
-
|
|
108
|
-
@expected = size
|
|
109
|
-
@seen = 0
|
|
110
|
-
@halted = false
|
|
111
|
-
end
|
|
112
|
-
|
|
113
92
|
def intervene!
|
|
114
|
-
@expected = nil
|
|
115
93
|
return unless @pending
|
|
116
94
|
@pending = false
|
|
117
|
-
return if @halted # nothing will read it (halt_when): drop, never deliver
|
|
95
|
+
return if @batch.halted? # nothing will read it (halt_when): drop, never deliver
|
|
118
96
|
|
|
119
97
|
@intervened = true
|
|
120
98
|
name, = @last
|
|
@@ -132,12 +110,5 @@ module Insika
|
|
|
132
110
|
else value
|
|
133
111
|
end
|
|
134
112
|
end
|
|
135
|
-
|
|
136
|
-
def field(message, name)
|
|
137
|
-
return message.public_send(name) if message.respond_to?(name)
|
|
138
|
-
return message[name] || message[name.to_s] if message.respond_to?(:[])
|
|
139
|
-
|
|
140
|
-
nil
|
|
141
|
-
end
|
|
142
113
|
end
|
|
143
114
|
end
|
data/lib/insika/mcp_store.rb
CHANGED
|
@@ -135,7 +135,7 @@ module Insika
|
|
|
135
135
|
|
|
136
136
|
def http_like?(transport) = %w[http sse].include?(transport.to_s)
|
|
137
137
|
|
|
138
|
-
# -> [{"name","description","inputSchema"}] string-keyed, dropping any
|
|
138
|
+
# -> [{"name","description","inputSchema"[,"annotations"]}] string-keyed, dropping any
|
|
139
139
|
# entry without a name (nothing to register a Registry::Entry under).
|
|
140
140
|
def normalize_tools_cache(tools)
|
|
141
141
|
Array(tools).filter_map do |t|
|
|
@@ -143,7 +143,10 @@ module Insika
|
|
|
143
143
|
name = presence(h["name"])
|
|
144
144
|
next nil if name.nil?
|
|
145
145
|
|
|
146
|
-
{ "name" => name, "description" => h["description"].to_s, "inputSchema" => h["inputSchema"] || {} }
|
|
146
|
+
cached = { "name" => name, "description" => h["description"].to_s, "inputSchema" => h["inputSchema"] || {} }
|
|
147
|
+
# the server's own hints (readOnlyHint decides side_effect at registration)
|
|
148
|
+
cached["annotations"] = h["annotations"] if h["annotations"].is_a?(Hash)
|
|
149
|
+
cached
|
|
147
150
|
end
|
|
148
151
|
end
|
|
149
152
|
|
|
@@ -71,11 +71,18 @@ module Insika
|
|
|
71
71
|
instance = record["name"]
|
|
72
72
|
Insika::Registry::Entry.new(
|
|
73
73
|
name: tool["name"], plugin: "mcp:#{instance}",
|
|
74
|
-
metadata: { optional: false, side_effect:
|
|
74
|
+
metadata: { optional: false, side_effect: !read_only?(tool), group: "mcp:#{instance}", tags: [] },
|
|
75
75
|
factory: -> { build_tool(record, tool) }
|
|
76
76
|
)
|
|
77
77
|
end
|
|
78
78
|
|
|
79
|
+
# An MCP tool is a side effect unless its server says otherwise
|
|
80
|
+
# (`annotations.readOnlyHint`): a write is never re-run on resume and runs
|
|
81
|
+
# serially within the session; a declared read keeps `tool_concurrency`.
|
|
82
|
+
def read_only?(tool)
|
|
83
|
+
Coercion.truthy?(tool.dig("annotations", "readOnlyHint"))
|
|
84
|
+
end
|
|
85
|
+
|
|
79
86
|
# Lazy require (McpLiveTool < RubyLLM::Tool pulls in ruby_llm) — kept out
|
|
80
87
|
# of insika.rb load-time, loaded on the 1st instance (turn time), same
|
|
81
88
|
# discipline as OverlayToolRegistry#build_tool for data-tools.
|
data/lib/insika/memory_store.rb
CHANGED
|
@@ -42,6 +42,18 @@ module Insika
|
|
|
42
42
|
# ids live in a different namespace in practice.
|
|
43
43
|
SESSION_TAG = "chat"
|
|
44
44
|
|
|
45
|
+
# The ONE memory-cell rule, read by the turn (Executor) and by whoever
|
|
46
|
+
# writes a cell the turn must find (SeedSession): a customer -> the
|
|
47
|
+
# "[tenant:]customer" cell; otherwise the tenant's cell; otherwise the marked
|
|
48
|
+
# per-session cell "chat:<session id>" (nil for a one-shot turn with no
|
|
49
|
+
# session — the store applies _default).
|
|
50
|
+
def self.scope_for(tenant:, customer:, session_id:)
|
|
51
|
+
return [tenant, customer].compact.join(":") if customer
|
|
52
|
+
return tenant if tenant
|
|
53
|
+
|
|
54
|
+
"#{SESSION_TAG}:#{session_id}" if session_id
|
|
55
|
+
end
|
|
56
|
+
|
|
45
57
|
Fact = Data.define(:key, :value, :origin, :created_at, :updated_at, :expires_at)
|
|
46
58
|
Note = Data.define(:id, :text, :created_at)
|
|
47
59
|
|
|
@@ -112,6 +112,11 @@ module Insika
|
|
|
112
112
|
# Lazy require: DataDefinedTool inherits from RubyLLM::Tool (pulls in the gem) -> kept
|
|
113
113
|
# out of insika.rb load-time, loaded on the 1st instance (turn time).
|
|
114
114
|
def build_tool(definition)
|
|
115
|
+
if definition.presentation?
|
|
116
|
+
require_relative "tools/present"
|
|
117
|
+
return Insika::Tools::Present.new(definition: definition, event_stream: @event_stream)
|
|
118
|
+
end
|
|
119
|
+
|
|
115
120
|
require_relative "tools/data_defined_tool"
|
|
116
121
|
Insika::Tools::DataDefinedTool.new(
|
|
117
122
|
definition: definition, http: @http, egress: @egress,
|
|
@@ -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
|
|
@@ -74,6 +74,13 @@ module Insika
|
|
|
74
74
|
|
|
75
75
|
def delete(id) = @cs.delete(SCOPE, id.to_s)
|
|
76
76
|
|
|
77
|
+
# The STORED records, unbuilt (string keys, straight off the JSON
|
|
78
|
+
# round-trip). `all` runs every record through AgentProfile.build, which
|
|
79
|
+
# normalizes — and repairs — what it reads, so a caller that needs to judge
|
|
80
|
+
# the truth ON DISK (the doctor) cannot use it: a repaired-on-read profile
|
|
81
|
+
# is indistinguishable there from a well-formed record.
|
|
82
|
+
def all_raw = @cs.all(SCOPE)
|
|
83
|
+
|
|
77
84
|
private
|
|
78
85
|
|
|
79
86
|
# Rebuilds the AgentProfile from the record (the JSON round-trip turns symbols
|
|
@@ -108,6 +115,7 @@ module Insika
|
|
|
108
115
|
# only a stored explicit false turns the discipline block off.
|
|
109
116
|
tool_persistence: h[:tool_persistence],
|
|
110
117
|
tool_output_compression: h[:tool_output_compression],
|
|
118
|
+
fencing: h[:fencing],
|
|
111
119
|
# params/model_policy: the resolver tolerates string keys from
|
|
112
120
|
# the JSON round-trip (ModelResolver#normalize_params / ModelPolicy), so no
|
|
113
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
|
)
|
|
@@ -12,9 +12,9 @@ module Insika
|
|
|
12
12
|
# OpenAI Responses SSE frame (or nil for events with no counterpart). Follows the
|
|
13
13
|
# constitutional rule: no business logic, no store access here.
|
|
14
14
|
#
|
|
15
|
-
# Request: { model: "
|
|
15
|
+
# Request: { model: "insika:<agent>", user: "<chat.id>", stream: true,
|
|
16
16
|
# input: "<string with already-composed blocks>" } + header
|
|
17
|
-
# X-
|
|
17
|
+
# X-Insika-Agent (agent fallback). The `input` enters VERBATIM as the
|
|
18
18
|
# turn's message — the blocks (<memoria>/<dados_conhecidos>/directives) already come
|
|
19
19
|
# composed by the consumer (the engine does not interpret them).
|
|
20
20
|
module Responses
|
|
@@ -32,8 +32,8 @@ module Insika
|
|
|
32
32
|
# gets that filtered structurally instead of by a regex on the leading tag.
|
|
33
33
|
# Omitted = a customer typed it, which is what every turn meant before.
|
|
34
34
|
def parse_request(body, req)
|
|
35
|
-
agent = body[:model].to_s.sub(/\
|
|
36
|
-
agent = req.get_header("
|
|
35
|
+
agent = body[:model].to_s.sub(/\Ainsika:/, "")
|
|
36
|
+
agent = req.get_header("HTTP_X_INSIKA_AGENT").to_s if agent.empty?
|
|
37
37
|
raise Insika::ValidationError, "model/agent missing" if agent.strip.empty?
|
|
38
38
|
|
|
39
39
|
user = body[:user].to_s
|
|
@@ -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
|
@@ -23,11 +23,11 @@ module Insika
|
|
|
23
23
|
KEY_PREFIX = "session:"
|
|
24
24
|
|
|
25
25
|
Session = Data.define(:id, :messages, :vars, :memory_refs,
|
|
26
|
-
:created_at, :updated_at, :briefing, :evidence) do
|
|
26
|
+
:created_at, :updated_at, :briefing, :evidence, :compaction) do
|
|
27
27
|
# Trailing members with defaults: an old record without the "briefing" /
|
|
28
|
-
# "evidence" keys reads as empty/nil without a migration.
|
|
28
|
+
# "evidence" / "compaction" keys reads as empty/nil without a migration.
|
|
29
29
|
def initialize(id:, messages:, vars:, memory_refs:, created_at:, updated_at:,
|
|
30
|
-
briefing: nil, evidence: nil)
|
|
30
|
+
briefing: nil, evidence: nil, compaction: nil)
|
|
31
31
|
super
|
|
32
32
|
end
|
|
33
33
|
end
|
|
@@ -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)
|
|
@@ -150,6 +153,35 @@ module Insika
|
|
|
150
153
|
to_session(record)
|
|
151
154
|
end
|
|
152
155
|
|
|
156
|
+
# -> Session. Persists the in-session compaction state (RFC-0044): the
|
|
157
|
+
# summary of messages[0...upto] plus the boundary. `upto` is MONOTONIC —
|
|
158
|
+
# a write that does not move the boundary forward is a no-op (a stale
|
|
159
|
+
# double-write from a racing worker can never move it backwards). `runs`
|
|
160
|
+
# counts compactions over the session's lifetime (the trace reports it).
|
|
161
|
+
#
|
|
162
|
+
# CONCURRENCY NOTE: an unlocked RMW (read -> mutate -> set), like
|
|
163
|
+
# update_briefing — and safe for the same reason: nothing in the
|
|
164
|
+
# read/mutate/set path suspends, so no other writer can interleave
|
|
165
|
+
# mid-RMW. The LLM call that produced the summary happened BEFORE this
|
|
166
|
+
# method; only the plain write lives here.
|
|
167
|
+
def set_compaction(id, summary:, upto:, model: nil)
|
|
168
|
+
record = fetch!(id)
|
|
169
|
+
current = record["compaction"]
|
|
170
|
+
upto = Integer(upto)
|
|
171
|
+
return to_session(record) if current && upto <= current["upto"].to_i
|
|
172
|
+
|
|
173
|
+
record["compaction"] = {
|
|
174
|
+
"summary" => Coercion.utf8(summary.to_s),
|
|
175
|
+
"upto" => upto,
|
|
176
|
+
"runs" => (current ? current["runs"].to_i : 0) + 1,
|
|
177
|
+
"model" => Coercion.presence(model.to_s),
|
|
178
|
+
"at" => timestamp
|
|
179
|
+
}.compact
|
|
180
|
+
record["updated_at"] = timestamp
|
|
181
|
+
@store.set(SCOPE, key_for(id), record)
|
|
182
|
+
to_session(record)
|
|
183
|
+
end
|
|
184
|
+
|
|
153
185
|
# -> bool (delegates to the backend: false for a nonexistent id)
|
|
154
186
|
def delete(id)
|
|
155
187
|
@store.delete(SCOPE, key_for(id))
|
|
@@ -189,7 +221,8 @@ module Insika
|
|
|
189
221
|
created_at: record["created_at"],
|
|
190
222
|
updated_at: record["updated_at"],
|
|
191
223
|
briefing: record["briefing"] || { "fields" => {}, "next_step" => nil },
|
|
192
|
-
evidence: record["evidence"]
|
|
224
|
+
evidence: record["evidence"],
|
|
225
|
+
compaction: record["compaction"]
|
|
193
226
|
)
|
|
194
227
|
end
|
|
195
228
|
|
|
@@ -30,7 +30,18 @@ module Insika
|
|
|
30
30
|
"max_retries" => 2,
|
|
31
31
|
"turn_timeout" => 120,
|
|
32
32
|
"tool_timeout" => 30,
|
|
33
|
-
|
|
33
|
+
# In-session compaction (RFC-0044): when a session's UNCOMPACTED
|
|
34
|
+
# transcript grows past `compact_after` messages, everything but the
|
|
35
|
+
# last `keep_last` is summarized (model -> compaction.model, else the
|
|
36
|
+
# platform utility_model) into one history fragment. `prompt` replaces
|
|
37
|
+
# the engine default wholesale (the distill convention). enabled: false
|
|
38
|
+
# = parity (nothing runs). Additive keys — reads overlay DEFAULTS.
|
|
39
|
+
"compaction" => { "enabled" => false, "keep_last" => 20,
|
|
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 },
|
|
34
45
|
# Data lifecycle (WS8, phase 2): the RETENTION window in days. The
|
|
35
46
|
# tick's Retention sweep purges sessions (+traces), terminal tasks
|
|
36
47
|
# (+checkpoints), memory cells and outcomes older than this. nil/0 =
|
|
@@ -74,7 +85,12 @@ module Insika
|
|
|
74
85
|
"aggregate" => "median",
|
|
75
86
|
"min_agreement" => 0.5,
|
|
76
87
|
"quorum" => 1,
|
|
77
|
-
"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
|
|
78
94
|
},
|
|
79
95
|
# Edge limits — the platform layer of the EdgeLimiter.
|
|
80
96
|
# nil/0 = off (opt-in). chat_rate_limit = turn attempts per chat per
|
data/lib/insika/soak/runner.rb
CHANGED
|
@@ -50,7 +50,7 @@ module Insika
|
|
|
50
50
|
|
|
51
51
|
Environment:
|
|
52
52
|
INSIKA_URL base URL of the engine (default: http://localhost:9292)
|
|
53
|
-
|
|
53
|
+
INSIKA_GATEWAY_TOKEN Bearer; falls back to ADMIN_TOKEN, then "local-demo"
|
|
54
54
|
TXT
|
|
55
55
|
|
|
56
56
|
# Poisson inter-arrival seconds: `-mean * Math.log(1.0 - rand)`. Seeded,
|
|
@@ -245,7 +245,7 @@ module Insika
|
|
|
245
245
|
" agent: #{@agent}",
|
|
246
246
|
" shape: #{@envelope[:turns_per_hour]} turns/h poisson, #{@envelope[:session_turns]}-turn sessions, " \
|
|
247
247
|
"cap #{@envelope[:concurrency_cap]}, #{@envelope[:duration_hours]}h (#{@envelope[:warmup_hours]}h warmup)",
|
|
248
|
-
" sample: POST #{URI.join(target_url + '/', 'v1/responses')} model=
|
|
248
|
+
" sample: POST #{URI.join(target_url + '/', 'v1/responses')} model=insika:#{@agent} user=soak-1",
|
|
249
249
|
" out: #{@out}/"
|
|
250
250
|
]
|
|
251
251
|
unless @envelope.calibrated?
|
|
@@ -345,7 +345,7 @@ module Insika
|
|
|
345
345
|
|
|
346
346
|
private
|
|
347
347
|
|
|
348
|
-
def resolve_token(env) = env["
|
|
348
|
+
def resolve_token(env) = env["INSIKA_GATEWAY_TOKEN"] || env["ADMIN_TOKEN"] || "local-demo"
|
|
349
349
|
|
|
350
350
|
def install_traps
|
|
351
351
|
%w[INT TERM].each { |sig| Signal.trap(sig) { @stop_reason = "interrupted" } }
|
|
@@ -489,7 +489,7 @@ module Insika
|
|
|
489
489
|
req["Authorization"] = "Bearer #{@token}"
|
|
490
490
|
req["Content-Type"] = "application/json"
|
|
491
491
|
req["Accept"] = "text/event-stream"
|
|
492
|
-
req.body = JSON.generate(model: "
|
|
492
|
+
req.body = JSON.generate(model: "insika:#{agent}", user: user, stream: true, input: message)
|
|
493
493
|
|
|
494
494
|
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
495
495
|
ttfb = nil
|
|
@@ -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
|