insika 0.2.0 → 0.3.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 +145 -17
- data/README.md +7 -1
- data/bin/insika +211 -8
- data/docs/AGENTS.md +377 -2
- data/docs/API.md +56 -0
- data/docs/CHANNELS.md +99 -9
- data/docs/CONTEXT.md +127 -16
- data/docs/DEPLOY.md +5 -2
- data/docs/EVALS.md +19 -0
- data/docs/FACTS.md +135 -0
- data/docs/HARVEST.md +117 -0
- data/docs/LOADTEST.md +14 -8
- data/docs/OBSERVABILITY.md +54 -0
- data/docs/REFINEMENT.md +3 -3
- data/docs/RELEASING.md +27 -0
- data/docs/RUNNING-LOCAL.md +3 -3
- data/docs/SECURITY.md +61 -0
- data/docs/SKILLS.md +2 -2
- data/docs/SOAK.md +127 -0
- data/docs/TOOLS.md +70 -2
- data/docs/domain.md +115 -0
- data/docs/index.md +1 -1
- data/lib/insika/agent_profile.rb +145 -10
- data/lib/insika/balloon_splitter.rb +102 -0
- data/lib/insika/budget_ledger.rb +34 -3
- data/lib/insika/cache_series_store.rb +49 -0
- data/lib/insika/channel_delivery.rb +118 -13
- data/lib/insika/channels/relay.rb +77 -3
- data/lib/insika/channels/web.rb +2 -2
- data/lib/insika/chat_builder.rb +84 -1
- data/lib/insika/checkpoint_store.rb +16 -0
- data/lib/insika/commands/agent_payload.rb +3 -3
- data/lib/insika/commands/cancel_followup.rb +49 -0
- data/lib/insika/commands/delete_tenant_data.rb +95 -0
- data/lib/insika/commands/export_customer_memory.rb +48 -0
- data/lib/insika/commands/forget_customer.rb +117 -0
- data/lib/insika/commands/freeze_funnel_baseline.rb +113 -0
- data/lib/insika/commands/gate_harvest.rb +138 -0
- data/lib/insika/commands/gate_refinement.rb +1 -1
- data/lib/insika/commands/judge_shadow_pairs.rb +124 -0
- data/lib/insika/commands/memory_forget_fact.rb +20 -4
- data/lib/insika/commands/memory_put_fact.rb +23 -4
- data/lib/insika/commands/promote_harvest.rb +130 -0
- data/lib/insika/commands/record_outcome.rb +46 -0
- data/lib/insika/commands/record_shadow_reply.rb +68 -0
- data/lib/insika/commands/reject_harvest.rb +38 -0
- data/lib/insika/commands/resolve_proposal.rb +108 -0
- data/lib/insika/commands/revoke_contact.rb +49 -0
- data/lib/insika/commands/rollback_harvest.rb +86 -0
- data/lib/insika/commands/run_distillation.rb +186 -0
- data/lib/insika/commands/run_harvest.rb +393 -0
- data/lib/insika/commands/send_message.rb +103 -6
- data/lib/insika/commands/session_purge.rb +67 -0
- data/lib/insika/contact_store.rb +183 -0
- data/lib/insika/context/builder.rb +21 -3
- data/lib/insika/context/fragment.rb +7 -3
- data/lib/insika/context/priority.rb +3 -0
- data/lib/insika/context/provider.rb +17 -3
- data/lib/insika/context/providers/briefing.rb +96 -0
- data/lib/insika/context/providers/memory.rb +16 -7
- data/lib/insika/context/providers/prompt.rb +30 -2
- data/lib/insika/context/providers/skill.rb +2 -0
- data/lib/insika/context/providers/tool_search.rb +2 -0
- data/lib/insika/context_trace_store.rb +38 -2
- data/lib/insika/distill.rb +224 -0
- data/lib/insika/distill_engine.rb +169 -0
- data/lib/insika/doctor.rb +716 -6
- data/lib/insika/dsl/runtime.rb +9 -3
- data/lib/insika/dsl/server_boot.rb +70 -1
- data/lib/insika/dsl.rb +83 -3
- data/lib/insika/edge_limiter.rb +28 -7
- data/lib/insika/env_schema.rb +9 -2
- data/lib/insika/errors.rb +11 -0
- data/lib/insika/evals/assertions.rb +3 -2
- data/lib/insika/evals/pairwise.rb +18 -6
- data/lib/insika/evidence.rb +183 -0
- data/lib/insika/executor.rb +687 -70
- data/lib/insika/followup_engine.rb +207 -0
- data/lib/insika/followup_policy.rb +221 -0
- data/lib/insika/followup_store.rb +306 -0
- data/lib/insika/funnel_declaration.rb +106 -0
- data/lib/insika/funnel_fold.rb +179 -0
- data/lib/insika/funnel_store.rb +163 -0
- data/lib/insika/golden_store.rb +1 -1
- data/lib/insika/grounding/matcher.rb +69 -0
- data/lib/insika/grounding.rb +44 -0
- data/lib/insika/harvest/conversion_gate.rb +159 -0
- data/lib/insika/harvest/criterion.rb +98 -0
- data/lib/insika/harvest/gate.rb +194 -0
- data/lib/insika/harvest/negative_list.rb +199 -0
- data/lib/insika/harvest.rb +241 -0
- data/lib/insika/harvest_engine.rb +193 -0
- data/lib/insika/harvest_store.rb +548 -0
- data/lib/insika/media.rb +298 -0
- data/lib/insika/memory_audit_store.rb +85 -0
- data/lib/insika/memory_store.rb +264 -23
- data/lib/insika/message_origin.rb +6 -1
- data/lib/insika/model_visible.rb +87 -0
- data/lib/insika/model_visible_trace_store.rb +66 -0
- data/lib/insika/onboarding.rb +6 -1
- data/lib/insika/outbox_store.rb +42 -4
- data/lib/insika/outcome_store.rb +147 -0
- data/lib/insika/packaging.rb +163 -0
- data/lib/insika/parity/criterion.rb +79 -0
- data/lib/insika/parity/verdict.rb +318 -0
- data/lib/insika/prefix_fingerprint.rb +58 -0
- data/lib/insika/profile_source.rb +22 -1
- data/lib/insika/proposal_store.rb +271 -0
- data/lib/insika/queue_policy.rb +4 -1
- data/lib/insika/refinement/proposer.rb +1 -1
- data/lib/insika/reliability.rb +32 -6
- data/lib/insika/retention.rb +281 -0
- data/lib/insika/routing.rb +101 -0
- data/lib/insika/safety/config.rb +44 -4
- data/lib/insika/safety/corpus.rb +255 -0
- data/lib/insika/safety/detectors.rb +32 -113
- data/lib/insika/safety/factory.rb +15 -2
- data/lib/insika/safety/grounding_enforcer.rb +59 -0
- data/lib/insika/safety/grounding_validator.rb +49 -0
- data/lib/insika/safety/input_guardrail.rb +1 -1
- data/lib/insika/safety/moderator.rb +1 -1
- data/lib/insika/safety/output_filter.rb +9 -5
- data/lib/insika/safety/output_validator.rb +11 -5
- data/lib/insika/schema_guard.rb +35 -0
- data/lib/insika/server/app.rb +144 -4
- data/lib/insika/server/rack_app.rb +16 -0
- data/lib/insika/server/responses.rb +40 -1
- data/lib/insika/session_actor.rb +8 -4
- data/lib/insika/session_store.rb +65 -2
- data/lib/insika/settings_store.rb +10 -0
- data/lib/insika/shadow_pair_store.rb +258 -0
- data/lib/insika/skill_catalog.rb +4 -0
- data/lib/insika/soak/envelope.rb +140 -0
- data/lib/insika/soak/report.rb +392 -0
- data/lib/insika/soak/runner.rb +554 -0
- data/lib/insika/store.rb +10 -1
- data/lib/insika/stores/memory.rb +6 -0
- data/lib/insika/stores/sqlite.rb +8 -0
- data/lib/insika/studio/app.rb +875 -14
- data/lib/insika/studio/assets/dist/application.css +1 -1
- data/lib/insika/studio/assets/dist/application.js +22 -22
- data/lib/insika/studio/assets/dist/favicon.svg +6 -0
- data/lib/insika/studio/forms.rb +262 -5
- data/lib/insika/studio/nav_icons.rb +6 -1
- data/lib/insika/studio/views/agent_detail.erb +627 -84
- data/lib/insika/studio/views/agents.erb +10 -6
- data/lib/insika/studio/views/approvals.erb +4 -1
- data/lib/insika/studio/views/chats.erb +4 -1
- data/lib/insika/studio/views/customer.erb +94 -0
- data/lib/insika/studio/views/customers.erb +32 -0
- data/lib/insika/studio/views/evals.erb +4 -1
- data/lib/insika/studio/views/facts.erb +133 -0
- data/lib/insika/studio/views/followups.erb +125 -0
- data/lib/insika/studio/views/funnel.erb +106 -0
- data/lib/insika/studio/views/harvest.erb +234 -0
- data/lib/insika/studio/views/home.erb +2 -1
- data/lib/insika/studio/views/layout.erb +1 -0
- data/lib/insika/studio/views/parity.erb +147 -0
- data/lib/insika/studio/views/playground.erb +7 -1
- data/lib/insika/studio/views/session.erb +56 -1
- data/lib/insika/studio/views/settings.erb +2 -0
- data/lib/insika/studio/views/skills.erb +0 -1
- data/lib/insika/studio/views/system_files.erb +1 -1
- data/lib/insika/studio/views/task.erb +13 -0
- data/lib/insika/studio/views/tasks.erb +4 -1
- data/lib/insika/studio/views/tools.erb +0 -1
- data/lib/insika/task_store.rb +21 -1
- data/lib/insika/testing/store_contract.rb +27 -6
- data/lib/insika/tick.rb +25 -1
- data/lib/insika/tool_definition.rb +17 -7
- data/lib/insika/tool_envelope.rb +69 -0
- data/lib/insika/tool_manifest.rb +5 -1
- data/lib/insika/tools/data_defined_tool.rb +10 -0
- data/lib/insika/tools/generate_image.rb +44 -0
- data/lib/insika/tools/schedule_followup.rb +164 -0
- data/lib/insika/tools/tts.rb +47 -0
- data/lib/insika/tools/update_briefing.rb +126 -0
- data/lib/insika/turn_state.rb +38 -1
- data/lib/insika/turn_timing.rb +22 -2
- data/lib/insika/version.rb +1 -1
- data/lib/insika/vitals.rb +84 -0
- data/lib/insika/wiring/graph.rb +300 -13
- data/lib/insika.rb +92 -3
- metadata +77 -1
data/docs/CONTEXT.md
CHANGED
|
@@ -20,19 +20,21 @@ Providers are chosen by a double gate (the provider opts in for the profile **an
|
|
|
20
20
|
the agent's `context_providers` allowlist permits it), then assembled by priority
|
|
21
21
|
into a deterministic prompt:
|
|
22
22
|
|
|
23
|
-
| Provider | Block | Priority | Notes |
|
|
24
|
-
|
|
25
|
-
| **Identity** | system | **100 — pinned** | The agent's prompt files (global system files first). Never cut. |
|
|
26
|
-
| **
|
|
27
|
-
| **
|
|
28
|
-
| **
|
|
29
|
-
| **
|
|
30
|
-
| **
|
|
31
|
-
| **
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
23
|
+
| Provider | Block | Priority | Layer | Notes |
|
|
24
|
+
|----------|-------|:--------:|-------|-------|
|
|
25
|
+
| **Identity** | system | **100 — pinned** | identity | The agent's prompt files (global system files first). Never cut. |
|
|
26
|
+
| **Skills** | `<available_skills>` | 80 | identity | Level-1 skill list, minus whatever is already eager — see [Skills](SKILLS.md). |
|
|
27
|
+
| **Tool search** | `<available_tools>` | 70 | identity | Level-1 list of deferred tools — see [Tools](TOOLS.md). |
|
|
28
|
+
| **Skill trigger** | `<active_skill>` | 85 | volatile | Level-2 bodies: the agent's `skills_eager` set, plus the ones whose `triggers:` match the message — see [Skills](SKILLS.md). |
|
|
29
|
+
| **Memory** | `<memory>` | 75 | volatile | Durable facts + recent notes, only if `memory` is on. Cuttable. |
|
|
30
|
+
| **Briefing** | `<briefing>` | 65 | volatile | The session's working state (known fields, still-missing list, next step) — only if the pack declared `briefing_fields`. Cuttable. |
|
|
31
|
+
| **Session** | history | 60–79 | volatile | The running transcript; priority scales with recency. |
|
|
32
|
+
| **Request** | `<request_context>` | 40 | volatile | Turn variables + tenant. Most cuttable; sits last. |
|
|
33
|
+
|
|
34
|
+
The ordering is deliberate: the render order is **identity layer first, volatile
|
|
35
|
+
layer after** — nothing volatile can sit above the cache boundary, whatever its
|
|
36
|
+
priority — and within each layer the priority sort above holds. That keeps the
|
|
37
|
+
cacheable prefix byte-stable (see the prefix cache below).
|
|
36
38
|
|
|
37
39
|
## Budget and eviction — the actual "compaction"
|
|
38
40
|
|
|
@@ -78,19 +80,82 @@ budget first.
|
|
|
78
80
|
With `memory` enabled, an agent gains a built-in `remember` tool for durable
|
|
79
81
|
facts, and those facts (plus recent notes) are injected back into the prompt on
|
|
80
82
|
later turns — **including turns in a different session**. Memory is scoped per
|
|
81
|
-
agent
|
|
83
|
+
agent, per `(tenant, customer)` when the message carries a `customer`, and per
|
|
84
|
+
session otherwise — a session's own memory lives in a marked `memory:chat:<session id>`
|
|
85
|
+
cell, never a bare one, so the Customers drill cannot read a conversation as a
|
|
86
|
+
customer. This is distinct from *session history*, which is the transcript of one
|
|
82
87
|
conversation; memory is the small set of facts that should outlive any single
|
|
83
88
|
conversation. Facts and notes are editable from the Studio agent page. See
|
|
84
89
|
[`examples/memory/`](https://github.com/guizaols/insika/tree/main/examples/memory/) for a runnable cross-session example.
|
|
85
90
|
|
|
91
|
+
Facts carry **provenance metadata** (RFC-0031): every fact record stores `origin`
|
|
92
|
+
(who wrote it — `"engine"`, `"operator"`, `"legacy"` or `"distilled"`),
|
|
93
|
+
`created_at` / `updated_at` timestamps, and an optional `expires_at` (ISO8601) —
|
|
94
|
+
**an expired fact is never injected**, even before the daily sweep prunes it. The
|
|
95
|
+
Studio Customers drill reads and edits the same cell the next turn reads (injection
|
|
96
|
+
unchanged), and every operator mutation lands in the content-free audit trail
|
|
97
|
+
(digests, never values). The sweep honors the `memory_ttl_days` setting on its own
|
|
98
|
+
knob — see [Security](SECURITY.md#memory-and-the-right-to-be-forgotten-lgpd-rfc-0031).
|
|
99
|
+
|
|
100
|
+
An **approved distilled fact** (RFC-0034 — see [Facts](FACTS.md)) lands in the
|
|
101
|
+
same cell this provider injects, stamped `distilled:<session_ref>` — approved on
|
|
102
|
+
the Studio Facts page, never applied automatically.
|
|
103
|
+
|
|
104
|
+
## Briefing — the session's working state
|
|
105
|
+
|
|
106
|
+
The **briefing** is the per-conversation working state the agent keeps and asks
|
|
107
|
+
for: which facts it already learned (size, budget, delivery day) and the agreed
|
|
108
|
+
next step. It is **engine-owned data** — one `"briefing"` key on the session
|
|
109
|
+
record, written only by the agent through tools — whose *fields* come from the
|
|
110
|
+
pack:
|
|
111
|
+
|
|
112
|
+
```jsonc
|
|
113
|
+
// pack agent.config.json
|
|
114
|
+
{ "id": "store-support", "briefing_fields": ["size", "budget", "delivery_day"] }
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
```ruby
|
|
118
|
+
# or the DSL — [] = the feature is off (no block, no tools)
|
|
119
|
+
briefing_fields "size", "budget", "delivery_day"
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
With fields declared, the turn's `:system` context gains a `<briefing>` block
|
|
123
|
+
(priority 65 — below identity/skill/memory so it never breaks the cacheable
|
|
124
|
+
prefix, above the turn's own `<request_context>`):
|
|
125
|
+
|
|
126
|
+
```
|
|
127
|
+
<briefing>
|
|
128
|
+
known:
|
|
129
|
+
size: M
|
|
130
|
+
still missing: budget, delivery_day
|
|
131
|
+
next step: send the payment link tomorrow at 10
|
|
132
|
+
</briefing>
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
The `still missing` list is the point: the *model* sees which declared fields are
|
|
136
|
+
still unanswered, so it stops re-asking for something already given. Stored keys
|
|
137
|
+
that the pack no longer declares are never rendered. The Studio session screen
|
|
138
|
+
shows the persisted state (known fields + next step), read-only.
|
|
139
|
+
|
|
140
|
+
The agent writes the briefing through two built-in tools, wired only when the
|
|
141
|
+
pack declared fields:
|
|
142
|
+
|
|
143
|
+
- `update_briefing(field:, value:)` — records a field. An undeclared `field`
|
|
144
|
+
returns an envelope error (`unknown field '…'; declared: …`) and nothing is
|
|
145
|
+
persisted; a blank `value` clears the field.
|
|
146
|
+
- `set_next_step(text:)` — records the agreed next step; a blank `text` clears it.
|
|
147
|
+
|
|
148
|
+
Both are deterministic in-process writes (never enveloped) and survive across
|
|
149
|
+
turns and resumes — a resumed conversation re-opens with the briefing intact.
|
|
150
|
+
|
|
86
151
|
## The provider prefix cache
|
|
87
152
|
|
|
88
153
|
Two distinct caching mechanisms — don't conflate them:
|
|
89
154
|
|
|
90
155
|
- **Automatic server-side prefix cache.** Some providers prefix-cache a stable
|
|
91
156
|
system prefix automatically, at no cost to configure. This works **only because**
|
|
92
|
-
the
|
|
93
|
-
|
|
157
|
+
the engine renders the system in two layers (below) and the volatile half sits
|
|
158
|
+
**under** the identity boundary, keeping the cacheable prefix byte-stable.
|
|
94
159
|
Anything that injects volatile content high in the system block breaks the cache.
|
|
95
160
|
- **Manual cache breakpoints (opt-in).** With `prompt_caching` on **and** a
|
|
96
161
|
provider that supports explicit cache control, the builder sets one cache
|
|
@@ -100,6 +165,52 @@ Two distinct caching mechanisms — don't conflate them:
|
|
|
100
165
|
Cache accounting surfaces as `cached_tokens` (reads) and `cache_creation_tokens`
|
|
101
166
|
(writes), visible in telemetry and the Studio tokens chip.
|
|
102
167
|
|
|
168
|
+
### The two layers (RFC-0030)
|
|
169
|
+
|
|
170
|
+
The system block is partitioned into two cache layers:
|
|
171
|
+
|
|
172
|
+
- **Identity** — bytes that change only on deploy/config edit: the persona
|
|
173
|
+
prompt (`Prompt`), the level-1 skill list (`Skill`) and the deferred-tool
|
|
174
|
+
catalog (`ToolSearch`). This is the cacheable prefix.
|
|
175
|
+
- **Volatile** — bytes that may change per turn: memory, session history,
|
|
176
|
+
triggered skill bodies, the `<request_context>`. Everything else.
|
|
177
|
+
|
|
178
|
+
The layer is a **provider-class contract**, not profile data: `ContextProvider`
|
|
179
|
+
declares `def layer = :volatile` (conservative — nothing gets pinned by
|
|
180
|
+
accident) and the three identity builtins override to `:identity`. A pack does
|
|
181
|
+
not set it — a pack reorganizes *which content goes into the Prompt provider vs
|
|
182
|
+
the volatile providers*. The Builder stamps the layer on every fragment at
|
|
183
|
+
production, and the render order is **identity first, volatile after** — a
|
|
184
|
+
volatile block can never land above the cache boundary, whatever its priority.
|
|
185
|
+
Within each partition the existing priority sort is untouched.
|
|
186
|
+
|
|
187
|
+
The engine's own `doctor` check verifies the declaration: an engine-known
|
|
188
|
+
volatile provider (Memory, Session, Request, SkillTrigger) that overrides to
|
|
189
|
+
`:identity` is an **error** (guaranteed cache kill); any other custom
|
|
190
|
+
`:identity` provider is a **warning** (purity unverifiable from outside — the
|
|
191
|
+
output must be byte-stable across turns).
|
|
192
|
+
|
|
193
|
+
### The observable cache: fingerprints and the invalidation reason
|
|
194
|
+
|
|
195
|
+
Each turn, the Executor hashes the rendered prefix into a PII-free fingerprint
|
|
196
|
+
chain — one SHA-256 per system category in render order, one for the tool
|
|
197
|
+
schemas, one cumulative `prefix` — and compares it against the previous turn's
|
|
198
|
+
entry. The **invalidation reason** is the first category whose bytes changed (or
|
|
199
|
+
vanished); a turn whose prefix held reports nothing. History is deliberately
|
|
200
|
+
excluded: a new user message is a divergence every turn, which would be noise,
|
|
201
|
+
not a reason.
|
|
202
|
+
|
|
203
|
+
The Studio surfaces it in two places: the **session Context card** shows the
|
|
204
|
+
turn's cache-hit percentage and the `broke: <category>` line (plus the
|
|
205
|
+
`identity` marker on the category rows), and the **agent detail** carries a
|
|
206
|
+
cache tab with the per-agent hit series over time. The per-agent series lives
|
|
207
|
+
in its own capped store, because a session does not stamp its author — the
|
|
208
|
+
per-session trace cannot answer "cache-hit over time for *this* agent".
|
|
209
|
+
|
|
210
|
+
With the prefix stable by construction, the existing `prompt_caching` breakpoint
|
|
211
|
+
sits on bytes that stay put — the first (write) turn of a deployment pays the
|
|
212
|
+
cache write once, every subsequent turn reads.
|
|
213
|
+
|
|
103
214
|
## The volume
|
|
104
215
|
|
|
105
216
|
Agents, prompts, skills, and tools are **data in SQLite**, not files on a volume.
|
data/docs/DEPLOY.md
CHANGED
|
@@ -37,7 +37,9 @@ The image boots **N Falcon worker processes over one SQLite file**
|
|
|
37
37
|
knob**: it decides which engine semantics hold cluster-wide and which are
|
|
38
38
|
per-worker. The default is 1 because the per-worker semantics are the product
|
|
39
39
|
— the queue modes (`collect`/`steer`) and FIFO ordering need one
|
|
40
|
-
session actor; raise N only with sticky routing per session in front.
|
|
40
|
+
session actor; raise N only with sticky routing per session in front. It is
|
|
41
|
+
also a [Soak](SOAK.md) precondition: with more than one worker the hourly RSS
|
|
42
|
+
series is a random process per sample, and a soak refuses to start. The
|
|
41
43
|
contract:
|
|
42
44
|
|
|
43
45
|
> Everything here describes N workers of **one** deployment — one graph, replicated.
|
|
@@ -112,10 +114,11 @@ this section is the single source of truth for what changing it means.
|
|
|
112
114
|
| `DEEPSEEK_MODEL` | `deepseek-v4-flash` | model |
|
|
113
115
|
| `CONSUMER_INTERNAL_URL` | — | base URL for data-tools calling back a consumer's internal API (see below) |
|
|
114
116
|
| `INSIKA_EGRESS_HOSTS` | — | outbound host allowlist (SSRF guard) |
|
|
115
|
-
| `INSIKA_EGRESS_ALLOW_HTTP` / `_ALLOW_PRIVATE` | off | for `http`/loopback callbacks only (**never in cloud**) |
|
|
117
|
+
| `INSIKA_EGRESS_ALLOW_HTTP` / `_ALLOW_PRIVATE` | off | for `http`/loopback callbacks only (**never in cloud**). Also opens inbound **media** fetches (audio/image URLs) to `http`/private targets — a local run serving media over `http://` needs it |
|
|
116
118
|
| `INSIKA_RELAY_TOKEN` | — | **mounts the relay channel** at `POST /channels/relay/events`, and is the Bearer it requires. Empty = the route does not exist (`404`). See [Channels](CHANNELS.md) |
|
|
117
119
|
| `INSIKA_RELAY_DELIVER_URL` | — | your callback; the engine POSTs each reply there. Goes through the egress guard |
|
|
118
120
|
| `INSIKA_RELAY_DELIVER_TOKEN` | — | Bearer the engine sends **to** your callback (optional) |
|
|
121
|
+
| `INSIKA_RELAY_DELIVERY` | `at_end` | how the relay flushes the outbox: `at_end` (one POST) or `progressive` (one POST per balloon — [RFC-0027](CHANNELS.md#delivery-policy)) |
|
|
119
122
|
| `INSIKA_WIDGET_ORIGINS` | — | exact-match origins allowed to embed the [web widget](CHANNELS.md#the-web-widget), comma-separated. No wildcards. **Half the switch**: with `INSIKA_WIDGET_AGENTS` unset, nothing is mounted (`404`) |
|
|
120
123
|
| `INSIKA_WIDGET_AGENTS` | — | agent ids a widget visitor may address, comma-separated. The other half of the switch. **A chat rate limit is also required** or the widget answers `503` |
|
|
121
124
|
| `LITESTREAM_REPLICA_URL` | — | **enables Litestream** (backup/DR). Empty = disabled (default). See below |
|
data/docs/EVALS.md
CHANGED
|
@@ -232,6 +232,15 @@ That is what you run before merging a prompt, tool or model change. A case with
|
|
|
232
232
|
baseline entry never blocks: it shows as failing in the report, but a brand-new case is
|
|
233
233
|
not a regression.
|
|
234
234
|
|
|
235
|
+
The same gate machinery guards the two automated loops — [Refinement](REFINEMENT.md)
|
|
236
|
+
(cloned-agent replay of a proposed edit) and [Harvest](HARVEST.md) (cloned-agent
|
|
237
|
+
replay of a mined skill). **Judges are mandatory for those gates** in exactly three
|
|
238
|
+
shapes, the P18 lesson: a gate without a recorded baseline refuses, an all-red
|
|
239
|
+
baseline refuses, and a baseline recorded WITH judge scores, replayed with no judge
|
|
240
|
+
configured, refuses — a rubric'd case with no verdict reads as a pass, so the
|
|
241
|
+
candidate would beat a measurement it never took. A store with no golden cases cannot
|
|
242
|
+
gate, and cannot promote.
|
|
243
|
+
|
|
235
244
|
## Honest limits
|
|
236
245
|
|
|
237
246
|
- **A judge is a model.** It has taste and it has bad days; that is why the
|
|
@@ -271,3 +280,13 @@ needs to score a candidate agent with the same judge, and a second copy of the j
|
|
|
271
280
|
would be the worst possible outcome. It stays a **client** even so: it reaches a running
|
|
272
281
|
deployment over HTTP through `POST /v1/responses` and never reads a store directly.
|
|
273
282
|
`evals/run.rb` is a thin CLI over it.
|
|
283
|
+
|
|
284
|
+
## Parity — the shadow criterion
|
|
285
|
+
|
|
286
|
+
The shadow experiment's rule lives in a **deployment-side criterion file** —
|
|
287
|
+
the path `INSIKA_PARITY_CRITERION` points at, prose a human reads and a `yaml`
|
|
288
|
+
block the machine applies, in one file, so there is exactly one place to edit.
|
|
289
|
+
The file's whole bytes are hashed; every shadow pair records that hash,
|
|
290
|
+
and a window whose pairs disagree produces `:invalid`, never a verdict. Editing
|
|
291
|
+
the criterion mid-experiment is *caught*, not averaged away. The fold itself is
|
|
292
|
+
`lib/insika/parity/*` (see [Channels](CHANNELS.md#shadow-mode)).
|
data/docs/FACTS.md
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Facts
|
|
3
|
+
parent: Operate & prove it
|
|
4
|
+
nav_order: 6
|
|
5
|
+
permalink: /facts/
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Facts — distilled customer memory, human-gated
|
|
9
|
+
|
|
10
|
+
Finished conversations teach a shop things — the customer's size, their budget,
|
|
11
|
+
how they like to pay — and today that knowledge dies with the session. The
|
|
12
|
+
engine can read it back out of the transcripts: **distillation** turns an idle,
|
|
13
|
+
finished customer conversation into a list of proposed facts, a human approves
|
|
14
|
+
or rejects them in the Studio, and an approved fact lands in the customer's
|
|
15
|
+
memory cell — the same cell the `<memory>` block injects on every later turn.
|
|
16
|
+
|
|
17
|
+
The loop has one hard rule: **nothing is ever applied automatically.** Zero
|
|
18
|
+
facts reach the store without a click. Distillation proposes; the operator
|
|
19
|
+
decides; the engine never applies its own proposal.
|
|
20
|
+
|
|
21
|
+
## The loop
|
|
22
|
+
|
|
23
|
+
1. A customer conversation (a session tagged with a `customer`, per
|
|
24
|
+
[Context](CONTEXT.md#memory)) goes idle — nobody has written for the
|
|
25
|
+
configured hours.
|
|
26
|
+
2. The engine's distillation duty picks the session, sends its transcript to
|
|
27
|
+
the platform `utility_model` (off the turn path, on its own worker fiber),
|
|
28
|
+
and the model answers with a JSON list of durable facts: `name`, `value`,
|
|
29
|
+
an optional `confidence`, and the transcript message indexes that support
|
|
30
|
+
the fact (the **evidence**).
|
|
31
|
+
3. The engine filters the answer against a safe subset (no invented scopes, no
|
|
32
|
+
oversized values, no out-of-range evidence), dedups it against the ledger,
|
|
33
|
+
and writes the survivors as **proposals**.
|
|
34
|
+
4. The **Facts** page in the Studio shows the pending proposals with their
|
|
35
|
+
evidence excerpt. The operator **approves** (the fact is written to the
|
|
36
|
+
customer's memory, stamped with its origin), **rejects** (optionally with a
|
|
37
|
+
reason) or **dismisses** (it will never be proposed again).
|
|
38
|
+
5. Approved facts join the customer's memory cell and are injected by the
|
|
39
|
+
Memory provider on the next turn of any session of that customer.
|
|
40
|
+
|
|
41
|
+
## Enabling it — the `distill:` block
|
|
42
|
+
|
|
43
|
+
Distillation is pack data on the agent, exactly like `refinement:` or
|
|
44
|
+
`followup:` — absent = the feature is off for that agent, byte-identical engine:
|
|
45
|
+
|
|
46
|
+
```ruby
|
|
47
|
+
agent = Insika.agent("store-support") do
|
|
48
|
+
instructions "…"
|
|
49
|
+
distill enabled: true,
|
|
50
|
+
idle_hours: 6, # how idle a session must be before it distills
|
|
51
|
+
min_messages: 3, # a shorter session distills noise, not facts
|
|
52
|
+
max_proposals: 10 # cap per session pass
|
|
53
|
+
# prompt: "<what counts as a fact for THIS store>" — the pack-authored half;
|
|
54
|
+
# absent = the engine's generic prompt. `model:` (absent = the platform
|
|
55
|
+
# utility_model) can name the distiller explicitly.
|
|
56
|
+
end
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The same keys work in a pack's `agent.config.json`. `idle_hours` /
|
|
60
|
+
`min_messages` / `max_proposals` are per-agent data; `prompt` is the store's
|
|
61
|
+
half — what counts as a fact for a fashion store ("size, preference, budget")
|
|
62
|
+
is not what counts for a logistics one ("address, delivery window, carrier").
|
|
63
|
+
The engine never writes store vocabulary.
|
|
64
|
+
|
|
65
|
+
## The human gate, precisely
|
|
66
|
+
|
|
67
|
+
The **Facts** page (operate group, next to Follow-ups) shows:
|
|
68
|
+
|
|
69
|
+
- **Pending** — oldest first, because evidence ages. Each card is the fact,
|
|
70
|
+
its confidence, its scope (`tenant:customer`), and the evidence excerpt read
|
|
71
|
+
from the transcript at request time (evidence is a link, never a copy). Three
|
|
72
|
+
buttons: **Approve & save to memory**, **Reject** (with an optional reason,
|
|
73
|
+
shown on the card), **Dismiss** (ghost — labelled "will not be proposed
|
|
74
|
+
again").
|
|
75
|
+
- **Stale** — the CAS-lost re-present (below): the proposed value struck
|
|
76
|
+
through next to the operator's current value, resolved by dismissal.
|
|
77
|
+
- **Recent** — every resolved proposal, most recent first, with operator and
|
|
78
|
+
note.
|
|
79
|
+
|
|
80
|
+
**The latch** — a dismissed *or* rejected `(name, value)` tuple is never
|
|
81
|
+
proposed again. The proposal rows ARE the ledger: a human saw that tuple and
|
|
82
|
+
said no, and re-proposing it would train the operator to stop reading. An
|
|
83
|
+
unanswered proposal is never piled on, either. A *different* value for the same
|
|
84
|
+
name is a different tuple — "wears M" dismissed does not block "wears L".
|
|
85
|
+
|
|
86
|
+
**The CAS guarantee** — approval never silently overwrites an operator edit.
|
|
87
|
+
At distill time the engine records the target fact's existence and revision; at
|
|
88
|
+
approve time it writes through the store's optimistic compare-and-swap. A fact
|
|
89
|
+
the operator moved in between flips the proposal to `stale` with both values
|
|
90
|
+
visible — the operator's edit always wins, never a silent overwrite.
|
|
91
|
+
|
|
92
|
+
## Provenance
|
|
93
|
+
|
|
94
|
+
An approved fact is written with `origin: "distilled:<session_ref>"` — the
|
|
95
|
+
RFC-0031 provenance discipline: `"engine"` (the `remember` tool), `"operator"`
|
|
96
|
+
(Studio edits), `"legacy"`, and `"distilled"` (+ the session that produced it).
|
|
97
|
+
The Closed loop reads the same cell every later turn injects, and the
|
|
98
|
+
distillation ledger suppresses re-proposing a fact that is already applied with
|
|
99
|
+
a `distilled:` origin.
|
|
100
|
+
|
|
101
|
+
## LGPD
|
|
102
|
+
|
|
103
|
+
Distilled facts are personal data, and the engine treats them like it:
|
|
104
|
+
|
|
105
|
+
- **Forget a customer** — `forget_customer` purges the customer's proposals
|
|
106
|
+
(every status) along with their memory cell and sessions.
|
|
107
|
+
- **Delete a tenant** — `delete_tenant_data` purges the tenant's proposals.
|
|
108
|
+
- **Retention** — proposals age out under the same `retention_days` sweep as
|
|
109
|
+
the rest of the footprint (pending included — a proposal is evidence of a
|
|
110
|
+
transcript, and when the transcript dies the pending fact is stale). The
|
|
111
|
+
session marker dies with its proposals: **an unreviewed proposal that ages
|
|
112
|
+
out is expired, not lost-locked** — the session is re-distillable, and a
|
|
113
|
+
duplicate survivor is filtered by the ledger, never applied.
|
|
114
|
+
|
|
115
|
+
## The honest limits
|
|
116
|
+
|
|
117
|
+
- **Best-effort extraction, re-scan recovery.** There is no distillation
|
|
118
|
+
queue. A crash mid-pass leaves the session unmarked; the next pass re-scans
|
|
119
|
+
it, and the ledger filters any duplicate proposal. Exactly-once is not
|
|
120
|
+
claimed — facts are re-derivable, and a duplicate is filtered, never applied.
|
|
121
|
+
- **Precision is a forge audit.** The engine guarantees the *gates* (schema,
|
|
122
|
+
dedup, CAS, human approval); it cannot guarantee the *model's judgment*.
|
|
123
|
+
"Is this fact true and durable?" is audited on real traffic, per store — the
|
|
124
|
+
pack prompt is where that judgment is tuned.
|
|
125
|
+
- **Scope comes from the session, never the model.** The proposal's landing
|
|
126
|
+
cell is assembled by the engine from the session's tenant and customer; the
|
|
127
|
+
schema rejects a model-authored scope outright (a cross-tenant escape), and
|
|
128
|
+
an untagged session is never distilled.
|
|
129
|
+
- **Sessions only.** Distillation targets customer-tagged sessions; a
|
|
130
|
+
session without a customer has no landing zone and is skipped.
|
|
131
|
+
- **One stamp, once.** A session receives `vars["agent"]` on the same write
|
|
132
|
+
that stamps its `customer` — the first tagged turn. The stamp is
|
|
133
|
+
idempotent: a session that already carries a `customer` (a pre-upgrade
|
|
134
|
+
conversation, or one that since moved to this agent) never gains `agent`
|
|
135
|
+
on its own, so it can never participate in distillation.
|
data/docs/HARVEST.md
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Harvest
|
|
3
|
+
parent: Operate & prove it
|
|
4
|
+
nav_order: 7
|
|
5
|
+
permalink: /harvest/
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Harvest — skills from real traffic, promoted only if eval AND conversion hold
|
|
9
|
+
|
|
10
|
+
The harvest is the loop that makes a store smarter with its own traffic: it
|
|
11
|
+
reads the store's **finished** conversations, asks a model to propose SKILLS
|
|
12
|
+
for the agent's playbook, filters every proposal through two pre-registered
|
|
13
|
+
lists (the negative list, the evidence ledger), scores it with a double gate,
|
|
14
|
+
and — **only after a human approves** — lands it as a live skill. Nothing is
|
|
15
|
+
ever applied automatically, and a skill that fails the gate is terminal: the
|
|
16
|
+
same finding must re-surface with new evidence, there is no silent retry.
|
|
17
|
+
|
|
18
|
+
Per-store data (`harvest:` on the agent), absent = the loop is off for that
|
|
19
|
+
agent:
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
harvest enabled: true,
|
|
23
|
+
negative_list: [ { rule: "no-competitor-prices", pattern: "concorrente" } ],
|
|
24
|
+
miner: { model: "deepseek-v4-flash", # absent = the platform utility_model
|
|
25
|
+
window: { last_sessions: 200 },
|
|
26
|
+
max_proposals: 10,
|
|
27
|
+
budget: { tokens: 100_000 } },
|
|
28
|
+
idle_hours: 24, # how idle a session must be before it mines
|
|
29
|
+
min_messages: 3 # a shorter session mines noise
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## The pass
|
|
33
|
+
|
|
34
|
+
A mine reads ONLY durable, finished data — never an in-flight turn, never a
|
|
35
|
+
live prompt prefix (the "fork" is structural: the mining writes nothing to the
|
|
36
|
+
sessions it read, so a customer turn's cache is untouched by construction). The
|
|
37
|
+
miner gets the transcript slices (masked), the session's evidence-ledger ids,
|
|
38
|
+
and the agent's current skill names so it does not re-propose them. Then the
|
|
39
|
+
filters, each drop counted and logged:
|
|
40
|
+
|
|
41
|
+
1. **The negative list** — the versioned rules file (seed, imported
|
|
42
|
+
per store by `insika harvest:negative import --agent ID --file F`) and the
|
|
43
|
+
profile's hot-editable `harvest.negative_list`. A rule is a phrase or a
|
|
44
|
+
regex; phrases match case/accent-folded at word boundaries. Every rejected
|
|
45
|
+
candidate is logged with the rule id.
|
|
46
|
+
2. **The grounding filter** — every product reference in a proposal must be
|
|
47
|
+
in the union of the origin sessions' evidence ids (RFC-0029's ledger).
|
|
48
|
+
A store without `grounding.matcher.sku` does not mine at all: product
|
|
49
|
+
claims that cannot be verified are blocked by refusal, not by prompt.
|
|
50
|
+
3. **Dedup** — an open `(agent, name)` tuple or a skill the store already has.
|
|
51
|
+
|
|
52
|
+
## The double gate
|
|
53
|
+
|
|
54
|
+
A candidate that survives mining is scored by RUNNING it — the eval gate
|
|
55
|
+
clones the agent, writes the candidate skill into the clone's agent-scoped
|
|
56
|
+
store, enables it on the clone's allowlist, replays the golden set over the
|
|
57
|
+
ordinary public surface and compares to the accepted baseline. **Any
|
|
58
|
+
regression disqualifies** — the gate is a veto, never a score to argue with.
|
|
59
|
+
Judges are mandatory in exactly the shapes the refinement gate already
|
|
60
|
+
refuses: no recorded baseline, an all-red baseline, and a judged baseline
|
|
61
|
+
replayed with no judge (a rubric'd case with no verdict would count as a
|
|
62
|
+
pass — the P18 lesson, see [Evals](EVALS.md)).
|
|
63
|
+
|
|
64
|
+
The conversion gate is the second ruler: the store's funnel metric over the
|
|
65
|
+
criterion's window, compared to the **frozen baseline** (the RFC-0032
|
|
66
|
+
`freeze_funnel_baseline`). Outcome is evidence — this gate can only say "the
|
|
67
|
+
store is measurably worse than the accepted state" or "there is nothing to
|
|
68
|
+
compare against". It refuses on missing data, never passes: no frozen
|
|
69
|
+
baseline, no criterion, no funnel store, a fold that has not converged — each
|
|
70
|
+
named on the Harvest page.
|
|
71
|
+
|
|
72
|
+
Both passed → the candidate awaits **a human**. Promotion is snapshot-first
|
|
73
|
+
(the pre-promotion content + allowlist), then the two existing write commands,
|
|
74
|
+
then an append-only log row carrying `skill`, `origin`, `eval_ref`,
|
|
75
|
+
`conversion_ref`, `approver`, `snapshot_ref` and the criterion's sha. The
|
|
76
|
+
conversion ruler is re-read at the moment the skill lands — a store that
|
|
77
|
+
dipped below its frozen baseline since gating parks the promotion with the
|
|
78
|
+
current numbers, and a criterion file that changed since boot is a criterion
|
|
79
|
+
nobody froze.
|
|
80
|
+
|
|
81
|
+
## Rollback
|
|
82
|
+
|
|
83
|
+
One click, deterministic: the snapshot is restored — `WriteSkill` with the
|
|
84
|
+
pre-promotion bytes (or `DeleteSkill` when the skill did not exist), the
|
|
85
|
+
allowlist restored to the snapshot's set, and the promotion row stamped
|
|
86
|
+
`rolled_back_at`. The log stays the single ledger: a skill promoted, rolled
|
|
87
|
+
back, re-promoted is three readable rows.
|
|
88
|
+
|
|
89
|
+
## The honest limits
|
|
90
|
+
|
|
91
|
+
- **A store with no golden cases cannot gate, and cannot promote.** The gate's
|
|
92
|
+
strength is entirely the golden set.
|
|
93
|
+
- **Grounding first.** A store without a matcher does not mine — the product
|
|
94
|
+
loop is blocked until the evidence ledger is live (by refusal, not by
|
|
95
|
+
warning).
|
|
96
|
+
- **A promoted skill is live text**, like any skill: the SkillStore's version
|
|
97
|
+
history plus the harvest snapshot plus the log make the rollback path
|
|
98
|
+
deterministic.
|
|
99
|
+
- **The first-10 audit is human.** The negative list can carry a false
|
|
100
|
+
restriction (a phrase too broad) — that is exactly the Hermes failure, and
|
|
101
|
+
it is caught by the audit, not by the engine: the list grows and the loop
|
|
102
|
+
stops (`harvest.enabled: false`), both by data.
|
|
103
|
+
- **The harvest costs provider money**: one miner call per window plus one
|
|
104
|
+
full golden replay per gated candidate. The `budget` cap, the recorded run
|
|
105
|
+
cost and the manual trigger bound it; the automated loop mines one session
|
|
106
|
+
per claim window.
|
|
107
|
+
|
|
108
|
+
## The operator surface
|
|
109
|
+
|
|
110
|
+
- `insika harvest --agent ID [--last-sessions N] [--since ISO] [--full]` — mine one window.
|
|
111
|
+
- `insika harvest:negative import --agent ID --file F` — seed the profile's list from a rules file.
|
|
112
|
+
- `insika harvest:criterion check --file F` — strict-load the frozen conversion criterion (the hook before any promotion).
|
|
113
|
+
- The **Harvest page** in the Studio: the human's inbox (each candidate with
|
|
114
|
+
its evidence excerpt, the eval report, the conversion card, promote/reject),
|
|
115
|
+
the gated-but-blocked rows with the named ruler hole, the pending list, the
|
|
116
|
+
append-only promoted log with the rollback mirror, the negative list with
|
|
117
|
+
per-rule rejection counts, and the criterion block read-only.
|
data/docs/LOADTEST.md
CHANGED
|
@@ -16,7 +16,7 @@ The whole point: the engine exposes `POST /v1/responses` as an **SSE drop-in** o
|
|
|
16
16
|
the OpenClaw gateway. Same contract → the same load tools work against either side,
|
|
17
17
|
so you can measure the engine you are about to ship against the gateway it replaces.
|
|
18
18
|
|
|
19
|
-
There are
|
|
19
|
+
There are four scripts, each answering a different question:
|
|
20
20
|
|
|
21
21
|
| Script | Question it answers | Needs a provider? |
|
|
22
22
|
|--------|---------------------|-------------------|
|
|
@@ -24,8 +24,13 @@ There are three scripts, each answering a different question:
|
|
|
24
24
|
| `scripts/loadtest.rb` | End-to-end: TTFB/total/tokens/cache/error against `/v1/responses` | Yes |
|
|
25
25
|
| `scripts/loadtest-local.sh` | Single-proc baseline vs N-worker multi-proc on one box | Yes |
|
|
26
26
|
| `scripts/loadtest_session.rb` | A full multi-message session (CEP, searches, FAQ) under C concurrent sessions — direct to the engine (`--surface engine`, stream vs steer) or through the consumer's real ingress (`--surface web`, the consumer's widget API) | Yes |
|
|
27
|
+
| `insika soak` | Does the deploy degrade over 72 h of steady load? | Yes |
|
|
27
28
|
|
|
28
|
-
|
|
29
|
+
The first four take `--help` / `-h`; the soak is a shipped command (`insika soak --help`) rather
|
|
30
|
+
than a repo script. Bursts and uptime are different questions: a wave driver
|
|
31
|
+
measures a burst, and the soak's arrival process measures *degradation over
|
|
32
|
+
uptime* — the load-test table above deliberately stops where
|
|
33
|
+
[Soak](SOAK.md) begins.
|
|
29
34
|
|
|
30
35
|
---
|
|
31
36
|
|
|
@@ -64,7 +69,7 @@ frame that carries it.
|
|
|
64
69
|
INSIKA_URL=http://localhost:9292 \
|
|
65
70
|
OPENCLAW_GATEWAY_TOKEN=xxx \
|
|
66
71
|
bundle exec ruby scripts/loadtest.rb \
|
|
67
|
-
--agents
|
|
72
|
+
--agents demo,my-store --concurrency 16 --iterations 3 \
|
|
68
73
|
--message "hi, how are you?"
|
|
69
74
|
```
|
|
70
75
|
|
|
@@ -75,7 +80,7 @@ Runs against a local server **or** a remote one (e.g. Railway) — just point
|
|
|
75
80
|
|
|
76
81
|
| Flag | Default | Meaning |
|
|
77
82
|
|------|---------|---------|
|
|
78
|
-
| `--agents a,b,c` | `
|
|
83
|
+
| `--agents a,b,c` | `demo` | comma-separated agent ids (mapped to `model: openclaw:<agent>`) |
|
|
79
84
|
| `--concurrency N` | `8` | concurrent turns per wave |
|
|
80
85
|
| `--iterations N` | `1` | number of waves per agent |
|
|
81
86
|
| `--message TEXT` | greeting | user message sent every turn |
|
|
@@ -98,7 +103,7 @@ Use `--dry-run` to sanity-check your flags/URL/token before firing real traffic
|
|
|
98
103
|
|
|
99
104
|
```bash
|
|
100
105
|
INSIKA_URL=http://localhost:9292 OPENCLAW_GATEWAY_TOKEN=xxx \
|
|
101
|
-
bundle exec ruby scripts/loadtest.rb --agents
|
|
106
|
+
bundle exec ruby scripts/loadtest.rb --agents demo --concurrency 16 --dry-run
|
|
102
107
|
```
|
|
103
108
|
|
|
104
109
|
### `--same-user` and the cache
|
|
@@ -130,7 +135,7 @@ DEEPSEEK_API_KEY=sk-... ./scripts/loadtest-local.sh [WORKERS] [CONCURRENCY]
|
|
|
130
135
|
| `DEEPSEEK_API_KEY` | — (required) | real turns hit the provider; also auto-sourced from `.env.local` |
|
|
131
136
|
| `OPENCLAW_GATEWAY_TOKEN` | falls back to `ADMIN_TOKEN`, then `local-demo` | Bearer for the sweep |
|
|
132
137
|
| `PORT` | `9299` | bind port for the local Falcon |
|
|
133
|
-
| `AGENT` | `
|
|
138
|
+
| `AGENT` | `demo` | agent id to load |
|
|
134
139
|
|
|
135
140
|
The final block prints the lock counts; the expected reading is `0` for both:
|
|
136
141
|
|
|
@@ -163,7 +168,7 @@ drop-in for the gateway, you only change **where it points**:
|
|
|
163
168
|
# In the OpenClaw checkout, run its gateway loadtest against the HARNESS:
|
|
164
169
|
OPENCLAW_GATEWAY_URL=http://localhost:9292 \
|
|
165
170
|
OPENCLAW_GATEWAY_TOKEN=<same bearer the engine accepts> \
|
|
166
|
-
node scripts/loadtest-gateway.mjs --agents
|
|
171
|
+
node scripts/loadtest-gateway.mjs --agents demo --concurrency 16 --iterations 3
|
|
167
172
|
```
|
|
168
173
|
|
|
169
174
|
Then run the exact same command with `OPENCLAW_GATEWAY_URL` pointing at the real
|
|
@@ -174,7 +179,7 @@ gateway, and diff the two reports. This is the shadow comparison the pilot needs
|
|
|
174
179
|
- The OpenClaw checkout containing `scripts/loadtest-gateway.mjs` and Node installed.
|
|
175
180
|
- A **bearer token accepted by both** sides. For the engine that is
|
|
176
181
|
`OPENCLAW_GATEWAY_TOKEN` (see DEPLOY.md); point the gateway run at its own token.
|
|
177
|
-
- **The same agent id provisioned on both** sides (e.g. `
|
|
182
|
+
- **The same agent id provisioned on both** sides (e.g. `demo`) so `model:
|
|
178
183
|
openclaw:<agent>` resolves on each. On the engine, provision via
|
|
179
184
|
`scripts/import_pack.rb`.
|
|
180
185
|
- The **same provider** (or an equivalent-latency one) behind each, otherwise you
|
|
@@ -224,6 +229,7 @@ Work top-down and **measure before assuming** — avoid premature topology optim
|
|
|
224
229
|
| 4 | Insika vs gateway, identical knobs | §4 (either method) | Is the engine at parity with the engine it replaces before cut-over? |
|
|
225
230
|
| 5 | Cold vs hot conversation (cache) | `loadtest.rb` with/without `--same-user 1` | Expected steady-state cost/latency once conversations warm up. |
|
|
226
231
|
| 6 | Remote (Railway) vs local | `loadtest.rb` with `INSIKA_URL` remote | Network/deploy overhead of the real environment. |
|
|
232
|
+
| 7 | Degradation over uptime (72 h) | `insika soak` (see [Soak](SOAK.md)) | The cut argument: does latency or memory grow with uptime? Run it last — it needs the topology settled first. |
|
|
227
233
|
|
|
228
234
|
**Reaching for horizontal scale is only justified after 1–3 show the single box is
|
|
229
235
|
the limit.** If it is, the paths are: sharding-by-tenant +
|
data/docs/OBSERVABILITY.md
CHANGED
|
@@ -41,7 +41,18 @@ something that left no task of its own behind:
|
|
|
41
41
|
| `:delivery_failed` | `channel`, `outbox_id`, `status`, `attempts`, `error` | a delivery exhausted its bounded retries — the alert face of the row above (WS6) |
|
|
42
42
|
| `:budget_warning` | `agent`, `tenant`, `window`, `spent`, `cap` | a calendar budget crossed its threshold (`alert_at` or a soft cap) — once per window (WS2) |
|
|
43
43
|
| `:breaker_open` | `agent`, `ref`, `tenant` | the reliability circuit breaker tripped for a `(tenant, provider/model)` — further turns fail fast until the cooldown (WS3/WS6) |
|
|
44
|
+
| `:provider_failure` | `agent`, `ref`, `error`, `kind` | one attempt against `ref` failed and spent a retry — emitted with or without a circuit breaker (WS3) |
|
|
45
|
+
| `:provider_fallback` | `agent`, `from`, `to`, `error`, `kind` | the turn ROTATED mid-flight to the next node of the fallback chain, and the error that caused it (WS3) |
|
|
44
46
|
| `:ttft` | `task_id`, `session_id`, `ttft_ms` | the provider's time-to-first-token on the streaming envelope — only under `INSIKA_TURN_TIMING`, once per turn (WS6) |
|
|
47
|
+
| `:distillation_completed` | `session_ref`, `agent`, `proposals`, `dropped{}`, `deduped`, `cost{}` | a session's traffic was distilled into N proposals (RFC-0034) — counts and ids only, never a fact value |
|
|
48
|
+
| `:proposal_approved` | `proposal_id`, `status`, `operator` | a human approved a distilled fact on the Facts page — written to memory via CAS |
|
|
49
|
+
| `:proposal_rejected` | `proposal_id`, `status`, `operator` | a human rejected it (the reason is on the proposal record, not in the event) |
|
|
50
|
+
| `:proposal_dismissed` | `proposal_id`, `status`, `operator` | a human dismissed it — the tuple is latched, never proposed again |
|
|
51
|
+
| `:proposal_stale` | `proposal_id`, `status`, `operator` | the CAS lost: the fact moved after distillation — re-presented with both values on the wiki, never silently overwritten |
|
|
52
|
+
| `:harvest_mined` | `agent`, `run_id`, `candidates`, `rejected{}`, `cost{}` | a mining pass finished (RFC-0035) — counts and rule ids only, never a skill body |
|
|
53
|
+
| `:harvest_gated` | `run_id`, `candidate_id`, `agent`, `eval_passed`, `conversion_passed`, `reason` | the double gate's verdict on one candidate — ids and verdicts only |
|
|
54
|
+
| `:skill_promoted` | `agent`, `skill`, `candidate_id`, `snapshot_ref`, `promotion_ref`, `approver` | a human approved a mined skill — it is live for the store, with the snapshot for rollback |
|
|
55
|
+
| `:skill_rolled_back` | `snapshot_ref`, `skill`, `agent`, `operator` | the snapshot was restored — the promotion row carries the `rolled_back_at` stamp |
|
|
45
56
|
|
|
46
57
|
`delivery_failed` and `breaker_open` are the two the operator config is pointed at
|
|
47
58
|
(`alerts.webhook` on the profile): each only fires when something durable did
|
|
@@ -366,6 +377,49 @@ consumer that fell far behind would have its subscription closed (telemetry stop
|
|
|
366
377
|
the turn does not). Span and instrument operations are cheap, so there's ample
|
|
367
378
|
headroom.
|
|
368
379
|
|
|
380
|
+
## Process vitals — `GET /v1/vitals`
|
|
381
|
+
|
|
382
|
+
OTel carries turn/tool telemetry; it says nothing about the **process**. For the
|
|
383
|
+
questions a soak (or any operator) asks — *which process is this, how long has it
|
|
384
|
+
been up, how much memory does it hold, and what is the Ruby heap doing?* — there
|
|
385
|
+
is one read-only route:
|
|
386
|
+
|
|
387
|
+
```bash
|
|
388
|
+
curl -H "Authorization: Bearer $TOKEN" https://<target>/v1/vitals
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
```jsonc
|
|
392
|
+
{
|
|
393
|
+
"boot_id": "20260820T09-…", // one per container start; a change = a restart
|
|
394
|
+
"pid": 42,
|
|
395
|
+
"started_at": "2026-08-20T09:00:00Z",
|
|
396
|
+
"uptime_s": 259200,
|
|
397
|
+
"version": "0.2.0",
|
|
398
|
+
"ruby": "ruby 3.4.1 …",
|
|
399
|
+
"yjit": true,
|
|
400
|
+
"rss_bytes": 536870912, // nil when unreadable — never a guess
|
|
401
|
+
"gc": { "heap_live_slots": …, "major_gc_count": …, "malloc_increase_bytes": … },
|
|
402
|
+
"threads": 8,
|
|
403
|
+
"in_flight": 1, // the executor's in-flight turns
|
|
404
|
+
"db_bytes": { "db": …, "wal": …, "shm": … },
|
|
405
|
+
"at": "2026-08-20T09:00:00Z"
|
|
406
|
+
}
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
The two fields that make it a **restart detector**: `boot_id` (one per container
|
|
410
|
+
start, exported by the entrypoint and shared by every worker) and `pid`. A
|
|
411
|
+
`boot_id` change is a container restart; a `pid` change under the same `boot_id`
|
|
412
|
+
is a worker respawn — the event a platform metrics API cannot see.
|
|
413
|
+
|
|
414
|
+
- **Operator-only.** The route is not in the public allowlist (no bearer →
|
|
415
|
+
unauthorized) and not on the tenant surface, so only an operator reads
|
|
416
|
+
process internals. `/up` stays the public health probe and carries no
|
|
417
|
+
process data.
|
|
418
|
+
- **Reads no store.** Pure OS/VM readings — safe to poll at any rate, and it
|
|
419
|
+
cannot contend with turns.
|
|
420
|
+
- **The soak's sampler.** [Soak](SOAK.md) polls it hourly; a `nil` RSS reads as
|
|
421
|
+
missing coverage, never as zero.
|
|
422
|
+
|
|
369
423
|
---
|
|
370
424
|
|
|
371
425
|
*Packaging note.* Today the bridge lives in the Insika repo as an opt-in core
|