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
data/docs/OBSERVABILITY.md
CHANGED
|
@@ -25,7 +25,9 @@ authoring writes (`:golden_written`, `:agent_file_written`, …), queue bookkeep
|
|
|
25
25
|
channel delivery (`:channel_delivered` — see [Channels](CHANNELS.md))
|
|
26
26
|
travel the same stream and are **ignored** by the bridge: they open no span and
|
|
27
27
|
touch no instrument, because they are not part of a turn's latency or cost. Any
|
|
28
|
-
other subscriber still sees them.
|
|
28
|
+
other subscriber still sees them. The one mid-turn event the bridge does consume
|
|
29
|
+
is `:tool_loop_intervened` — it feeds the `insika.tool.loop_intervened` counter
|
|
30
|
+
(no span: the intervention is a fact about the turn, not a timed operation).
|
|
29
31
|
|
|
30
32
|
They are worth subscribing to even so, because each is the ONLY record of
|
|
31
33
|
something that left no task of its own behind:
|
|
@@ -145,12 +147,32 @@ knows its outcome.
|
|
|
145
147
|
| `insika.cost` | counter | `{USD}` | the turn's model is priced (see below) |
|
|
146
148
|
| `insika.tool.calls` | counter | `{call}` | a tool call completes |
|
|
147
149
|
| `insika.tool.duration` | histogram | `s` | a `tool_call`/`tool_result` pair completes |
|
|
150
|
+
| `insika.cache.hit_rate` | histogram | `%` | a turn reported billed prompt tokens (see below) |
|
|
151
|
+
| `insika.tool.loop_intervened` | counter | `{intervention}` | the loop detector delivered its one-shot warning |
|
|
152
|
+
| `insika.context.compacted` | counter | `{compaction}` | an in-session compaction was persisted (RFC-0044) |
|
|
148
153
|
|
|
149
154
|
`insika.tool.duration` is deliberately **not** recorded for data-tools: those are a
|
|
150
155
|
single point-in-time event, so there is no measured duration to report. A tool left
|
|
151
156
|
open by a mid-turn failure is not counted as a completed call either — its span is
|
|
152
157
|
closed, but a failed call must not inflate the success histogram.
|
|
153
158
|
|
|
159
|
+
`insika.cache.hit_rate` is the same arithmetic the Studio's per-agent series uses:
|
|
160
|
+
cache **reads** over the whole **billed** prompt (fresh input + cache reads + cache
|
|
161
|
+
writes), always in `[0,100]`. A turn with no billed prompt tokens records nothing —
|
|
162
|
+
absence is not a 0% hit. It is recorded per turn on the terminal event, so it
|
|
163
|
+
carries the turn labels (`insika.agent`, `insika.model`, …); the token-counter
|
|
164
|
+
recipe below still works and answers the fleet-wide version of the question.
|
|
165
|
+
|
|
166
|
+
`insika.tool.loop_intervened` counts deliveries of the loop detector's one
|
|
167
|
+
warning per turn (see [Agents](AGENTS.md) `max_tool_repeat`), labelled with
|
|
168
|
+
`insika.tool`. It counts interventions, not repeats: a turn contributes at most 1.
|
|
169
|
+
|
|
170
|
+
`insika.context.compacted` counts persisted in-session compactions
|
|
171
|
+
(`:context_compacted` — see [Context](CONTEXT.md)), labelled with
|
|
172
|
+
`insika.agent` and `insika.model` (the summarizer's model, not the turn's). It
|
|
173
|
+
fires post-turn, after the turn span already closed, so it deliberately rides
|
|
174
|
+
its own labels rather than the open-turn set.
|
|
175
|
+
|
|
154
176
|
## Attribute reference
|
|
155
177
|
|
|
156
178
|
The same names are used on spans and on metrics. **Metrics carry a deliberate
|
|
@@ -267,7 +289,14 @@ climbing while `input` stays flat.
|
|
|
267
289
|
|
|
268
290
|
**Cache hit ratio**
|
|
269
291
|
`insika.tokens` filtered to `insika.token.type="cached"` over the same counter
|
|
270
|
-
filtered to `input`. This is the number that moves your bill.
|
|
292
|
+
filtered to `input`. This is the number that moves your bill. For the per-turn
|
|
293
|
+
distribution (does every turn hit, or do fleet averages hide cold agents?), chart
|
|
294
|
+
`insika.cache.hit_rate` — p50 by `insika.agent`; a healthy agent sits near 100.
|
|
295
|
+
|
|
296
|
+
**Loop interventions**
|
|
297
|
+
`insika.tool.loop_intervened`, rate, grouped by `insika.agent` and `insika.tool`.
|
|
298
|
+
Any sustained non-zero rate means one tool keeps being retried with identical
|
|
299
|
+
arguments — fix the tool's contract or the prompt, not the detector.
|
|
271
300
|
|
|
272
301
|
**Spend per tenant**
|
|
273
302
|
`insika.cost`, rate (or `increase` over a billing window), grouped by
|
data/docs/POLICY.md
CHANGED
|
@@ -18,13 +18,19 @@ matters, and editable hot.
|
|
|
18
18
|
## Layer 1: Tools (what it can call)
|
|
19
19
|
|
|
20
20
|
`tools_allow` / `tools_deny` / `tools_allow_groups` decide which tools enter the
|
|
21
|
-
turn's tool-loop, enforced by the
|
|
22
|
-
for
|
|
21
|
+
turn's tool-loop, enforced by the builtin `tool_allowlist` policy — which the
|
|
22
|
+
engine adds for you the moment any of the three is declared, so you never have to
|
|
23
|
+
name it in `policies` (see [Agents](AGENTS.md#the-allowlist-convention)). See
|
|
24
|
+
[Tools](TOOLS.md) for how tools are defined and registered, and
|
|
25
|
+
[`examples/data-tool/`](https://github.com/guizaols/insika/tree/main/examples/data-tool/).
|
|
23
26
|
|
|
24
27
|
## Layer 2: Policies and approvals
|
|
25
28
|
|
|
26
|
-
Policies are named entries evaluated before the turn runs.
|
|
27
|
-
|
|
29
|
+
Policies are named entries evaluated before the turn runs. **Only the policies a
|
|
30
|
+
profile names run** — which is why `tool_allowlist` is added implicitly by a
|
|
31
|
+
declared tool list; an allowlist nobody applies is worse than no allowlist.
|
|
32
|
+
Builtins cover tool-, skill-, and workflow-allowlisting, plus
|
|
33
|
+
**`ApprovalRequired`** — which
|
|
28
34
|
does not allow or deny but *tags* a tool as needing human approval. Set
|
|
29
35
|
`approvals_required: [tool names]`; the gate then fires when the model tries to
|
|
30
36
|
call that tool, suspending the turn until an operator approves it in the Studio.
|
data/docs/RUNNING-LOCAL.md
CHANGED
|
@@ -62,7 +62,7 @@ whole surface answers `503`, never open by omission.
|
|
|
62
62
|
| `INSIKA_DB` | — (ephemeral memory) | SQLite path → config + execution survive a restart |
|
|
63
63
|
| `BIND` | `http://localhost:9292` | host:port |
|
|
64
64
|
| `ADMIN_TOKEN` | `local-demo` | token for `/studio` |
|
|
65
|
-
| `
|
|
65
|
+
| `INSIKA_GATEWAY_TOKEN` | falls back to `ADMIN_TOKEN` | Bearer for the whole `/v1` + `/a2a` surface |
|
|
66
66
|
| `DEEPSEEK_MODEL` | `deepseek-v4-flash` | model |
|
|
67
67
|
|
|
68
68
|
With persistence:
|
|
@@ -128,7 +128,7 @@ Provision it (runs as a client against the live server; the internal token comes
|
|
|
128
128
|
from the environment, never disk):
|
|
129
129
|
|
|
130
130
|
```bash
|
|
131
|
-
INSIKA_URL=http://localhost:9292
|
|
131
|
+
INSIKA_URL=http://localhost:9292 INSIKA_GATEWAY_TOKEN=local-demo \
|
|
132
132
|
bundle exec ruby scripts/import_pack.rb /path/to/pack
|
|
133
133
|
```
|
|
134
134
|
|
data/docs/SECURITY.md
CHANGED
|
@@ -27,7 +27,7 @@ The layers, from the edge inward:
|
|
|
27
27
|
## The Bearer gate
|
|
28
28
|
|
|
29
29
|
The `/v1` and `/a2a` surface answers only with
|
|
30
|
-
`Authorization: Bearer <
|
|
30
|
+
`Authorization: Bearer <INSIKA_GATEWAY_TOKEN>` (which falls back to `ADMIN_TOKEN`).
|
|
31
31
|
The check runs in the router, **before** any dispatch, against an **allowlist** of
|
|
32
32
|
public routes — so a route added later is closed until someone deliberately publishes
|
|
33
33
|
it. Only these answer without a token:
|
data/docs/SOAK.md
CHANGED
|
@@ -88,7 +88,7 @@ insika soak --dry-run --envelope soak-envelope.md
|
|
|
88
88
|
# every precondition, and nothing else
|
|
89
89
|
insika soak --preflight --envelope soak-envelope.md
|
|
90
90
|
|
|
91
|
-
# the run itself (INSIKA_URL +
|
|
91
|
+
# the run itself (INSIKA_URL + INSIKA_GATEWAY_TOKEN, like loadtest.rb)
|
|
92
92
|
INSIKA_URL=https://<target> insika soak --run --envelope soak-envelope.md --out soak-out/
|
|
93
93
|
|
|
94
94
|
# resume after a short outage (the gap is recorded and counts against the window)
|
data/docs/TOOLS.md
CHANGED
|
@@ -485,6 +485,30 @@ and gets the URL back; the tenant is bound from the turn, never a parameter the
|
|
|
485
485
|
model types. See [Artifacts](ARTIFACTS.md) for the tool contract, the serving
|
|
486
486
|
routes, the signed link and the retention/LGPD reach.
|
|
487
487
|
|
|
488
|
+
## The usage report — `insika tools:report`
|
|
489
|
+
|
|
490
|
+
The per-session trace answers "what did this conversation call"; nothing used to
|
|
491
|
+
answer "what does this agent carry and never use". The report aggregates the
|
|
492
|
+
stored traces per agent (tasks → sessions → `tool_traces`, the same read the
|
|
493
|
+
Studio does) and flags three shapes:
|
|
494
|
+
|
|
495
|
+
- **`never_called`** — in `tools_allow`, zero calls in any stored trace. Dead
|
|
496
|
+
weight: its schema ships on every request and buys nothing.
|
|
497
|
+
- **`error_rate`** — over 30% conventional errors (the trace's `ok` flag) inside
|
|
498
|
+
the window (default 14 days). Either the tool is broken or the model cannot
|
|
499
|
+
hold its contract.
|
|
500
|
+
- **`stale`** — called at some point, but not once inside the window.
|
|
501
|
+
|
|
502
|
+
```bash
|
|
503
|
+
insika tools:report # every stored agent
|
|
504
|
+
insika tools:report --agent store-support # one agent
|
|
505
|
+
insika tools:report --days 30 --json # wider window, machine-readable
|
|
506
|
+
```
|
|
507
|
+
|
|
508
|
+
Read-only by design: the report names candidates, the **operator** removes — a
|
|
509
|
+
flagged tool may still be the one a rare but critical flow needs. Counts are
|
|
510
|
+
"at least", never exact: the trace keeps a capped tail per session.
|
|
511
|
+
|
|
488
512
|
## See also
|
|
489
513
|
|
|
490
514
|
- [Agents](AGENTS.md) — allowlists, groups, and per-agent tool exposure.
|
data/docs/prompts/GO-LIVE.md
CHANGED
|
@@ -40,7 +40,7 @@ convenience only:
|
|
|
40
40
|
| Token | Gates | Rotating it |
|
|
41
41
|
|---|---|---|
|
|
42
42
|
| `ADMIN_TOKEN` | `/studio` login (the operator — just you) | safe, independent |
|
|
43
|
-
| `
|
|
43
|
+
| `INSIKA_GATEWAY_TOKEN` | Bearer for `/v1/responses` + `/v1/agents` (your API consumers) | both sides together, same step |
|
|
44
44
|
|
|
45
45
|
Generate each: `ruby -rsecurerandom -e 'puts SecureRandom.hex(24)'`. Set them as
|
|
46
46
|
platform env vars. **Never** write either into a file, a commit, or your own output.
|
|
@@ -77,7 +77,7 @@ Any Docker host, same contract:
|
|
|
77
77
|
```bash
|
|
78
78
|
docker build -t insika .
|
|
79
79
|
docker run -p 9292:9292 -v insika-data:/data \
|
|
80
|
-
-e DEEPSEEK_API_KEY=... -e ADMIN_TOKEN=... -e
|
|
80
|
+
-e DEEPSEEK_API_KEY=... -e ADMIN_TOKEN=... -e INSIKA_GATEWAY_TOKEN=... \
|
|
81
81
|
insika
|
|
82
82
|
```
|
|
83
83
|
|
|
@@ -92,7 +92,7 @@ In order, each with evidence:
|
|
|
92
92
|
|
|
93
93
|
```bash
|
|
94
94
|
curl -N https://<host>/v1/responses \
|
|
95
|
-
-H "Authorization: Bearer $
|
|
95
|
+
-H "Authorization: Bearer $INSIKA_GATEWAY_TOKEN" \
|
|
96
96
|
-H "Content-Type: application/json" \
|
|
97
97
|
-d '{"model":"<agent-id>","user":"go-live-check","input":"hello"}'
|
|
98
98
|
```
|
data/lib/insika/agent_profile.rb
CHANGED
|
@@ -333,7 +333,9 @@ outputs: nil, stt_prompt: nil, briefing_fields: nil, grounding: nil, funnel: nil
|
|
|
333
333
|
tools_deny: Array(tools_deny), tools_allow_groups: tools_allow_groups, skills: skills,
|
|
334
334
|
skills_eager: skills_eager,
|
|
335
335
|
context_providers: context_providers, workflows_allow: workflows_allow,
|
|
336
|
-
policies:
|
|
336
|
+
policies: normalize_policies(policies, tools_allow: tools_allow, tools_deny: tools_deny,
|
|
337
|
+
tools_allow_groups: tools_allow_groups),
|
|
338
|
+
prompt_refs: Array(prompt_refs),
|
|
337
339
|
limits: DEFAULT_LIMITS.merge(limits), approvals_required: approvals_required,
|
|
338
340
|
capabilities: capabilities,
|
|
339
341
|
# opt-in like capabilities: nil => NONE. Array-normalize a present value so
|
|
@@ -400,6 +402,29 @@ outputs: nil, stt_prompt: nil, briefing_fields: nil, grounding: nil, funnel: nil
|
|
|
400
402
|
)
|
|
401
403
|
end
|
|
402
404
|
|
|
405
|
+
# Declaring a tool allow/deny list IS opting into it. The list is only ever
|
|
406
|
+
# applied by the builtin `tool_allowlist` policy, and the Policy::Engine runs
|
|
407
|
+
# ONLY the policies a profile names — so a profile with `tools_allow: [a, b]`
|
|
408
|
+
# and no policies sent EVERY registered tool to the model, silently. A
|
|
409
|
+
# declared allowlist that does nothing is the failure mode; same rule as the
|
|
410
|
+
# "mcp:<name>" group auto-added to `tools_allow_groups` by the DSL.
|
|
411
|
+
#
|
|
412
|
+
# Presence, not emptiness, is the trigger for the two nil-able lists:
|
|
413
|
+
# `tools_allow: []` means "no tools" and must enforce just as hard.
|
|
414
|
+
# `tools_deny` has no nil state (it defaults to []), so only a non-empty
|
|
415
|
+
# deny list counts as a declaration.
|
|
416
|
+
#
|
|
417
|
+
# Appended, never prepended: profile-declared policies keep their order. The
|
|
418
|
+
# engine intersects allows and unions denies, so position changes only the
|
|
419
|
+
# audit order, not the outcome.
|
|
420
|
+
def self.normalize_policies(policies, tools_allow:, tools_deny:, tools_allow_groups:)
|
|
421
|
+
names = Array(policies)
|
|
422
|
+
declared = !tools_allow.nil? || !tools_allow_groups.nil? || !Array(tools_deny).empty?
|
|
423
|
+
return names unless declared && names.none? { |n| n.to_s == "tool_allowlist" }
|
|
424
|
+
|
|
425
|
+
names + ["tool_allowlist"]
|
|
426
|
+
end
|
|
427
|
+
|
|
403
428
|
# nil/absent -> nil; a single Hash -> [Hash]; else an Array of Hashes —
|
|
404
429
|
# deep-stringified so JSON round-trips stay stable.
|
|
405
430
|
def self.normalize_schedules(list)
|
data/lib/insika/chat_builder.rb
CHANGED
|
@@ -332,20 +332,29 @@ module Insika
|
|
|
332
332
|
end
|
|
333
333
|
|
|
334
334
|
# RubyLLM's additive callbacks become events. load_skill becomes
|
|
335
|
-
# :skill_activated.
|
|
336
|
-
# here we only count and abort.
|
|
335
|
+
# :skill_activated. Hangs the turn's TurnBudget on them: the loop is
|
|
336
|
+
# RubyLLM's; here we only count, warn and abort.
|
|
337
337
|
def wire_callbacks(chat, state, emit)
|
|
338
|
-
#
|
|
339
|
-
# (MRI fibers do not preempt between the read
|
|
340
|
-
# correlation is NOT — it lives in fiber
|
|
341
|
-
# each call gets its own fiber once tool
|
|
342
|
-
|
|
343
|
-
|
|
338
|
+
# The turn's ONE tool-call counter. Safe as a closure-held object even
|
|
339
|
+
# under concurrent tool calls (MRI fibers do not preempt between the read
|
|
340
|
+
# and the write). The per-CALL correlation is NOT — it lives in fiber
|
|
341
|
+
# storage behind TurnState, because each call gets its own fiber once tool
|
|
342
|
+
# concurrency is on.
|
|
343
|
+
#
|
|
344
|
+
# It counts, warns at 10/5/2 calls remaining, and raises the
|
|
345
|
+
# `stage: :tool_limit` abort — the guard-rail that used to be inline here.
|
|
346
|
+
# Announcing the budget needs #add_message at the batch boundary; a chat
|
|
347
|
+
# without it (smoke shim, minimal double) still gets the count and the
|
|
348
|
+
# abort, just no notice.
|
|
349
|
+
budget = Insika::TurnBudget.new(
|
|
350
|
+
chat: chat, max: state.profile.limits[:max_tool_calls] || 50, emit: emit
|
|
351
|
+
)
|
|
344
352
|
|
|
345
353
|
# the loop detector. Needs #after_message + #add_message for the
|
|
346
354
|
# batch-boundary intervention; a chat without them (smoke shim, minimal
|
|
347
355
|
# double) stays bounded by max_tool_calls alone — never half-wired.
|
|
348
|
-
|
|
356
|
+
appendable = %i[after_message add_message].all? { |m| chat.respond_to?(m) }
|
|
357
|
+
detector = if appendable
|
|
349
358
|
repeat = state.profile.limits[:max_tool_repeat] || Insika::AgentProfile::DEFAULT_LIMITS[:max_tool_repeat]
|
|
350
359
|
Insika::LoopDetector.new(chat: chat, limit: repeat, emit: emit) if repeat >= 2
|
|
351
360
|
end
|
|
@@ -355,11 +364,7 @@ module Insika
|
|
|
355
364
|
state.current_tool_call = tool_call
|
|
356
365
|
# max_tool_calls guard-rail: stays inline (not as a registered hook)
|
|
357
366
|
# because Hooks is shared across turns and has no unregister.
|
|
358
|
-
|
|
359
|
-
if tool_calls > max_tool_calls
|
|
360
|
-
raise Insika::TimeoutError.new("tool call limit exceeded (#{max_tool_calls})",
|
|
361
|
-
stage: :tool_limit)
|
|
362
|
-
end
|
|
367
|
+
budget.tool_call
|
|
363
368
|
|
|
364
369
|
# AFTER the count, BEFORE the call runs — a post-warning
|
|
365
370
|
# repeat raises here, so the stubborn loop pays for no extra call.
|
|
@@ -394,13 +399,19 @@ module Insika
|
|
|
394
399
|
# the RAW result — the only place a Tool::Halt (halt_when) is
|
|
395
400
|
# still recognizable, and a halted batch must receive no intervention.
|
|
396
401
|
detector&.tool_result(result)
|
|
402
|
+
budget.tool_result(result)
|
|
397
403
|
result = @hooks.run_after(:tool, result)
|
|
398
404
|
emit.call(:tool_result, { name: state.current_tool_name, result: result.to_s })
|
|
399
405
|
end
|
|
400
406
|
|
|
401
|
-
#
|
|
402
|
-
#
|
|
403
|
-
|
|
407
|
+
# both appends land at the batch boundary (the Nth tool result closing) —
|
|
408
|
+
# never between two tool results of one batch.
|
|
409
|
+
return unless appendable
|
|
410
|
+
|
|
411
|
+
chat.after_message do |message|
|
|
412
|
+
detector&.message_ended(message)
|
|
413
|
+
budget.message_ended(message)
|
|
414
|
+
end
|
|
404
415
|
end
|
|
405
416
|
end
|
|
406
417
|
end
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
# In-session compaction: when a session's UNCOMPACTED transcript
|
|
5
|
+
# grows past `compact_after` messages, everything but the last `keep_last`
|
|
6
|
+
# is summarized by a cheap model into one fragment; the tail stays verbatim.
|
|
7
|
+
# This module is the pure half — boundary math, the prompt, and the
|
|
8
|
+
# Summarizer over an injected ask (the Distiller shape: unit-testable
|
|
9
|
+
# without a provider). The trigger lives in the Executor (post-turn, off
|
|
10
|
+
# the critical path); the persistence in SessionStore#set_compaction; the
|
|
11
|
+
# read path in Context::Providers::Session.
|
|
12
|
+
module Compaction
|
|
13
|
+
DEFAULT_KEEP_LAST = 20
|
|
14
|
+
DEFAULT_COMPACT_AFTER = 40
|
|
15
|
+
# A summary that outgrows this is truncated — the compaction must never
|
|
16
|
+
# grow the context it exists to shrink.
|
|
17
|
+
MAX_SUMMARY_CHARS = 6_000
|
|
18
|
+
# Per-message cap in the transcript slice sent to the summarizer (a
|
|
19
|
+
# `role: tool` body can be 4 000 chars in the store); the head of a long
|
|
20
|
+
# result carries the identity of what happened, which is what a summary needs.
|
|
21
|
+
MESSAGE_CHAR_CAP = 1_000
|
|
22
|
+
|
|
23
|
+
# The engine's generic prompt. A platform `compaction.prompt` REPLACES it
|
|
24
|
+
# wholesale (the distill convention) — the engine never writes store
|
|
25
|
+
# vocabulary. The preserve-list is the P28 contract: facts (CEP, order
|
|
26
|
+
# numbers), commitments, the MISSING list, decisions.
|
|
27
|
+
DEFAULT_PROMPT = <<~PROMPT.freeze
|
|
28
|
+
You are compacting the OLD part of an ongoing customer conversation into
|
|
29
|
+
one summary that the assistant will read INSTEAD of those messages. The
|
|
30
|
+
recent messages stay verbatim; your summary is the only surviving trace
|
|
31
|
+
of the old ones — anything you drop is gone for good.
|
|
32
|
+
|
|
33
|
+
Preserve, verbatim where short:
|
|
34
|
+
- every fact the customer stated (sizes, budget, address, postal code/CEP,
|
|
35
|
+
order numbers, product choices, dates, quantities);
|
|
36
|
+
- every commitment the assistant made (promises, prices quoted, delivery
|
|
37
|
+
windows, agreed next steps);
|
|
38
|
+
- what was asked and is still unanswered (the missing information);
|
|
39
|
+
- decisions already made, so nothing gets re-asked or re-litigated.
|
|
40
|
+
|
|
41
|
+
Do not invent, do not editorialize, do not add advice. Answer with the
|
|
42
|
+
summary text only — plain text, compact, in the conversation's own language.
|
|
43
|
+
PROMPT
|
|
44
|
+
|
|
45
|
+
# The compaction plan: summarize messages[from...upto] (from = the previous
|
|
46
|
+
# boundary), keep messages[upto..] verbatim. count = upto - from.
|
|
47
|
+
Plan = Data.define(:from, :upto, :count)
|
|
48
|
+
|
|
49
|
+
module_function
|
|
50
|
+
|
|
51
|
+
# Decides whether (and what) to compact. -> Plan | nil.
|
|
52
|
+
# messages: the session transcript (append-only, RFC-0016).
|
|
53
|
+
# state: the persisted "compaction" hash ({"upto"=>, ...}) | nil.
|
|
54
|
+
# config: the Settings "compaction" hash (keep_last/compact_after).
|
|
55
|
+
# `compact_after` is clamped to at least `keep_last` so the plan always
|
|
56
|
+
# moves the boundary forward. The boundary retreats over `role: "tool"`
|
|
57
|
+
# messages so an eviction unit (assistant-with-tool_calls + its results)
|
|
58
|
+
# is never split — the whole cycle stays verbatim instead.
|
|
59
|
+
def plan(messages:, state:, config:)
|
|
60
|
+
msgs = Array(messages)
|
|
61
|
+
keep_last = positive(config && config["keep_last"], DEFAULT_KEEP_LAST)
|
|
62
|
+
compact_after = positive(config && config["compact_after"], DEFAULT_COMPACT_AFTER)
|
|
63
|
+
compact_after = keep_last if compact_after < keep_last
|
|
64
|
+
from = state ? state["upto"].to_i : 0
|
|
65
|
+
return nil unless msgs.size - from > compact_after
|
|
66
|
+
|
|
67
|
+
upto = msgs.size - keep_last
|
|
68
|
+
upto -= 1 while upto > from && role_of(msgs[upto]) == "tool"
|
|
69
|
+
return nil unless upto > from
|
|
70
|
+
|
|
71
|
+
Plan.new(from: from, upto: upto, count: upto - from)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# The full prompt for one compaction run: the base rules, the PREVIOUS
|
|
75
|
+
# summary (so a fact from turn 3 survives every re-compaction — each
|
|
76
|
+
# summary folds the last one in) and only the NEW slice. The slice is
|
|
77
|
+
# sent UNREDACTED on purpose: it replaces transcript the main model
|
|
78
|
+
# already reads raw, inside the same trust boundary, and redaction would
|
|
79
|
+
# delete exactly the facts (CEP, order id) the summary must preserve.
|
|
80
|
+
def prompt(messages:, plan:, previous: nil, base: nil)
|
|
81
|
+
rules = Coercion.presence(base.to_s) || DEFAULT_PROMPT
|
|
82
|
+
parts = [rules.rstrip]
|
|
83
|
+
if Coercion.presence(previous.to_s)
|
|
84
|
+
parts << "## The summary so far (fold it into the new one — its facts must survive)\n\n#{previous}"
|
|
85
|
+
end
|
|
86
|
+
parts << "## The messages to compact\n\n#{transcript(messages, plan)}"
|
|
87
|
+
parts.join("\n\n")
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# "[i] role: content" over the plan's slice, one line per message; a
|
|
91
|
+
# tool-calling assistant message with no text renders the tool names.
|
|
92
|
+
def transcript(messages, plan)
|
|
93
|
+
Array(messages)[plan.from...plan.upto].to_a.each_with_index.map do |msg, offset|
|
|
94
|
+
"[#{plan.from + offset}] #{role_of(msg)}: #{text_of(msg)}"
|
|
95
|
+
end.join("\n")
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def role_of(msg) = (msg["role"] || msg[:role]).to_s
|
|
99
|
+
|
|
100
|
+
def text_of(msg)
|
|
101
|
+
content = (msg["content"] || msg[:content]).to_s.strip.gsub(/\s+/, " ")
|
|
102
|
+
if content.empty?
|
|
103
|
+
calls = msg["tool_calls"] || msg[:tool_calls]
|
|
104
|
+
names = Array(calls).filter_map { |c| c.is_a?(Hash) ? (c["name"] || c[:name] || c.dig("function", "name")) : nil }
|
|
105
|
+
content = names.empty? ? "(empty)" : "(tool calls: #{names.join(', ')})"
|
|
106
|
+
end
|
|
107
|
+
content[0, MESSAGE_CHAR_CAP]
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def positive(value, default)
|
|
111
|
+
n = value.to_i
|
|
112
|
+
n.positive? ? n : default
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# The one place compaction asks a model for anything. Pure over an
|
|
116
|
+
# injected `ask` (the Distiller shape); the real ask is a lambda built by
|
|
117
|
+
# SummarizerFactory (ruby_llm required lazily, load_guard stays green).
|
|
118
|
+
class Summarizer
|
|
119
|
+
# A blank answer must not overwrite the boundary — empty output is a
|
|
120
|
+
# loud failure, never "the old turns said nothing".
|
|
121
|
+
class Unusable < Insika::ValidationError; end
|
|
122
|
+
|
|
123
|
+
# ask: ->(prompt) { "<raw model text>" } | something answering #content
|
|
124
|
+
# (+ #input_tokens/#output_tokens/#cached_tokens for cost).
|
|
125
|
+
# model: the ref recorded on the event ("utility_model" default).
|
|
126
|
+
attr_reader :model
|
|
127
|
+
|
|
128
|
+
def initialize(ask:, model: "utility_model")
|
|
129
|
+
@ask = ask
|
|
130
|
+
@model = model.to_s
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# -> { summary: String, cost: { "spent" => N, "cached" => N } | nil }
|
|
134
|
+
# Raises Unusable on a blank answer; truncates past MAX_SUMMARY_CHARS.
|
|
135
|
+
def summarize(prompt:)
|
|
136
|
+
answer = @ask.call(prompt)
|
|
137
|
+
text = Coercion.utf8(text_of(answer)).strip
|
|
138
|
+
raise Unusable, "the summarizer answered with nothing" if text.empty?
|
|
139
|
+
|
|
140
|
+
{ summary: text[0, MAX_SUMMARY_CHARS], cost: cost_of(answer) }
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
private
|
|
144
|
+
|
|
145
|
+
def text_of(answer) = (answer.respond_to?(:content) ? answer.content : answer).to_s
|
|
146
|
+
|
|
147
|
+
# nil when the provider said nothing — never 0 (the Distiller's
|
|
148
|
+
# discipline). The cached prefix is INCLUDED in the spent total.
|
|
149
|
+
def cost_of(answer)
|
|
150
|
+
return nil unless answer.respond_to?(:input_tokens) && answer.respond_to?(:output_tokens)
|
|
151
|
+
|
|
152
|
+
input = answer.input_tokens.to_i
|
|
153
|
+
output = answer.output_tokens.to_i
|
|
154
|
+
cached = answer.respond_to?(:cached_tokens) ? answer.cached_tokens.to_i : 0
|
|
155
|
+
{ "spent" => input + output, "cached" => cached }
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Resolves WHICH model summarizes and builds the ask. compaction.model ->
|
|
160
|
+
# platform utility_model -> nil (nil means "feature inert", never a guess —
|
|
161
|
+
# the DistillerFactory ladder). NOT the fallbacks chain: that one answers
|
|
162
|
+
# "which model serves the customer turn". `ask_factory`/`llm` injectable (specs).
|
|
163
|
+
module SummarizerFactory
|
|
164
|
+
module_function
|
|
165
|
+
|
|
166
|
+
# config: the Settings "compaction" hash. -> Summarizer | nil
|
|
167
|
+
def build(config, utility_model: nil, ask_factory: nil, llm: nil)
|
|
168
|
+
ref = Coercion.presence(config && config["model"]) || Coercion.presence(utility_model)
|
|
169
|
+
return nil if ref.nil?
|
|
170
|
+
|
|
171
|
+
provider, model = split_ref(ref)
|
|
172
|
+
factory = ask_factory || ->(m, p) { ruby_llm_ask(m, p, llm: llm) }
|
|
173
|
+
Summarizer.new(ask: factory.call(model, provider), model: ref)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# "provider/model" -> [provider, model]; "model" -> [nil, model] — one
|
|
177
|
+
# syntax for "which model" across features (DistillerFactory's reading).
|
|
178
|
+
def split_ref(ref)
|
|
179
|
+
prov, name = ref.to_s.split("/", 2)
|
|
180
|
+
name ? [prov, name] : [nil, prov]
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Temperature 0: the same slice must compact to the same boundary
|
|
184
|
+
# deterministically. `ruby_llm` is required lazily so nothing loads a
|
|
185
|
+
# provider gem until compaction is actually configured (load_guard stays green).
|
|
186
|
+
def ruby_llm_ask(model, provider, llm: nil)
|
|
187
|
+
require "ruby_llm"
|
|
188
|
+
llm ||= RubyLLM
|
|
189
|
+
lambda do |prompt|
|
|
190
|
+
llm.chat(model: model, provider: provider, assume_model_exists: true)
|
|
191
|
+
.with_temperature(0).ask(prompt)
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
@@ -151,13 +151,17 @@ module Insika
|
|
|
151
151
|
identity, volatile = system_frags.partition { |f| (f.layer || :volatile) == :identity }
|
|
152
152
|
system_frags = sort_canonical(identity) + sort_canonical(volatile)
|
|
153
153
|
history_frags = fragments.select { |f| f.placement == :history } # production order (chronological)
|
|
154
|
+
# :tail renders after ALL history — the last thing before the current user
|
|
155
|
+
# message. Attention is strongest at the end of the context, so the goal
|
|
156
|
+
# restated here survives a 30-call turn that the head prompt no longer does.
|
|
157
|
+
tail_frags = fragments.select { |f| f.placement == :tail }
|
|
154
158
|
tool_frags = fragments.select { |f| f.placement == :tool_context }
|
|
155
159
|
|
|
156
160
|
system = system_frags.map(&:content).join("\n\n")
|
|
157
|
-
history = history_frags.map(&:content)
|
|
161
|
+
history = history_frags.map(&:content) + tail_frags.map(&:content)
|
|
158
162
|
tool_context = tool_frags.empty? ? nil : tool_frags.map(&:content).join("\n\n")
|
|
159
163
|
|
|
160
|
-
canonical = system_frags + history_frags + tool_frags
|
|
164
|
+
canonical = system_frags + history_frags + tail_frags + tool_frags
|
|
161
165
|
ContextPackage.new(
|
|
162
166
|
system: system, history: history, tool_context: tool_context,
|
|
163
167
|
fragments: canonical, budget: { cap: cap, used: canonical.sum(&:tokens), evicted: evicted }
|
|
@@ -3,7 +3,10 @@
|
|
|
3
3
|
module Insika
|
|
4
4
|
# Unit of context produced by a provider.
|
|
5
5
|
# SHARED type (Insika::, not Insika::Context::).
|
|
6
|
-
# placement: :system | :history | :tool_context
|
|
6
|
+
# placement: :system | :history | :tail | :tool_context
|
|
7
|
+
# :tail renders AFTER the whole history, i.e. as the last thing
|
|
8
|
+
# the model reads before the current user message. Its content is
|
|
9
|
+
# a message Hash ({role:, content:}), like a :history fragment.
|
|
7
10
|
# priority: Integer; higher = more important (survives cuts)
|
|
8
11
|
# tokens: Integer | nil; estimated by the Builder when nil
|
|
9
12
|
# source: String — provider id (audit)
|
|
@@ -17,6 +17,9 @@ module Insika
|
|
|
17
17
|
# the injected block.
|
|
18
18
|
module Priority
|
|
19
19
|
IDENTITY = 100 # IDENTITY/SOUL (Prompt) — pinned
|
|
20
|
+
RECITATION = 95 # <recitation> the goal restated at the TAIL of context
|
|
21
|
+
# (Briefing). Never cut: it is two lines, and the one
|
|
22
|
+
# turn it gets evicted is the long turn that needed it.
|
|
20
23
|
PROMPT_REF = 90 # Prompt Catalog guardrails/refs (Prompt) — pinned
|
|
21
24
|
SKILL_BODY = 85 # <active_skill> trigger-matched body (SkillTrigger)
|
|
22
25
|
SKILL = 80 # <available_skills> level 1 (Skill)
|
|
@@ -29,6 +32,9 @@ module Insika
|
|
|
29
32
|
# pinned prefix), above the turn's own <request_context>.
|
|
30
33
|
HISTORY_MAX = 79 # history ceiling by recency (Session)
|
|
31
34
|
HISTORY_BASE = 60 # history base; +idx up to the ceiling (Session)
|
|
35
|
+
COMPACTION = 59 # <conversation_summary> the compacted prefix (RFC-0044)
|
|
36
|
+
# — one step below the oldest verbatim message: under
|
|
37
|
+
# budget it is the "oldest unit" and drops first.
|
|
32
38
|
REQUEST = 40 # <request_context> — turn injection, the most cuttable
|
|
33
39
|
end
|
|
34
40
|
end
|