insika 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +116 -0
- data/README.md +4 -3
- data/bin/insika +43 -2
- data/docs/AGENTS.md +22 -3
- data/docs/ARTIFACTS.md +42 -0
- data/docs/CONTEXT.md +54 -12
- data/docs/DEPLOY.md +16 -7
- data/docs/LOADTEST.md +15 -27
- data/docs/MEDIA.md +1 -1
- data/docs/OBSERVABILITY.md +31 -2
- data/docs/POLICY.md +10 -4
- data/docs/RUNNING-LOCAL.md +2 -2
- data/docs/SECURITY.md +1 -1
- data/docs/SOAK.md +1 -1
- data/docs/TOOLS.md +24 -0
- data/docs/prompts/GO-LIVE.md +3 -3
- data/lib/insika/agent_profile.rb +26 -1
- data/lib/insika/chat_builder.rb +28 -17
- data/lib/insika/compaction.rb +196 -0
- data/lib/insika/context/builder.rb +6 -2
- data/lib/insika/context/fragment.rb +4 -1
- data/lib/insika/context/priority.rb +6 -0
- data/lib/insika/context/providers/briefing.rb +53 -24
- data/lib/insika/context/providers/session.rb +46 -10
- data/lib/insika/context_trace_store.rb +11 -1
- data/lib/insika/doctor.rb +105 -10
- data/lib/insika/dsl/runtime.rb +4 -0
- data/lib/insika/env_schema.rb +5 -6
- data/lib/insika/evals/transport.rb +1 -1
- data/lib/insika/executor.rb +64 -0
- data/lib/insika/loop_detector.rb +5 -34
- data/lib/insika/profile_source.rb +7 -0
- data/lib/insika/server/responses.rb +4 -4
- data/lib/insika/session_store.rb +34 -4
- data/lib/insika/settings_store.rb +8 -1
- data/lib/insika/soak/runner.rb +4 -4
- data/lib/insika/studio/app.rb +28 -6
- data/lib/insika/studio/forms.rb +11 -0
- data/lib/insika/studio/views/settings.erb +11 -0
- data/lib/insika/telemetry/recorder.rb +49 -1
- data/lib/insika/templates/daily-digest/README.md +9 -0
- data/lib/insika/templates/research-analyst/agent.rb +10 -0
- data/lib/insika/tool_batch.rb +67 -0
- data/lib/insika/tool_usage_report.rb +162 -0
- data/lib/insika/turn_budget.rb +91 -0
- data/lib/insika/version.rb +1 -1
- data/lib/insika.rb +7 -0
- metadata +5 -1
|
@@ -5,9 +5,20 @@ module Insika
|
|
|
5
5
|
module Providers
|
|
6
6
|
# Read path for the session briefing: the per-session
|
|
7
7
|
# working-state the agent keeps and asks for. Thin adapter over the
|
|
8
|
-
# SessionStore, same pattern as Memory
|
|
9
|
-
#
|
|
10
|
-
#
|
|
8
|
+
# SessionStore, same pattern as Memory, deterministic. The MISSING list is
|
|
9
|
+
# rendered, never implied — that list is what stops the model re-asking
|
|
10
|
+
# for a field already given.
|
|
11
|
+
#
|
|
12
|
+
# TWO fragments, and the split is the whole point:
|
|
13
|
+
# · `<briefing>` (:system) — the DURABLE facts, what is already known.
|
|
14
|
+
# Head of the prompt, where reference material belongs.
|
|
15
|
+
# · `<recitation>` (:tail) — what is still missing and what the next step
|
|
16
|
+
# is, rendered AFTER the whole history, as the last thing before the
|
|
17
|
+
# current user message.
|
|
18
|
+
# Attention is strongest at the END of the context: a goal stated only in
|
|
19
|
+
# the head is the first thing a 30-call turn forgets. The recitation lives
|
|
20
|
+
# in exactly one place — it was MOVED out of the head, never duplicated, so
|
|
21
|
+
# a turn pays for it once.
|
|
11
22
|
class Briefing < ContextProvider
|
|
12
23
|
def initialize(session_store:)
|
|
13
24
|
@session_store = session_store
|
|
@@ -34,12 +45,10 @@ module Insika
|
|
|
34
45
|
declared = Array(request.profile.briefing_fields)
|
|
35
46
|
return [] if declared.empty? # defensive; enabled_for? already gates
|
|
36
47
|
|
|
37
|
-
|
|
38
|
-
|
|
48
|
+
missing = declared.reject { |name| Coercion.present?(fields[name]) }
|
|
49
|
+
next_step = briefing["next_step"]
|
|
39
50
|
|
|
40
|
-
[
|
|
41
|
-
priority: Context::Priority::BRIEFING,
|
|
42
|
-
source: id)]
|
|
51
|
+
[head_fragment(declared, fields), tail_fragment(missing, next_step)].compact
|
|
43
52
|
end
|
|
44
53
|
|
|
45
54
|
private
|
|
@@ -52,37 +61,57 @@ module Insika
|
|
|
52
61
|
@session_store.find(session.id)&.briefing || {}
|
|
53
62
|
end
|
|
54
63
|
|
|
55
|
-
#
|
|
64
|
+
# The HEAD block — durable facts only. Byte contract:
|
|
56
65
|
# <briefing>
|
|
57
66
|
# known:
|
|
58
67
|
# size: M
|
|
59
|
-
# still missing: delivery_day
|
|
60
|
-
# next step: send the payment link tomorrow at 10
|
|
61
68
|
# </briefing>
|
|
62
|
-
#
|
|
63
|
-
#
|
|
64
|
-
#
|
|
65
|
-
#
|
|
66
|
-
|
|
67
|
-
# the pack re-declares them).
|
|
68
|
-
def format_block(declared, fields, next_step)
|
|
69
|
+
# Nothing known yet -> no fragment at all (an empty `known:` header
|
|
70
|
+
# teaches the model nothing and still costs a cache invalidation).
|
|
71
|
+
# Stored keys NOT in the declaration are never rendered (they stay in the
|
|
72
|
+
# store and reappear if the pack re-declares them).
|
|
73
|
+
def head_fragment(declared, fields)
|
|
69
74
|
known = declared.filter_map do |name|
|
|
70
75
|
" #{name}: #{flatten(fields[name])}" if Coercion.present?(fields[name])
|
|
71
76
|
end
|
|
72
|
-
|
|
77
|
+
return nil if known.empty?
|
|
73
78
|
|
|
79
|
+
block = <<~BLOCK.strip
|
|
80
|
+
<briefing>
|
|
81
|
+
known:
|
|
82
|
+
#{known.join("\n")}
|
|
83
|
+
</briefing>
|
|
84
|
+
BLOCK
|
|
85
|
+
ContextFragment.build(content: block, placement: :system,
|
|
86
|
+
priority: Context::Priority::BRIEFING, source: id)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# The TAIL recitation — two lines, no more. Byte contract:
|
|
90
|
+
# <recitation>
|
|
91
|
+
# still missing: delivery_day
|
|
92
|
+
# next step: send the payment link tomorrow at 10
|
|
93
|
+
# </recitation>
|
|
94
|
+
# `still missing` renders every declared field with no stored value
|
|
95
|
+
# (including the all-missing case — that is this block's job); `next step`
|
|
96
|
+
# renders only when non-nil. Neither -> no fragment.
|
|
97
|
+
#
|
|
98
|
+
# A `user` message, like every other engine append inside a turn
|
|
99
|
+
# (LoopDetector, TurnBudget): the system prefix stays byte-stable, so the
|
|
100
|
+
# cache breakpoint at its end keeps hitting.
|
|
101
|
+
def tail_fragment(missing, next_step)
|
|
74
102
|
lines = []
|
|
75
|
-
lines << "known:" unless known.empty?
|
|
76
|
-
lines.concat(known)
|
|
77
103
|
lines << "still missing: #{missing.join(', ')}" unless missing.empty?
|
|
78
104
|
lines << "next step: #{flatten(next_step)}" if Coercion.present?(next_step)
|
|
79
105
|
return nil if lines.empty?
|
|
80
106
|
|
|
81
|
-
<<~BLOCK.strip
|
|
82
|
-
<
|
|
107
|
+
block = <<~BLOCK.strip
|
|
108
|
+
<recitation>
|
|
83
109
|
#{lines.join("\n")}
|
|
84
|
-
</
|
|
110
|
+
</recitation>
|
|
85
111
|
BLOCK
|
|
112
|
+
ContextFragment.build(content: { role: :user, content: block },
|
|
113
|
+
placement: :tail,
|
|
114
|
+
priority: Context::Priority::RECITATION, source: id)
|
|
86
115
|
end
|
|
87
116
|
|
|
88
117
|
# utf8 the value and flatten newlines/whitespace so a value can never
|
|
@@ -14,8 +14,8 @@ module Insika
|
|
|
14
14
|
end
|
|
15
15
|
|
|
16
16
|
def call(request)
|
|
17
|
-
messages = transcript_for(request)
|
|
18
|
-
return [] if messages.nil? || messages.empty?
|
|
17
|
+
messages, compaction = transcript_for(request)
|
|
18
|
+
return [] if messages.nil? || (messages.empty? && compaction.nil?)
|
|
19
19
|
|
|
20
20
|
# A3/C3 opt-in: identical tool results in the transcript collapse to a
|
|
21
21
|
# back-reference (the cheap half of compaction). CHANGES WHAT THE MODEL
|
|
@@ -28,7 +28,7 @@ module Insika
|
|
|
28
28
|
# the fragment level means the budget cut (apply_budget) drops a whole
|
|
29
29
|
# tool cycle atomically — a tool_use is NEVER seeded without its result
|
|
30
30
|
# (which providers reject), without touching apply_budget itself.
|
|
31
|
-
eviction_units(messages).each_with_index.map do |unit, idx|
|
|
31
|
+
fragments = eviction_units(messages).each_with_index.map do |unit, idx|
|
|
32
32
|
ContextFragment.build(
|
|
33
33
|
# single message stays a Hash (compat with existing fragments); a
|
|
34
34
|
# multi-message cycle is an Array (seed_history flattens it back).
|
|
@@ -39,6 +39,7 @@ module Insika
|
|
|
39
39
|
source: id
|
|
40
40
|
)
|
|
41
41
|
end
|
|
42
|
+
compaction ? [compaction_fragment(compaction)] + fragments : fragments
|
|
42
43
|
end
|
|
43
44
|
|
|
44
45
|
private
|
|
@@ -90,15 +91,50 @@ module Insika
|
|
|
90
91
|
end
|
|
91
92
|
|
|
92
93
|
# Precedence: checkpoint -> explicit history -> store.
|
|
93
|
-
# The first present source wins; no merge.
|
|
94
|
+
# The first present source wins; no merge. -> [messages, compaction|nil].
|
|
95
|
+
# The compaction state (RFC-0044) applies to the STORE source only: a
|
|
96
|
+
# checkpoint resume replays the checkpoint's own tape (which already
|
|
97
|
+
# contains whatever summary the original turn saw) and an explicit
|
|
98
|
+
# vars[:history] is the caller's contract — neither is rewritten.
|
|
94
99
|
def transcript_for(request)
|
|
95
|
-
return request.checkpoint.messages if request.checkpoint
|
|
100
|
+
return [request.checkpoint.messages, nil] if request.checkpoint
|
|
96
101
|
|
|
97
102
|
explicit = explicit_history(request)
|
|
98
|
-
return explicit if explicit
|
|
99
|
-
return
|
|
103
|
+
return [explicit, nil] if explicit
|
|
104
|
+
return compacted_session_messages(request.session) if request.session
|
|
100
105
|
|
|
101
|
-
nil
|
|
106
|
+
[nil, nil]
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Splits the stored transcript at the persisted compaction boundary:
|
|
110
|
+
# messages[0...upto] are represented by the summary, messages[upto..]
|
|
111
|
+
# stay verbatim. `upto` is clamped to the transcript size (defensive —
|
|
112
|
+
# the store is append-only, so it should never outrun it).
|
|
113
|
+
def compacted_session_messages(session)
|
|
114
|
+
fresh = fresh_session(session)
|
|
115
|
+
return [[], nil] if fresh.nil?
|
|
116
|
+
|
|
117
|
+
messages = fresh.messages || []
|
|
118
|
+
state = fresh.respond_to?(:compaction) ? fresh.compaction : nil
|
|
119
|
+
upto = state ? state["upto"].to_i : 0
|
|
120
|
+
return [messages, nil] unless upto.positive? && Coercion.present?(state["summary"])
|
|
121
|
+
|
|
122
|
+
[messages.drop([upto, messages.size].min), state]
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# The compacted prefix as ONE history fragment, rendered FIRST (the
|
|
126
|
+
# Builder keeps history in production order). role "user" because it is
|
|
127
|
+
# provider-agnostic (a mid-history "system" message is not). Priority
|
|
128
|
+
# COMPACTION (59): the "oldest unit" — under budget it drops before any
|
|
129
|
+
# verbatim message. source "compaction" -> its own context-trace category.
|
|
130
|
+
def compaction_fragment(state)
|
|
131
|
+
ContextFragment.build(
|
|
132
|
+
content: { role: "user",
|
|
133
|
+
content: "<conversation_summary>\n#{state['summary']}\n</conversation_summary>" },
|
|
134
|
+
placement: :history,
|
|
135
|
+
priority: Context::Priority::COMPACTION,
|
|
136
|
+
source: "compaction"
|
|
137
|
+
)
|
|
102
138
|
end
|
|
103
139
|
|
|
104
140
|
# Source 2 (explicit history): the handler passes it in request.vars[:history].
|
|
@@ -111,8 +147,8 @@ module Insika
|
|
|
111
147
|
# CONDITIONAL requiredness: when a session is requested, a read
|
|
112
148
|
# failure becomes a ContextError (aborts the turn); the base required?
|
|
113
149
|
# does not receive the request, so the behavior lives here.
|
|
114
|
-
def
|
|
115
|
-
@session_store.find(session.id)
|
|
150
|
+
def fresh_session(session)
|
|
151
|
+
@session_store.find(session.id)
|
|
116
152
|
rescue StandardError => e # read failure (exception/StoreError)
|
|
117
153
|
raise ContextError.new("Session provider failed with a requested session: #{e.message}",
|
|
118
154
|
provider: id)
|
|
@@ -80,10 +80,20 @@ module Insika
|
|
|
80
80
|
"tools" => { "count" => int(tools[:count] || tools["count"]),
|
|
81
81
|
"tokens" => int(tools[:tokens] || tools["tokens"]) },
|
|
82
82
|
"fingerprints" => fingerprints_of(e[:fingerprints] || e["fingerprints"]),
|
|
83
|
-
"cache" => cache_of(e[:cache] || e["cache"])
|
|
83
|
+
"cache" => cache_of(e[:cache] || e["cache"]),
|
|
84
|
+
"compaction" => compaction_of(e[:compaction] || e["compaction"])
|
|
84
85
|
}.compact
|
|
85
86
|
end
|
|
86
87
|
|
|
88
|
+
# { upto, runs } — present only when the session was compacted (RFC-0044).
|
|
89
|
+
# Counts only, like everything else here; the summary text never lands.
|
|
90
|
+
def compaction_of(raw)
|
|
91
|
+
return nil unless raw.is_a?(Hash)
|
|
92
|
+
|
|
93
|
+
{ "upto" => int(raw[:upto] || raw["upto"]),
|
|
94
|
+
"runs" => int(raw[:runs] || raw["runs"]) }
|
|
95
|
+
end
|
|
96
|
+
|
|
87
97
|
# { name => sha256-hex }; names stringified, non-strings
|
|
88
98
|
# dropped. Absent when the caller passed nothing (a trace recorded before
|
|
89
99
|
# this feature has no key and the view guards on nil).
|
data/lib/insika/doctor.rb
CHANGED
|
@@ -179,7 +179,8 @@ module Insika
|
|
|
179
179
|
check_relay_channel check_web_widget check_skill_eager check_skill_drift check_shadow_parity
|
|
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
|
-
check_harvest check_schedules check_guardrail_corpora
|
|
182
|
+
check_compaction check_harvest check_schedules check_guardrail_corpora
|
|
183
|
+
check_tool_allowlist_policy]
|
|
183
184
|
|
|
184
185
|
def safe(check)
|
|
185
186
|
Array(send(check))
|
|
@@ -722,26 +723,59 @@ module Insika
|
|
|
722
723
|
# the mangled prompt on every turn, and nothing else would ever say so: the file is
|
|
723
724
|
# present, non-empty, and the agent answers — worse than a crash. Found on the pilot
|
|
724
725
|
# by an `insika refine` report, three weeks after the fact.
|
|
726
|
+
#
|
|
727
|
+
# The same sweep also WARNS (never errors) on a file that outgrew a prompt.
|
|
728
|
+
# Merchant packs are LLM-generated (generate-merchant-pack), and generated prose
|
|
729
|
+
# bloats: the pilot's 28 KB AGENTS.md is the local example, and the ETH Zurich
|
|
730
|
+
# instruction-file study puts the cost of that shape at 20%+ extra tokens per
|
|
731
|
+
# turn for no extra instruction-following. The thresholds are deliberately
|
|
732
|
+
# generous — a hand-written file never meets them; only the generated shape does.
|
|
725
733
|
def check_prompt_files
|
|
726
734
|
return [] unless @agent_file_store
|
|
727
735
|
|
|
728
736
|
agents = @agent_file_store.agents
|
|
729
|
-
wrapped =
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
+
wrapped = []
|
|
738
|
+
oversized = []
|
|
739
|
+
agents.each do |agent|
|
|
740
|
+
@agent_file_store.list(agent).each do |name|
|
|
741
|
+
content = @agent_file_store.read(agent, name)
|
|
742
|
+
if wrapped_content?(content)
|
|
743
|
+
wrapped << Finding.new(check: "prompt-files", severity: :error, fix: nil,
|
|
744
|
+
message: "agent '#{agent}' file '#{name}' holds a serialized object, not text — " \
|
|
745
|
+
"the model receives `{\"content\" => …}` on one line, escapes and all. " \
|
|
746
|
+
"Recover the markdown from inside the wrapper and write it back.")
|
|
747
|
+
elsif (finding = oversized_prompt(agent, name, content))
|
|
748
|
+
oversized << finding
|
|
749
|
+
end
|
|
737
750
|
end
|
|
738
751
|
end
|
|
739
|
-
return wrapped if wrapped.any?
|
|
752
|
+
return wrapped + oversized if wrapped.any? || oversized.any?
|
|
740
753
|
|
|
741
754
|
total = agents.sum { |a| @agent_file_store.list(a).length }
|
|
742
755
|
[ok("prompt-files", "#{total} prompt file(s) across #{agents.length} agent(s): all text")]
|
|
743
756
|
end
|
|
744
757
|
|
|
758
|
+
# WARN thresholds for one prompt file. ~6 000 estimated tokens (~24 KB) or 600
|
|
759
|
+
# lines: the pilot's generated 28 KB / 292-line AGENTS.md trips the token bar,
|
|
760
|
+
# every hand-written demo file stays far under both. Estimate = chars/4, the
|
|
761
|
+
# same yardstick as Insika::TokenEstimator — cheap and honest about being ±15%.
|
|
762
|
+
PROMPT_FILE_WARN_TOKENS = 6_000
|
|
763
|
+
PROMPT_FILE_WARN_LINES = 600
|
|
764
|
+
|
|
765
|
+
def oversized_prompt(agent, name, content)
|
|
766
|
+
text = content.to_s
|
|
767
|
+
tokens = Insika::TokenEstimator.estimate(text)
|
|
768
|
+
lines = text.lines.count
|
|
769
|
+
return nil if tokens <= PROMPT_FILE_WARN_TOKENS && lines <= PROMPT_FILE_WARN_LINES
|
|
770
|
+
|
|
771
|
+
Finding.new(check: "prompt-files", severity: :warn, fix: nil,
|
|
772
|
+
message: "agent '#{agent}' file '#{name}' is ~#{tokens} tokens over #{lines} line(s) " \
|
|
773
|
+
"(threshold: #{PROMPT_FILE_WARN_TOKENS} tokens / #{PROMPT_FILE_WARN_LINES} lines) — " \
|
|
774
|
+
"a prompt this large costs 20%+ more tokens on every turn for no better " \
|
|
775
|
+
"instruction-following. Trim it, or split the reference material into skills " \
|
|
776
|
+
"the agent loads on demand.")
|
|
777
|
+
end
|
|
778
|
+
|
|
745
779
|
# Cheap and specific: Ruby's inspect of a Hash whose first key is a string. A real
|
|
746
780
|
# prompt does not open with `{"…" =>`.
|
|
747
781
|
def wrapped_content?(content) = /\A\s*\{\s*"[^"]+"\s*=>/.match?(content.to_s)
|
|
@@ -868,6 +902,46 @@ def wrapped_content?(content) = /\A\s*\{\s*"[^"]+"\s*=>/.match?(content.to_s)
|
|
|
868
902
|
# harmless but useless — every claim passes and the audit reads zero. A
|
|
869
903
|
# warning, never an error: the pack owns matcher quality; the engine refuses
|
|
870
904
|
# only uncompileable data.
|
|
905
|
+
# A stored agent that declares `tools_allow` / `tools_deny` /
|
|
906
|
+
# `tools_allow_groups` but does not name the `tool_allowlist` policy. Only
|
|
907
|
+
# that policy applies the lists, and the Policy::Engine runs ONLY the
|
|
908
|
+
# policies a profile names — so as written on disk the lists are inert and
|
|
909
|
+
# the model receives every registered tool (hundreds of schemas, tens of
|
|
910
|
+
# thousands of tokens, per request).
|
|
911
|
+
#
|
|
912
|
+
# AgentProfile.build now adds the policy whenever a list is declared, so a
|
|
913
|
+
# live turn off this record is already safe. The record itself stays wrong
|
|
914
|
+
# until something re-saves it, and anything that reads the stored shape
|
|
915
|
+
# directly — an older engine, a pack export, an operator auditing the
|
|
916
|
+
# Studio form — still sees an allowlist that does nothing. That is why this
|
|
917
|
+
# reads the RAW record and not `all`.
|
|
918
|
+
def check_tool_allowlist_policy
|
|
919
|
+
return [] unless @profile_source.respond_to?(:all_raw)
|
|
920
|
+
|
|
921
|
+
records = @profile_source.all_raw
|
|
922
|
+
bad = records.select { |r| tool_lists_declared?(r) && !names_tool_allowlist?(r) }
|
|
923
|
+
return [ok("tool-allowlist", "#{records.length} stored agent(s): every declared tool list names the policy")] if bad.empty?
|
|
924
|
+
|
|
925
|
+
bad.map do |r|
|
|
926
|
+
Finding.new(check: "tool-allowlist", severity: :error, fix: nil,
|
|
927
|
+
message: "agent '#{r["id"]}' declares a tool allow/deny list but its policies " \
|
|
928
|
+
"(#{Array(r["policies"]).join(", ").then { |p| p.empty? ? "none" : p }}) " \
|
|
929
|
+
"do not include tool_allowlist — as stored, the list is never applied and " \
|
|
930
|
+
"the model receives every registered tool. The engine repairs this when it " \
|
|
931
|
+
"loads the agent; re-save it (Studio, or the pack) so the stored policies " \
|
|
932
|
+
"match the intent.")
|
|
933
|
+
end
|
|
934
|
+
end
|
|
935
|
+
|
|
936
|
+
# Presence, not emptiness, for the two nil-able lists: `tools_allow: []`
|
|
937
|
+
# means "no tools" and is as much a declaration as a list of names.
|
|
938
|
+
# `tools_deny` has no nil state, so only a non-empty one counts.
|
|
939
|
+
def tool_lists_declared?(raw)
|
|
940
|
+
!raw["tools_allow"].nil? || !raw["tools_allow_groups"].nil? || !Array(raw["tools_deny"]).empty?
|
|
941
|
+
end
|
|
942
|
+
|
|
943
|
+
def names_tool_allowlist?(raw) = Array(raw["policies"]).any? { |p| p.to_s == "tool_allowlist" }
|
|
944
|
+
|
|
871
945
|
def check_grounding
|
|
872
946
|
return [] unless @profile_source
|
|
873
947
|
|
|
@@ -1230,6 +1304,27 @@ def wrapped_content?(content) = /\A\s*\{\s*"[^"]+"\s*=>/.match?(content.to_s)
|
|
|
1230
1304
|
stale: @proposal_store.stale(limit: 10_000).size }
|
|
1231
1305
|
end
|
|
1232
1306
|
|
|
1307
|
+
# the in-session compaction check (RFC-0044): platform-gated,
|
|
1308
|
+
# so ONE line — enabled with no resolvable model (no compaction.model, no
|
|
1309
|
+
# platform utility_model) can never run; the warn is the same "declared
|
|
1310
|
+
# but dead" signal as check_distill's (the engine never guesses a model).
|
|
1311
|
+
def check_compaction
|
|
1312
|
+
return [] unless @settings_store
|
|
1313
|
+
|
|
1314
|
+
settings = @settings_store.get
|
|
1315
|
+
config = settings["compaction"] || {}
|
|
1316
|
+
return [ok("compaction", "in-session compaction off")] unless Coercion.truthy?(config["enabled"])
|
|
1317
|
+
|
|
1318
|
+
if Coercion.presence(config["model"]).nil? && Coercion.presence(settings["utility_model"]).nil?
|
|
1319
|
+
[Finding.new(check: "compaction", severity: :warn, fix: nil,
|
|
1320
|
+
message: "in-session compaction is enabled but has no model slot — it will " \
|
|
1321
|
+
"never run (set compaction.model or the platform utility_model).")]
|
|
1322
|
+
else
|
|
1323
|
+
[ok("compaction", "in-session compaction on — keep_last #{config['keep_last']}, " \
|
|
1324
|
+
"compact_after #{config['compact_after']}")]
|
|
1325
|
+
end
|
|
1326
|
+
end
|
|
1327
|
+
|
|
1233
1328
|
# the harvest check — per profile WITH a harvest hash:
|
|
1234
1329
|
# declared-without-model warn (D12), no grounding matcher warn (D3),
|
|
1235
1330
|
# malformed negative list error (D4), else ok with the pending counts.
|
data/lib/insika/dsl/runtime.rb
CHANGED
|
@@ -236,6 +236,10 @@ module Insika
|
|
|
236
236
|
Insika::Context::Providers::ToolSearch.new(catalog: c[:tool_catalog]),
|
|
237
237
|
Insika::Context::Providers::Memory.new(store: spine.memory_store),
|
|
238
238
|
Insika::Context::Providers::Knowledge.new(store: spine.knowledge_store),
|
|
239
|
+
# Session briefing: read path. Inert for agents without briefing_fields.
|
|
240
|
+
# Before Session, so the durable head renders above the transcript and
|
|
241
|
+
# the tail recitation lands after it.
|
|
242
|
+
Insika::Context::Providers::Briefing.new(session_store: spine.session_store),
|
|
239
243
|
Insika::Context::Providers::Session.new(session_store: spine.session_store)
|
|
240
244
|
] # NOT frozen: load_plugins appends plugin providers at boot
|
|
241
245
|
end
|
data/lib/insika/env_schema.rb
CHANGED
|
@@ -72,10 +72,10 @@ module Insika
|
|
|
72
72
|
|
|
73
73
|
# Prefixes the engine fully OWNS: an unknown key under one of these is a typo, not
|
|
74
74
|
# a foreign var. INSIKA_ (current) and HARNESS_ (legacy, still honored during the
|
|
75
|
-
# deprecation window). Deliberately NOT OPENCLAW_ (
|
|
76
|
-
#
|
|
77
|
-
#
|
|
78
|
-
#
|
|
75
|
+
# deprecation window). Deliberately NOT OPENCLAW_ (the OpenClaw gateway product
|
|
76
|
+
# sets its own OPENCLAW_HOME/_STATE_DIR/… on the same host; the engine reads none
|
|
77
|
+
# of them), nor LITESTREAM_ (the sidecar owns it), nor OTEL_ (the OpenTelemetry
|
|
78
|
+
# SDK owns its env).
|
|
79
79
|
OWNED_PREFIXES = [PREFIX, LEGACY_PREFIX].freeze
|
|
80
80
|
|
|
81
81
|
BOOLEANS = %w[1 0 true false yes no on off].freeze
|
|
@@ -142,8 +142,7 @@ module Insika
|
|
|
142
142
|
spec(name: "INSIKA_ROUTER_BACKEND_TIMEOUT", type: :integer, description: "`insika-router`'s connect/read timeout to a backend, in seconds (default 10)."),
|
|
143
143
|
spec(name: "INSIKA_ROUTER_HOST", description: "bind address for `insika-router` itself (default 0.0.0.0)."),
|
|
144
144
|
spec(name: "INSIKA_ROUTER_PORT", type: :integer, description: "listen port for `insika-router` itself (default 9090)."),
|
|
145
|
-
spec(name: "
|
|
146
|
-
spec(name: "OPENCLAW_AGENTS_DIR", type: :path, description: "Directory of OpenClaw-style agent packs."),
|
|
145
|
+
spec(name: "INSIKA_GATEWAY_TOKEN", secret: true, description: "Bearer for /v1 + /a2a (falls back to ADMIN_TOKEN when unset)."),
|
|
147
146
|
spec(name: "INSIKA_PLUGIN_DIR", type: :path, description: "Workspace plugin root (directories with insika.plugin.yml). Loaded at boot; ids still need INSIKA_PLUGINS."),
|
|
148
147
|
spec(name: "INSIKA_PLUGINS", type: :csv, description: "Plugin ids to enable from the workspace/bundled roots. Announced gems are enabled by installing them."),
|
|
149
148
|
spec(name: "INSIKA_PLUGINS_DISABLED", type: :csv, description: "Plugin ids that never load — the absolute veto, wins over INSIKA_PLUGINS and over an announced gem."),
|
|
@@ -135,7 +135,7 @@ module Insika
|
|
|
135
135
|
req["Authorization"] = "Bearer #{@token}"
|
|
136
136
|
req["Content-Type"] = "application/json"
|
|
137
137
|
req["Accept"] = "text/event-stream"
|
|
138
|
-
req.body = JSON.generate(model: "
|
|
138
|
+
req.body = JSON.generate(model: "insika:#{agent}", user: conv, stream: true, input: message)
|
|
139
139
|
|
|
140
140
|
t0 = mono
|
|
141
141
|
ttfb = nil
|
data/lib/insika/executor.rb
CHANGED
|
@@ -1038,6 +1038,11 @@ module Insika
|
|
|
1038
1038
|
tokens: estimate_tools_tokens(state.allowed_tools) },
|
|
1039
1039
|
fingerprints: fingerprints,
|
|
1040
1040
|
cache: { invalidation_reason: reason } }
|
|
1041
|
+
# the compaction state this turn was BUILT over (RFC-0044) —
|
|
1042
|
+
# {upto, runs}, counts only; the summary fragment itself already shows
|
|
1043
|
+
# as the trace's own "compaction" category via its fragment source.
|
|
1044
|
+
compaction = state.session.respond_to?(:compaction) ? state.session.compaction : nil
|
|
1045
|
+
entry[:compaction] = { upto: compaction["upto"], runs: compaction["runs"] } if compaction
|
|
1041
1046
|
# Park the SANITIZED entry (string keys) — the stage-8 stamp merges into
|
|
1042
1047
|
# it and re-records the same key; a raw entry would add a SECOND "cache"
|
|
1043
1048
|
# key that sanitize would then ignore (the symbol one wins).
|
|
@@ -2476,6 +2481,12 @@ module Insika
|
|
|
2476
2481
|
# next door to the other two, for the same reason: it fires for a fresh
|
|
2477
2482
|
# turn and a recovered one.
|
|
2478
2483
|
finalize_knowledge_extraction(task, profile, new_messages)
|
|
2484
|
+
|
|
2485
|
+
# in-session compaction (RFC-0044): when the uncompacted
|
|
2486
|
+
# transcript crossed the threshold this turn, summarize the old prefix
|
|
2487
|
+
# with the cheap model and move the boundary — off the critical path,
|
|
2488
|
+
# same terminal hook, same best-effort discipline as the other three.
|
|
2489
|
+
finalize_compaction(task, profile)
|
|
2479
2490
|
end
|
|
2480
2491
|
|
|
2481
2492
|
# Records the answer in the outbox and dispatches it. The discriminator is the
|
|
@@ -2607,6 +2618,59 @@ module Insika
|
|
|
2607
2618
|
@settings_store.get["utility_model"]
|
|
2608
2619
|
end
|
|
2609
2620
|
|
|
2621
|
+
# in-session compaction (RFC-0044). Platform-gated
|
|
2622
|
+
# (Settings compaction.enabled — parity when off), planned over the
|
|
2623
|
+
# POST-append transcript (persist_turn already wrote this turn's messages),
|
|
2624
|
+
# dispatched off the critical path — the SAME shape as
|
|
2625
|
+
# finalize_knowledge_extraction: inline when non-supervised, a child of the
|
|
2626
|
+
# turn supervisor when serving. Best-effort: any failure leaves the session
|
|
2627
|
+
# record untouched and the next turn re-plans.
|
|
2628
|
+
def finalize_compaction(task, profile)
|
|
2629
|
+
return unless task.session_id && @settings_store
|
|
2630
|
+
|
|
2631
|
+
config = Coercion.deep_stringify(@settings_store.get["compaction"])
|
|
2632
|
+
return unless config && Coercion.truthy?(config["enabled"])
|
|
2633
|
+
|
|
2634
|
+
session = @session_store.find(task.session_id)
|
|
2635
|
+
return unless session
|
|
2636
|
+
|
|
2637
|
+
state = session.respond_to?(:compaction) ? session.compaction : nil
|
|
2638
|
+
plan = Insika::Compaction.plan(messages: session.messages, state: state, config: config)
|
|
2639
|
+
return unless plan
|
|
2640
|
+
|
|
2641
|
+
# compaction.model -> platform utility_model -> inert (never a guess);
|
|
2642
|
+
# `insika doctor` warns on enabled-with-no-model. @llm rides along so a
|
|
2643
|
+
# deployment (or spec) that injects its LLM seam covers this call too.
|
|
2644
|
+
summarizer = Compaction::SummarizerFactory.build(config, utility_model: utility_model, llm: @llm)
|
|
2645
|
+
return unless summarizer
|
|
2646
|
+
|
|
2647
|
+
run = lambda { run_compaction(task, profile, session, state, plan, config, summarizer) }
|
|
2648
|
+
return run.call unless @supervised
|
|
2649
|
+
|
|
2650
|
+
turn_parent.async do |t|
|
|
2651
|
+
t.annotate("compaction:#{task.id}")
|
|
2652
|
+
run.call
|
|
2653
|
+
end
|
|
2654
|
+
end
|
|
2655
|
+
|
|
2656
|
+
def run_compaction(task, profile, session, state, plan, config, summarizer)
|
|
2657
|
+
prompt = Insika::Compaction.prompt(messages: session.messages, plan: plan,
|
|
2658
|
+
previous: state && state["summary"],
|
|
2659
|
+
base: config["prompt"])
|
|
2660
|
+
result = summarizer.summarize(prompt: prompt)
|
|
2661
|
+
updated = @session_store.set_compaction(task.session_id, summary: result[:summary],
|
|
2662
|
+
upto: plan.upto, model: summarizer.model)
|
|
2663
|
+
# counts and ids only — the summary text never enters the stream. Feeds
|
|
2664
|
+
# the insika.context.compacted counter (Telemetry::Recorder).
|
|
2665
|
+
emit(:context_compacted,
|
|
2666
|
+
{ task_id: task.id, agent: profile.id, from: plan.from, upto: plan.upto,
|
|
2667
|
+
messages: plan.count, runs: updated.compaction && updated.compaction["runs"],
|
|
2668
|
+
model: summarizer.model, cost: result[:cost] },
|
|
2669
|
+
task: task)
|
|
2670
|
+
rescue StandardError
|
|
2671
|
+
nil # best-effort: compaction never re-fails an already-committed turn.
|
|
2672
|
+
end
|
|
2673
|
+
|
|
2610
2674
|
# ONE supervisor fiber for the whole chain. Sequential deliver
|
|
2611
2675
|
# calls, so balloon N+1 cannot overtake balloon N on the wire. Still off the
|
|
2612
2676
|
# session's FIFO — the customer's next message does not wait on this turn's
|
data/lib/insika/loop_detector.rb
CHANGED
|
@@ -50,9 +50,7 @@ module Insika
|
|
|
50
50
|
@streak = 0
|
|
51
51
|
@intervened = false # the ONE warning of this turn has been delivered
|
|
52
52
|
@pending = false # detection fired; waiting for the batch boundary
|
|
53
|
-
@
|
|
54
|
-
@seen = 0
|
|
55
|
-
@halted = false
|
|
53
|
+
@batch = ToolBatch.new
|
|
56
54
|
end
|
|
57
55
|
|
|
58
56
|
# From ChatBuilder's before_tool_call. Raises BEFORE the call executes once
|
|
@@ -80,41 +78,21 @@ module Insika
|
|
|
80
78
|
|
|
81
79
|
# From ChatBuilder's after_tool_result, with the RAW result — the only place
|
|
82
80
|
# a Tool::Halt is still recognizable (SteerInjector's comment applies here).
|
|
83
|
-
def tool_result(result)
|
|
84
|
-
@halted = true if defined?(RubyLLM::Tool::Halt) && result.is_a?(RubyLLM::Tool::Halt)
|
|
85
|
-
end
|
|
81
|
+
def tool_result(result) = @batch.halt!(result)
|
|
86
82
|
|
|
87
83
|
# RubyLLM after_message. An assistant message carrying tool calls OPENS a
|
|
88
84
|
# batch; the Nth tool result CLOSES it — the one boundary where appending
|
|
89
|
-
# is valid.
|
|
85
|
+
# is valid (ToolBatch owns that arithmetic; TurnBudget follows the same rule).
|
|
90
86
|
def message_ended(message)
|
|
91
|
-
|
|
92
|
-
return open_batch(message) if role == "assistant"
|
|
93
|
-
return unless role == "tool" && @expected
|
|
94
|
-
|
|
95
|
-
@seen += 1
|
|
96
|
-
intervene! if @seen >= @expected
|
|
87
|
+
intervene! if @batch.closed?(message)
|
|
97
88
|
end
|
|
98
89
|
|
|
99
90
|
private
|
|
100
91
|
|
|
101
|
-
def open_batch(message)
|
|
102
|
-
calls = field(message, :tool_calls)
|
|
103
|
-
size = calls.respond_to?(:size) ? calls.size : 0
|
|
104
|
-
# No tool call = the model talking; the turn is ending and a pending
|
|
105
|
-
# warning is moot — the loop resolved itself.
|
|
106
|
-
return @expected = nil if size.zero?
|
|
107
|
-
|
|
108
|
-
@expected = size
|
|
109
|
-
@seen = 0
|
|
110
|
-
@halted = false
|
|
111
|
-
end
|
|
112
|
-
|
|
113
92
|
def intervene!
|
|
114
|
-
@expected = nil
|
|
115
93
|
return unless @pending
|
|
116
94
|
@pending = false
|
|
117
|
-
return if @halted # nothing will read it (halt_when): drop, never deliver
|
|
95
|
+
return if @batch.halted? # nothing will read it (halt_when): drop, never deliver
|
|
118
96
|
|
|
119
97
|
@intervened = true
|
|
120
98
|
name, = @last
|
|
@@ -132,12 +110,5 @@ module Insika
|
|
|
132
110
|
else value
|
|
133
111
|
end
|
|
134
112
|
end
|
|
135
|
-
|
|
136
|
-
def field(message, name)
|
|
137
|
-
return message.public_send(name) if message.respond_to?(name)
|
|
138
|
-
return message[name] || message[name.to_s] if message.respond_to?(:[])
|
|
139
|
-
|
|
140
|
-
nil
|
|
141
|
-
end
|
|
142
113
|
end
|
|
143
114
|
end
|
|
@@ -74,6 +74,13 @@ module Insika
|
|
|
74
74
|
|
|
75
75
|
def delete(id) = @cs.delete(SCOPE, id.to_s)
|
|
76
76
|
|
|
77
|
+
# The STORED records, unbuilt (string keys, straight off the JSON
|
|
78
|
+
# round-trip). `all` runs every record through AgentProfile.build, which
|
|
79
|
+
# normalizes — and repairs — what it reads, so a caller that needs to judge
|
|
80
|
+
# the truth ON DISK (the doctor) cannot use it: a repaired-on-read profile
|
|
81
|
+
# is indistinguishable there from a well-formed record.
|
|
82
|
+
def all_raw = @cs.all(SCOPE)
|
|
83
|
+
|
|
77
84
|
private
|
|
78
85
|
|
|
79
86
|
# Rebuilds the AgentProfile from the record (the JSON round-trip turns symbols
|
|
@@ -12,9 +12,9 @@ module Insika
|
|
|
12
12
|
# OpenAI Responses SSE frame (or nil for events with no counterpart). Follows the
|
|
13
13
|
# constitutional rule: no business logic, no store access here.
|
|
14
14
|
#
|
|
15
|
-
# Request: { model: "
|
|
15
|
+
# Request: { model: "insika:<agent>", user: "<chat.id>", stream: true,
|
|
16
16
|
# input: "<string with already-composed blocks>" } + header
|
|
17
|
-
# X-
|
|
17
|
+
# X-Insika-Agent (agent fallback). The `input` enters VERBATIM as the
|
|
18
18
|
# turn's message — the blocks (<memoria>/<dados_conhecidos>/directives) already come
|
|
19
19
|
# composed by the consumer (the engine does not interpret them).
|
|
20
20
|
module Responses
|
|
@@ -32,8 +32,8 @@ module Insika
|
|
|
32
32
|
# gets that filtered structurally instead of by a regex on the leading tag.
|
|
33
33
|
# Omitted = a customer typed it, which is what every turn meant before.
|
|
34
34
|
def parse_request(body, req)
|
|
35
|
-
agent = body[:model].to_s.sub(/\
|
|
36
|
-
agent = req.get_header("
|
|
35
|
+
agent = body[:model].to_s.sub(/\Ainsika:/, "")
|
|
36
|
+
agent = req.get_header("HTTP_X_INSIKA_AGENT").to_s if agent.empty?
|
|
37
37
|
raise Insika::ValidationError, "model/agent missing" if agent.strip.empty?
|
|
38
38
|
|
|
39
39
|
user = body[:user].to_s
|