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/AGENTS.md
CHANGED
|
@@ -18,7 +18,7 @@ keeps the profile it captured when it started; the next turn sees the new one.
|
|
|
18
18
|
>
|
|
19
19
|
> ```ruby
|
|
20
20
|
> agent = Insika.agent("assistant") do
|
|
21
|
-
> model "deepseek-
|
|
21
|
+
> model "deepseek-v4-flash"
|
|
22
22
|
> provider :deepseek
|
|
23
23
|
> instructions "You are a concise, friendly assistant."
|
|
24
24
|
> end
|
|
@@ -41,7 +41,7 @@ ergonomics, not in what they produce.
|
|
|
41
41
|
re-import that drops a tool or skill also removes it. `DELETE /v1/agents/:id`
|
|
42
42
|
removes an agent.
|
|
43
43
|
- **Studio** — create and edit an agent by hand in the control UI (Config /
|
|
44
|
-
Prompts / Skills / Memory / History tabs), backed by the same commands.
|
|
44
|
+
Prompts / Skills / Memory / Outcomes / Cache / History / **loops** tabs), backed by the same commands.
|
|
45
45
|
|
|
46
46
|
Creating an agent validates its id (required, must be unique) and its subagent
|
|
47
47
|
graph (cycle/depth — see [subagents](#delegation-subagents)) **before**
|
|
@@ -84,13 +84,44 @@ lookups.
|
|
|
84
84
|
```ruby
|
|
85
85
|
DEFAULT_LIMITS = {
|
|
86
86
|
turn_timeout: 300, tool_timeout: 60, provider_timeout: 5,
|
|
87
|
-
context_budget: 8_000, max_tool_calls: 50,
|
|
88
|
-
tool_concurrency: 1
|
|
87
|
+
context_budget: 8_000, max_tool_calls: 50, max_tool_repeat: 3,
|
|
88
|
+
approval_timeout: 3_600, tool_concurrency: 1
|
|
89
89
|
}
|
|
90
90
|
```
|
|
91
91
|
|
|
92
92
|
`build` merges your overrides over these — you set only the deltas.
|
|
93
93
|
|
|
94
|
+
`max_tool_repeat` is the loop guard: the same tool called with **identical
|
|
95
|
+
arguments** that many times in a row gets ONE in-turn warning (a user message at
|
|
96
|
+
the next tool-batch boundary: "you already ran this, answer with what you
|
|
97
|
+
have"). A repeat after the warning aborts the turn like `max_tool_calls` does.
|
|
98
|
+
Set it below 2 to switch it off.
|
|
99
|
+
|
|
100
|
+
### `tool_persistence` — don't give up on the first empty result
|
|
101
|
+
|
|
102
|
+
The loop guard's mirror image. `max_tool_repeat` stops the model from repeating
|
|
103
|
+
the *same* call; `tool_persistence` stops it from giving up after *one* weak
|
|
104
|
+
call. When ON (the default), the engine appends a short **"Tool discipline"**
|
|
105
|
+
block after the agent's identity in the system prompt: a weak or empty tool
|
|
106
|
+
result means *try again with a different approach — a rephrased query, a
|
|
107
|
+
synonym, a broader term — before telling the user you found nothing* (and don't
|
|
108
|
+
narrate the retries); a tool error means *read it and fix the arguments*, never
|
|
109
|
+
repeat the exact same call. Without it, a search that returns 0 results reads as
|
|
110
|
+
final and the model answers "I couldn't find it" when a synonym one call away
|
|
111
|
+
would have.
|
|
112
|
+
|
|
113
|
+
This is the **one default-ON profile flag** — every field above is opt-in, this
|
|
114
|
+
one is opt-out, because the behavior is the proven default and the exception is
|
|
115
|
+
the thing worth declaring:
|
|
116
|
+
|
|
117
|
+
```ruby
|
|
118
|
+
tool_persistence false # remove the block for this agent
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
The block is a byte-stable constant, so `prompt_caching` stays effective: the
|
|
122
|
+
deploy that introduces it costs one cache write per agent, and every turn after
|
|
123
|
+
that hits as before.
|
|
124
|
+
|
|
94
125
|
### Why some limits are missing from that list
|
|
95
126
|
|
|
96
127
|
`chat_rate_limit`, `agent_token_ceiling`, `queue_mode`, `debounce_ms`,
|
|
@@ -213,6 +244,12 @@ separately lives in one event, emitted when the window closes:
|
|
|
213
244
|
Times and counts, never content. That is what answers "the customer says they
|
|
214
245
|
sent the order number" without keeping a throwaway task per fragment.
|
|
215
246
|
|
|
247
|
+
> **`steer` also collects at the door.** The two windows are the same policy's
|
|
248
|
+
> halves, not two modes: a `steer` agent that set a `debounce_ms` also merges the
|
|
249
|
+
> fragments that land before the turn starts (RFC-0027). The window value is what
|
|
250
|
+
> an operator replaces the legacy pre-batch buffer with — `steer` catches anything
|
|
251
|
+
> that arrives after the turn started, the door window the fragments before it.
|
|
252
|
+
|
|
216
253
|
#### `steer` — the message arrives while the turn is already running
|
|
217
254
|
|
|
218
255
|
`collect` only ever touches a turn that has **not started**. Once the agent is
|
|
@@ -304,7 +341,7 @@ What "abandoned" means, exactly:
|
|
|
304
341
|
visible to an *operator* (its `tool_call`/`tool_result` events and the trace), not to
|
|
305
342
|
the model — a half batch in the history would be an invalid prompt.
|
|
306
343
|
|
|
307
|
-
> **No grace knob.**
|
|
344
|
+
> **No grace knob.** An `interrupt_grace_ms` was sketched; it is not
|
|
308
345
|
> implemented, and would buy nothing here. The new turn is queued behind the abandoned
|
|
309
346
|
> one either way (one turn at a time per session is the invariant), and waiting for a
|
|
310
347
|
> boundary inside the request would break the ack-fast rule that put the debounce
|
|
@@ -330,7 +367,12 @@ For tools, a paired deny list (`tools_deny`) **always wins**, and
|
|
|
330
367
|
|
|
331
368
|
Three capabilities invert the default — `nil`/absent means **OFF**, not "all":
|
|
332
369
|
`subagents`, `memory`, and `guardrails` (each defaults to off or a conservative
|
|
333
|
-
setting, never "everything on").
|
|
370
|
+
setting, never "everything on"). `tool_output_compression` is a fourth: opt-in
|
|
371
|
+
mechanical dedupe of repeated tool results in the history (see
|
|
372
|
+
[Context](CONTEXT.md#compaction-is-not-wired--except-the-mechanical-dedupe)),
|
|
373
|
+
off by default because it changes what the model sees. And one flag inverts the
|
|
374
|
+
other way: `tool_persistence` is **ON unless you set it to `false`** (see
|
|
375
|
+
[`tool_persistence`](#tool_persistence--dont-give-up-on-the-first-empty-result)).
|
|
334
376
|
|
|
335
377
|
### Declaring what this deployment has
|
|
336
378
|
|
|
@@ -386,6 +428,123 @@ per agent (blank inherits the platform value, `0` explicitly disables it).
|
|
|
386
428
|
> *present but nil* reads as OFF for that agent — leave the key **absent** to
|
|
387
429
|
> inherit. See [Security](SECURITY.md#edge-limits).
|
|
388
430
|
|
|
431
|
+
#### Calendar budgets — the daily/monthly cost wall
|
|
432
|
+
|
|
433
|
+
A third, opt-in ceiling for the *billing* shape the windows above cannot express:
|
|
434
|
+
a spend cap over a CALENDAR day or month, per `(tenant, agent)` when
|
|
435
|
+
multi-tenant. Data on the profile (DSL `budget` or the pack's `budget` key):
|
|
436
|
+
|
|
437
|
+
```ruby
|
|
438
|
+
budget daily: 100_000, monthly: 2_000_000, soft: false # or soft: true
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
- Tokens count the **billed** spend — `input + output + cached + cache_creation`
|
|
442
|
+
(the cached prefix is the bulk of the bill, not an afterthought).
|
|
443
|
+
- **Hard** (the default, `soft` absent/false): a turn that arrives with the
|
|
444
|
+
window's spend already at/over the cap **fails** with the typed
|
|
445
|
+
`Insika::BudgetExceeded` — the envelope reads `budget_exceeded` +
|
|
446
|
+
`retry_after` (seconds until the window rolls). It is NOT a customer reply; it
|
|
447
|
+
is an operator signal.
|
|
448
|
+
- **Soft** (`soft: true`): the same turn RUNS — crossing the cap emits one
|
|
449
|
+
`budget_warning` event per window and injects a note into the context (the
|
|
450
|
+
model sees it, the transcript does not).
|
|
451
|
+
- Either way, crossing `alert_at` (default `0.8` of the cap) fires the same
|
|
452
|
+
warning **before** the wall, once per window.
|
|
453
|
+
|
|
454
|
+
> ⚠️ Unlike the ceilings above, the cap that counts is per **calendar** window —
|
|
455
|
+
> a `daily` budget rolls at UTC midnight, a `monthly` one on the 1st, whatever
|
|
456
|
+
> the sun. `agent_token_window` is a fixed seconds window and cannot express
|
|
457
|
+
> "the day resets at midnight".
|
|
458
|
+
|
|
459
|
+
#### Reliability — retries, fallback, circuit breaker (WS3)
|
|
460
|
+
|
|
461
|
+
The provider interaction is a single attempt by default (RubyLLM's own 2
|
|
462
|
+
transport retries aside). For a store that cannot have a dead model take the
|
|
463
|
+
chat down, the reliability policy is DATA on the profile:
|
|
464
|
+
|
|
465
|
+
```ruby
|
|
466
|
+
reliability retries: 2, backoff: "exponential",
|
|
467
|
+
fallback: ["openai/gpt-4o-mini"],
|
|
468
|
+
circuit_breaker: { after: 10, within: 60, cooldown: 300 }
|
|
469
|
+
```
|
|
470
|
+
|
|
471
|
+
- **Retries** — transient failures (`:retryable` / `:rate_limited_*` per the
|
|
472
|
+
error classification) retry with exponential backoff, up to `retries`.
|
|
473
|
+
A `:fatal` (auth, billing, bad request) is NEVER retried or rotated. Each
|
|
474
|
+
attempt runs on a fresh chat — the customer-visible answer comes only from
|
|
475
|
+
the attempt that returns.
|
|
476
|
+
- **Fallback** — after a node's retries, the turn ROTATES to the next model in
|
|
477
|
+
the chain: the profile's `fallback` refs first, then the platform
|
|
478
|
+
`fallback_models`. The turn's usage is attributed to the model that actually
|
|
479
|
+
spoke (`model_source: "fallback"`).
|
|
480
|
+
- **Circuit breaker** — per `(tenant, provider/model)`: `after` failures within
|
|
481
|
+
`within` seconds open the circuit; while open, the turn fail-fasts with the
|
|
482
|
+
typed `circuit_open` + `retry_after` (remaining cooldown) and the provider is
|
|
483
|
+
never touched. After `cooldown` a half-open trial closes the circuit on
|
|
484
|
+
success or reopens it on failure.
|
|
485
|
+
- **`timeout`** — per-attempt ceiling (default 30s), counted as a retryable
|
|
486
|
+
failure.
|
|
487
|
+
|
|
488
|
+
Absent `reliability` = the plain single attempt, byte-for-byte today's
|
|
489
|
+
behavior.
|
|
490
|
+
|
|
491
|
+
#### Intent routing — classify before you answer (WS4)
|
|
492
|
+
|
|
493
|
+
For a store that must tell "shopping" from "order" from "human" up front,
|
|
494
|
+
routing is data on the profile:
|
|
495
|
+
|
|
496
|
+
```ruby
|
|
497
|
+
routes "shopping" => "the customer wants to browse products",
|
|
498
|
+
"order" => { "description" => "asks about an existing order",
|
|
499
|
+
"delegate" => "order-agent" },
|
|
500
|
+
"human" => { "description" => "the customer asks for a person",
|
|
501
|
+
"stuck" => true, "message" => "A person will help you." },
|
|
502
|
+
"default" => "shopping",
|
|
503
|
+
"model" => "deepseek-v4-flash" # the cheap classifier (absent = the agent's own)
|
|
504
|
+
```
|
|
505
|
+
|
|
506
|
+
- **Classification** — when `routes:` is present, the message is classified into
|
|
507
|
+
one route with the configured model BEFORE the agent chat is assembled, from
|
|
508
|
+
a prompt auto-generated out of the descriptions (no per-route prompt file).
|
|
509
|
+
The route rides the turn: `state.route`, the `:route_classified` event, and
|
|
510
|
+
the terminal event additively.
|
|
511
|
+
- **Deterministic default** — the model's answer must be a route name; prose,
|
|
512
|
+
an unknown name, or an empty answer falls back to `default`, never invents.
|
|
513
|
+
A classifier call that FAILS leaves the turn unrouted (routing is additive —
|
|
514
|
+
it must not break the turn).
|
|
515
|
+
- **Cost** — the classification is an extra provider call, counted in the
|
|
516
|
+
turn's usage (the trace, the token ceiling and the budget all see it).
|
|
517
|
+
- **Actions** — a route value may be a description string, or a Hash:
|
|
518
|
+
`delegate: "<agent-id>"` hands the turn to that existing agent and its
|
|
519
|
+
answer becomes the parent's; `stuck: true` ends the turn with the [stuck
|
|
520
|
+
outcome](#the-stuck-signal--i-cannot-proceed-ws5) and the route's `message`
|
|
521
|
+
(or description) as the lead-in — the consumer interprets it. A route with
|
|
522
|
+
neither is just a label. A delegation counts against the same delegation
|
|
523
|
+
depth cap as a subagent (`INSIKA_SUBAGENT_DEPTH_CAP`, default 5), so a pair
|
|
524
|
+
of agents routing to each other stops instead of looping.
|
|
525
|
+
|
|
526
|
+
Absent `routes` = no classification, no extra call, byte-identical turn.
|
|
527
|
+
|
|
528
|
+
#### Operator alerts — the webhook (WS6)
|
|
529
|
+
|
|
530
|
+
Three operational events — `budget_warning`, `breaker_open`, `delivery_failed` —
|
|
531
|
+
can be answered per agent with a webhook:
|
|
532
|
+
|
|
533
|
+
```ruby
|
|
534
|
+
alerts webhook: "https://ops.example.com/insika-alerts"
|
|
535
|
+
```
|
|
536
|
+
|
|
537
|
+
When present, each such event is POSTed to the URL as JSON (the event's
|
|
538
|
+
type/data/meta, plus the agent). Delivery rides the same outbox + claim +
|
|
539
|
+
bounded-retry pipeline as channel answers — at-most-once, crashed deliveries
|
|
540
|
+
recovered at boot. The engine transports the event and does not interpret it: a
|
|
541
|
+
Slack/CRM adapter is the consumer's. Absent `alerts` = nothing is sent.
|
|
542
|
+
|
|
543
|
+
Separately, with `INSIKA_TURN_TIMING`, the provider's **live TTFB** is carried in
|
|
544
|
+
the streaming envelope: the first content chunk emits an `insika.ttft` frame
|
|
545
|
+
(`ttft_ms`) on `/v1/responses`, alongside the per-turn `timing` breakdown on the
|
|
546
|
+
final `response.completed`. Additive and opt-in — absent by default.
|
|
547
|
+
|
|
389
548
|
### Layer 5: Reasoning (thinking)
|
|
390
549
|
|
|
391
550
|
Controls the model's thinking budget, resolved by precedence
|
|
@@ -422,6 +581,41 @@ and each opted-in channel gets its own frame type, never the answer's. See
|
|
|
422
581
|
> reads. That is the operator's call, which is why it is neither a default nor a
|
|
423
582
|
> global.
|
|
424
583
|
|
|
584
|
+
## The stuck signal — "I cannot proceed" (WS5)
|
|
585
|
+
|
|
586
|
+
The engine doesn't decide what "I can't help you" means — the consumer does. What
|
|
587
|
+
the engine provides is the deterministic signal, so that a product wanting
|
|
588
|
+
**human escalation** can act on it instead of regexing the answer text:
|
|
589
|
+
|
|
590
|
+
```ruby
|
|
591
|
+
stuck_signal true
|
|
592
|
+
```
|
|
593
|
+
|
|
594
|
+
With `stuck_signal` on, the model may call `signal_stuck(reason:, message:)` when it
|
|
595
|
+
determines it cannot proceed (out of scope, missing data, a case a human must take
|
|
596
|
+
over). The turn then **ends** — a final message is published (the model's lead-in,
|
|
597
|
+
or the tool's `message` when it wrote none) — and the contract carries the signal
|
|
598
|
+
twice:
|
|
599
|
+
|
|
600
|
+
- the terminal event `task_completed` gains an additive sibling
|
|
601
|
+
`"outcome": "stuck"` (and the OpenAI `response.completed` frame too), so a
|
|
602
|
+
consumer that only reads the response can react;
|
|
603
|
+
- a dedicated `:turn_stuck` event is published with the `agent`, the `reason`, and
|
|
604
|
+
the final `message` — the subscription point for an operator inbox.
|
|
605
|
+
|
|
606
|
+
```jsonc
|
|
607
|
+
{ "type": "turn_stuck",
|
|
608
|
+
"data": { "agent": "store-support", "reason": "order outside my scope",
|
|
609
|
+
"message": "I'll transfer you to our human team." } }
|
|
610
|
+
```
|
|
611
|
+
|
|
612
|
+
Nothing about handing off, pausing, or resuming is in scope here — escalation is a
|
|
613
|
+
consumer concern. How a human joins the conversation is exactly what
|
|
614
|
+
`MessageOrigin.operator` ([Refinement](REFINEMENT.md#who-wrote-a-message)) stamps
|
|
615
|
+
an imported transcript with; this workstream provides the point at which that
|
|
616
|
+
handoff is *triggered*. Off by default (parity): without `stuck_signal`, the tool
|
|
617
|
+
is not wired and the outcome never appears.
|
|
618
|
+
|
|
425
619
|
## Refinement
|
|
426
620
|
|
|
427
621
|
`refinement` configures how an agent's own traffic is read back as a report — what
|
|
@@ -438,6 +632,56 @@ allowlist, one or more `proposers`, a token `budget`, and a gate that replays th
|
|
|
438
632
|
golden set before anything reaches a human. All of it is in
|
|
439
633
|
[Refinement](REFINEMENT.md); none of it is on until you name it.
|
|
440
634
|
|
|
635
|
+
## Distillation of customer facts
|
|
636
|
+
|
|
637
|
+
`distill` configures how finished, idle customer conversations are read back as
|
|
638
|
+
proposed facts — the human-gated loop documented in [Facts](FACTS.md). Pack
|
|
639
|
+
data, `refinement:`'s shape, and absent = off for that agent:
|
|
640
|
+
|
|
641
|
+
```ruby
|
|
642
|
+
distill enabled: true,
|
|
643
|
+
idle_hours: 6, # how idle a session must be before it distills
|
|
644
|
+
min_messages: 3, # a shorter session distills noise
|
|
645
|
+
max_proposals: 10 # cap per session pass
|
|
646
|
+
# prompt: "<what counts as a fact for THIS store>" (the forge's half)
|
|
647
|
+
# model: "<ref — absent = the platform utility_model>"
|
|
648
|
+
```
|
|
649
|
+
|
|
650
|
+
Nothing is ever applied automatically: the engine writes **proposals**, the
|
|
651
|
+
operator approves/rejects/dismisses them on the Studio **Facts** page (the
|
|
652
|
+
latch: a dismissed or rejected tuple is never proposed again), and an approval
|
|
653
|
+
writes the fact to the customer's memory cell stamped
|
|
654
|
+
`distilled:<session_ref>` through an optimistic CAS — an approval never
|
|
655
|
+
silently overwrites an operator edit. Sessions are the only candidates, and
|
|
656
|
+
the distiller rides the platform `utility_model`, never a new model slot.
|
|
657
|
+
|
|
658
|
+
## Harvest of skills from real traffic
|
|
659
|
+
|
|
660
|
+
`harvest` configures how finished, idle conversations are read back as
|
|
661
|
+
proposed **SKILLS** for the agent's playbook — the human-gated loop documented
|
|
662
|
+
in [Harvest](HARVEST.md). Pack data, `distill:`'s shape, and absent = off for
|
|
663
|
+
that agent:
|
|
664
|
+
|
|
665
|
+
```ruby
|
|
666
|
+
harvest enabled: true,
|
|
667
|
+
negative_list: [ { rule: "no-competitor-prices", pattern: "concorrente" } ],
|
|
668
|
+
miner: { model: "deepseek-v4-flash", # absent = the platform utility_model
|
|
669
|
+
window: { last_sessions: 200 } },
|
|
670
|
+
idle_hours: 24,
|
|
671
|
+
min_messages: 3
|
|
672
|
+
# prompt: "<what a harvestable skill is for THIS store>" (the forge's half)
|
|
673
|
+
```
|
|
674
|
+
|
|
675
|
+
The loop reads only finished traffic (the fork is structural — the mining
|
|
676
|
+
writes nothing to the sessions it read), filters every proposal through the
|
|
677
|
+
negative list and the evidence ledger (product claims must reference IDs the
|
|
678
|
+
origin sessions actually saw — an agent without `grounding.matcher.sku` does
|
|
679
|
+
not mine at all), scores survivors with a double gate (the eval replay against
|
|
680
|
+
the clone's golden set, judges mandatory in the three P18 shapes; the
|
|
681
|
+
conversion "not worse" check against the RFC-0032 frozen baseline), and lands
|
|
682
|
+
a skill **only after a human approves** — snapshot-first, append-only
|
|
683
|
+
promotion log, deterministic rollback. Nothing is ever applied automatically.
|
|
684
|
+
|
|
441
685
|
## Delegation (subagents)
|
|
442
686
|
|
|
443
687
|
An agent can delegate to **subagents**: named child agents it may invoke as a
|
|
@@ -484,6 +728,261 @@ next turn with no restart. See [Deploy](DEPLOY.md) for the durable-volume setup
|
|
|
484
728
|
[Context](CONTEXT.md#the-volume) for why editing a committed file does *not* change
|
|
485
729
|
a running agent.
|
|
486
730
|
|
|
731
|
+
## Media in the message contract (WS9)
|
|
732
|
+
|
|
733
|
+
The engine transports media, it never means it. The message accepts additive
|
|
734
|
+
**content parts** alongside the text — voice notes and photos travel, and any
|
|
735
|
+
skill (a fitting room, an image QA) stays a consumer layer on top:
|
|
736
|
+
|
|
737
|
+
```bash
|
|
738
|
+
curl -X POST /v1/messages?stream=false -H "Authorization: Bearer $TOKEN" \
|
|
739
|
+
-d '{ "agent": "store-support", "session_id": "chat-7",
|
|
740
|
+
"message": "", "parts": [
|
|
741
|
+
{ "type": "audio", "url": "https://cdn.example.com/voz.ogg" },
|
|
742
|
+
{ "type": "image", "url": "https://cdn.example.com/sofa.jpg" }
|
|
743
|
+
] }'
|
|
744
|
+
```
|
|
745
|
+
|
|
746
|
+
- **Audio** is transcribed (RubyLLM STT; model via `INSIKA_STT_MODEL`) and the
|
|
747
|
+
text enters the turn marked `source: "voice"` on the terminal event — the
|
|
748
|
+
consumer's signal the person spoke. A consumer that transcribes itself can
|
|
749
|
+
send the text with `"source": "voice"` directly.
|
|
750
|
+
- **Images** attach to the model's ask (vision); the provider bills them and
|
|
751
|
+
the usage flows like any ask. The first image URL is also
|
|
752
|
+
`{{ctx.image_url}}` for data tools — photo analysis outside the prompt, the
|
|
753
|
+
tool's own egress applying when it fetches. Media URLs (audio AND image) are fetched by
|
|
754
|
+
the engine through the same egress guard (a private/metadata target is
|
|
755
|
+
refused — SSRF) and the same size ceiling (1 MB audio, 5 MB image: the bytes
|
|
756
|
+
land in this process). A refused, oversized or unreadable part fails the turn
|
|
757
|
+
loudly at the `:media` stage, never a silent drop.
|
|
758
|
+
- **Media alone is a turn.** A voice note with no caption is `parts` and an
|
|
759
|
+
empty `message` — the transcription becomes the message at the `:media`
|
|
760
|
+
stage. A media message never joins another turn (`collect`/`steer` move text
|
|
761
|
+
only, and the parts would be left behind), and a transcription that comes
|
|
762
|
+
back empty fails the turn instead of asking the model about nothing.
|
|
763
|
+
- **Parts are contract at the edge** — a malformed part (unknown type, an
|
|
764
|
+
image/audio without `url`, a text without `text`) is a 422 before dispatch
|
|
765
|
+
on `/v1/messages` and `/v1/responses`.
|
|
766
|
+
- `/v1/responses` accepts the OpenAI multimodal shape: `input` as an array of
|
|
767
|
+
text/image/audio parts.
|
|
768
|
+
|
|
769
|
+
## Generated media as outputs (WS9, saída)
|
|
770
|
+
|
|
771
|
+
The turn can **produce** an image or a voice clip — but only when both sides of
|
|
772
|
+
the gate agree, because nothing leaks by default. The agent declares it may
|
|
773
|
+
generate media (`outputs` on the profile), and the **channel** declares it can
|
|
774
|
+
receive it (`channel.capabilities` on the request):
|
|
775
|
+
|
|
776
|
+
```bash
|
|
777
|
+
curl -X POST /v1/responses -H "Authorization: Bearer $TOKEN" -d '{
|
|
778
|
+
"model": "openclaw:store-support", "user": "chat-7",
|
|
779
|
+
"input": "manda a foto do sofá da promoção",
|
|
780
|
+
"channel": { "capabilities": ["image_output", "audio_output"] }
|
|
781
|
+
}'
|
|
782
|
+
```
|
|
783
|
+
|
|
784
|
+
```ruby
|
|
785
|
+
agent = Insika.agent("store-support") do
|
|
786
|
+
instructions "…"
|
|
787
|
+
outputs image: { model: "gpt-image-1", size: "1024x1024" }, # the AGENT's half
|
|
788
|
+
tts: { model: "tts-1", voice: "alloy" }
|
|
789
|
+
end
|
|
790
|
+
```
|
|
791
|
+
|
|
792
|
+
- **Both gates** must pass for the model to even see the `generate_image` /
|
|
793
|
+
`tts` tools: the agent opted in (`outputs`) and the request declared the
|
|
794
|
+
matching capability (`image_output` / `audio_output` — an unknown value is a
|
|
795
|
+
422, never a silent ignore). The "abstraction admits only what leaks" rule.
|
|
796
|
+
- **The media rides the envelope, never the answer text.** The terminal event
|
|
797
|
+
and the `/v1/responses` completed frame carry an additive `output_parts`
|
|
798
|
+
array — `{ type: "image", mime_type:, base64:, model: }` /
|
|
799
|
+
`{ type: "audio", mime_type:, base64:, model: }`. The model's prose stays
|
|
800
|
+
the `:content` answer; the channel consumes the bytes next to it.
|
|
801
|
+
- **Generation is billed and counted.** Image tokens join the turn's usage
|
|
802
|
+
(like any ask). The speech API reports no token counts, so a TTS call adds
|
|
803
|
+
an honest `usage.media` counter and the part carries the `model` for
|
|
804
|
+
consumer-side pricing.
|
|
805
|
+
- **Seams, not magic.** The generator is injectable per kind (specs stub it);
|
|
806
|
+
the defaults are lazy: images via RubyLLM (paint), speech via a thin POST to
|
|
807
|
+
the OpenAI-compatible `/audio/speech` endpoint using the same provider
|
|
808
|
+
config the chat uses — RubyLLM as of 1.16.0 has no speech API. A generated
|
|
809
|
+
part over 8 MB refuses loudly, never silently truncates.
|
|
810
|
+
- **Not here:** what the generated image *means* — a fitting room, a product
|
|
811
|
+
mockup — is a skill on top. The engine transports bytes and cost.
|
|
812
|
+
|
|
813
|
+
## Customer-scoped memory and the right to be forgotten (WS8)
|
|
814
|
+
|
|
815
|
+
Memory is naturally **per customer, not per tenant**. A message that carries a
|
|
816
|
+
`customer` key moves the engine's memory scope to that person:
|
|
817
|
+
|
|
818
|
+
```bash
|
|
819
|
+
curl -X POST /v1/messages?stream=false -H "Authorization: Bearer $TOKEN" \
|
|
820
|
+
-d '{ "agent": "store-support", "session_id": "chat-7",
|
|
821
|
+
"customer": "c-123", "message": "cadê meu pedido" }'
|
|
822
|
+
```
|
|
823
|
+
|
|
824
|
+
- **Scope** — with `customer` present, the `remember` tool and the `<memory>`
|
|
825
|
+
block read/write the `[tenant:]customer` cell: two customers under the same
|
|
826
|
+
tenant never see each other, and the `<request_context>` tenant label (the
|
|
827
|
+
merchant) is untouched. Absent `customer` = today's per-tenant/per-chat
|
|
828
|
+
behavior.
|
|
829
|
+
- **Right to be forgotten** — `POST /v1/commands/forget_customer` (operator)
|
|
830
|
+
purges the customer's memory cell, their sessions and everything those
|
|
831
|
+
sessions left behind — per-session traces, the tasks (the message text lives
|
|
832
|
+
in the persisted command), their checkpoints (the transcript) and the outbox
|
|
833
|
+
deliveries (the answer as it was handed to the channel) — and nothing else's:
|
|
834
|
+
`{ "customer": "c-123", "tenant": "acme" }`. **Name the tenant**: the
|
|
835
|
+
operator credential carries none, and without one the purge means the whole
|
|
836
|
+
deployment (the untagged memory cell, plus that customer's sessions in every
|
|
837
|
+
tenant) — right for a single-tenant deployment, never what a multi-tenant
|
|
838
|
+
operator means. Facts also support an optimistic CAS write
|
|
839
|
+
(`replace_if_revision`) for an integration that must not clobber a concurrent
|
|
840
|
+
edit.
|
|
841
|
+
- **Tenant deletion** — `POST /v1/commands/delete_tenant_data` (operator)
|
|
842
|
+
purges EVERYTHING the engine holds about one tenant: its sessions and their
|
|
843
|
+
whole footprint (traces, tasks, checkpoints, outbox deliveries), every memory
|
|
844
|
+
cell under the tenant (its own + the customer cells — enumerated from the
|
|
845
|
+
store, so even a cell whose session was already deleted goes) and its outcome
|
|
846
|
+
records: `{ "tenant": "acme" }`. Its API tokens are **revoked first** (before
|
|
847
|
+
the sweep): an offboarded tenant whose credentials still resolved kept
|
|
848
|
+
authenticating and could open a new session over the erasure. The tenant
|
|
849
|
+
string is the isolation boundary; a neighbour is untouched.
|
|
850
|
+
- **Retention** — the age-based counterpart, as data: the settings key
|
|
851
|
+
`retention_days` (Integer days; absent/0 = OFF, the engine never sweeps by
|
|
852
|
+
default). The tick's daily sweep (at most once per 24 h, behind the same
|
|
853
|
+
single-key claim the stale-task sweep uses) purges sessions (+traces),
|
|
854
|
+
terminal tasks (+checkpoints), delivered/failed outbox records, memory
|
|
855
|
+
facts/notes and outcomes older than the window. A non-terminal task is never
|
|
856
|
+
touched — the Recovery sweep owns those lives — and neither is a delivery
|
|
857
|
+
still owed to somebody. One thing the same daily pass sweeps **regardless of
|
|
858
|
+
`retention_days`**: the budget counter cells whose window already rolled over
|
|
859
|
+
(and their once-per-window alert markers). Those are engine bookkeeping, not
|
|
860
|
+
customer content, and nothing else ever collected them.
|
|
861
|
+
|
|
862
|
+
## Outcomes — business results over real traffic (WS7)
|
|
863
|
+
|
|
864
|
+
The engine measures what it is told to measure. The operator or the integration
|
|
865
|
+
records a conversation's business outcome after the fact — `conversion`,
|
|
866
|
+
`escalation`, `deflected`, anything, optionally with a monetary `value`:
|
|
867
|
+
|
|
868
|
+
```bash
|
|
869
|
+
curl -X POST /v1/outcomes -H "Authorization: Bearer $TOKEN" \
|
|
870
|
+
-d '{ "agent": "store-support", "session_id": "chat-7",
|
|
871
|
+
"outcome": "conversion", "value": 129.9 }'
|
|
872
|
+
```
|
|
873
|
+
|
|
874
|
+
The endpoint is **additive and outside the response contract** — the turn never
|
|
875
|
+
knows or cares; the engine transports the outcome and never interprets it (what
|
|
876
|
+
"conversion" means is yours). Records are tenant-stamped (a tenant principal
|
|
877
|
+
writes and reads only its own), and `GET /v1/outcomes?agent=` serves the last
|
|
878
|
+
outcome per agent plus the per-day series — the last-outcome pill on the Studio
|
|
879
|
+
agent grid, and the per-day series on the agent detail.
|
|
880
|
+
|
|
881
|
+
### The outcome funnel (RFC-0032)
|
|
882
|
+
|
|
883
|
+
A store's funnel is pack data on the agent — the engine folds WS7 outcomes into
|
|
884
|
+
the **declared** stages, and never hard-codes one itself (the stage vocabulary
|
|
885
|
+
is the forge's):
|
|
886
|
+
|
|
887
|
+
```ruby
|
|
888
|
+
agent = Insika.agent("store-support") do
|
|
889
|
+
instructions "…"
|
|
890
|
+
funnel stages: %w[greeted qualified cart paid],
|
|
891
|
+
advance_on: { "abandoned_cart" => "cart", "pix_paid" => "paid" },
|
|
892
|
+
primary: "paid", attribution_window: "72h"
|
|
893
|
+
end
|
|
894
|
+
```
|
|
895
|
+
|
|
896
|
+
The fold contract:
|
|
897
|
+
|
|
898
|
+
- **Tick-driven, cumulative event counts on the declared order.** An outcome of
|
|
899
|
+
kind K means the session *reached* `advance_on[K]`; the fold increments
|
|
900
|
+
`stages[0..index]` for the reached stage. A per-stage-complete integration
|
|
901
|
+
and a terminal-event integration therefore produce identical counts — a
|
|
902
|
+
session that paid also emitted the earlier events. A duplicate event
|
|
903
|
+
double-counts (the integration's defect, not the engine's); **do not declare
|
|
904
|
+
a stage off the linear path** (a "handoff" stage would be inflated by every
|
|
905
|
+
later event). Counts are **event counts, not distinct sessions** — the
|
|
906
|
+
baseline is events-based.
|
|
907
|
+
- **Idempotent**: a per-pair `{at, ids}` cursor inside one transaction; a crash
|
|
908
|
+
mid-fold never double counts, and a second pass folds only what is new.
|
|
909
|
+
- **The attribution window is carried data, never computed** — `72h` is
|
|
910
|
+
validated, rendered, and copied into the baseline snapshot; causal
|
|
911
|
+
attribution stays human.
|
|
912
|
+
- **The baseline freeze** (Studio > Funnel, or `:freeze_funnel_baseline` on the
|
|
913
|
+
bus) sums the folded cells over a span of **≥ 28 days** (shorter spans are
|
|
914
|
+
refused) into one current snapshot per `(tenant, agent)` — the number
|
|
915
|
+
RFC-0033 (follow-up A/B) and RFC-0035 (promotion gate) compare against.
|
|
916
|
+
- **Malformed declarations never crash the tick**: the fold skips them, the
|
|
917
|
+
doctor names the defect, the Studio shows nothing until it is fixed.
|
|
918
|
+
- Vocabulary note: in the gem this is the **outcome funnel** — the stage names
|
|
919
|
+
are the forge's, and a bare install (no `funnel:` on any agent) shows no
|
|
920
|
+
funnel and no stage names at all.
|
|
921
|
+
|
|
922
|
+
## Follow-ups — the seller who comes back (RFC-0033)
|
|
923
|
+
|
|
924
|
+
The agent can book a follow-up with a customer at a future time — "te chamo
|
|
925
|
+
amanhã se o PIX não cair" said in-conversation and meant. The engine fires the
|
|
926
|
+
synthetic turn on its own tick, with consent and without spam. Everything is
|
|
927
|
+
pack data on the profile:
|
|
928
|
+
|
|
929
|
+
```ruby
|
|
930
|
+
agent = Insika.agent("store-support") do
|
|
931
|
+
instructions "…"
|
|
932
|
+
followup arm: "schedule",
|
|
933
|
+
policy: { quiet_hours: { timezone: "America/Sao_Paulo",
|
|
934
|
+
start: "21:30", end: "09:00" },
|
|
935
|
+
max_frequency: "2/24h", # N outbound per window, per customer
|
|
936
|
+
cancel_keywords: ["não quero mais contato"],
|
|
937
|
+
silence_after_sends: 3 } # N fires without a reply -> :unavailable
|
|
938
|
+
end
|
|
939
|
+
```
|
|
940
|
+
|
|
941
|
+
The pieces:
|
|
942
|
+
|
|
943
|
+
- **`schedule(at:, reason:)`** — a built-in tool the agent calls when the
|
|
944
|
+
customer agrees to be contacted again (a product, a cart, a pending payment).
|
|
945
|
+
The call itself IS the consent record — recorded without ever lifting
|
|
946
|
+
`:unavailable` or resetting the silence counter (ONLY a customer message
|
|
947
|
+
reopens, so a re-booking inside a follow-up turn cannot clear the silence
|
|
948
|
+
protection). `cancel_followup(id:)` is the sibling. A customer who opted out
|
|
949
|
+
can never be rescheduled.
|
|
950
|
+
- **Contact state per customer** — `granted | revoked | unavailable` in a
|
|
951
|
+
durable cell per `(tenant, customer)`. Only `granted` may be messaged;
|
|
952
|
+
`revoked` is immediate and permanent until the customer speaks again;
|
|
953
|
+
`unavailable` means silence ≠ refusal — the engine stops firing after
|
|
954
|
+
`silence_after_sends` unanswered sends, and ANY customer message reopens.
|
|
955
|
+
The policy's `cancel_keywords` are matched on every inbound message: a
|
|
956
|
+
match revokes the contact and cancels its pending follow-ups in one
|
|
957
|
+
transaction.
|
|
958
|
+
- **Firing is the tick's third duty** — the engine claims the due records
|
|
959
|
+
(one per claim window, at-most-once across workers), applies the policy in
|
|
960
|
+
force AT FIRE TIME (contact state, quiet hours, dedup per
|
|
961
|
+
`(customer, reason)`, frequency ceiling) and either enqueues the synthetic
|
|
962
|
+
turn or marks the record `blocked` with the failing rule — auditable, never
|
|
963
|
+
silent. Blocking happens at fire time, never at schedule time: the schedule
|
|
964
|
+
is a promise made in-conversation, and only the policy in force then may
|
|
965
|
+
revoke it.
|
|
966
|
+
- **The synthetic turn** — a first-class inbound turn stamped
|
|
967
|
+
`origin: "scheduled"` (a refinement read can never mistake the engine's
|
|
968
|
+
kick for the customer repeating themselves), delivered through the full
|
|
969
|
+
pipeline on the channel the conversation came in on. It skips the edge's
|
|
970
|
+
ENTRY rate/token checks like a resume does — a follow-up she agreed to must
|
|
971
|
+
not receive the rate-limit reply; its usage still lands on the ledger.
|
|
972
|
+
- **The Follow-ups page** (Studio) — per agent: the pending/fired/cancelled/
|
|
973
|
+
blocked records (blocked rows carry the reason), the read-only policy
|
|
974
|
+
summary and the A/B card: per arm, `sent` vs `conversions` (against the
|
|
975
|
+
RFC-0032 baseline) vs `opt-outs`. The only mutations — cancel a pending
|
|
976
|
+
record, force-revoke a contact — go through bus commands.
|
|
977
|
+
- **LGPD** — the records and cells die with the customer (`forget_customer`),
|
|
978
|
+
the tenant (`delete_tenant_data`) and age out under the same
|
|
979
|
+
`retention_days` sweep as the rest of the footprint.
|
|
980
|
+
|
|
981
|
+
Absent `followup:` = the feature is off for that agent — no tools wired, no
|
|
982
|
+
records, byte-identical turns. The A/B against an existing cron is an
|
|
983
|
+
operator experiment: the engine only keeps the records and the read card (the
|
|
984
|
+
cron arm writes through the same store class with its own `arm` label).
|
|
985
|
+
|
|
487
986
|
## See also
|
|
488
987
|
|
|
489
988
|
- [Tools](TOOLS.md) — define, register, and troubleshoot tools.
|
data/docs/API.md
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: The /v1 API
|
|
3
|
+
parent: Build an agent
|
|
4
|
+
nav_order: 9
|
|
5
|
+
permalink: /api/
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# The /v1 API — the frozen compatibility contract
|
|
9
|
+
|
|
10
|
+
The HTTP surface is the drop-in OpenAI-Responses-compatible contract: a client
|
|
11
|
+
that speaks `/v1` today speaks it tomorrow. This page is the WRITTEN promise
|
|
12
|
+
(RFC-0036 C6) — the mechanical half is the version gate in the server, and the
|
|
13
|
+
two cannot drift (a spec pins the gate's version to the date below).
|
|
14
|
+
|
|
15
|
+
## The surface
|
|
16
|
+
|
|
17
|
+
| Route | What it is |
|
|
18
|
+
|-------|------------|
|
|
19
|
+
| `POST /v1/responses` | the OpenAI-Responses-compatible turn ingress (streaming SSE) |
|
|
20
|
+
| `POST /v1/messages` | the channel-style message ingress (one turn per message, ack-fast) |
|
|
21
|
+
| `POST /v1/agents` | provisioning by definition/pack (idempotent, authoritative) |
|
|
22
|
+
| `GET/DELETE /v1/agents` | list / remove agents |
|
|
23
|
+
| `POST /v1/sessions` | create a session explicitly |
|
|
24
|
+
| `POST /v1/outcomes` | record a business outcome (`conversion`, `escalation`, any label) |
|
|
25
|
+
| `GET /v1/outcomes` | last outcome per agent + the per-day series |
|
|
26
|
+
| `POST /v1/commands/...` | operator commands (`forget_customer`, `delete_tenant_data`, the follow-up mutations) |
|
|
27
|
+
| `GET /v1/vitals` | process health/uptime vitals |
|
|
28
|
+
| `GET /v1/...` | the onboarding surface (`start.md`, `models.json`, `/docs/<name>.md`) |
|
|
29
|
+
|
|
30
|
+
The turn endpoints speak the OpenAI `response.completed` wire format; the
|
|
31
|
+
`Insika-Version` header declares the compatibility vintage of a request.
|
|
32
|
+
|
|
33
|
+
## The compatibility rule
|
|
34
|
+
|
|
35
|
+
**Request and response shapes are stable; changes are additive-only.** A new
|
|
36
|
+
field on a request or response is a release. Removing, renaming or re-typing a
|
|
37
|
+
field is a BREAKING change and requires, together, all three:
|
|
38
|
+
|
|
39
|
+
1. a new `Insika-Version` entry in the server's known-versions gate (an
|
|
40
|
+
unknown value is refused with 400 before auth — fail-closed);
|
|
41
|
+
2. a route branch that serves both vintages (a compatibility branch, per
|
|
42
|
+
RFC-0016 A5), so an old client keeps working while the new shape rolls;
|
|
43
|
+
3. a new `**Frozen as of:**` line below.
|
|
44
|
+
|
|
45
|
+
Additive changes need none of the three.
|
|
46
|
+
|
|
47
|
+
**Frozen as of: 2026-08-08** — the date that is the 1.0 promise. The gate's
|
|
48
|
+
first known version, pinned by `spec/insika/server/version_gate_spec.rb`; the
|
|
49
|
+
release writes the 1.0 freeze date here at release time.
|
|
50
|
+
|
|
51
|
+
## The 400-on-unknown rule
|
|
52
|
+
|
|
53
|
+
A request carrying an `Insika-Version` the gate does not know is refused with
|
|
54
|
+
`400 Bad Request` BEFORE authentication — a client that speaks a future or
|
|
55
|
+
misspelled vintage learns immediately, and an unauthenticated probe cannot
|
|
56
|
+
distinguish the gate from auth (fail-closed, constant-time).
|