insika 0.8.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 +312 -2
- data/README.md +10 -6
- data/bin/insika +4 -1
- data/bin/insika-router +0 -1
- data/docs/AGENTS.md +142 -18
- data/docs/API.md +73 -0
- data/docs/ARCHITECTURE.md +56 -50
- data/docs/ARTIFACTS.md +33 -3
- data/docs/BENCHMARK.md +50 -7
- data/docs/CHANNELS.md +19 -2
- data/docs/CONTEXT.md +96 -27
- data/docs/CROSS-HARNESS-BENCH.md +157 -0
- data/docs/DEPLOY.md +70 -13
- data/docs/EMBEDDING.md +10 -0
- data/docs/EVALS.md +129 -10
- data/docs/FACTS.md +12 -6
- data/docs/HARVEST.md +8 -3
- data/docs/KNOWLEDGE.md +14 -0
- data/docs/MEDIA.md +6 -2
- data/docs/OBSERVABILITY.md +164 -28
- data/docs/PERFORMANCE.md +110 -0
- data/docs/PLUGINS.md +4 -4
- data/docs/POLICY.md +26 -14
- data/docs/RELEASING.md +9 -0
- data/docs/ROUTER.md +7 -0
- data/docs/RUBYLLM_2_MIGRATION.md +307 -0
- data/docs/SECURITY.md +34 -1
- data/docs/SHARED-CONVERSATIONS.md +40 -0
- data/docs/SKILLS.md +5 -3
- data/docs/STUDIO.md +122 -0
- data/docs/TOOLS.md +238 -42
- data/docs/operate.md +3 -0
- 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_defaults.rb +140 -0
- data/lib/insika/agent_file_store.rb +12 -0
- data/lib/insika/agent_profile.rb +99 -17
- data/lib/insika/artifact_signing.rb +11 -0
- data/lib/insika/channels/web/widget.js +33 -0
- data/lib/insika/channels/web.rb +5 -2
- data/lib/insika/chat_builder.rb +124 -28
- 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_distillation.rb +5 -8
- data/lib/insika/commands/run_harvest.rb +17 -1
- data/lib/insika/commands/seed_session.rb +128 -0
- 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/builder.rb +29 -9
- data/lib/insika/context/priority.rb +5 -0
- data/lib/insika/context/provider.rb +7 -2
- 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 +32 -6
- data/lib/insika/context/providers/memory.rb +61 -8
- 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 +46 -6
- data/lib/insika/context/reranker.rb +75 -0
- data/lib/insika/distill.rb +22 -13
- data/lib/insika/doctor.rb +142 -4
- data/lib/insika/dsl/runtime.rb +37 -4
- data/lib/insika/dsl/server_boot.rb +4 -12
- data/lib/insika/dsl.rb +19 -5
- data/lib/insika/edge_limiter.rb +27 -16
- data/lib/insika/egress_guard.rb +11 -3
- data/lib/insika/env_schema.rb +33 -73
- data/lib/insika/errors.rb +1 -0
- data/lib/insika/evals/assertions.rb +259 -18
- data/lib/insika/evals/golden.rb +230 -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 +64 -4
- data/lib/insika/evals/simulator.rb +21 -3
- data/lib/insika/evals/transport.rb +317 -15
- data/lib/insika/event_stream.rb +63 -7
- data/lib/insika/evidence.rb +105 -17
- data/lib/insika/executor.rb +698 -164
- data/lib/insika/fence.rb +96 -0
- data/lib/insika/golden_store.rb +3 -0
- 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 +37 -3
- data/lib/insika/mcp_tool_registry.rb +76 -26
- data/lib/insika/media.rb +38 -25
- data/lib/insika/memory_store.rb +16 -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 +30 -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/prefix_fingerprint.rb +32 -27
- data/lib/insika/profile_source.rb +5 -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 +58 -7
- data/lib/insika/server/rack_app.rb +2 -0
- data/lib/insika/server/responses.rb +50 -5
- data/lib/insika/session_actor.rb +58 -28
- data/lib/insika/session_store.rb +163 -11
- data/lib/insika/settings_store.rb +57 -2
- 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/spoken_transcript.rb +31 -0
- 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 +1459 -163
- 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 +189 -12
- 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 +83 -243
- 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} +38 -11
- 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 +153 -118
- 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 +115 -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_assembly.rb +21 -13
- data/lib/insika/tool_batch.rb +1 -1
- data/lib/insika/tool_catalog.rb +12 -2
- data/lib/insika/tool_definition.rb +82 -13
- data/lib/insika/tool_envelope.rb +266 -29
- data/lib/insika/tool_manifest.rb +1 -0
- data/lib/insika/tool_store.rb +12 -4
- data/lib/insika/tool_trace_store.rb +15 -8
- data/lib/insika/tool_usage_report.rb +14 -4
- 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 +24 -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 +115 -0
- data/lib/insika/tools/remember.rb +7 -2
- data/lib/insika/tools/run_persona_eval.rb +9 -6
- 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 +11 -7
- 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 +31 -1
- data/lib/insika/turn_timing.rb +51 -14
- data/lib/insika/version.rb +1 -1
- data/lib/insika/wiring/graph.rb +88 -16
- data/lib/insika/wiring/graph_chat.rb +4 -0
- data/lib/insika/workflow.rb +25 -64
- data/lib/insika.rb +23 -3
- metadata +74 -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
|
@@ -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
|
""
|
|
@@ -39,7 +39,7 @@ module Insika
|
|
|
39
39
|
source: id
|
|
40
40
|
)
|
|
41
41
|
end
|
|
42
|
-
compaction ? [compaction_fragment(compaction)] + fragments : fragments
|
|
42
|
+
compaction ? [compaction_fragment(compaction, request)] + fragments : fragments
|
|
43
43
|
end
|
|
44
44
|
|
|
45
45
|
private
|
|
@@ -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
|
|
@@ -127,10 +163,14 @@ module Insika
|
|
|
127
163
|
# provider-agnostic (a mid-history "system" message is not). Priority
|
|
128
164
|
# COMPACTION (59): the "oldest unit" — under budget it drops before any
|
|
129
165
|
# verbatim message. source "compaction" -> its own context-trace category.
|
|
130
|
-
|
|
166
|
+
# Fenced when the agent has `fencing` on: the summary is model-written from
|
|
167
|
+
# customer text, and the notice promises the model this block is material.
|
|
168
|
+
def compaction_fragment(state, request)
|
|
169
|
+
summary = state["summary"].to_s
|
|
170
|
+
summary = Insika::Fence.sanitize_text(summary) if Insika::Fence.enabled?(request.profile)
|
|
131
171
|
ContextFragment.build(
|
|
132
172
|
content: { role: "user",
|
|
133
|
-
content: "<conversation_summary>\n#{
|
|
173
|
+
content: "<conversation_summary>\n#{summary}\n</conversation_summary>" },
|
|
134
174
|
placement: :history,
|
|
135
175
|
priority: Context::Priority::COMPACTION,
|
|
136
176
|
source: "compaction"
|
|
@@ -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
|
@@ -180,7 +180,8 @@ module Insika
|
|
|
180
180
|
check_soak_envelope check_turn_timing check_grounding check_cache_layers
|
|
181
181
|
check_memory_scopes check_funnel_declarations check_followup check_distill
|
|
182
182
|
check_compaction check_harvest check_schedules check_guardrail_corpora
|
|
183
|
-
check_tool_allowlist_policy
|
|
183
|
+
check_tool_allowlist_policy check_fencing check_eval_seeding
|
|
184
|
+
check_presentation_tools check_provenance check_customer_confirm]
|
|
184
185
|
|
|
185
186
|
def safe(check)
|
|
186
187
|
Array(send(check))
|
|
@@ -315,16 +316,18 @@ module Insika
|
|
|
315
316
|
[Finding.new(check: "default-model", severity: :warn, message: msg, fix: fix)]
|
|
316
317
|
end
|
|
317
318
|
|
|
319
|
+
def backend_name = @backend.class.name.split("::").last
|
|
320
|
+
|
|
318
321
|
def check_db
|
|
319
|
-
unless
|
|
322
|
+
unless Insika::Stores.durable?(@backend)
|
|
320
323
|
return [Finding.new(check: "db", severity: :info,
|
|
321
324
|
message: "ephemeral backend (INSIKA_DB unset) — config and state do NOT survive a restart", fix: nil)]
|
|
322
325
|
end
|
|
323
326
|
|
|
324
327
|
@backend.get("__doctor__", "probe") # a read round-trips the handle; raises if the file is broken
|
|
325
|
-
[ok("db", "durable backend:
|
|
328
|
+
[ok("db", "durable backend: #{backend_name} (readable)")]
|
|
326
329
|
rescue StandardError => e
|
|
327
|
-
[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)]
|
|
328
331
|
end
|
|
329
332
|
|
|
330
333
|
def check_llm_provider
|
|
@@ -836,6 +839,7 @@ def wrapped_content?(content) = /\A\s*\{\s*"[^"]+"\s*=>/.match?(content.to_s)
|
|
|
836
839
|
# identity block bills a cache write every turn).
|
|
837
840
|
IDENTITY_BUILTINS = %w[
|
|
838
841
|
Insika::Context::Providers::Prompt
|
|
842
|
+
Insika::Context::Providers::FenceNotice
|
|
839
843
|
Insika::Context::Providers::Skill
|
|
840
844
|
Insika::Context::Providers::ToolSearch
|
|
841
845
|
].freeze
|
|
@@ -942,6 +946,96 @@ def wrapped_content?(content) = /\A\s*\{\s*"[^"]+"\s*=>/.match?(content.to_s)
|
|
|
942
946
|
|
|
943
947
|
def names_tool_allowlist?(raw) = Array(raw["policies"]).any? { |p| p.to_s == "tool_allowlist" }
|
|
944
948
|
|
|
949
|
+
# A stored agent that allows a PRESENTATION tool but no tool declaring
|
|
950
|
+
# `evidence`. The presentation tool shows only cards an evidence tool returned
|
|
951
|
+
# this session, so with no evidence tool in reach every call drops every id —
|
|
952
|
+
# the model keeps asking to show products and nothing ever appears. Silent
|
|
953
|
+
# unless a presentation tool exists. Data tools only: a code tool's evidence
|
|
954
|
+
# lives in registry metadata the doctor does not read.
|
|
955
|
+
def check_presentation_tools
|
|
956
|
+
return [] unless @tool_store && @profile_source
|
|
957
|
+
|
|
958
|
+
raws = @tool_store.all_raw
|
|
959
|
+
presenting = raws.select { |r| r["presentation"] }.map { |r| r["name"].to_s }
|
|
960
|
+
return [] if presenting.empty?
|
|
961
|
+
|
|
962
|
+
evidence = raws.select { |r| r["evidence"] }.map { |r| r["name"].to_s }
|
|
963
|
+
bad = @profile_source.all.filter_map do |profile|
|
|
964
|
+
allowed = allowed_data_tools(profile, raws)
|
|
965
|
+
shows = presenting & allowed
|
|
966
|
+
next if shows.empty? || !(evidence & allowed).empty?
|
|
967
|
+
|
|
968
|
+
Finding.new(check: "presentation-tools", severity: :warn, fix: nil,
|
|
969
|
+
message: "agent '#{profile.id}' allows #{shows.join(', ')} but no tool declaring " \
|
|
970
|
+
"evidence — a presentation tool can only show cards an evidence tool " \
|
|
971
|
+
"returned this session, so every call would drop every id. Allow the " \
|
|
972
|
+
"search tool that declares `evidence`, or drop the presentation tool.")
|
|
973
|
+
end
|
|
974
|
+
return bad unless bad.empty?
|
|
975
|
+
|
|
976
|
+
[ok("presentation-tools", "#{presenting.length} presentation tool(s): every agent that shows cards " \
|
|
977
|
+
"also allows an evidence tool")]
|
|
978
|
+
end
|
|
979
|
+
|
|
980
|
+
# The data tools a profile may call, by the allowlist's own rules: nil lists =
|
|
981
|
+
# all; otherwise names ∪ groups; deny wins.
|
|
982
|
+
def allowed_data_tools(profile, raws)
|
|
983
|
+
names = profile.tools_allow.nil? ? nil : Array(profile.tools_allow).map(&:to_s)
|
|
984
|
+
groups = profile.tools_allow_groups.nil? ? nil : Array(profile.tools_allow_groups).map(&:to_s)
|
|
985
|
+
allowed = if names.nil? && groups.nil?
|
|
986
|
+
raws.map { |r| r["name"].to_s }
|
|
987
|
+
else
|
|
988
|
+
Array(names) | raws.select { |r| Array(groups).include?(r["group"].to_s) }.map { |r| r["name"].to_s }
|
|
989
|
+
end
|
|
990
|
+
allowed - Array(profile.tools_deny).map(&:to_s)
|
|
991
|
+
end
|
|
992
|
+
|
|
993
|
+
def check_provenance
|
|
994
|
+
return [] unless @tool_store && @profile_source.respond_to?(:all_raw)
|
|
995
|
+
|
|
996
|
+
tools = @tool_store.all_raw
|
|
997
|
+
@profile_source.all_raw.filter_map do |raw|
|
|
998
|
+
allowed = tools.select do |tool|
|
|
999
|
+
name = tool["name"]
|
|
1000
|
+
unrestricted = raw["tools_allow"].nil? && raw["tools_allow_groups"].nil?
|
|
1001
|
+
(unrestricted || Array(raw["tools_allow"]).include?(name) ||
|
|
1002
|
+
Array(raw["tools_allow_groups"]).include?(tool["group"])) &&
|
|
1003
|
+
!Array(raw["tools_deny"]).include?(name)
|
|
1004
|
+
end
|
|
1005
|
+
gated = allowed.select { |tool| tool["requires_evidence"] }
|
|
1006
|
+
next if gated.empty? || allowed.any? { |tool| tool["evidence"] }
|
|
1007
|
+
|
|
1008
|
+
Finding.new(check: "provenance", severity: :warn, fix: nil,
|
|
1009
|
+
message: "agent '#{raw["id"]}' allows #{gated.map { |t| t["name"] }.join(', ')} " \
|
|
1010
|
+
"with requires_evidence but no stored evidence tool — verify that an allowed code tool supplies evidence")
|
|
1011
|
+
end
|
|
1012
|
+
end
|
|
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
|
+
|
|
945
1039
|
def check_grounding
|
|
946
1040
|
return [] unless @profile_source
|
|
947
1041
|
|
|
@@ -1314,6 +1408,9 @@ def wrapped_content?(content) = /\A\s*\{\s*"[^"]+"\s*=>/.match?(content.to_s)
|
|
|
1314
1408
|
settings = @settings_store.get
|
|
1315
1409
|
config = settings["compaction"] || {}
|
|
1316
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
|
|
1317
1414
|
|
|
1318
1415
|
if Coercion.presence(config["model"]).nil? && Coercion.presence(settings["utility_model"]).nil?
|
|
1319
1416
|
[Finding.new(check: "compaction", severity: :warn, fix: nil,
|
|
@@ -1325,6 +1422,47 @@ def wrapped_content?(content) = /\A\s*\{\s*"[^"]+"\s*=>/.match?(content.to_s)
|
|
|
1325
1422
|
end
|
|
1326
1423
|
end
|
|
1327
1424
|
|
|
1425
|
+
# `evals.seeding` opens POST /v1/conversations/:id/seed: a snapshot written
|
|
1426
|
+
# into a real conversation under the tenant token, before its first turn. Right
|
|
1427
|
+
# on the machine running snapshot evals; wrong left on in production, where any
|
|
1428
|
+
# consumer holding the token could write a customer's history. A warning, not
|
|
1429
|
+
# an error — the operator may be running the evals right now.
|
|
1430
|
+
def check_eval_seeding
|
|
1431
|
+
return [] unless @settings_store
|
|
1432
|
+
|
|
1433
|
+
on = ((@settings_store.get["evals"] || {})["seeding"]) == true
|
|
1434
|
+
return [ok("eval-seeding", "evals.seeding off — conversations cannot be seeded")] unless on
|
|
1435
|
+
|
|
1436
|
+
[Finding.new(check: "eval-seeding", severity: :warn, fix: nil,
|
|
1437
|
+
message: "evals.seeding is ON — POST /v1/conversations/:id/seed accepts a fabricated " \
|
|
1438
|
+
"conversation state under the tenant token. Fine while running snapshot " \
|
|
1439
|
+
"evals; turn it off (Studio > Settings, or `update_settings`) in production.")]
|
|
1440
|
+
end
|
|
1441
|
+
|
|
1442
|
+
# A customer-facing agent — one reachable through an inbound channel: any
|
|
1443
|
+
# agent when the relay is mounted (the event names the agent), the listed
|
|
1444
|
+
# ones for the widget — with `fencing` off reads third-party bytes as-is.
|
|
1445
|
+
# A warning, never an error: the default is off this release on purpose
|
|
1446
|
+
# (the goldens were baselined unfenced), so the message says exactly what
|
|
1447
|
+
# is unsanitized today. No inbound channel = nothing customer-facing = silent.
|
|
1448
|
+
def check_fencing
|
|
1449
|
+
return [] unless @profile_source
|
|
1450
|
+
|
|
1451
|
+
relay = Insika::Coercion.present?(@env["INSIKA_RELAY_TOKEN"])
|
|
1452
|
+
widget = @env["INSIKA_WIDGET_AGENTS"].to_s.split(",").map(&:strip).reject(&:empty?)
|
|
1453
|
+
return [] unless relay || widget.any?
|
|
1454
|
+
|
|
1455
|
+
exposed = @profile_source.all.select { |p| relay || widget.include?(p.id.to_s) }
|
|
1456
|
+
unfenced = exposed.reject { |p| Insika::Fence.enabled?(p) }.map(&:id)
|
|
1457
|
+
return [ok("fencing", "fencing on for every agent behind an inbound channel")] if unfenced.empty?
|
|
1458
|
+
|
|
1459
|
+
[Finding.new(check: "fencing", severity: :warn, fix: nil,
|
|
1460
|
+
message: "agent(s) #{unfenced.join(', ')}: reachable through an inbound channel with " \
|
|
1461
|
+
"`fencing` off — tool results, <memory> facts and <knowledge> concepts reach " \
|
|
1462
|
+
"the model byte-for-byte (invisible characters, forged turn markers and " \
|
|
1463
|
+
"transcript-shaped tags included). Set `fencing true` on the agent.")]
|
|
1464
|
+
end
|
|
1465
|
+
|
|
1328
1466
|
# the harvest check — per profile WITH a harvest hash:
|
|
1329
1467
|
# declared-without-model warn (D12), no grounding matcher warn (D3),
|
|
1330
1468
|
# malformed negative list error (D4), else ok with the pending counts.
|
data/lib/insika/dsl/runtime.rb
CHANGED
|
@@ -114,7 +114,8 @@ module Insika
|
|
|
114
114
|
edge_limiter: c[:edge_limiter],
|
|
115
115
|
executor_extra: { settings_store: c[:settings_store], tool_trace_store: c[:tool_trace_store],
|
|
116
116
|
context_trace_store: c[:context_trace_store],
|
|
117
|
-
cache_series_store: c[:cache_series_store], llm: @llm
|
|
117
|
+
cache_series_store: c[:cache_series_store], llm: @llm,
|
|
118
|
+
llm_refresh: -> { c[:configurator].refresh } }
|
|
118
119
|
)
|
|
119
120
|
register_authoring_commands(graph, c)
|
|
120
121
|
register_workflows(graph)
|
|
@@ -153,7 +154,8 @@ module Insika
|
|
|
153
154
|
end
|
|
154
155
|
|
|
155
156
|
graph.bus.register(:trigger_workflow, Insika::Commands::TriggerWorkflow.new(
|
|
156
|
-
profiles: graph.profiles,
|
|
157
|
+
profiles: Insika::AgentDefaults.for_turns(graph.profiles, @components[:settings_store]),
|
|
158
|
+
session_store: graph.session_store,
|
|
157
159
|
task_store: graph.task_store, executor: graph.executor,
|
|
158
160
|
workflow_registry: graph.workflow_registry
|
|
159
161
|
))
|
|
@@ -180,9 +182,35 @@ module Insika
|
|
|
180
182
|
attrs[:headers] = existing["headers"]
|
|
181
183
|
end
|
|
182
184
|
mcp_store.upsert(attrs)
|
|
185
|
+
list_tools_once(c, attrs[:name])
|
|
183
186
|
end
|
|
184
187
|
end
|
|
185
188
|
|
|
189
|
+
# A CODE-DECLARED server whose tools were never listed offers the agent
|
|
190
|
+
# NOTHING: the registry's `entries` reads the cached descriptors, so an empty
|
|
191
|
+
# cache means the model is never told those tools exist and answers out of its
|
|
192
|
+
# own head instead — silently, which is the worst shape a missing tool can
|
|
193
|
+
# take (a bench run caught exactly that: an agent with no catalogue invented
|
|
194
|
+
# one). So the FIRST boot lists them.
|
|
195
|
+
#
|
|
196
|
+
# A failure to list does NOT stop the boot — a stdio server behind a closed
|
|
197
|
+
# gate and a server that is momentarily down are both ordinary, and a
|
|
198
|
+
# deployment that refuses to start over either would be worse than the problem.
|
|
199
|
+
# It is said out loud instead, naming the consequence, because the failure this
|
|
200
|
+
# exists to end is the silent one.
|
|
201
|
+
#
|
|
202
|
+
# Only when the cache is empty. A later boot is free, and an operator who
|
|
203
|
+
# refreshed by hand is not overruled.
|
|
204
|
+
def list_tools_once(c, name)
|
|
205
|
+
record = c[:mcp_store].get_raw(name.to_s)
|
|
206
|
+
return unless record && record["enabled"] && Array(record["tools_cache"]).empty?
|
|
207
|
+
|
|
208
|
+
c[:mcp_tool_registry].refresh(name.to_s)
|
|
209
|
+
rescue StandardError => e
|
|
210
|
+
warn "insika: mcp \"#{name}\" was declared in code but its tools could not be listed " \
|
|
211
|
+
"(#{e.message}). The agent runs WITHOUT them until `insika mcp refresh #{name}` succeeds."
|
|
212
|
+
end
|
|
213
|
+
|
|
186
214
|
def build_components(backend, spine)
|
|
187
215
|
config_store = Insika::ConfigStore.new(store: backend)
|
|
188
216
|
settings_store = Insika::SettingsStore.new(config_store: config_store)
|
|
@@ -231,14 +259,16 @@ module Insika
|
|
|
231
259
|
base: "", catalog: c[:prompt_catalog],
|
|
232
260
|
agent_files: c[:agent_file_store], system_files: c[:system_file_store]
|
|
233
261
|
),
|
|
262
|
+
Insika::Context::Providers::FenceNotice.new,
|
|
234
263
|
Insika::Context::Providers::Skill.new(catalog: c[:skill_catalog]),
|
|
235
264
|
Insika::Context::Providers::SkillTrigger.new(catalog: c[:skill_catalog]),
|
|
236
265
|
Insika::Context::Providers::ToolSearch.new(catalog: c[:tool_catalog]),
|
|
237
|
-
Insika::Context::Providers::Memory.new(store: spine.memory_store),
|
|
238
|
-
Insika::Context::Providers::Knowledge.new(store: spine.knowledge_store),
|
|
266
|
+
Insika::Context::Providers::Memory.new(store: spine.memory_store, llm: @llm),
|
|
267
|
+
Insika::Context::Providers::Knowledge.new(store: spine.knowledge_store, llm: @llm),
|
|
239
268
|
# Session briefing: read path. Inert for agents without briefing_fields.
|
|
240
269
|
# Before Session, so the durable head renders above the transcript and
|
|
241
270
|
# the tail recitation lands after it.
|
|
271
|
+
Insika::Context::Providers::PendingConfirmation.new(pending_action_store: spine.pending_action_store),
|
|
242
272
|
Insika::Context::Providers::Briefing.new(session_store: spine.session_store),
|
|
243
273
|
Insika::Context::Providers::Session.new(session_store: spine.session_store)
|
|
244
274
|
] # NOT frozen: load_plugins appends plugin providers at boot
|
|
@@ -267,6 +297,8 @@ module Insika
|
|
|
267
297
|
# the LGPD access right — the Studio Customers drill exports.
|
|
268
298
|
bus.register(:export_customer_memory, Insika::Commands::ExportCustomerMemory.new(memory_store: mem, event_stream: es))
|
|
269
299
|
bus.register(:update_settings, Insika::Commands::UpdateSettings.new(settings_store: c[:settings_store], event_stream: es))
|
|
300
|
+
bus.register(:write_kit, Insika::Commands::WriteKit.new(settings_store: c[:settings_store], event_stream: es))
|
|
301
|
+
bus.register(:delete_kit, Insika::Commands::DeleteKit.new(settings_store: c[:settings_store], event_stream: es))
|
|
270
302
|
bus.register(:upsert_llm_provider, Insika::Commands::UpsertLLMProvider.new(provider_store: c[:provider_store], configurator: c[:configurator], event_stream: es))
|
|
271
303
|
bus.register(:delete_llm_provider, Insika::Commands::DeleteLLMProvider.new(provider_store: c[:provider_store], configurator: c[:configurator], event_stream: es))
|
|
272
304
|
bus.register(:upsert_mcp, Insika::Commands::UpsertMcp.new(mcp_store: c[:mcp_store], mcp_registry: c[:mcp_tool_registry], event_stream: es))
|
|
@@ -309,6 +341,7 @@ module Insika
|
|
|
309
341
|
return nil unless RubyLLM.respond_to?(:context)
|
|
310
342
|
|
|
311
343
|
primary = provider_name
|
|
344
|
+
# Preserve the host instrumenter; Executor wraps a copy for each task chat.
|
|
312
345
|
RubyLLM.context do |cfg|
|
|
313
346
|
provider_names.each do |provider|
|
|
314
347
|
# The explicit api_key/api_base belong to the PRIMARY provider; the
|
|
@@ -95,6 +95,8 @@ module Insika
|
|
|
95
95
|
session_store: @graph.session_store,
|
|
96
96
|
settings_store: @rt.component(:settings_store), llm_provider_store: @rt.component(:provider_store),
|
|
97
97
|
mcp_store: @rt.component(:mcp_store), system_file_store: @rt.component(:system_file_store),
|
|
98
|
+
llm_trace_store: @graph.llm_trace_store,
|
|
99
|
+
model_metrics_store: @graph.model_metrics_store,
|
|
98
100
|
tool_trace_store: @rt.component(:tool_trace_store),
|
|
99
101
|
context_trace_store: @rt.component(:context_trace_store),
|
|
100
102
|
# the per-agent cache-hit series (agent-detail card).
|
|
@@ -134,20 +136,10 @@ module Insika
|
|
|
134
136
|
# config (nil without INSIKA_ARTIFACT_SIGNING_KEY) decides whether a
|
|
135
137
|
# signed sharing link exists at all.
|
|
136
138
|
artifact_store: @graph.artifact_store,
|
|
137
|
-
artifact_signing:
|
|
139
|
+
artifact_signing: Insika::ArtifactSigning.studio_config
|
|
138
140
|
)
|
|
139
141
|
end
|
|
140
142
|
|
|
141
|
-
# -> { key:, ttl:, base_url: } | nil (no signing key -> no signed surface).
|
|
142
|
-
def artifact_signing
|
|
143
|
-
key = Insika::EnvSchema.read("INSIKA_ARTIFACT_SIGNING_KEY")
|
|
144
|
-
return nil if key.to_s.empty?
|
|
145
|
-
|
|
146
|
-
{ key: key,
|
|
147
|
-
ttl: (Insika::EnvSchema.read("INSIKA_ARTIFACT_SIGNING_TTL") || 604_800).to_i,
|
|
148
|
-
base_url: Insika::Coercion.presence(Insika::EnvSchema.read("INSIKA_PUBLIC_URL")).to_s }
|
|
149
|
-
end
|
|
150
|
-
|
|
151
143
|
# The frozen criterion — best-effort at boot: a bare install
|
|
152
144
|
# without the env var renders the ruler's hole instead of
|
|
153
145
|
# crashing the Studio.
|
|
@@ -166,7 +158,7 @@ module Insika
|
|
|
166
158
|
end
|
|
167
159
|
|
|
168
160
|
def persistence
|
|
169
|
-
@graph.durable? ? "durable (
|
|
161
|
+
@graph.durable? ? "durable (#{@graph.backend.class.name.split("::").last.downcase})" : "ephemeral (memory)"
|
|
170
162
|
end
|
|
171
163
|
|
|
172
164
|
def banner(telemetry = nil)
|