insika 0.9.0 → 1.0.1
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 +237 -2
- data/README.md +5 -3
- data/bin/insika +3 -0
- data/bin/insika-router +0 -1
- data/docs/AGENTS.md +90 -7
- data/docs/ARCHITECTURE.md +12 -7
- data/docs/ARTIFACTS.md +33 -3
- data/docs/BENCHMARK.md +50 -7
- data/docs/CONTEXT.md +63 -0
- data/docs/CROSS-HARNESS-BENCH.md +157 -0
- data/docs/DEPLOY.md +57 -11
- data/docs/EMBEDDING.md +10 -0
- data/docs/EVALS.md +33 -4
- data/docs/FACTS.md +8 -6
- data/docs/HARVEST.md +8 -3
- data/docs/KNOWLEDGE.md +7 -0
- data/docs/MEDIA.md +6 -2
- data/docs/OBSERVABILITY.md +143 -22
- data/docs/PERFORMANCE.md +110 -0
- data/docs/PLUGINS.md +4 -4
- data/docs/POLICY.md +22 -13
- data/docs/RELEASING.md +5 -0
- data/docs/ROUTER.md +7 -0
- data/docs/RUBYLLM_2_MIGRATION.md +307 -0
- data/docs/SECURITY.md +7 -0
- data/docs/SHARED-CONVERSATIONS.md +40 -0
- data/docs/SKILLS.md +5 -3
- data/docs/STUDIO.md +122 -0
- data/docs/TOOLS.md +111 -9
- data/docs/operate.md +3 -0
- data/lib/insika/agent_defaults.rb +140 -0
- data/lib/insika/agent_file_store.rb +12 -0
- data/lib/insika/agent_profile.rb +78 -8
- data/lib/insika/artifact_signing.rb +11 -0
- data/lib/insika/chat_builder.rb +79 -25
- data/lib/insika/checkpoint.rb +6 -1
- data/lib/insika/checkpoint_store.rb +59 -7
- data/lib/insika/commands/agent_payload.rb +3 -3
- data/lib/insika/commands/delete_kit.rb +26 -0
- data/lib/insika/commands/gate_harvest.rb +3 -0
- data/lib/insika/commands/review_guardrail_flag.rb +38 -0
- data/lib/insika/commands/run_harvest.rb +17 -1
- data/lib/insika/commands/seed_session.rb +11 -1
- data/lib/insika/commands/send_message.rb +9 -3
- data/lib/insika/commands/update_settings.rb +23 -1
- data/lib/insika/commands/write_kit.rb +27 -0
- data/lib/insika/compaction.rb +33 -7
- data/lib/insika/config_store.rb +87 -7
- data/lib/insika/context/priority.rb +3 -0
- data/lib/insika/context/provider.rb +2 -2
- data/lib/insika/context/providers/knowledge.rb +25 -2
- data/lib/insika/context/providers/memory.rb +53 -4
- data/lib/insika/context/providers/pending_confirmation.rb +52 -0
- data/lib/insika/context/providers/prompt.rb +17 -4
- data/lib/insika/context/providers/session.rb +39 -3
- data/lib/insika/context/reranker.rb +75 -0
- data/lib/insika/distill.rb +22 -13
- data/lib/insika/doctor.rb +34 -4
- data/lib/insika/dsl/runtime.rb +36 -4
- data/lib/insika/dsl/server_boot.rb +4 -12
- data/lib/insika/dsl.rb +13 -5
- data/lib/insika/edge_limiter.rb +23 -15
- data/lib/insika/egress_guard.rb +11 -3
- data/lib/insika/env_schema.rb +33 -73
- data/lib/insika/evals/assertions.rb +171 -16
- data/lib/insika/evals/golden.rb +141 -4
- data/lib/insika/evals/judge.rb +19 -10
- data/lib/insika/evals/moderator_compare.rb +47 -0
- data/lib/insika/evals/report.rb +61 -9
- data/lib/insika/evals/runner.rb +44 -4
- data/lib/insika/evals/simulator.rb +10 -1
- data/lib/insika/evals/transport.rb +200 -0
- data/lib/insika/event_stream.rb +63 -7
- data/lib/insika/evidence.rb +29 -8
- data/lib/insika/executor.rb +668 -141
- data/lib/insika/harvest.rb +26 -14
- data/lib/insika/harvest_engine.rb +1 -0
- data/lib/insika/harvest_store.rb +12 -0
- data/lib/insika/http_client.rb +87 -4
- data/lib/insika/kits.rb +54 -0
- data/lib/insika/knowledge.rb +34 -20
- data/lib/insika/llm_configurator.rb +46 -4
- data/lib/insika/llm_provider_store.rb +1 -3
- data/lib/insika/llm_trace_store.rb +54 -0
- data/lib/insika/mcp_client.rb +27 -63
- data/lib/insika/mcp_live_tool.rb +55 -9
- data/lib/insika/mcp_store.rb +32 -1
- data/lib/insika/mcp_tool_registry.rb +69 -26
- data/lib/insika/media.rb +38 -25
- data/lib/insika/memory_store.rb +4 -3
- data/lib/insika/memory_trim.rb +60 -0
- data/lib/insika/middleware.rb +12 -3
- data/lib/insika/model_metrics_store.rb +285 -0
- data/lib/insika/model_registry.rb +110 -0
- data/lib/insika/model_resolver.rb +1 -1
- data/lib/insika/model_selection.rb +14 -21
- data/lib/insika/model_visible.rb +17 -6
- data/lib/insika/onboarding.rb +1 -0
- data/lib/insika/outcome_store.rb +1 -1
- data/lib/insika/overlay_tool_registry.rb +25 -2
- data/lib/insika/pack_importer.rb +54 -9
- data/lib/insika/packaging.rb +14 -5
- data/lib/insika/pending_action_store.rb +45 -18
- data/lib/insika/plugin/loader.rb +4 -2
- data/lib/insika/profile_source.rb +4 -0
- data/lib/insika/provider_error_classifier.rb +18 -3
- data/lib/insika/queue_policy.rb +7 -2
- data/lib/insika/refinement/proposer.rb +42 -17
- data/lib/insika/reliability.rb +12 -3
- data/lib/insika/router/app.rb +14 -5
- data/lib/insika/router/session_key.rb +35 -5
- data/lib/insika/safety/config.rb +20 -3
- data/lib/insika/safety/corpus.rb +10 -3
- data/lib/insika/safety/factory.rb +39 -1
- data/lib/insika/safety/input_guardrail.rb +21 -1
- data/lib/insika/safety/judge_moderator.rb +70 -0
- data/lib/insika/safety/output_validator.rb +29 -5
- data/lib/insika/safety/prompt_echo.rb +89 -0
- data/lib/insika/schema_guard.rb +53 -76
- data/lib/insika/server/app.rb +17 -8
- data/lib/insika/server/responses.rb +19 -1
- data/lib/insika/session_actor.rb +58 -28
- data/lib/insika/session_store.rb +159 -10
- data/lib/insika/settings_store.rb +47 -1
- data/lib/insika/shared_conversations.rb +305 -0
- data/lib/insika/skill_catalog.rb +28 -2
- data/lib/insika/skill_store.rb +6 -0
- data/lib/insika/soak/runner.rb +1 -1
- data/lib/insika/steer_injector.rb +7 -48
- data/lib/insika/store.rb +29 -1
- data/lib/insika/stores/json_value.rb +37 -0
- data/lib/insika/stores/memory.rb +22 -35
- data/lib/insika/stores/postgres.rb +295 -0
- data/lib/insika/stores/sqlite.rb +257 -43
- data/lib/insika/stores/turn_counter.rb +25 -0
- data/lib/insika/studio/app.rb +1453 -161
- data/lib/insika/studio/assets/dist/application.css +1 -1
- data/lib/insika/studio/assets/dist/application.js +25 -25
- data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-400-normal.woff2 +0 -0
- data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-500-normal.woff2 +0 -0
- data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-600-normal.woff2 +0 -0
- data/lib/insika/studio/assets/dist/fonts/ibm-plex-sans-latin-wght-normal.woff2 +0 -0
- data/lib/insika/studio/forms.rb +171 -9
- data/lib/insika/studio/nav_icons.rb +3 -0
- data/lib/insika/studio/views/_agent_config_retrieval.erb +73 -0
- data/lib/insika/studio/views/_agent_picker.erb +15 -0
- data/lib/insika/studio/views/_agent_tab_config.erb +78 -242
- data/lib/insika/studio/views/_agent_tab_history.erb +56 -1
- data/lib/insika/studio/views/_agent_tab_performance.erb +34 -0
- data/lib/insika/studio/views/_agent_tab_prompts.erb +7 -9
- data/lib/insika/studio/views/_agent_tab_skills.erb +16 -7
- data/lib/insika/studio/views/_agent_tab_tools.erb +90 -0
- data/lib/insika/studio/views/_agents_master.erb +8 -12
- data/lib/insika/studio/views/_cfg_alerts.erb +4 -0
- data/lib/insika/studio/views/_cfg_budget.erb +20 -0
- data/lib/insika/studio/views/_cfg_distill.erb +26 -0
- data/lib/insika/studio/views/_cfg_followup.erb +30 -0
- data/lib/insika/studio/views/_cfg_grounding.erb +21 -0
- data/lib/insika/studio/views/_cfg_guardrails.erb +47 -0
- data/lib/insika/studio/views/_cfg_harvest.erb +35 -0
- data/lib/insika/studio/views/_cfg_reliability.erb +32 -0
- data/lib/insika/studio/views/_chats_master.erb +66 -0
- data/lib/insika/studio/views/_customers_master.erb +37 -0
- data/lib/insika/studio/views/_empty.erb +15 -0
- data/lib/insika/studio/views/_facts_master.erb +42 -0
- data/lib/insika/studio/views/_harvest_candidate_detail.erb +14 -0
- data/lib/insika/studio/views/_identity.erb +16 -0
- data/lib/insika/studio/views/_kpi.erb +17 -0
- data/lib/insika/studio/views/_message.erb +40 -2
- data/lib/insika/studio/views/_model_chart.erb +82 -0
- data/lib/insika/studio/views/_refinement_master.erb +38 -0
- data/lib/insika/studio/views/{tool_edit.erb → _tool_edit.erb} +32 -9
- data/lib/insika/studio/views/_tool_users.erb +17 -0
- data/lib/insika/studio/views/_why.erb +53 -0
- data/lib/insika/studio/views/agent_detail.erb +17 -10
- data/lib/insika/studio/views/agents.erb +28 -16
- data/lib/insika/studio/views/approvals.erb +16 -11
- data/lib/insika/studio/views/artifact.erb +20 -7
- data/lib/insika/studio/views/artifacts.erb +19 -14
- data/lib/insika/studio/views/chats.erb +22 -29
- data/lib/insika/studio/views/customer.erb +88 -43
- data/lib/insika/studio/views/customers.erb +16 -26
- data/lib/insika/studio/views/evals.erb +49 -31
- data/lib/insika/studio/views/fact_detail.erb +114 -0
- data/lib/insika/studio/views/facts.erb +15 -116
- data/lib/insika/studio/views/followups.erb +63 -52
- data/lib/insika/studio/views/funnel.erb +104 -43
- data/lib/insika/studio/views/harvest.erb +85 -59
- data/lib/insika/studio/views/home.erb +138 -23
- data/lib/insika/studio/views/inbox.erb +36 -0
- data/lib/insika/studio/views/kits.erb +176 -0
- data/lib/insika/studio/views/knowledge.erb +92 -80
- data/lib/insika/studio/views/layout.erb +42 -11
- data/lib/insika/studio/views/live.erb +45 -0
- data/lib/insika/studio/views/mcp.erb +43 -13
- data/lib/insika/studio/views/models.erb +187 -0
- data/lib/insika/studio/views/parity.erb +38 -32
- data/lib/insika/studio/views/playground.erb +13 -9
- data/lib/insika/studio/views/refinement.erb +15 -224
- data/lib/insika/studio/views/refinement_detail.erb +243 -0
- data/lib/insika/studio/views/session.erb +152 -117
- data/lib/insika/studio/views/settings.erb +86 -22
- data/lib/insika/studio/views/skills.erb +53 -19
- data/lib/insika/studio/views/system_files.erb +7 -7
- data/lib/insika/studio/views/task.erb +45 -15
- data/lib/insika/studio/views/tasks.erb +35 -18
- data/lib/insika/studio/views/tools.erb +62 -73
- data/lib/insika/task_actor.rb +4 -1
- data/lib/insika/task_store.rb +67 -1
- data/lib/insika/telemetry/pricing.rb +16 -14
- data/lib/insika/telemetry/recorder.rb +103 -9
- data/lib/insika/telemetry/ruby_llm_instrumenter.rb +110 -0
- data/lib/insika/telemetry.rb +12 -2
- data/lib/insika/templates/daily-digest/README.md +13 -0
- data/lib/insika/templates/daily-digest/agent.rb +22 -11
- data/lib/insika/testing/store_contract.rb +65 -0
- data/lib/insika/text_tool_call.rb +66 -0
- data/lib/insika/tick.rb +7 -1
- data/lib/insika/token_store.rb +5 -0
- data/lib/insika/tool_batch.rb +1 -1
- data/lib/insika/tool_catalog.rb +12 -2
- data/lib/insika/tool_definition.rb +10 -4
- data/lib/insika/tool_envelope.rb +165 -28
- data/lib/insika/tool_manifest.rb +1 -0
- data/lib/insika/tool_store.rb +3 -0
- data/lib/insika/tool_trace_store.rb +15 -8
- data/lib/insika/tool_usage_report.rb +2 -2
- data/lib/insika/tools/a2a_remote.rb +1 -1
- data/lib/insika/tools/agent_enum.rb +1 -1
- data/lib/insika/tools/confirm_pending.rb +112 -0
- data/lib/insika/tools/data_defined_tool.rb +23 -15
- data/lib/insika/tools/generate_image.rb +2 -2
- data/lib/insika/tools/load_knowledge.rb +7 -3
- data/lib/insika/tools/load_skill.rb +3 -3
- data/lib/insika/tools/present.rb +1 -8
- data/lib/insika/tools/remember.rb +7 -2
- data/lib/insika/tools/run_persona_eval.rb +3 -5
- data/lib/insika/tools/save_artifact.rb +16 -6
- data/lib/insika/tools/schedule_followup.rb +3 -3
- data/lib/insika/tools/stuck_signal.rb +3 -3
- data/lib/insika/tools/subagent.rb +5 -5
- data/lib/insika/tools/subagents.rb +3 -3
- data/lib/insika/tools/tool_search.rb +7 -5
- data/lib/insika/tools/tts.rb +2 -2
- data/lib/insika/tools/update_briefing.rb +4 -4
- data/lib/insika/turn_output.rb +2 -2
- data/lib/insika/turn_state.rb +18 -0
- data/lib/insika/turn_timing.rb +51 -14
- data/lib/insika/version.rb +1 -1
- data/lib/insika/wiring/graph.rb +81 -16
- data/lib/insika/workflow.rb +25 -64
- data/lib/insika.rb +19 -3
- metadata +69 -13
- data/lib/insika/commands/import_mcp_tools.rb +0 -48
- data/lib/insika/mcp_http_client.rb +0 -67
- data/lib/insika/mcp_tool_ingestor.rb +0 -165
data/lib/insika/config_store.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "json"
|
|
4
|
+
require "securerandom"
|
|
3
5
|
require "time"
|
|
4
6
|
|
|
5
7
|
module Insika
|
|
@@ -34,39 +36,117 @@ module Insika
|
|
|
34
36
|
|
|
35
37
|
class UnknownScope < Insika::Error; end
|
|
36
38
|
|
|
37
|
-
|
|
39
|
+
# Rewritten on every config write. A cache sees another process's write by
|
|
40
|
+
# finding a different token. Outside SCOPES, so no authored scope lists it.
|
|
41
|
+
VERSION_SCOPE = "#{SCOPE_PREFIX}:_version"
|
|
42
|
+
VERSION_KEY = "token"
|
|
43
|
+
# The server's recheck window (seconds): how stale another worker's write may
|
|
44
|
+
# be seen. Embedders keep 0 (no cache) unless they opt in.
|
|
45
|
+
RECHECK = 1.0
|
|
46
|
+
# Fiber-storage flag a running turn sets. The cache serves only turns: Studio
|
|
47
|
+
# requests and pack imports read a record, change it and write it back, and
|
|
48
|
+
# that read must see another worker's write that landed a moment ago.
|
|
49
|
+
TURN_KEY = :insika_in_turn
|
|
50
|
+
|
|
51
|
+
# recheck: 0 = no cache, every read goes to the store (the default).
|
|
52
|
+
# recheck > 0 = reads made inside a turn are served from memory; the version token is re-read
|
|
53
|
+
# at most once per `recheck` seconds and a changed token drops the cache.
|
|
54
|
+
def initialize(store:, recheck: 0, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
|
|
38
55
|
@store = store
|
|
56
|
+
@recheck = recheck
|
|
57
|
+
@clock = clock
|
|
58
|
+
@cache = {}
|
|
59
|
+
@token = nil
|
|
60
|
+
@checked_at = nil
|
|
61
|
+
@gen = 0
|
|
62
|
+
@lock = Mutex.new
|
|
39
63
|
end
|
|
40
64
|
|
|
65
|
+
attr_reader :recheck
|
|
66
|
+
|
|
67
|
+
# The token every config write bumps. Another process reads it to learn that
|
|
68
|
+
# something changed since it last looked (one small store read, never cached).
|
|
69
|
+
def version_token = @store.get(VERSION_SCOPE, VERSION_KEY)
|
|
70
|
+
|
|
41
71
|
# Upsert (last-write-wins). -> value (the same Hash that was passed)
|
|
42
72
|
def put(scope, key, value)
|
|
43
|
-
|
|
73
|
+
s = ns(scope)
|
|
74
|
+
@store.transaction do
|
|
75
|
+
@store.set(s, key.to_s, stringify(value))
|
|
76
|
+
bump_version
|
|
77
|
+
end
|
|
78
|
+
forget
|
|
44
79
|
value
|
|
45
80
|
end
|
|
46
81
|
|
|
47
82
|
# -> Hash | nil
|
|
48
83
|
def get(scope, key)
|
|
49
|
-
|
|
84
|
+
s = ns(scope)
|
|
85
|
+
cached([:get, s, key.to_s]) { @store.get(s, key.to_s) }
|
|
50
86
|
end
|
|
51
87
|
|
|
52
88
|
# -> bool (did it exist?)
|
|
53
89
|
def delete(scope, key)
|
|
54
|
-
|
|
90
|
+
s = ns(scope)
|
|
91
|
+
existed = @store.transaction do
|
|
92
|
+
@store.delete(s, key.to_s).tap { |gone| bump_version if gone }
|
|
93
|
+
end
|
|
94
|
+
forget
|
|
95
|
+
existed
|
|
55
96
|
end
|
|
56
97
|
|
|
57
98
|
# -> [String] keys of the scope, sorted lexicographically
|
|
58
99
|
def keys(scope)
|
|
59
|
-
|
|
100
|
+
s = ns(scope)
|
|
101
|
+
cached([:keys, s]) { @store.list(s) }
|
|
60
102
|
end
|
|
61
103
|
|
|
62
|
-
# -> [Hash] all records of the scope (lexicographic key order)
|
|
104
|
+
# -> [Hash] all records of the scope (lexicographic key order), one store call
|
|
63
105
|
def all(scope)
|
|
64
106
|
s = ns(scope)
|
|
65
|
-
|
|
107
|
+
cached([:all, s]) { @store.entries(s).map(&:last) }
|
|
66
108
|
end
|
|
67
109
|
|
|
68
110
|
private
|
|
69
111
|
|
|
112
|
+
# The cache keeps JSON text, not objects: every hit parses a fresh copy, so a
|
|
113
|
+
# caller that mutates what it got never changes what the next caller reads.
|
|
114
|
+
def cached(entry)
|
|
115
|
+
return yield unless @recheck.positive? && Fiber[TURN_KEY]
|
|
116
|
+
|
|
117
|
+
revalidate
|
|
118
|
+
gen, raw = @lock.synchronize { [@gen, @cache[entry]] }
|
|
119
|
+
return JSON.parse(raw) if raw
|
|
120
|
+
|
|
121
|
+
value = yield
|
|
122
|
+
# A drop while the read was in flight means the value may predate it.
|
|
123
|
+
@lock.synchronize { @cache[entry] = JSON.generate(value) if gen == @gen }
|
|
124
|
+
value
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def revalidate
|
|
128
|
+
now = @clock.call
|
|
129
|
+
return if @checked_at && now - @checked_at < @recheck
|
|
130
|
+
|
|
131
|
+
token = @store.get(VERSION_SCOPE, VERSION_KEY)
|
|
132
|
+
@lock.synchronize do
|
|
133
|
+
drop unless token == @token
|
|
134
|
+
@token = token
|
|
135
|
+
@checked_at = now
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def bump_version = @store.set(VERSION_SCOPE, VERSION_KEY, SecureRandom.hex(8))
|
|
140
|
+
|
|
141
|
+
# Our own write: drop everything and re-read the token on the next read.
|
|
142
|
+
def forget = @lock.synchronize { drop; @checked_at = nil }
|
|
143
|
+
|
|
144
|
+
# Callers hold @lock. The generation tells an in-flight read its value is stale.
|
|
145
|
+
def drop
|
|
146
|
+
@cache.clear
|
|
147
|
+
@gen += 1
|
|
148
|
+
end
|
|
149
|
+
|
|
70
150
|
def ns(scope)
|
|
71
151
|
key = scope.to_s
|
|
72
152
|
raise UnknownScope, "unknown config scope: #{scope.inspect}" unless SCOPES.include?(key)
|
|
@@ -19,6 +19,9 @@ module Insika
|
|
|
19
19
|
IDENTITY = 100 # IDENTITY/SOUL (Prompt) — pinned
|
|
20
20
|
FENCE_NOTICE = 99 # the one-sentence fencing notice (FenceNotice) —
|
|
21
21
|
# right under the identity, byte-stable, above the boundary
|
|
22
|
+
PENDING_CONFIRMATION = 96 # the call held for the customer's confirmation
|
|
23
|
+
# (PendingConfirmation) — tail, pinned: it is the
|
|
24
|
+
# question the current message is answering
|
|
22
25
|
RECITATION = 95 # <recitation> the goal restated at the TAIL of context
|
|
23
26
|
# (Briefing). Never cut: it is two lines, and the one
|
|
24
27
|
# turn it gets evicted is the long turn that needed it.
|
|
@@ -29,9 +29,9 @@ module Insika
|
|
|
29
29
|
# fall back to tenant || session). Kept separate from `tenant`
|
|
30
30
|
# (the <request_context> merchant label) on purpose.
|
|
31
31
|
ContextRequest = Data.define(:session, :message, :profile, :tenant, :vars,
|
|
32
|
-
:checkpoint, :memory_scope) do
|
|
32
|
+
:checkpoint, :memory_scope, :diagnostics) do
|
|
33
33
|
def initialize(session: nil, message: nil, profile: nil, tenant: nil, vars: {},
|
|
34
|
-
checkpoint: nil, memory_scope: nil)
|
|
34
|
+
checkpoint: nil, memory_scope: nil, diagnostics: nil)
|
|
35
35
|
super
|
|
36
36
|
end
|
|
37
37
|
end
|
|
@@ -9,8 +9,9 @@ module Insika
|
|
|
9
9
|
# `SkillTrigger`, unlike the static `CatalogProvider` subclasses — this
|
|
10
10
|
# builds its `<knowledge>` block directly in `call`, never a fixed list.
|
|
11
11
|
class Knowledge < ContextProvider
|
|
12
|
-
def initialize(store:)
|
|
12
|
+
def initialize(store:, llm: nil)
|
|
13
13
|
@store = store
|
|
14
|
+
@reranker = Reranker.new(llm: llm) if llm
|
|
14
15
|
# One Index PER TYPE, built once and reused for every agent/turn —
|
|
15
16
|
# never per call. This provider instance itself lives for the
|
|
16
17
|
# process's lifetime (built once at boot, see wiring), so an
|
|
@@ -28,15 +29,37 @@ module Insika
|
|
|
28
29
|
# required? == false (default): a store failure degrades to a
|
|
29
30
|
# :provider_warning, never aborts the turn.
|
|
30
31
|
def call(request)
|
|
32
|
+
if request.vars&.key?('shared_memory')
|
|
33
|
+
records = request.vars['shared_memory'].fetch('knowledge', [])
|
|
34
|
+
hits = records.map { [{name:_1['id'],description:_1['excerpt'],confidence:0.6,provenance:_1['origin'] == 'operator' ? 'curated' : 'observed'}, 'shared'] }
|
|
35
|
+
return [] if hits.empty?
|
|
36
|
+
return [ContextFragment.build(content:format_block(hits,true),placement: :system,priority:Context::Priority::KNOWLEDGE,source:id)]
|
|
37
|
+
end
|
|
31
38
|
config = request.profile.knowledge
|
|
32
39
|
return [] unless config
|
|
33
40
|
|
|
34
41
|
top_k = positive_int(config["top_k"]) || 5
|
|
35
42
|
index = @indexes[config["index"].to_s]
|
|
43
|
+
rerank = config["rerank"]
|
|
44
|
+
candidate_limit = rerank ? rerank.fetch("candidate_limit") : top_k
|
|
36
45
|
matches = index.search(request.profile.id, tenant: request.tenant,
|
|
37
|
-
query: request.message.to_s, top_k:
|
|
46
|
+
query: request.message.to_s, top_k: candidate_limit)
|
|
38
47
|
return [] if matches.empty?
|
|
39
48
|
|
|
49
|
+
if rerank && @reranker
|
|
50
|
+
# Leave room inside the Builder's outer provider timeout to return
|
|
51
|
+
# lexical results when the reranker times out.
|
|
52
|
+
timeout = [rerank.fetch("timeout_seconds"),
|
|
53
|
+
(request.profile.limits[:provider_timeout] || 5) * 0.9].min
|
|
54
|
+
documents = matches.map { |c| [c[:name], c[:description], c[:body]].join("\n") }
|
|
55
|
+
indexes = @reranker.select(query: request.message.to_s, documents: documents,
|
|
56
|
+
config: rerank.merge("timeout_seconds" => timeout), top_k: top_k,
|
|
57
|
+
emit: request.diagnostics, budget: request.profile.limits[:context_budget] || 8_000)
|
|
58
|
+
matches = indexes ? indexes.map { |i| matches[i] } : matches.first(top_k)
|
|
59
|
+
else
|
|
60
|
+
matches = matches.first(top_k)
|
|
61
|
+
end
|
|
62
|
+
|
|
40
63
|
hits = matches.map { |c| [c, "top-K match"] } +
|
|
41
64
|
expand_links(matches, request, top_k).map { |c| [c, "one-hop link"] }
|
|
42
65
|
|
|
@@ -5,12 +5,12 @@ module Insika
|
|
|
5
5
|
module Providers
|
|
6
6
|
# Read path for cross-session memory. Thin adapter over
|
|
7
7
|
# the MemoryStore — same pattern as the Skill/ToolSearch provider: one
|
|
8
|
-
# `:system` fragment with the tenant's facts + recent notes.
|
|
9
|
-
# (no embeddings/ranking).
|
|
8
|
+
# `:system` fragment with the tenant's facts + recent notes.
|
|
10
9
|
class Memory < ContextProvider
|
|
11
|
-
def initialize(store:, notes_limit: 10)
|
|
10
|
+
def initialize(store:, notes_limit: 10, llm: nil)
|
|
12
11
|
@store = store
|
|
13
12
|
@notes_limit = notes_limit
|
|
13
|
+
@reranker = Reranker.new(llm: llm) if llm
|
|
14
14
|
end
|
|
15
15
|
|
|
16
16
|
# Per-agent opt-in. The Builder still applies the `context_providers`
|
|
@@ -20,11 +20,28 @@ module Insika
|
|
|
20
20
|
# required? == false (default): a failure (store unavailable) becomes a
|
|
21
21
|
# :provider_warning + graceful degradation — never aborts the turn.
|
|
22
22
|
def call(request)
|
|
23
|
+
if request.vars&.key?('shared_memory')
|
|
24
|
+
records = request.vars['shared_memory'].fetch('facts', [])
|
|
25
|
+
facts = records.reject { _1['kind'] == 'note' }.map { MemoryStore::Fact.new(key:_1['id'],value:_1['value'].to_s,origin:_1['origin'],created_at:_1['created_at'],updated_at:_1['updated_at'],expires_at:_1['expires_at']) }
|
|
26
|
+
notes = records.select { _1['kind'] == 'note' }.map { MemoryStore::Note.new(id:_1['id'],text:_1['value'].to_s,created_at:_1['created_at']) }
|
|
27
|
+
return [] if records.empty?
|
|
28
|
+
return [ContextFragment.build(content:format_block(facts,notes,true),placement: :system,priority:Context::Priority::MEMORY,source:id)]
|
|
29
|
+
end
|
|
23
30
|
tenant = memory_scope(request)
|
|
24
31
|
facts = @store.facts(tenant: tenant)
|
|
25
|
-
|
|
32
|
+
config = request.profile.memory_retrieval
|
|
33
|
+
limit = config ? [@notes_limit, config.dig("rerank", "candidate_limit")].max : @notes_limit
|
|
34
|
+
notes = @store.notes(tenant: tenant, limit: limit)
|
|
26
35
|
return [] if facts.empty? && notes.empty?
|
|
27
36
|
|
|
37
|
+
legacy_notes = notes.first(@notes_limit)
|
|
38
|
+
if config && @reranker && !request.message.to_s.strip.empty?
|
|
39
|
+
selected = select_memory(request, facts, notes.first(config.dig("rerank", "candidate_limit")), config)
|
|
40
|
+
facts, notes = selected || [facts, legacy_notes]
|
|
41
|
+
else
|
|
42
|
+
notes = legacy_notes
|
|
43
|
+
end
|
|
44
|
+
|
|
28
45
|
# priority MEMORY (75): between skills (80) and deferred tools (70) in
|
|
29
46
|
# the sacrifice order. pinned false (cuttable under a tight budget).
|
|
30
47
|
[ContextFragment.build(content: format_block(facts, notes, Insika::Fence.enabled?(request.profile)),
|
|
@@ -33,6 +50,38 @@ module Insika
|
|
|
33
50
|
|
|
34
51
|
private
|
|
35
52
|
|
|
53
|
+
def select_memory(request, facts, notes, config)
|
|
54
|
+
query = request.message.to_s
|
|
55
|
+
terms = query.downcase.scan(/[[:alnum:]]+/).uniq
|
|
56
|
+
candidates = facts.map do |fact|
|
|
57
|
+
{ kind: :fact, record: fact, id: "fact:#{fact.key}",
|
|
58
|
+
text: "#{fact.key} #{fact.value}", at: fact.updated_at.to_s }
|
|
59
|
+
end + notes.map do |note|
|
|
60
|
+
{ kind: :note, record: note, id: "note:#{note.id}",
|
|
61
|
+
text: note.text.to_s, at: note.created_at.to_s }
|
|
62
|
+
end
|
|
63
|
+
candidates.sort_by! do |item|
|
|
64
|
+
words = item[:text].downcase.scan(/[[:alnum:]]+/).uniq
|
|
65
|
+
[-terms.count { |term| words.include?(term) }, -Time.iso8601(item[:at]).to_f, item[:id]]
|
|
66
|
+
rescue ArgumentError
|
|
67
|
+
[-terms.count { |term| words.include?(term) }, 0, item[:id]]
|
|
68
|
+
end
|
|
69
|
+
candidates = candidates.first(config.dig("rerank", "candidate_limit"))
|
|
70
|
+
rerank = config.fetch("rerank")
|
|
71
|
+
timeout = [rerank.fetch("timeout_seconds"),
|
|
72
|
+
(request.profile.limits[:provider_timeout] || 5) * 0.9].min
|
|
73
|
+
documents = candidates.map { |item| item[:text] }
|
|
74
|
+
indexes = @reranker.select(query: query, documents: documents,
|
|
75
|
+
config: rerank.merge("timeout_seconds" => timeout),
|
|
76
|
+
top_k: config.fetch("top_k"), emit: request.diagnostics,
|
|
77
|
+
source: "Memory", budget: request.profile.limits[:context_budget] || 8_000)
|
|
78
|
+
return nil unless indexes
|
|
79
|
+
|
|
80
|
+
selected = indexes.map { |index| candidates[index] }
|
|
81
|
+
[selected.filter_map { |item| item[:record] if item[:kind] == :fact },
|
|
82
|
+
selected.filter_map { |item| item[:record] if item[:kind] == :note }]
|
|
83
|
+
end
|
|
84
|
+
|
|
36
85
|
# Engine memory scope (WS8): the request's CUSTOMER-scoped cell
|
|
37
86
|
# ("[tenant:]customer" — engine-owner memory is per customer, never per
|
|
38
87
|
# tenant) wins; otherwise an EXPLICIT tenant from the Command (the
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
module Context
|
|
7
|
+
module Providers
|
|
8
|
+
# The read path of a customer confirmation: the calls the envelope held on
|
|
9
|
+
# an earlier turn of this session, rendered at the TAIL of the context —
|
|
10
|
+
# right before the message that answers them — so the last thing the model
|
|
11
|
+
# reads before replying is the question the customer is replying to. Zero
|
|
12
|
+
# bytes when nothing is open, which is every turn of most conversations.
|
|
13
|
+
class PendingConfirmation < ContextProvider
|
|
14
|
+
def initialize(pending_action_store:)
|
|
15
|
+
@store = pending_action_store
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def id = "pending_confirmation"
|
|
19
|
+
|
|
20
|
+
# Pack-declared: no customer_confirm -> no provider.
|
|
21
|
+
def enabled_for?(profile)
|
|
22
|
+
list = profile.respond_to?(:customer_confirm) ? profile.customer_confirm : nil
|
|
23
|
+
!Array(list).empty?
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def allowlisted? = false # `customer_confirm` is the opt-in, not the allowlist
|
|
27
|
+
|
|
28
|
+
def call(request)
|
|
29
|
+
session = request.respond_to?(:session) ? request.session : nil
|
|
30
|
+
return [] if session.nil? || @store.nil?
|
|
31
|
+
|
|
32
|
+
open = @store.open_for_session(session.id, kind: PendingActionStore::CUSTOMER)
|
|
33
|
+
return [] if open.empty?
|
|
34
|
+
|
|
35
|
+
# A tail fragment IS a history message ({role:, content:}), like the
|
|
36
|
+
# Briefing's recitation — the Builder appends it after the transcript.
|
|
37
|
+
[ContextFragment.build(content: { role: :user, content: render(open) }, placement: :tail,
|
|
38
|
+
pinned: true, priority: Context::Priority::PENDING_CONFIRMATION, source: id)]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def render(open)
|
|
44
|
+
lines = open.map { |pa| "- #{pa.tool} #{JSON.generate(pa.args || {})} — pending_id #{pa.id}" }
|
|
45
|
+
"## Awaiting the customer's confirmation\n#{lines.join("\n")}\n" \
|
|
46
|
+
"Call confirm_pending only if the customer's message agrees to exactly this. " \
|
|
47
|
+
"Anything else — a change, a question, a new request — is cancel_pending."
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -33,6 +33,9 @@ module Insika
|
|
|
33
33
|
"the user you found nothing. Do not narrate the retries. Then conclude.\n" \
|
|
34
34
|
"- Tool error: read the error, fix the arguments or try another path; never " \
|
|
35
35
|
"repeat the exact same call.\n" \
|
|
36
|
+
"- Report an action as done only after its tool call returned success. Never " \
|
|
37
|
+
"say it happened before the call, and never for a call that failed, was " \
|
|
38
|
+
"blocked, or is still awaiting confirmation.\n" \
|
|
36
39
|
"- A URL in a tool result (e.g. a `url` field): quote it byte-for-byte. Never " \
|
|
37
40
|
"construct, guess, or rewrite the domain, host, or path."
|
|
38
41
|
|
|
@@ -83,7 +86,9 @@ module Insika
|
|
|
83
86
|
# NO identity at all, and a chatty model answered plausibly enough to hide
|
|
84
87
|
# it. Additive to prompt_files, not exclusive: an agent may carry both.
|
|
85
88
|
parts << profile&.base_prompt.to_s
|
|
86
|
-
Array(profile&.prompt_files)
|
|
89
|
+
sources = Array(profile&.prompt_files)
|
|
90
|
+
stored = stored_files(profile, sources)
|
|
91
|
+
sources.each { |src| parts << read_source(stored, src.to_s) }
|
|
87
92
|
identity = parts.reject { |p| p.nil? || p.strip.empty? }.join("\n\n")
|
|
88
93
|
# Discipline rides an EXISTING identity, never substitutes one: an
|
|
89
94
|
# empty identity here is fatal (`call` raises), not silently patched
|
|
@@ -109,10 +114,18 @@ module Insika
|
|
|
109
114
|
@system_files.list.map { |name| @system_files.read(name).to_s }
|
|
110
115
|
end
|
|
111
116
|
|
|
117
|
+
# The agent's files from ONE store read (`contents`); an agent_files object
|
|
118
|
+
# that only answers `read(agent_id, name)` is read per file, as before.
|
|
119
|
+
def stored_files(profile, sources)
|
|
120
|
+
return {} if sources.empty? || profile.id.nil? || @agent_files.nil?
|
|
121
|
+
return @agent_files.contents(profile.id) if @agent_files.respond_to?(:contents)
|
|
122
|
+
|
|
123
|
+
sources.to_h { |src| [src.to_s, @agent_files.read(profile.id, src.to_s)] }.compact
|
|
124
|
+
end
|
|
125
|
+
|
|
112
126
|
# Per-agent store first, disk second (compat/seed).
|
|
113
|
-
def read_source(
|
|
114
|
-
stored
|
|
115
|
-
return stored if stored
|
|
127
|
+
def read_source(stored, src)
|
|
128
|
+
return stored[src] if stored[src]
|
|
116
129
|
return File.read(src, encoding: "UTF-8") if File.exist?(src)
|
|
117
130
|
|
|
118
131
|
""
|
|
@@ -71,7 +71,11 @@ module Insika
|
|
|
71
71
|
# Symbol-keyed message preserving tool_calls / tool_call_id when present
|
|
72
72
|
# (a plain message keeps just {role, content} — parity with the old shape).
|
|
73
73
|
def normalize(msg)
|
|
74
|
+
return msg if msg["native_compaction"]
|
|
75
|
+
|
|
74
76
|
h = { role: msg[:role] || msg["role"], content: msg[:content] || msg["content"] }
|
|
77
|
+
attachments = msg[:attachments] || msg["attachments"]
|
|
78
|
+
h[:attachments] = attachments if attachments && !attachments.empty?
|
|
75
79
|
tool_calls = msg[:tool_calls] || msg["tool_calls"]
|
|
76
80
|
tool_call_id = msg[:tool_call_id] || msg["tool_call_id"]
|
|
77
81
|
h[:tool_calls] = tool_calls if tool_calls
|
|
@@ -114,12 +118,44 @@ module Insika
|
|
|
114
118
|
fresh = fresh_session(session)
|
|
115
119
|
return [[], nil] if fresh.nil?
|
|
116
120
|
|
|
117
|
-
|
|
121
|
+
# Operator corrections affect replay only, never the stored content or tool protocol.
|
|
122
|
+
messages = (fresh.messages || []).map do |message|
|
|
123
|
+
if message["role"] == "assistant" && Array(message["tool_calls"]).empty? && message["context_content"].is_a?(String)
|
|
124
|
+
message.merge("content" => message["context_content"])
|
|
125
|
+
else
|
|
126
|
+
message
|
|
127
|
+
end
|
|
128
|
+
end
|
|
118
129
|
state = fresh.respond_to?(:compaction) ? fresh.compaction : nil
|
|
119
130
|
upto = state ? state["upto"].to_i : 0
|
|
120
|
-
|
|
131
|
+
if upto.positive? && state["native"]
|
|
132
|
+
# ponytail: budget the full fallback too; model-aware allocation can
|
|
133
|
+
# recover this headroom once model selection precedes context building.
|
|
134
|
+
prefix = { "native_compaction" => state["native"], "messages" => without_no_reply(messages.take(upto)) }
|
|
135
|
+
return [[prefix] + without_no_reply(messages.drop(upto)), nil]
|
|
136
|
+
end
|
|
137
|
+
return [without_no_reply(messages), nil] unless upto.positive? && Coercion.present?(state["summary"])
|
|
138
|
+
|
|
139
|
+
[without_no_reply(messages.drop([upto, messages.size].min)), state]
|
|
140
|
+
end
|
|
121
141
|
|
|
122
|
-
|
|
142
|
+
# `NO_REPLY` is the OpenClaw gateway's silence sentinel. Conversations migrated
|
|
143
|
+
# from it carry it as agent replies, and the compaction summary restates the
|
|
144
|
+
# rule ("call_support → responder NO_REPLY"); the Insika prompts forbid the
|
|
145
|
+
# word (silence is an empty answer here), so replaying it only teaches the
|
|
146
|
+
# model to write it (Loja Exemplo and Supermercado Exemplo, 23/10/2026).
|
|
147
|
+
# Replay-only, like the operator corrections above: the stored transcript is
|
|
148
|
+
# untouched. Applied after the compaction cut so `upto` keeps indexing the
|
|
149
|
+
# stored list. A message left empty is dropped — the customer never saw it.
|
|
150
|
+
def without_no_reply(messages)
|
|
151
|
+
messages.filter_map do |message|
|
|
152
|
+
content = message["content"]
|
|
153
|
+
next message unless message["role"] == "assistant" && Array(message["tool_calls"]).empty? &&
|
|
154
|
+
content.is_a?(String) && content.include?("NO_REPLY")
|
|
155
|
+
|
|
156
|
+
kept = content.lines.grep_v(/NO_REPLY/).join.strip
|
|
157
|
+
message.merge("content" => kept) unless kept.empty?
|
|
158
|
+
end
|
|
123
159
|
end
|
|
124
160
|
|
|
125
161
|
# The compacted prefix as ONE history fragment, rendered FIRST (the
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
module Context
|
|
5
|
+
# Optional second pass over already scoped retrieval candidates.
|
|
6
|
+
class Reranker
|
|
7
|
+
MAX_INPUT_TOKENS = 4_000
|
|
8
|
+
|
|
9
|
+
def initialize(llm:)
|
|
10
|
+
@llm = llm
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Returns indexes into documents, or nil so the caller keeps lexical order.
|
|
14
|
+
def select(query:, documents:, config:, top_k:, emit: nil, source: "Knowledge", budget: MAX_INPUT_TOKENS)
|
|
15
|
+
return nil if documents.empty?
|
|
16
|
+
|
|
17
|
+
# The same estimator used by the context budget bounds the provider input.
|
|
18
|
+
tokens = [budget.to_i, MAX_INPUT_TOKENS].min
|
|
19
|
+
query_tokens = [tokens / 4, 1_000].min
|
|
20
|
+
per_document = [(tokens - query_tokens) / documents.length, 400].min
|
|
21
|
+
return nil if per_document < 1
|
|
22
|
+
|
|
23
|
+
bounded = documents.map { |text| trim(Insika::Safety::Detectors.redact(text).first, per_document) }
|
|
24
|
+
bounded_query = trim(Insika::Safety::Detectors.redact(query).first, query_tokens)
|
|
25
|
+
llm = operation_context(emit, config)
|
|
26
|
+
result = Async::Task.current.with_timeout(config.fetch("timeout_seconds")) do
|
|
27
|
+
llm.rerank(bounded_query, bounded, provider: config.fetch("provider"),
|
|
28
|
+
model: config.fetch("model"), top_n: top_k)
|
|
29
|
+
end
|
|
30
|
+
indexes = result.results.map(&:index)
|
|
31
|
+
unless indexes.length == [top_k, documents.length].min &&
|
|
32
|
+
indexes.all? { |index| index.is_a?(Integer) && index >= 0 && index < documents.length } &&
|
|
33
|
+
indexes.uniq.length == indexes.length
|
|
34
|
+
safe_emit(emit, :provider_warning, { provider: source, message: "invalid rerank indexes" })
|
|
35
|
+
return nil
|
|
36
|
+
end
|
|
37
|
+
safe_emit(emit, :retrieval_reranked,
|
|
38
|
+
{ provider: source, candidate_count: documents.length, selected_count: indexes.length })
|
|
39
|
+
indexes
|
|
40
|
+
rescue StandardError => error
|
|
41
|
+
# Provider messages may quote the query or a document. Only the class is safe.
|
|
42
|
+
safe_emit(emit, :provider_warning, { provider: source, message: "rerank failed: #{error.class.name}" })
|
|
43
|
+
nil
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def trim(value, limit)
|
|
49
|
+
text = value.to_s[0, limit * 4]
|
|
50
|
+
text = text[0...-1] while TokenEstimator.estimate(text) > limit
|
|
51
|
+
text
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def safe_emit(emit, type, data)
|
|
55
|
+
emit&.call(type, data)
|
|
56
|
+
rescue StandardError
|
|
57
|
+
nil
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def operation_context(emit, config)
|
|
61
|
+
source = @llm.respond_to?(:call) ? @llm.call : @llm
|
|
62
|
+
return source unless emit && source.respond_to?(:config)
|
|
63
|
+
|
|
64
|
+
require "ruby_llm"
|
|
65
|
+
require_relative "../telemetry/ruby_llm_instrumenter"
|
|
66
|
+
copy = source.config.dup
|
|
67
|
+
copy.instrumenter = Insika::Telemetry::RubyLLMInstrumenter.new(
|
|
68
|
+
delegate: copy.instrumenter, operation: "rerank", model: config.fetch("model"),
|
|
69
|
+
emit: ->(type, data) { safe_emit(emit, type, data) }
|
|
70
|
+
)
|
|
71
|
+
RubyLLM::Context.new(copy)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
data/lib/insika/distill.rb
CHANGED
|
@@ -73,7 +73,7 @@ module Insika
|
|
|
73
73
|
DROP_KEYS = %w[schema unknown_key oversized bad_turns duplicate capped].freeze
|
|
74
74
|
|
|
75
75
|
# ask: ->(prompt) { "<raw model text>" } | something answering #content
|
|
76
|
-
# (+ #
|
|
76
|
+
# (+ RubyLLM-compatible #tokens for cost).
|
|
77
77
|
# model: the ref recorded in the events ("utility_model" default).
|
|
78
78
|
attr_reader :model
|
|
79
79
|
|
|
@@ -93,7 +93,7 @@ module Insika
|
|
|
93
93
|
# Drops are counted, never fixed up.
|
|
94
94
|
def distill(prompt:, message_count:, max_proposals: 10)
|
|
95
95
|
answer = @ask.call(prompt)
|
|
96
|
-
raw = parse(
|
|
96
|
+
raw = parse(answer)
|
|
97
97
|
proposals = []
|
|
98
98
|
dropped = DROP_KEYS.to_h { |k| [k, 0] }
|
|
99
99
|
seen = {}
|
|
@@ -124,20 +124,25 @@ module Insika
|
|
|
124
124
|
# nil when the provider said nothing — never 0 (the Proposer's
|
|
125
125
|
# discipline). The cached prefix is INCLUDED in the spent total.
|
|
126
126
|
def cost_of(answer)
|
|
127
|
-
return nil unless answer.respond_to?(:
|
|
127
|
+
return nil unless answer.respond_to?(:tokens)
|
|
128
128
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
129
|
+
usage = answer.tokens
|
|
130
|
+
input = usage.input.to_i
|
|
131
|
+
output = usage.output.to_i
|
|
132
|
+
cached = usage.cache_read.to_i
|
|
133
|
+
spent = input + output + cached + usage.cache_write.to_i
|
|
133
134
|
spent.positive? ? { "spent" => spent, "cached" => cached } : nil
|
|
134
135
|
end
|
|
135
136
|
|
|
136
|
-
#
|
|
137
|
-
#
|
|
138
|
-
def parse(
|
|
139
|
-
|
|
140
|
-
|
|
137
|
+
# Native responses own JSON parsing; injected text asks retain their
|
|
138
|
+
# existing fenced-JSON contract. Domain validation still runs below.
|
|
139
|
+
def parse(answer)
|
|
140
|
+
parsed = if answer.respond_to?(:parsed)
|
|
141
|
+
answer.parsed
|
|
142
|
+
else
|
|
143
|
+
JSON.parse(text_of(answer).strip.gsub(/\A```(?:json)?\s*|\s*```\z/, ""))
|
|
144
|
+
end
|
|
145
|
+
parsed = parsed["items"] if parsed.is_a?(Hash)
|
|
141
146
|
raise Unusable, "the distiller's answer is not an array" unless parsed.is_a?(Array)
|
|
142
147
|
|
|
143
148
|
parsed
|
|
@@ -215,8 +220,12 @@ module Insika
|
|
|
215
220
|
require "ruby_llm"
|
|
216
221
|
llm ||= RubyLLM
|
|
217
222
|
lambda do |prompt|
|
|
223
|
+
# DeepSeek's native adapter uses JSON-object mode, requiring an envelope.
|
|
224
|
+
schema = { "type" => "object", "properties" => { "items" => PROPOSAL_SCHEMA.json_schema },
|
|
225
|
+
"required" => ["items"], "additionalProperties" => false }
|
|
218
226
|
llm.chat(model: model, provider: provider, assume_model_exists: true)
|
|
219
|
-
.with_temperature(0).
|
|
227
|
+
.with_temperature(0).with_schema("schema" => schema, "strict" => false)
|
|
228
|
+
.ask("#{prompt}\n\nReturn the requested array inside a JSON object with the key \"items\".")
|
|
220
229
|
end
|
|
221
230
|
end
|
|
222
231
|
end
|
data/lib/insika/doctor.rb
CHANGED
|
@@ -181,7 +181,7 @@ module Insika
|
|
|
181
181
|
check_memory_scopes check_funnel_declarations check_followup check_distill
|
|
182
182
|
check_compaction check_harvest check_schedules check_guardrail_corpora
|
|
183
183
|
check_tool_allowlist_policy check_fencing check_eval_seeding
|
|
184
|
-
check_presentation_tools check_provenance]
|
|
184
|
+
check_presentation_tools check_provenance check_customer_confirm]
|
|
185
185
|
|
|
186
186
|
def safe(check)
|
|
187
187
|
Array(send(check))
|
|
@@ -316,16 +316,18 @@ module Insika
|
|
|
316
316
|
[Finding.new(check: "default-model", severity: :warn, message: msg, fix: fix)]
|
|
317
317
|
end
|
|
318
318
|
|
|
319
|
+
def backend_name = @backend.class.name.split("::").last
|
|
320
|
+
|
|
319
321
|
def check_db
|
|
320
|
-
unless
|
|
322
|
+
unless Insika::Stores.durable?(@backend)
|
|
321
323
|
return [Finding.new(check: "db", severity: :info,
|
|
322
324
|
message: "ephemeral backend (INSIKA_DB unset) — config and state do NOT survive a restart", fix: nil)]
|
|
323
325
|
end
|
|
324
326
|
|
|
325
327
|
@backend.get("__doctor__", "probe") # a read round-trips the handle; raises if the file is broken
|
|
326
|
-
[ok("db", "durable backend:
|
|
328
|
+
[ok("db", "durable backend: #{backend_name} (readable)")]
|
|
327
329
|
rescue StandardError => e
|
|
328
|
-
[Finding.new(check: "db", severity: :error, message: "
|
|
330
|
+
[Finding.new(check: "db", severity: :error, message: "#{backend_name} backend not usable: #{e.class}: #{e.message}", fix: nil)]
|
|
329
331
|
end
|
|
330
332
|
|
|
331
333
|
def check_llm_provider
|
|
@@ -1009,6 +1011,31 @@ def wrapped_content?(content) = /\A\s*\{\s*"[^"]+"\s*=>/.match?(content.to_s)
|
|
|
1009
1011
|
end
|
|
1010
1012
|
end
|
|
1011
1013
|
|
|
1014
|
+
# `customer_confirm` names tools; one the agent denies, or leaves out of an
|
|
1015
|
+
# explicit allowlist, is a hold that can never happen. An allowlist carrying an
|
|
1016
|
+
# `mcp:` entry is not explicit about tool NAMES (the server contributes them),
|
|
1017
|
+
# so it is not judged.
|
|
1018
|
+
def check_customer_confirm
|
|
1019
|
+
return [] unless @profile_source
|
|
1020
|
+
|
|
1021
|
+
findings = @profile_source.all.flat_map do |profile|
|
|
1022
|
+
names = profile.respond_to?(:customer_confirm) ? Array(profile.customer_confirm) : []
|
|
1023
|
+
next [] if names.empty?
|
|
1024
|
+
|
|
1025
|
+
allow = profile.tools_allow
|
|
1026
|
+
explicit = !allow.nil? && Array(allow).none? { |a| a.to_s.start_with?("mcp:") }
|
|
1027
|
+
dead = names.select do |name|
|
|
1028
|
+
Array(profile.tools_deny).include?(name) || (explicit && !Array(allow).include?(name))
|
|
1029
|
+
end
|
|
1030
|
+
next [] if dead.empty?
|
|
1031
|
+
|
|
1032
|
+
[Finding.new(check: "customer_confirm", severity: :warn, fix: nil,
|
|
1033
|
+
message: "agent '#{profile.id}' lists #{dead.join(', ')} under customer_confirm but does " \
|
|
1034
|
+
"not allow the tool — the hold can never happen")]
|
|
1035
|
+
end
|
|
1036
|
+
findings.empty? ? [ok("customer_confirm", "customer_confirm: every held tool is allowed")] : findings
|
|
1037
|
+
end
|
|
1038
|
+
|
|
1012
1039
|
def check_grounding
|
|
1013
1040
|
return [] unless @profile_source
|
|
1014
1041
|
|
|
@@ -1381,6 +1408,9 @@ def wrapped_content?(content) = /\A\s*\{\s*"[^"]+"\s*=>/.match?(content.to_s)
|
|
|
1381
1408
|
settings = @settings_store.get
|
|
1382
1409
|
config = settings["compaction"] || {}
|
|
1383
1410
|
return [ok("compaction", "in-session compaction off")] unless Coercion.truthy?(config["enabled"])
|
|
1411
|
+
if config["mode"] == "native"
|
|
1412
|
+
return [ok("compaction", "native compaction on for supported Responses models; no summary fallback")]
|
|
1413
|
+
end
|
|
1384
1414
|
|
|
1385
1415
|
if Coercion.presence(config["model"]).nil? && Coercion.presence(settings["utility_model"]).nil?
|
|
1386
1416
|
[Finding.new(check: "compaction", severity: :warn, fix: nil,
|