insika 0.1.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 +199 -5
- data/README.md +8 -2
- data/bin/insika +231 -13
- data/docs/AGENTS.md +505 -6
- data/docs/API.md +56 -0
- data/docs/CHANNELS.md +100 -10
- data/docs/CONTEXT.md +147 -19
- data/docs/DEPLOY.md +34 -11
- data/docs/EMBEDDING.md +11 -7
- data/docs/EVALS.md +20 -1
- data/docs/FACTS.md +135 -0
- data/docs/HARVEST.md +117 -0
- data/docs/LOADTEST.md +17 -10
- data/docs/OBSERVABILITY.md +65 -2
- data/docs/REFINEMENT.md +9 -9
- data/docs/RELEASING.md +34 -7
- data/docs/RUNNING-LOCAL.md +4 -4
- data/docs/SECURITY.md +85 -11
- data/docs/SKILLS.md +189 -3
- data/docs/SOAK.md +127 -0
- data/docs/TOOLS.md +70 -2
- data/docs/WHY.md +1 -1
- data/docs/WORKFLOWS.md +2 -2
- data/docs/domain.md +115 -0
- data/docs/index.md +2 -2
- data/docs/onboarding/start.md +1 -1
- data/lib/insika/agent_profile.rb +228 -26
- data/lib/insika/alert_dispatcher.rb +139 -0
- data/lib/insika/balloon_splitter.rb +102 -0
- data/lib/insika/baseline_store.rb +2 -2
- data/lib/insika/budget_ledger.rb +166 -0
- data/lib/insika/cache_series_store.rb +49 -0
- data/lib/insika/channel_delivery.rb +132 -24
- data/lib/insika/channel_registry.rb +1 -1
- data/lib/insika/channels/relay.rb +80 -6
- data/lib/insika/channels/web/widget.js +2 -2
- data/lib/insika/channels/web.rb +9 -9
- data/lib/insika/channels/webhook.rb +58 -0
- data/lib/insika/chat_builder.rb +145 -13
- data/lib/insika/checkpoint_store.rb +16 -0
- data/lib/insika/circuit_state.rb +114 -0
- data/lib/insika/coercion.rb +8 -0
- data/lib/insika/commands/agent_payload.rb +6 -4
- data/lib/insika/commands/cancel_followup.rb +49 -0
- data/lib/insika/commands/create_agent.rb +2 -2
- data/lib/insika/commands/create_session.rb +1 -1
- data/lib/insika/commands/delete_llm_provider.rb +1 -1
- data/lib/insika/commands/delete_skill.rb +43 -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 +12 -12
- data/lib/insika/commands/import_mcp_tools.rb +1 -1
- data/lib/insika/commands/import_tools.rb +4 -4
- data/lib/insika/commands/issue_tenant_token.rb +41 -0
- 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/resolve_refinement.rb +1 -1
- data/lib/insika/commands/revoke_contact.rb +49 -0
- data/lib/insika/commands/revoke_token.rb +39 -0
- data/lib/insika/commands/rollback_harvest.rb +86 -0
- data/lib/insika/commands/rotate_tenant_token.rb +43 -0
- data/lib/insika/commands/run_distillation.rb +186 -0
- data/lib/insika/commands/run_harvest.rb +393 -0
- data/lib/insika/commands/run_refinement.rb +5 -5
- data/lib/insika/commands/send_message.rb +112 -15
- data/lib/insika/commands/session_purge.rb +67 -0
- data/lib/insika/commands/set_agent_tools.rb +1 -1
- data/lib/insika/commands/set_skill_agents.rb +60 -19
- data/lib/insika/commands/trigger_workflow.rb +1 -1
- data/lib/insika/commands/update_agent.rb +1 -1
- data/lib/insika/commands/write_data_tool.rb +1 -1
- data/lib/insika/commands/write_golden.rb +1 -1
- data/lib/insika/commands/write_skill.rb +19 -9
- data/lib/insika/config_store.rb +8 -4
- data/lib/insika/contact_store.rb +183 -0
- data/lib/insika/context/builder.rb +23 -5
- data/lib/insika/context/fragment.rb +31 -3
- data/lib/insika/context/priority.rb +6 -2
- 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/request.rb +1 -1
- data/lib/insika/context/providers/session.rb +17 -2
- data/lib/insika/context/providers/skill.rb +7 -1
- data/lib/insika/context/providers/skill_trigger.rb +128 -0
- data/lib/insika/context/providers/tool_search.rb +2 -0
- data/lib/insika/context_trace_store.rb +128 -0
- data/lib/insika/delegation_store.rb +2 -2
- data/lib/insika/distill.rb +224 -0
- data/lib/insika/distill_engine.rb +169 -0
- data/lib/insika/doctor.rb +962 -7
- data/lib/insika/dsl/runtime.rb +20 -11
- data/lib/insika/dsl/server_boot.rb +74 -4
- data/lib/insika/dsl/system.rb +1 -1
- data/lib/insika/dsl.rb +152 -15
- data/lib/insika/edge_limiter.rb +167 -8
- data/lib/insika/egress_guard.rb +3 -3
- data/lib/insika/env_schema.rb +22 -12
- data/lib/insika/errors.rb +72 -5
- data/lib/insika/evals/assertions.rb +15 -14
- data/lib/insika/evals/baseline.rb +3 -3
- data/lib/insika/evals/golden.rb +8 -8
- data/lib/insika/evals/judge.rb +7 -7
- data/lib/insika/evals/pairwise.rb +21 -9
- data/lib/insika/evals/report.rb +2 -2
- data/lib/insika/evals/runner.rb +6 -6
- data/lib/insika/evals/transport.rb +2 -2
- data/lib/insika/event_stream.rb +23 -5
- data/lib/insika/evidence.rb +183 -0
- data/lib/insika/executor.rb +1092 -160
- 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/frontmatter.rb +1 -1
- 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 +3 -3
- 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/http_client.rb +3 -3
- data/lib/insika/inbound_log.rb +1 -1
- data/lib/insika/llm_configurator.rb +3 -3
- data/lib/insika/loop_detector.rb +143 -0
- data/lib/insika/mcp_http_client.rb +4 -4
- data/lib/insika/mcp_tool_ingestor.rb +6 -6
- 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 +8 -3
- data/lib/insika/model_resolver.rb +1 -1
- data/lib/insika/model_selection.rb +5 -4
- data/lib/insika/model_visible.rb +87 -0
- data/lib/insika/model_visible_trace_store.rb +66 -0
- data/lib/insika/onboarding.rb +8 -3
- data/lib/insika/outbox_store.rb +44 -6
- data/lib/insika/outcome_store.rb +147 -0
- data/lib/insika/overlay_tool_registry.rb +3 -4
- data/lib/insika/pack.rb +3 -3
- data/lib/insika/pack_importer.rb +17 -15
- 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/pending_action_store.rb +1 -1
- data/lib/insika/plugin/loader.rb +2 -2
- data/lib/insika/policy/policy.rb +1 -1
- data/lib/insika/prefix_fingerprint.rb +58 -0
- data/lib/insika/profile_source.rb +34 -7
- data/lib/insika/proposal_store.rb +271 -0
- data/lib/insika/provider_error_classifier.rb +160 -0
- data/lib/insika/queue_policy.rb +6 -3
- data/lib/insika/recovery.rb +47 -6
- data/lib/insika/refinement/candidate.rb +4 -4
- data/lib/insika/refinement/evidence_collector.rb +6 -6
- data/lib/insika/refinement/gate.rb +7 -7
- data/lib/insika/refinement/panel.rb +7 -7
- data/lib/insika/refinement/proposer.rb +10 -10
- data/lib/insika/refinement_store.rb +12 -12
- data/lib/insika/reliability.rb +211 -0
- data/lib/insika/retention.rb +281 -0
- data/lib/insika/routing.rb +101 -0
- data/lib/insika/safety/config.rb +46 -6
- data/lib/insika/safety/corpus.rb +255 -0
- data/lib/insika/safety/detectors.rb +34 -115
- data/lib/insika/safety/factory.rb +18 -5
- 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 +20 -5
- data/lib/insika/safety/moderator.rb +19 -11
- data/lib/insika/safety/output_filter.rb +10 -6
- data/lib/insika/safety/output_validator.rb +13 -7
- data/lib/insika/safety/safe_responses.rb +1 -1
- data/lib/insika/sandbox/boundary.rb +2 -2
- data/lib/insika/sandbox.rb +1 -1
- data/lib/insika/schema_guard.rb +35 -0
- data/lib/insika/server/app.rb +366 -54
- data/lib/insika/server/boot.rb +4 -4
- data/lib/insika/server/rack_app.rb +31 -7
- data/lib/insika/server/responses.rb +58 -9
- data/lib/insika/server/tenant_auth.rb +61 -0
- data/lib/insika/session_actor.rb +11 -7
- data/lib/insika/session_store.rb +66 -3
- data/lib/insika/settings_store.rb +15 -5
- data/lib/insika/shadow_pair_store.rb +258 -0
- data/lib/insika/shutdown.rb +4 -4
- data/lib/insika/skill_catalog.rb +131 -20
- data/lib/insika/skill_store.rb +70 -22
- 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/steer_injector.rb +1 -1
- data/lib/insika/store.rb +11 -2
- data/lib/insika/stores/memory.rb +6 -0
- data/lib/insika/stores/sqlite.rb +8 -0
- data/lib/insika/studio/app.rb +1058 -75
- data/lib/insika/studio/assets/dist/application.css +1 -1
- data/lib/insika/studio/assets/dist/application.js +27 -26
- data/lib/insika/studio/assets/dist/favicon.svg +6 -0
- data/lib/insika/studio/forms.rb +274 -22
- data/lib/insika/studio/nav_icons.rb +7 -2
- data/lib/insika/studio/views/_message.erb +2 -2
- data/lib/insika/studio/views/agent_detail.erb +629 -86
- data/lib/insika/studio/views/agents.erb +11 -7
- 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/refinement.erb +4 -4
- data/lib/insika/studio/views/session.erb +133 -3
- data/lib/insika/studio/views/settings.erb +9 -12
- data/lib/insika/studio/views/skills.erb +66 -12
- 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/subagent_graph.rb +3 -3
- data/lib/insika/task_actor.rb +3 -3
- data/lib/insika/task_store.rb +22 -2
- data/lib/insika/telemetry/pricing.rb +3 -3
- data/lib/insika/telemetry/recorder.rb +1 -1
- data/lib/insika/telemetry.rb +2 -2
- data/lib/insika/testing/store_contract.rb +54 -33
- data/lib/insika/tick.rb +146 -0
- data/lib/insika/token_store.rb +168 -0
- data/lib/insika/tool_assembly.rb +5 -5
- data/lib/insika/tool_definition.rb +25 -15
- data/lib/insika/tool_envelope.rb +70 -1
- data/lib/insika/tool_manifest.rb +11 -7
- data/lib/insika/tool_output_compressor.rb +100 -0
- data/lib/insika/tool_store.rb +1 -1
- data/lib/insika/tool_trace_store.rb +1 -1
- data/lib/insika/tools/concurrency.rb +2 -2
- data/lib/insika/tools/data_defined_tool.rb +14 -5
- data/lib/insika/tools/generate_image.rb +44 -0
- data/lib/insika/tools/load_skill.rb +61 -3
- data/lib/insika/tools/schedule_followup.rb +164 -0
- data/lib/insika/tools/stuck_signal.rb +44 -0
- data/lib/insika/tools/subagent.rb +4 -4
- data/lib/insika/tools/subagents.rb +1 -1
- data/lib/insika/tools/tts.rb +47 -0
- data/lib/insika/tools/update_briefing.rb +126 -0
- data/lib/insika/turn_output.rb +2 -2
- data/lib/insika/turn_state.rb +54 -13
- data/lib/insika/turn_timing.rb +24 -4
- data/lib/insika/usage_ledger.rb +1 -1
- data/lib/insika/version.rb +1 -1
- data/lib/insika/vitals.rb +84 -0
- data/lib/insika/wiring/graph.rb +372 -34
- data/lib/insika/workflow.rb +1 -1
- data/lib/insika/workflow_registry.rb +1 -1
- data/lib/insika.rb +122 -16
- metadata +95 -2
- data/lib/insika/server/admin_auth.rb +0 -29
data/docs/SECURITY.md
CHANGED
|
@@ -47,16 +47,26 @@ dispatch **any** registered authoring Command (`write_agent_file`, `write_data_t
|
|
|
47
47
|
`upsert_llm_provider`, `update_settings`, `delete_agent`). Treat that token as
|
|
48
48
|
operator-grade — it is not a read key, and a leak is agent takeover, not just usage.
|
|
49
49
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
50
|
+
### Multi-tenant mode (`INSIKA_TENANCY=multi_tenant`)
|
|
51
|
+
|
|
52
|
+
A single operator-grade token cannot host N stores. With `INSIKA_TENANCY=multi_tenant`
|
|
53
|
+
the Bearer is resolved to a **principal** before the routes:
|
|
54
|
+
|
|
55
|
+
- **Per-tenant tokens** (`POST /v1/commands/issue_tenant_token`) scope a caller to
|
|
56
|
+
one tenant; **operator** tokens (and the legacy gateway token — an existing
|
|
57
|
+
deployment switching modes keeps its credential) have the run of the deployment.
|
|
58
|
+
- Tokens are stored **only as SHA-256 hashes**; the plaintext is shown exactly once,
|
|
59
|
+
at issue time. Rotation (`rotate_tenant_token`) and revocation (`revoke_token`)
|
|
60
|
+
are operator commands — revoking one tenant's token never touches another's.
|
|
61
|
+
- A tenant principal reaches only its **own runtime surfaces** (`/v1/sessions`,
|
|
62
|
+
`/v1/messages`, `/v1/responses`, workflow runs, and its own session/task/event
|
|
63
|
+
reads). Every authoring/provisioning/config surface answers `403` to a tenant.
|
|
64
|
+
- Isolation is the key, not a convention: a tenant's sessions live under
|
|
65
|
+
`<tenant>:<session-id>`, its commands carry `meta.tenant` (memory scoping,
|
|
66
|
+
event tagging), and reading another tenant's session/task reads as `404`.
|
|
67
|
+
|
|
68
|
+
`single_tenant` (the default) is exactly the classic behavior above — one
|
|
69
|
+
operator credential, no principal, no stamping.
|
|
60
70
|
|
|
61
71
|
## The `/v1` contract is versioned by date
|
|
62
72
|
|
|
@@ -172,7 +182,10 @@ moderator off. See [`examples/guardrails/`](https://github.com/guizaols/insika/t
|
|
|
172
182
|
- **Input** — deterministic detectors (prompt-injection, abuse) run *before* the
|
|
173
183
|
model. A flagged input gets a **safe refusal without burning a model turn** — an
|
|
174
184
|
injection or a flood never reaches the provider. An LLM moderator can be layered
|
|
175
|
-
on top.
|
|
185
|
+
on top. The moderator is **fail-open**: an error or an unparseable reply never
|
|
186
|
+
blocks a legitimate customer — but silence is not a negative. That third state
|
|
187
|
+
surfaces as a `:guardrail_flagged` event with category `moderator_unavailable`,
|
|
188
|
+
so a degraded tier is distinguishable from a healthy one in the audit stream.
|
|
176
189
|
- **Output** — moderation plus PII/secret redaction on the streamed response, and
|
|
177
190
|
a post-turn validator.
|
|
178
191
|
|
|
@@ -354,6 +367,67 @@ rotated key or a typo never takes the whole service down. The `insika doctor`
|
|
|
354
367
|
command runs the same checks on demand against a live database. See
|
|
355
368
|
[Deploy](DEPLOY.md#strict-config-and-insika-doctor).
|
|
356
369
|
|
|
370
|
+
## Memory and the right to be forgotten (LGPD, RFC-0031)
|
|
371
|
+
|
|
372
|
+
Memory is scoped per **`(tenant, customer)`** — the cell `"memory:<tenant>:<customer>"`
|
|
373
|
+
is the isolation boundary. A query against one tenant never touches another's
|
|
374
|
+
cells. A turn with no customer falls back to the session's own **marked** cell
|
|
375
|
+
(`"memory:chat:<session id>"`) — the marker keeps the Studio drill and the doctor
|
|
376
|
+
from ever reading a conversation as a customer (a bare `memory:<id>` cell is
|
|
377
|
+
indistinguishable from a single-tenant customer ref). Cells written before the
|
|
378
|
+
marker (bare session ids) may still appear as customers in the drill until
|
|
379
|
+
forgotten or aged out. Three operations enforce the right to be forgotten:
|
|
380
|
+
|
|
381
|
+
- **Retention** — the `memory_ttl_days` setting (platform default per cell, or an
|
|
382
|
+
ops-authored per-tenant map) sweeps facts older than the window. A per-fact
|
|
383
|
+
`expires_at` override wins over the cell TTL. Both run on the daily retention
|
|
384
|
+
tick, **not** gated by `retention_days` (the conversation-footprint window).
|
|
385
|
+
Note the interaction: an explicit `expires_at` also exempts the fact from the
|
|
386
|
+
`retention_days` age-based sweep — an explicit date owns that fact's life, so a
|
|
387
|
+
far-future date makes the fact immune to *both* age passes. Export and forget
|
|
388
|
+
still see it; the only way to remove it early is the Customers drill.
|
|
389
|
+
- **Export** — `export_customer_memory` returns the full fact + note content to the
|
|
390
|
+
**operator** (the Studio turns it into a JSON download). The emitted event carries
|
|
391
|
+
counts only — the event stream, the audit store and every log stay content-free.
|
|
392
|
+
- **Forget** — `forget_customer` purges the customer's memory cell AND their
|
|
393
|
+
sessions (and everything those sessions left behind: traces, tasks, checkpoints,
|
|
394
|
+
outbox deliveries). The operator-mutation audit store records a digest-free line
|
|
395
|
+
("a purge happened, with N records") — content-free by construction.
|
|
396
|
+
|
|
397
|
+
### Distilled facts are personal data (RFC-0034)
|
|
398
|
+
|
|
399
|
+
The distillation loop ([Facts](FACTS.md)) writes **proposals** — a distilled
|
|
400
|
+
fact's name and value are personal data, and they are treated like the rest of
|
|
401
|
+
the memory footprint: `forget_customer` deletes the person's proposals (every
|
|
402
|
+
status), `delete_tenant_data` deletes a tenant's, and the `retention_days`
|
|
403
|
+
sweep ages them out with the transcripts they were distilled from. Provenance
|
|
404
|
+
holds: an approved fact is written with `origin: "distilled:<session_ref>"`
|
|
405
|
+
(the RFC-0031 closed set gains one spelling, never an open string). Events and
|
|
406
|
+
audit carry ids and counts only — a fact value never enters the stream, the
|
|
407
|
+
ledger or a log; the evidence excerpt is a link read from the transcript at
|
|
408
|
+
request time, never a copy.
|
|
409
|
+
|
|
410
|
+
The doctor's `memory-scopes` check flags bare cells only in a `multi_tenant`
|
|
411
|
+
deployment (in `single_tenant` the bare cell is the designed customer shape), and
|
|
412
|
+
never the session-marked cells. See [Context](CONTEXT.md#memory) and
|
|
413
|
+
[Deploy](DEPLOY.md#strict-config-and-insika-doctor).
|
|
414
|
+
|
|
415
|
+
### Harvest candidates are derived data (RFC-0035)
|
|
416
|
+
|
|
417
|
+
The harvest loop ([Harvest](HARVEST.md)) writes **candidates** — a mined skill
|
|
418
|
+
proposal is behavior instructions, the same trust level as any skill content,
|
|
419
|
+
and it is store-scoped, never customer-scoped: `forget_customer` does NOT reach
|
|
420
|
+
it (a candidate's skill body is not customer content). Candidates reference
|
|
421
|
+
sessions by id and their evidence excerpt is the transcript rendered at request
|
|
422
|
+
time through the output filter — never a copy. `delete_tenant_data` purges a
|
|
423
|
+
tenant's candidates, promotion rows and markers; the `retention_days` sweep
|
|
424
|
+
ages them out with the transcripts they were derived from (they are
|
|
425
|
+
re-derivable — pruning is never data loss). **No auto-application**: nothing
|
|
426
|
+
reaches the catalog without a human click, and the grounding filter refuses any
|
|
427
|
+
product claim the origin sessions' evidence ledger did not see. Events and the
|
|
428
|
+
promotion log carry ids, refs and verdicts only — a skill body never enters
|
|
429
|
+
the stream.
|
|
430
|
+
|
|
357
431
|
## See also
|
|
358
432
|
|
|
359
433
|
- [Agents](AGENTS.md) — the five access layers per agent.
|
data/docs/SKILLS.md
CHANGED
|
@@ -22,6 +22,8 @@ See [`examples/skills/`](https://github.com/guizaols/insika/tree/main/examples/s
|
|
|
22
22
|
---
|
|
23
23
|
name: refunds # must equal the directory name
|
|
24
24
|
description: When and how to process a refund # the Level-1 trigger text
|
|
25
|
+
triggers: [refund, money back] # optional: deterministic activation (below)
|
|
26
|
+
companions: [refund-policy] # optional: skills this one cannot work without
|
|
25
27
|
---
|
|
26
28
|
|
|
27
29
|
<the full playbook body — loaded only on demand>
|
|
@@ -33,17 +35,121 @@ which the `name:` field must match.
|
|
|
33
35
|
## Progressive loading: two levels
|
|
34
36
|
|
|
35
37
|
- **Level 1 — metadata only.** A context provider injects an `<available_skills>`
|
|
36
|
-
list
|
|
37
|
-
|
|
38
|
-
present for allowed skills.
|
|
38
|
+
list into the system prompt — the name, the one-line description and the
|
|
39
|
+
`triggers:` of every allowed skill — telling the model to load a skill before
|
|
40
|
+
acting on it. Cheap, and always present for allowed skills. **This is the routing
|
|
41
|
+
table, and it is generated:** it cannot disagree with the allowlist, so do not
|
|
42
|
+
hand-write one in a prompt file (see [Drift guards](#drift-guards)).
|
|
39
43
|
- **Level 2 — the full body.** A built-in `load_skill` tool returns the skill body
|
|
40
44
|
on demand. It enforces the agent's skill allowlist and is wired **automatically**
|
|
41
45
|
whenever the agent has any allowed skills — you do not add it to `tools_allow`.
|
|
46
|
+
- **Deterministic activation — `triggers:`.** When the user message contains one
|
|
47
|
+
of the skill's `triggers`, the body is injected for that turn — no model
|
|
48
|
+
decision, no `load_skill` call. Only matched skills, only that turn. Use it for
|
|
49
|
+
skills that MUST fire on known phrases; model loading stays as the fallback for
|
|
50
|
+
everything else. Matching is on **whole words**, case-insensitive and
|
|
51
|
+
accent-folded: `presente` fires on *um presente* and *presénte*, never inside
|
|
52
|
+
*apresente*.
|
|
42
53
|
|
|
43
54
|
The Level-1 list is budgeted like any other context fragment
|
|
44
55
|
(see [Context](CONTEXT.md)); the Level-2 body only costs tokens on the turns that
|
|
45
56
|
open it.
|
|
46
57
|
|
|
58
|
+
### Only trigger a skill that can finish the turn alone
|
|
59
|
+
|
|
60
|
+
A `triggers:` match is not a hint — the body lands in the prompt with the
|
|
61
|
+
authority of an instruction. So put triggers only on a skill that is
|
|
62
|
+
**self-sufficient** for the turn it fires on.
|
|
63
|
+
|
|
64
|
+
The failure mode is counter-intuitive: injecting a skill that is only *part* of
|
|
65
|
+
the answer is **worse than injecting nothing**. Give the model a reference table
|
|
66
|
+
whose procedure lives in a companion skill, and it now holds a plausible
|
|
67
|
+
half-recipe — so it never calls `load_skill` for the other half, and improvises
|
|
68
|
+
the missing part. A precise trigger on the wrong kind of skill still breaks the
|
|
69
|
+
turn.
|
|
70
|
+
|
|
71
|
+
Reference tables, vocabularies and lookup maps are the skills to leave on
|
|
72
|
+
level 1. Whole procedures ("run this journey", "recover from this error") are the
|
|
73
|
+
ones worth triggering.
|
|
74
|
+
|
|
75
|
+
## Always-on skills: `skills_eager`
|
|
76
|
+
|
|
77
|
+
A skill that every turn needs — output format, the marker vocabulary, how to
|
|
78
|
+
recover from a failed tool — should not depend on the model choosing to load it.
|
|
79
|
+
Name it on the **agent** and its body is in the prompt on every turn:
|
|
80
|
+
|
|
81
|
+
```ruby
|
|
82
|
+
Insika.agent("consultant") do
|
|
83
|
+
skills_eager "recommendation-formatting", "tool-error-recovery"
|
|
84
|
+
# skills_eager # or: every allowed skill (a corpus that fits the budget)
|
|
85
|
+
# skills_eager false # or: none — the default
|
|
86
|
+
end
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
An eager skill also **leaves level 1**: it is absent from `<available_skills>` and
|
|
90
|
+
`load_skill` refuses to serve it. There is no level 2 left to fetch, and a catalog
|
|
91
|
+
pointing at a body already in the prompt only invites a call that pays for a
|
|
92
|
+
duplicate.
|
|
93
|
+
|
|
94
|
+
### Why the agent decides, and not the skill
|
|
95
|
+
|
|
96
|
+
Eagerness used to be an `eager: true` key in the `SKILL.md` frontmatter. That put
|
|
97
|
+
the decision on the wrong object: **skills are shared.** `escalation-to-human`,
|
|
98
|
+
`recommendation-formatting` and `tool-error-recovery` each sit in several agents'
|
|
99
|
+
allowlists, and one flag on the skill forced one decision onto every agent holding
|
|
100
|
+
it — with no way to be always-on for the agent that needs it and discretionary for
|
|
101
|
+
the one that does not.
|
|
102
|
+
|
|
103
|
+
`skills_eager` is a per-agent list, so the same shared skill can be both. The
|
|
104
|
+
frontmatter key is **ignored** — `insika doctor` flags any skill still carrying it,
|
|
105
|
+
and names the agent setting that replaced it.
|
|
106
|
+
|
|
107
|
+
A name that is not in the agent's `skills` allowlist is a no-op (eagerness is
|
|
108
|
+
intersected with what the agent is allowed to see); `doctor` flags that too.
|
|
109
|
+
|
|
110
|
+
### Keep the discretionary skills on the load path
|
|
111
|
+
|
|
112
|
+
Making everything eager is a trap, and the reason is not the tokens: **it costs you
|
|
113
|
+
the signal**. When every body is present on every turn, "which skills were active"
|
|
114
|
+
is always "all of them", and you can no longer tell which one the model reached for.
|
|
115
|
+
The `load_skill` call is the only record of that choice — it is a persisted tool
|
|
116
|
+
message, so it shows up in the transcript on its own.
|
|
117
|
+
|
|
118
|
+
So the split is: **eager for what the turn always needs, `load_skill` for what the
|
|
119
|
+
turn might need.** The second group is where you want the model's choice on the
|
|
120
|
+
record, because that is the group where a wrong choice is worth seeing.
|
|
121
|
+
|
|
122
|
+
The token trade is real but smaller than it looks: eager bodies sit at a fixed
|
|
123
|
+
position ahead of the history, so they belong to the **cacheable prefix**, and they
|
|
124
|
+
are still evictable under budget pressure, unlike the pinned identity. Conditional
|
|
125
|
+
injection is what breaks that prefix, on exactly the turns it fires.
|
|
126
|
+
|
|
127
|
+
## Seeing which skills were active, and why
|
|
128
|
+
|
|
129
|
+
The load path is legible for free: `load_skill` is a tool, so the call is a
|
|
130
|
+
persisted message and shows up in the transcript on its own. The deterministic paths
|
|
131
|
+
are not a call, so the engine reports them itself — **with a reason per skill**:
|
|
132
|
+
|
|
133
|
+
| reason | what it means |
|
|
134
|
+
|---|---|
|
|
135
|
+
| `eager` | the agent's `skills_eager` names it, so every turn gets it |
|
|
136
|
+
| `trigger:<phrase>` | this message matched that `triggers:` entry — the phrase as **authored**, so you can find the line to edit |
|
|
137
|
+
| `pack` | a plugin's own context provider supplied the body |
|
|
138
|
+
|
|
139
|
+
Where it shows up, per turn, in the Studio session screen:
|
|
140
|
+
|
|
141
|
+
- an **activation card in the transcript thread**, placed at the top of its turn and
|
|
142
|
+
in the same visual language as a tool result, so a context-injected skill and a
|
|
143
|
+
model-loaded one read the same way;
|
|
144
|
+
- the **Context card**, next to that category's token count, for the after-the-fact
|
|
145
|
+
audit;
|
|
146
|
+
- the `skill_activated` **event** (`skills: [{name, reason}]`, `source: "context"`),
|
|
147
|
+
with full task/session correlation.
|
|
148
|
+
|
|
149
|
+
All three are computed from what actually reached the prompt **after the budget
|
|
150
|
+
cut**: a body the budget evicted is reported as an eviction, never as an activation.
|
|
151
|
+
A turn that mixes both paths is labelled `mixed`, and each line keeps its own reason.
|
|
152
|
+
|
|
47
153
|
## Where skills live: the store, over a disk seed
|
|
48
154
|
|
|
49
155
|
- Skills live as **rows in SQLite** — one row per skill, holding the entire
|
|
@@ -58,6 +164,68 @@ open it.
|
|
|
58
164
|
> runtime operation (Studio / API / DSL), not a commit. See
|
|
59
165
|
> [Context](CONTEXT.md#the-volume).
|
|
60
166
|
|
|
167
|
+
## Pairs that must not break: `companions:`
|
|
168
|
+
|
|
169
|
+
Injecting *part* of an answer is worse than injecting nothing. Give the model a line
|
|
170
|
+
map whose query-construction rules live in another skill and it holds a plausible
|
|
171
|
+
half-recipe — so it never calls `load_skill` for the other half, and improvises the
|
|
172
|
+
missing part. Measured on a real pack: the map arrived by trigger, the rules did not,
|
|
173
|
+
and the searches came out malformed. Twice.
|
|
174
|
+
|
|
175
|
+
Declare the dependency and it travels with whatever brought it — a trigger match, the
|
|
176
|
+
agent's eager set, or a `load_skill` call (which returns both bodies in the one call):
|
|
177
|
+
|
|
178
|
+
```yaml
|
|
179
|
+
companions: [query-construction]
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Two deliberate limits:
|
|
183
|
+
|
|
184
|
+
- **One level, no transitive walk.** A cycle would be a hang and a chain a budget
|
|
185
|
+
blowout, and "cannot work without" is a direct relationship.
|
|
186
|
+
- **Never widens an allowlist.** A companion the agent is not allowed to load is
|
|
187
|
+
simply absent; `insika doctor` flags the declaration instead.
|
|
188
|
+
|
|
189
|
+
## Specializing a shared skill for one agent
|
|
190
|
+
|
|
191
|
+
Skills are shared on purpose: `escalation-to-human` belongs in several agents'
|
|
192
|
+
allowlists. But sometimes one agent needs a different version of the same skill —
|
|
193
|
+
its own return policy, its own store name — and forking it under a second name
|
|
194
|
+
throws the sharing away and leaves two things to keep in step.
|
|
195
|
+
|
|
196
|
+
So the store has a second scope, and resolution is a **precedence chain** with one
|
|
197
|
+
more dimension:
|
|
198
|
+
|
|
199
|
+
```
|
|
200
|
+
for agent A, skill <name>: (A, <name>) in the agent scope,
|
|
201
|
+
then <name> in the shared scope
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
Three cases fall out of that one rule:
|
|
205
|
+
|
|
206
|
+
| case | what exists in the store |
|
|
207
|
+
|---|---|
|
|
208
|
+
| **shared** | only the shared record — every agent gets the same body |
|
|
209
|
+
| **override** | both — the agent's wins, for that agent only |
|
|
210
|
+
| **agent-private** | only the agent record — invisible elsewhere, and the name may collide freely |
|
|
211
|
+
|
|
212
|
+
The **name never changes.** An override keeps saying `name: escalation-to-human`
|
|
213
|
+
inside, because it *is* that skill, specialized; the allowlist, the `<available_skills>`
|
|
214
|
+
list, `load_skill` and the activation card all keep showing the bare name. What
|
|
215
|
+
decides which record you get is its **position in the store**, never the frontmatter —
|
|
216
|
+
otherwise an override would clobber the shared skill for everybody.
|
|
217
|
+
|
|
218
|
+
Write one with `agent:`, and remove it the same way (which un-specializes, leaving
|
|
219
|
+
the shared skill in place):
|
|
220
|
+
|
|
221
|
+
```ruby
|
|
222
|
+
dispatch(:write_skill, { name: "escalation-to-human", agent: "store-demo", content: md })
|
|
223
|
+
dispatch(:delete_skill, { name: "escalation-to-human", agent: "store-demo" })
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
In the Studio: **Skills → specialize for this agent**, which seeds the override from
|
|
227
|
+
the shared body.
|
|
228
|
+
|
|
61
229
|
## Making a new skill "show up"
|
|
62
230
|
|
|
63
231
|
For an agent to actually use a skill, **both** conditions must hold:
|
|
@@ -89,6 +257,24 @@ Two ways to satisfy both:
|
|
|
89
257
|
`load_skill` errors → the store row is missing or misnamed (condition 1); the
|
|
90
258
|
`name:` frontmatter must equal the directory name.
|
|
91
259
|
|
|
260
|
+
## Drift guards
|
|
261
|
+
|
|
262
|
+
A skill catalog drifts against the prose that routes to it, and every way it happened
|
|
263
|
+
on the pilot was silent — found by reading a customer conversation days later. So the
|
|
264
|
+
routing table is **generated** (above), and `insika doctor` reports the residue the
|
|
265
|
+
generator cannot remove. Every check takes mechanical inputs only — names, allowlists,
|
|
266
|
+
agent identities — because one false positive is enough for an operator to stop
|
|
267
|
+
reading the doctor:
|
|
268
|
+
|
|
269
|
+
| finding | what it means |
|
|
270
|
+
|---|---|
|
|
271
|
+
| a prompt file names a skill outside that agent's allowlist | leftover hand-written routing: the model is told to use something it cannot load |
|
|
272
|
+
| a shared skill's body names one of its own holders | specialized text in shared clothing — the other holders are served that store's policy as their own. Specialize it instead |
|
|
273
|
+
| a body references another catalog skill without declaring it a companion | the pair can still arrive apart |
|
|
274
|
+
| a declared companion is outside an agent's allowlist | the pair cannot travel for that agent, and the engine will not widen the allowlist |
|
|
275
|
+
| a skill still declares `eager:` in its frontmatter | the key is ignored; the decision moved to the agent |
|
|
276
|
+
| an agent marks a skill eager that it does not allow | the name is a no-op |
|
|
277
|
+
|
|
92
278
|
## See also
|
|
93
279
|
|
|
94
280
|
- [Context](CONTEXT.md) — how the skills list is budgeted into a turn.
|
data/docs/SOAK.md
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Soak
|
|
3
|
+
parent: Operate & prove it
|
|
4
|
+
nav_order: 4
|
|
5
|
+
permalink: /soak/
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# SOAK — does it degrade over uptime?
|
|
9
|
+
|
|
10
|
+
The soak is the third question the measurement toolbox answers, and it is the
|
|
11
|
+
one the other two cannot:
|
|
12
|
+
|
|
13
|
+
| Tool | Question | Drives |
|
|
14
|
+
|------|----------|--------|
|
|
15
|
+
| [`scripts/bench.rb`](BENCHMARK.md) | How much overhead does the engine itself add? | publishable claims |
|
|
16
|
+
| [Load test](LOADTEST.md) | What does a burst look like, end to end? | topology + provider choice |
|
|
17
|
+
| **`insika soak`** | **Does the deploy degrade over 72 hours of steady load?** | the cut argument: "it does not tax uptime" |
|
|
18
|
+
|
|
19
|
+
A load test fires a wave, joins it, and reports. A soak **sustains a declared
|
|
20
|
+
arrival rate for days** and asks whether the *process itself* — resident memory,
|
|
21
|
+
per-turn local work, restarts — degrades with uptime. A leak that only shows at
|
|
22
|
+
hour 40 is invisible to a 10-minute bench.
|
|
23
|
+
|
|
24
|
+
## The protocol in one paragraph
|
|
25
|
+
|
|
26
|
+
1. **Freeze the envelope first.** A deployment-side envelope file declares the
|
|
27
|
+
load shape, the gated ceilings and the isolation contract — *before* the
|
|
28
|
+
first turn is fired. Its SHA-256 is stamped into every hourly snapshot;
|
|
29
|
+
editing it mid-run turns the run `invalid` instead of producing a verdict.
|
|
30
|
+
2. **Preflight.** `insika soak --preflight` refuses to start unless the target
|
|
31
|
+
answers `/v1/vitals` with a readable RSS, turns carry the `INSIKA_TURN_TIMING`
|
|
32
|
+
breakdown *and* a `usage` block, the pid is stable, and the target host
|
|
33
|
+
matches the envelope. Each check is a named refusal, not a runtime warning.
|
|
34
|
+
3. **Run.** `insika soak --run` fires the arrival process (Poisson, seeded,
|
|
35
|
+
concurrency-capped), polls vitals hourly, and appends every observation to
|
|
36
|
+
`<out>/<run>.jsonl` as it happens — a runner that dies at hour 60 must
|
|
37
|
+
leave 60 usable hours behind. No retry, no self-healing: a failed turn is
|
|
38
|
+
evidence.
|
|
39
|
+
4. **Read the verdict.** `insika soak --verify <file>.jsonl.gz` recomputes the
|
|
40
|
+
whole verdict offline from the raw records — no network, no target, no
|
|
41
|
+
clock. Two people running it get the same answer; so does CI.
|
|
42
|
+
|
|
43
|
+
## What is gated, and what is only reported
|
|
44
|
+
|
|
45
|
+
The gate is on the part **Insika owns**:
|
|
46
|
+
|
|
47
|
+
| Gated (binding) | Reported (never gated) |
|
|
48
|
+
|-----------------|------------------------|
|
|
49
|
+
| `rss_growth_ratio` — upper 95% bound of the RSS fit, hour 6 → hour 72 | `rss_slope_mb_per_day`, `rss_peak_mb` |
|
|
50
|
+
| `prep_p95_drift_ratio` — p95 of the local per-turn work (`prep_ms`: context build, policy, guardrails, chat assembly), last 6 h vs first measured 6 h | `ttft_p95_ms`, `total_p95` hourly series |
|
|
51
|
+
| `restarts_max` — any `boot_id` **or** `pid` change is a fail; there is no "declared deploy" exception | `heap_growth_ratio`, `db_growth_mb` |
|
|
52
|
+
| `error_rate_ceiling`, `no_usage_rate_ceiling` | `cost_usd`, `tokens_per_turn` |
|
|
53
|
+
| calibrated absolute ceilings (written from the E1 dry run, ×2) | — |
|
|
54
|
+
|
|
55
|
+
Time-to-first-token is **reported, never gated**: it was measured at ~720 ms and
|
|
56
|
+
shown to be ~100% provider. Gating on a number the engine cannot move would mean
|
|
57
|
+
failing a clean soak because the provider had a bad afternoon — and then
|
|
58
|
+
loosening the envelope, which the protocol forbids.
|
|
59
|
+
|
|
60
|
+
The **`no_usage_rate`** ceiling is the trap detector. The edge limiter answers a
|
|
61
|
+
breach with a canned reply, zero LLM calls and HTTP 200 — so a soak that ran
|
|
62
|
+
entirely into the limiter looks like thousands of successful turns with a
|
|
63
|
+
gorgeous 2 ms p95 and flat memory. A turn with no `usage` block called no model;
|
|
64
|
+
the ceiling keeps that from reading as a pass.
|
|
65
|
+
|
|
66
|
+
## The isolation contract
|
|
67
|
+
|
|
68
|
+
- **Staging or a dedicated tenant, never a production tenant.** The run writes
|
|
69
|
+
~4,000 sessions and tasks into the store; against production that is
|
|
70
|
+
synthetic data mixed into real customer history. The envelope names the
|
|
71
|
+
tenant (`soak`), so `delete_tenant_data` cleans the entire run in one
|
|
72
|
+
command.
|
|
73
|
+
- **Its own agent, with the edge limits off.** At a real ~48k tokens per turn,
|
|
74
|
+
the production token ceiling admits about 10 turns per hour — a 60 turns/h
|
|
75
|
+
soak against a production-configured agent measures its own limiter within
|
|
76
|
+
minutes. `chat_rate_limit: 0` and `agent_token_ceiling: 0` on the soak agent
|
|
77
|
+
are part of the frozen envelope, not an afternoon decision.
|
|
78
|
+
- **Deploys are frozen for the window.** Any restart is a fail, including a
|
|
79
|
+
deployment — a marker the operator writes after the fact would turn every
|
|
80
|
+
crash into a deploy.
|
|
81
|
+
|
|
82
|
+
## Running it
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
# the plan + one sample request, no traffic
|
|
86
|
+
insika soak --dry-run --envelope soak-envelope.md
|
|
87
|
+
|
|
88
|
+
# every precondition, and nothing else
|
|
89
|
+
insika soak --preflight --envelope soak-envelope.md
|
|
90
|
+
|
|
91
|
+
# the run itself (INSIKA_URL + OPENCLAW_GATEWAY_TOKEN, like loadtest.rb)
|
|
92
|
+
INSIKA_URL=https://<target> insika soak --run --envelope soak-envelope.md --out soak-out/
|
|
93
|
+
|
|
94
|
+
# resume after a short outage (the gap is recorded and counts against the window)
|
|
95
|
+
insika soak --run --resume soak-out/staging-2026-08-20T09-00-00Z.jsonl
|
|
96
|
+
|
|
97
|
+
# the verdict, recomputed offline — the only step that needs no target
|
|
98
|
+
insika soak --verify soak-out/staging-2026-08-20T09-00-00Z.jsonl.gz
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Duration, rate and ceilings all come from the envelope — there is no
|
|
102
|
+
`--duration` override, so a short run cannot be passed off as a soak.
|
|
103
|
+
|
|
104
|
+
## Reading the verdict
|
|
105
|
+
|
|
106
|
+
`insika soak --verify` prints one of four, in this order of precedence:
|
|
107
|
+
|
|
108
|
+
- **`invalid`** — the snapshots carry more than one envelope hash, more than
|
|
109
|
+
one process id appeared under one boot generation, or the file is materially
|
|
110
|
+
truncated. **No metric is reported at all**; the file cannot be argued with.
|
|
111
|
+
- **`insufficient`** — coverage is short: a gap over 15 minutes, an hour under
|
|
112
|
+
the turn floor, or a runner that did not reach hour 72. Short coverage
|
|
113
|
+
restarts the window; it is never rounded up into a pass.
|
|
114
|
+
- **`fail`** — a gated ceiling was breached. The report names each one and
|
|
115
|
+
prints the leak-hunt starting point: whether the Ruby heap grew with RSS
|
|
116
|
+
(Ruby-side retention) or stayed flat while RSS climbed (allocator or a
|
|
117
|
+
native buffer).
|
|
118
|
+
- **`pass`** — none of the above.
|
|
119
|
+
|
|
120
|
+
A fail means *find the leak* — never cut, and never loosen the envelope.
|
|
121
|
+
|
|
122
|
+
## See also
|
|
123
|
+
|
|
124
|
+
- [Observability](OBSERVABILITY.md) — `GET /v1/vitals`, the process readings
|
|
125
|
+
the soak samples hourly.
|
|
126
|
+
- [Deploy](DEPLOY.md) — the process model; `WEB_CONCURRENCY=1` is a soak
|
|
127
|
+
precondition, not only a queue-semantics one.
|
data/docs/TOOLS.md
CHANGED
|
@@ -91,8 +91,10 @@ retries against. Structure is strict; a scalar may arrive in its lossless string
|
|
|
91
91
|
|
|
92
92
|
- `{{param}}` — a declared top-level parameter, filled from the model's call.
|
|
93
93
|
- `{{ctx.*}}` — turn context set **server-side, never by the model**: a closed set
|
|
94
|
-
of `chat_id`, `store_id`, `agent_id`, `tenant`. This is how a tool knows *which*
|
|
95
|
-
session/agent it is acting for without trusting the model.
|
|
94
|
+
of `chat_id`, `store_id`, `agent_id`, `tenant`, `image_url`. This is how a tool knows *which*
|
|
95
|
+
session/agent it is acting for without trusting the model. `image_url` is the
|
|
96
|
+
first image part on the message (a photo for analysis outside the prompt);
|
|
97
|
+
absent when the turn carried none.
|
|
96
98
|
- `{{secret.*}}` — allowed **only** inside a header named in `secret_headers`.
|
|
97
99
|
A secret placeholder anywhere else is rejected (it would leak unmasked). The
|
|
98
100
|
real secret value is injected at provision time and never lives on disk.
|
|
@@ -181,6 +183,72 @@ backend, not of whoever calls it. Every agent sharing the tool gets the same val
|
|
|
181
183
|
> there **preserves** it — the form carries the stored values through instead of
|
|
182
184
|
> replacing the record with only what it shows.
|
|
183
185
|
|
|
186
|
+
## Evidence: the lean envelope and grounding (RFC-0029)
|
|
187
|
+
|
|
188
|
+
A catalog tool returns products; the model should only ever quote the ones the tool
|
|
189
|
+
actually returned — the store dies of a SKU the model invented. `evidence` is the
|
|
190
|
+
declaration that makes "no claim without a tool ID" an engine rule instead of a
|
|
191
|
+
prompt convention. One declaration does **both** jobs: the engine strips the result
|
|
192
|
+
down to what the model sees (the lean envelope) **and** records every returned id on
|
|
193
|
+
the session's evidence ledger. There is no "lean but not evidence" mode.
|
|
194
|
+
|
|
195
|
+
```jsonc
|
|
196
|
+
{ "name": "search_products",
|
|
197
|
+
"response": { "extract": "evidence_envelope" },
|
|
198
|
+
"evidence": "products" } // bare kind
|
|
199
|
+
|
|
200
|
+
{ "evidence": { "kind": "products", // full form
|
|
201
|
+
"items": "results", // non-default paths
|
|
202
|
+
"attachments": "cards" } }
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
- `evidence_envelope` is the canonical extract: the raw response body arrives under
|
|
206
|
+
an engine-only key, the envelope parses `items`/`attachments` out of it, and
|
|
207
|
+
**nothing re-fattens** — the transcript and the tool trace record only the lean
|
|
208
|
+
result. It **requires** the `evidence` declaration (refused at load otherwise).
|
|
209
|
+
- **Wire contract** — the lean result the model sees is always
|
|
210
|
+
`{ "items": [ { "id": "…", "line": "…" } ] }` (≤ 16 items; `line` truncated to
|
|
211
|
+
200 chars). A tool whose result has no valid items yields `{ "items": [] }`,
|
|
212
|
+
never a null. A malformed evidence result becomes `{ "error": … }` back to the
|
|
213
|
+
model — a correctable tool answer, exactly like a malformed call.
|
|
214
|
+
- **Attachments** are the optional second half: `[{ "type": "card"|"image",
|
|
215
|
+
"url": "…", "caption": "…" }]` (≤ 16, url ≤ 500 chars, malformed dropped). They
|
|
216
|
+
**never** reach the model context or the transcript — they ride the channel
|
|
217
|
+
delivery as an additive `attachments` key on the outbox payload, and the channel
|
|
218
|
+
(or its consumer) decides what a card looks like.
|
|
219
|
+
- A **code tool** opts in the same way: it either returns `{ items, attachments }`
|
|
220
|
+
directly and declares `evidence` in its registry metadata, or exposes an
|
|
221
|
+
`evidence` reader. No declaration = today's tool behavior, byte for byte.
|
|
222
|
+
|
|
223
|
+
### Grounding: policing claims against the ledger
|
|
224
|
+
|
|
225
|
+
With the ledger fed, the pack declares how claims are policed — data on the agent,
|
|
226
|
+
not a separate code path (see [Agents](AGENTS.md)):
|
|
227
|
+
|
|
228
|
+
```ruby
|
|
229
|
+
grounding mode: :flag, matcher: { sku: '\b[A-Z]{2,4}\d{4,8}\b' }
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
- `mode` is `flag` (the default — audit), `enforce` (cut), or `off`. Absent = off.
|
|
233
|
+
- `matcher.sku` is a regex for the store's SKU shape, applied to the final answer;
|
|
234
|
+
every match that is **not** in the evidence ledger is an ungrounded claim.
|
|
235
|
+
Grounding is **SKU-only** by design: a name-based half cannot flag anything
|
|
236
|
+
without a "this is a product name" signal, so the ledger grounds ids, and the
|
|
237
|
+
model quoting a returned product by its *name* is simply outside the check
|
|
238
|
+
(the SKU path is the claim detector). A `sku` that does not compile is refused
|
|
239
|
+
at build; a matcher with no `sku` builds but matches nothing — `insika doctor`
|
|
240
|
+
warns about it.
|
|
241
|
+
- **`flag`** appends an `:ungrounded` flag (category `ungrounded`, source
|
|
242
|
+
`evidence`) to the existing `:guardrail_flagged` event — audit after the fact,
|
|
243
|
+
like every other output flag.
|
|
244
|
+
- **`enforce`** *cuts the sentence* containing an ungrounded claim from the content
|
|
245
|
+
the turn persists and delivers, and the flag carries `action: "cut"` so the audit
|
|
246
|
+
can tell a cut from a flag. It is honest about streaming: on a streaming surface
|
|
247
|
+
the already-streamed bytes are the channel's reality, which is exactly why the
|
|
248
|
+
default is `flag` — ship `enforce` only after a matcher audit proves precision.
|
|
249
|
+
- Grounding is **independent of the guardrails opt-in**: an agent with guardrails
|
|
250
|
+
off and `grounding.mode: :flag` still gets the check.
|
|
251
|
+
|
|
184
252
|
## Registering a tool
|
|
185
253
|
|
|
186
254
|
A tool appears in the Studio panel and enters an agent's tool-loop when it is
|
data/docs/WHY.md
CHANGED
|
@@ -32,7 +32,7 @@ the same speed-to-first-agent story Python teams tell, without leaving Ruby:
|
|
|
32
32
|
require "insika"
|
|
33
33
|
|
|
34
34
|
agent = Insika.agent("assistant") do
|
|
35
|
-
model "deepseek-
|
|
35
|
+
model "deepseek-v4-flash"
|
|
36
36
|
provider :deepseek
|
|
37
37
|
instructions "You are a concise, friendly assistant."
|
|
38
38
|
end
|
data/docs/WORKFLOWS.md
CHANGED
|
@@ -40,8 +40,8 @@ agents by id and those agents must resolve in the same runtime:
|
|
|
40
40
|
newsroom = Insika.system do
|
|
41
41
|
provider :deepseek
|
|
42
42
|
|
|
43
|
-
agent("writer") { model "deepseek-
|
|
44
|
-
agent("editor") { model "deepseek-
|
|
43
|
+
agent("writer") { model "deepseek-v4-flash"; instructions "Write ONE paragraph." }
|
|
44
|
+
agent("editor") { model "deepseek-v4-flash"; instructions "Rewrite as ONE sentence." }
|
|
45
45
|
|
|
46
46
|
workflow "publish",
|
|
47
47
|
description: "Draft a paragraph, then tighten it.",
|