insika 0.3.0 → 0.7.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.
Files changed (190) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +180 -0
  3. data/README.md +45 -10
  4. data/bin/insika +684 -0
  5. data/bin/insika-router +87 -0
  6. data/docs/AGENTS.md +94 -403
  7. data/docs/API.md +5 -5
  8. data/docs/ARCHITECTURE.md +3 -2
  9. data/docs/ARTIFACTS.md +95 -0
  10. data/docs/BENCHMARK.md +2 -2
  11. data/docs/CHANNELS.md +14 -14
  12. data/docs/CONTEXT.md +9 -7
  13. data/docs/DEMO.md +80 -0
  14. data/docs/DEPLOY.md +71 -3
  15. data/docs/EMBEDDING.md +1 -1
  16. data/docs/EVALS.md +128 -3
  17. data/docs/FACTS.md +3 -3
  18. data/docs/HARVEST.md +5 -6
  19. data/docs/KNOWLEDGE.md +290 -0
  20. data/docs/LOADTEST.md +2 -2
  21. data/docs/MEDIA.md +128 -0
  22. data/docs/OBSERVABILITY.md +15 -10
  23. data/docs/OUTCOMES.md +137 -0
  24. data/docs/PLUGINS.md +51 -6
  25. data/docs/POLICY.md +216 -0
  26. data/docs/REFINEMENT.md +14 -9
  27. data/docs/RELEASING.md +4 -4
  28. data/docs/ROUTER.md +213 -0
  29. data/docs/RUNNING-LOCAL.md +3 -3
  30. data/docs/SCHEDULING.md +121 -0
  31. data/docs/SECURITY.md +22 -6
  32. data/docs/SKILLS.md +11 -2
  33. data/docs/SOAK.md +2 -2
  34. data/docs/TEMPLATES.md +134 -0
  35. data/docs/TOOLS.md +152 -27
  36. data/docs/WHY.md +1 -1
  37. data/docs/WORKFLOWS.md +2 -2
  38. data/docs/_includes/head_custom.html +5 -0
  39. data/docs/_includes/title.html +13 -0
  40. data/docs/_sass/color_schemes/insika.scss +32 -0
  41. data/docs/_sass/custom/custom.scss +199 -0
  42. data/docs/_sass/custom/setup.scss +26 -0
  43. data/docs/assets/img/favicon.svg +7 -0
  44. data/docs/assets/img/insika-mark.svg +7 -0
  45. data/docs/core-concepts.md +21 -0
  46. data/docs/domain.md +4 -4
  47. data/docs/improve.md +20 -0
  48. data/docs/index.md +8 -5
  49. data/docs/integrate.md +20 -0
  50. data/docs/operate.md +13 -6
  51. data/docs/prompts/ADD-TOOL.md +118 -0
  52. data/docs/prompts/DIAGNOSE-TURN.md +65 -0
  53. data/docs/prompts/GO-LIVE.md +138 -0
  54. data/docs/prompts/RUN-EXAMPLES.md +70 -0
  55. data/docs/reference.md +19 -0
  56. data/docs/ship.md +10 -2
  57. data/docs/start-here.md +18 -0
  58. data/lib/insika/agent_profile.rb +73 -16
  59. data/lib/insika/artifact_signing.rb +82 -0
  60. data/lib/insika/artifact_store.rb +160 -0
  61. data/lib/insika/channel_delivery.rb +1 -1
  62. data/lib/insika/chat_builder.rb +22 -2
  63. data/lib/insika/commands/agent_payload.rb +2 -2
  64. data/lib/insika/commands/backfill_knowledge.rb +145 -0
  65. data/lib/insika/commands/delete_artifact.rb +35 -0
  66. data/lib/insika/commands/delete_concept.rb +34 -0
  67. data/lib/insika/commands/delete_mcp.rb +6 -2
  68. data/lib/insika/commands/delete_tenant_data.rb +15 -3
  69. data/lib/insika/commands/gate_refinement.rb +1 -1
  70. data/lib/insika/commands/refresh_mcp_tools.rb +47 -0
  71. data/lib/insika/commands/restore_concept.rb +34 -0
  72. data/lib/insika/commands/seed_demo_data.rb +31 -0
  73. data/lib/insika/commands/upsert_mcp.rb +6 -3
  74. data/lib/insika/commands/write_concept.rb +57 -0
  75. data/lib/insika/context/priority.rb +2 -0
  76. data/lib/insika/context/providers/knowledge.rb +108 -0
  77. data/lib/insika/context/providers/prompt.rb +30 -24
  78. data/lib/insika/cron.rb +189 -0
  79. data/lib/insika/demo/agent_attrs.rb +43 -0
  80. data/lib/insika/demo/golden_cases.rb +81 -0
  81. data/lib/insika/demo/seeder.rb +336 -0
  82. data/lib/insika/doctor.rb +176 -8
  83. data/lib/insika/dsl/definition.rb +3 -2
  84. data/lib/insika/dsl/runtime.rb +60 -79
  85. data/lib/insika/dsl/server_boot.rb +23 -1
  86. data/lib/insika/dsl/system.rb +10 -2
  87. data/lib/insika/dsl.rb +103 -2
  88. data/lib/insika/env_schema.rb +16 -1
  89. data/lib/insika/evals/golden.rb +41 -4
  90. data/lib/insika/evals/judge.rb +47 -2
  91. data/lib/insika/evals/pairwise.rb +11 -0
  92. data/lib/insika/evals/persona.rb +98 -0
  93. data/lib/insika/evals/runner.rb +9 -0
  94. data/lib/insika/evals/simulator.rb +225 -0
  95. data/lib/insika/evals/transport.rb +83 -1
  96. data/lib/insika/event_stream.rb +10 -0
  97. data/lib/insika/executor.rb +231 -55
  98. data/lib/insika/followup_policy.rb +2 -25
  99. data/lib/insika/golden_store.rb +16 -1
  100. data/lib/insika/grounding/matcher.rb +1 -1
  101. data/lib/insika/knowledge.rb +680 -0
  102. data/lib/insika/knowledge_store.rb +140 -0
  103. data/lib/insika/mcp_client.rb +94 -0
  104. data/lib/insika/mcp_json.rb +74 -0
  105. data/lib/insika/mcp_live_tool.rb +43 -0
  106. data/lib/insika/mcp_store.rb +98 -26
  107. data/lib/insika/mcp_tool_ingestor.rb +30 -8
  108. data/lib/insika/mcp_tool_registry.rb +100 -0
  109. data/lib/insika/media.rb +115 -31
  110. data/lib/insika/message_origin.rb +1 -1
  111. data/lib/insika/middleware.rb +9 -0
  112. data/lib/insika/onboarding.rb +17 -1
  113. data/lib/insika/outcome_store.rb +1 -1
  114. data/lib/insika/overlay_tool_registry.rb +37 -17
  115. data/lib/insika/packaging.rb +2 -2
  116. data/lib/insika/profile_source.rb +8 -1
  117. data/lib/insika/prompt_catalog.rb +10 -0
  118. data/lib/insika/retention.rb +36 -1
  119. data/lib/insika/router/app.rb +157 -0
  120. data/lib/insika/router/backend_pool.rb +98 -0
  121. data/lib/insika/router/hash_ring.rb +55 -0
  122. data/lib/insika/router/proxy_body.rb +34 -0
  123. data/lib/insika/router/session_key.rb +54 -0
  124. data/lib/insika/router.rb +18 -0
  125. data/lib/insika/schedule.rb +177 -0
  126. data/lib/insika/schedule_engine.rb +314 -0
  127. data/lib/insika/schedule_store.rb +208 -0
  128. data/lib/insika/server/app.rb +105 -15
  129. data/lib/insika/server/rack_app.rb +5 -1
  130. data/lib/insika/server/responses.rb +1 -1
  131. data/lib/insika/skill_catalog.rb +12 -0
  132. data/lib/insika/steer_injector.rb +21 -10
  133. data/lib/insika/studio/app.rb +567 -45
  134. data/lib/insika/studio/assets/dist/application.css +1 -1
  135. data/lib/insika/studio/assets/dist/application.js +21 -21
  136. data/lib/insika/studio/forms.rb +46 -5
  137. data/lib/insika/studio/nav_icons.rb +14 -1
  138. data/lib/insika/studio/views/_agent_tab_cache.erb +25 -0
  139. data/lib/insika/studio/views/_agent_tab_config.erb +514 -0
  140. data/lib/insika/studio/views/_agent_tab_history.erb +24 -0
  141. data/lib/insika/studio/views/_agent_tab_loops.erb +54 -0
  142. data/lib/insika/studio/views/_agent_tab_memory.erb +51 -0
  143. data/lib/insika/studio/views/_agent_tab_outcomes.erb +31 -0
  144. data/lib/insika/studio/views/_agent_tab_prompts.erb +108 -0
  145. data/lib/insika/studio/views/_agent_tab_skills.erb +38 -0
  146. data/lib/insika/studio/views/_agents_master.erb +44 -0
  147. data/lib/insika/studio/views/_message.erb +49 -32
  148. data/lib/insika/studio/views/agent_detail.erb +61 -820
  149. data/lib/insika/studio/views/agents.erb +70 -57
  150. data/lib/insika/studio/views/artifact.erb +23 -0
  151. data/lib/insika/studio/views/artifacts.erb +59 -0
  152. data/lib/insika/studio/views/evals.erb +2 -2
  153. data/lib/insika/studio/views/facts.erb +1 -1
  154. data/lib/insika/studio/views/funnel.erb +1 -1
  155. data/lib/insika/studio/views/home.erb +106 -67
  156. data/lib/insika/studio/views/knowledge.erb +123 -0
  157. data/lib/insika/studio/views/layout.erb +14 -11
  158. data/lib/insika/studio/views/mcp.erb +174 -80
  159. data/lib/insika/studio/views/session.erb +231 -177
  160. data/lib/insika/studio/views/settings.erb +39 -1
  161. data/lib/insika/studio/views/skills.erb +1 -1
  162. data/lib/insika/studio/views/tools.erb +24 -9
  163. data/lib/insika/templates/browser-agent/README.md +36 -0
  164. data/lib/insika/templates/browser-agent/agent.rb +49 -0
  165. data/lib/insika/templates/daily-digest/README.md +38 -0
  166. data/lib/insika/templates/daily-digest/agent.rb +77 -0
  167. data/lib/insika/templates/repo-explorer/README.md +36 -0
  168. data/lib/insika/templates/repo-explorer/agent.rb +45 -0
  169. data/lib/insika/templates/research-analyst/README.md +26 -0
  170. data/lib/insika/templates/research-analyst/agent.rb +58 -0
  171. data/lib/insika/templates/review-panel/README.md +20 -0
  172. data/lib/insika/templates/review-panel/agent.rb +50 -0
  173. data/lib/insika/templates/travel-planner/README.md +35 -0
  174. data/lib/insika/templates/travel-planner/agent.rb +87 -0
  175. data/lib/insika/templates.rb +112 -0
  176. data/lib/insika/tick.rb +24 -12
  177. data/lib/insika/timezone.rb +45 -0
  178. data/lib/insika/tools/generate_image.rb +52 -7
  179. data/lib/insika/tools/load_knowledge.rb +74 -0
  180. data/lib/insika/tools/run_persona_eval.rb +328 -0
  181. data/lib/insika/tools/save_artifact.rb +95 -0
  182. data/lib/insika/turn_output.rb +1 -1
  183. data/lib/insika/turn_state.rb +15 -4
  184. data/lib/insika/version.rb +1 -1
  185. data/lib/insika/wiring/graph.rb +184 -12
  186. data/lib/insika/wiring/graph_chat.rb +102 -0
  187. data/lib/insika.rb +57 -0
  188. metadata +105 -5
  189. data/docs/build.md +0 -14
  190. data/docs/understand.md +0 -10
data/docs/PLUGINS.md CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  title: Plugins
3
- parent: Build an agent
4
- nav_order: 7
3
+ parent: Integrate
4
+ nav_order: 5
5
5
  permalink: /plugins/
6
6
  ---
7
7
 
@@ -131,16 +131,34 @@ keyword is itself an error rather than being silently ignored.
131
131
 
132
132
  ### Discovery and enabling
133
133
 
134
+ The loader runs **at boot, in every composition root** — the server wirings and
135
+ a DSL-run agent alike — single-threaded, before the first request is accepted.
136
+ A plugin therefore either exists for the whole life of the process or not at
137
+ all; there is no half-loaded state a turn can observe.
138
+
134
139
  Plugins come from three kinds of root, and they differ in **who has to say yes**:
135
140
 
136
141
  | Root | How it is found | Enabled by default |
137
142
  |---|---|---|
138
143
  | **Gem** | the gem calls `Insika::Plugin.announce(__dir__)` when its `lib/` loads | **yes** — installing it is the consent |
139
- | **Workspace** | a directory in the deployment's plugin roots | no — must be listed in `enabled:` |
140
- | **Bundled** | `plugins/` in this repo | no — must be listed in `enabled:` |
144
+ | **Workspace** | the directory named by `INSIKA_PLUGIN_DIR` | no — list the id in `INSIKA_PLUGINS` |
145
+ | **Bundled** | `plugins/` in this repo | no — list the id in `INSIKA_PLUGINS` |
146
+
147
+ ```sh
148
+ INSIKA_PLUGIN_DIR=/srv/insika/plugins # workspace root to scan
149
+ INSIKA_PLUGINS=weather,acme # ids to enable from workspace/bundled roots
150
+ INSIKA_PLUGINS_DISABLED=insika-code # ids that never load, no matter what
151
+ ```
141
152
 
142
- `disabled:` is an absolute veto: an id listed there never loads, even if it is
143
- also in `enabled:` (deny wins, the same rule as every allowlist in the engine).
153
+ `INSIKA_PLUGINS_DISABLED` is an absolute veto: an id listed there never loads,
154
+ even if it is enabled or shipped by an installed gem (deny wins, the same rule
155
+ as every allowlist in the engine).
156
+
157
+ When two roots ship the same `id`, precedence is workspace → gems → bundled:
158
+ the first root wins, so an operator's local copy always overrides an installed
159
+ one. Skills and prompts a plugin ships join the catalogs at the **lowest**
160
+ precedence for the same reason — a workspace or Studio-authored skill beats a
161
+ plugin's same-named one.
144
162
 
145
163
  A gem announces itself explicitly — Insika never scans the load path or your
146
164
  installed gems:
@@ -173,6 +191,33 @@ config_schema:
173
191
  The manifest is committed; the secret is not. This mirrors how data tools handle
174
192
  `{{secret.*}}`.
175
193
 
194
+ ## Why it is built this way
195
+
196
+ Five decisions carry the whole design; knowing them explains every behavior
197
+ above.
198
+
199
+ 1. **Data before code.** Most agent runtimes make code the unit of extension
200
+ and a restart the price of every integration. Insika inverts that: tier 1
201
+ (data tools, MCP imports, skills) covers most integrations hot, with no
202
+ deploy, and tier 2 exists only for what genuinely needs to run in-process.
203
+ A smaller code-plugin surface is a feature — less to audit, less to break.
204
+ 2. **Manifest before code.** Discovery never executes anything: the manifest is
205
+ read, validated, and gated first, and only then is the entry `require`d.
206
+ You can inventory, enable, and veto plugins without running them.
207
+ 3. **Contracts are the public API.** Anything addressable by name — tools,
208
+ workflows, capabilities, channels — must be declared in `contracts`, and an
209
+ undeclared registration is ignored with a warning. A plugin cannot quietly
210
+ widen its surface between versions, and a channel cannot mount a route
211
+ nobody asked for.
212
+ 4. **Announce, never scan.** A gem opts in with one explicit
213
+ `Insika::Plugin.announce` call; Insika never walks the load path or your
214
+ installed gems looking for candidates. What loads is exactly what was
215
+ announced or configured — auditable from the boot log alone.
216
+ 5. **One bad plugin never takes the deployment down.** Registration is staged
217
+ and committed atomically; a raise inside `register(api)` rolls back
218
+ everything that plugin staged and boot continues. Enablement is explicit,
219
+ and deny wins.
220
+
176
221
  ## Publishing a plugin
177
222
 
178
223
  - **Name it `insika-plugin-<thing>`.** The convention *is* the registry for now:
data/docs/POLICY.md ADDED
@@ -0,0 +1,216 @@
1
+ ---
2
+ title: Limits and policy
3
+ parent: Core concepts
4
+ nav_order: 2
5
+ permalink: /policy/
6
+ ---
7
+
8
+ # Limits and policy
9
+
10
+ What an agent may do is layered. Each layer is independent, opt-in where it
11
+ matters, and editable hot — the same profile keys covered in [Agents](AGENTS.md),
12
+ grouped here because they answer one question: what is this agent allowed to do,
13
+ and what stops it when it tries to do more.
14
+
15
+ What an agent may do is layered. Each layer is independent, opt-in where it
16
+ matters, and editable hot.
17
+
18
+ ## Layer 1: Tools (what it can call)
19
+
20
+ `tools_allow` / `tools_deny` / `tools_allow_groups` decide which tools enter the
21
+ turn's tool-loop, enforced by the tool-allowlist policy. See [Tools](TOOLS.md)
22
+ for how tools are defined and registered, and [`examples/data-tool/`](https://github.com/guizaols/insika/tree/main/examples/data-tool/).
23
+
24
+ ## Layer 2: Policies and approvals
25
+
26
+ Policies are named entries evaluated before the turn runs. Builtins cover
27
+ tool-, skill-, and workflow-allowlisting, plus **`ApprovalRequired`** — which
28
+ does not allow or deny but *tags* a tool as needing human approval. Set
29
+ `approvals_required: [tool names]`; the gate then fires when the model tries to
30
+ call that tool, suspending the turn until an operator approves it in the Studio.
31
+ See [Security](SECURITY.md#human-approval).
32
+
33
+ ## Layer 3: Guardrails (content safety)
34
+
35
+ `guardrails` configures input/output content safety per agent — **opt-in**, so an
36
+ agent that says nothing gets a conservative default (deterministic detectors on,
37
+ LLM moderator off). See [Security](SECURITY.md#guardrails) and
38
+ [`examples/guardrails/`](https://github.com/guizaols/insika/tree/main/examples/guardrails/).
39
+
40
+ ## Layer 4: Edge limits (flood and spend control)
41
+
42
+ Two independent, opt-in ceilings, enforced *before* the model is ever called —
43
+ opt-in everywhere except on a public channel, where `chat_rate_limit` is
44
+ [required](CHANNELS.md#a-rate-limit-is-required-not-suggested) and the
45
+ [web widget](CHANNELS.md#the-web-widget) refuses to serve without one:
46
+
47
+ - **`chat_rate_limit`** — turn attempts per session per `chat_rate_window`.
48
+ - **`agent_token_ceiling`** — total tokens per agent per `agent_token_window`.
49
+
50
+ On breach the turn halts gracefully with a configurable `limit_response` and
51
+ **zero LLM calls**. Windows are set at the platform level; the ceilings can be set
52
+ per agent (blank inherits the platform value, `0` explicitly disables it).
53
+
54
+ > ⚠️ The token window default is **86400 (daily)**. To express "500k tokens per
55
+ > **hour**", set `agent_token_window = 3600` explicitly. A per-agent key that is
56
+ > *present but nil* reads as OFF for that agent — leave the key **absent** to
57
+ > inherit. See [Security](SECURITY.md#edge-limits).
58
+
59
+ ### Calendar budgets — the daily/monthly cost wall
60
+
61
+ A third, opt-in ceiling for the *billing* shape the windows above cannot express:
62
+ a spend cap over a CALENDAR day or month, per `(tenant, agent)` when
63
+ multi-tenant. Data on the profile (DSL `budget` or the pack's `budget` key):
64
+
65
+ ```ruby
66
+ budget daily: 100_000, monthly: 2_000_000, soft: false # or soft: true
67
+ ```
68
+
69
+ - Tokens count the **billed** spend — `input + output + cached + cache_creation`
70
+ (the cached prefix is the bulk of the bill, not an afterthought).
71
+ - **Hard** (the default, `soft` absent/false): a turn that arrives with the
72
+ window's spend already at/over the cap **fails** with the typed
73
+ `Insika::BudgetExceeded` — the envelope reads `budget_exceeded` +
74
+ `retry_after` (seconds until the window rolls). It is NOT a customer reply; it
75
+ is an operator signal.
76
+ - **Soft** (`soft: true`): the same turn RUNS — crossing the cap emits one
77
+ `budget_warning` event per window and injects a note into the context (the
78
+ model sees it, the transcript does not).
79
+ - Either way, crossing `alert_at` (default `0.8` of the cap) fires the same
80
+ warning **before** the wall, once per window.
81
+
82
+ > ⚠️ Unlike the ceilings above, the cap that counts is per **calendar** window —
83
+ > a `daily` budget rolls at UTC midnight, a `monthly` one on the 1st, whatever
84
+ > the sun. `agent_token_window` is a fixed seconds window and cannot express
85
+ > "the day resets at midnight".
86
+
87
+ ### Reliability — retries, fallback, circuit breaker
88
+
89
+ The provider interaction is a single attempt by default (RubyLLM's own 2
90
+ transport retries aside). For a store that cannot have a dead model take the
91
+ chat down, the reliability policy is DATA on the profile:
92
+
93
+ ```ruby
94
+ reliability retries: 2, backoff: "exponential",
95
+ fallback: ["openai/gpt-4o-mini"],
96
+ circuit_breaker: { after: 10, within: 60, cooldown: 300 }
97
+ ```
98
+
99
+ - **Retries** — transient failures (`:retryable` / `:rate_limited_*` per the
100
+ error classification) retry with exponential backoff, up to `retries`.
101
+ A `:fatal` (auth, billing, bad request) is NEVER retried or rotated. Each
102
+ attempt runs on a fresh chat — the customer-visible answer comes only from
103
+ the attempt that returns.
104
+ - **Fallback** — after a node's retries, the turn ROTATES to the next model in
105
+ the chain: the profile's `fallback` refs first, then the platform
106
+ `fallback_models`. The turn's usage is attributed to the model that actually
107
+ spoke (`model_source: "fallback"`).
108
+ - **Circuit breaker** — per `(tenant, provider/model)`: `after` failures within
109
+ `within` seconds open the circuit; while open, the turn fail-fasts with the
110
+ typed `circuit_open` + `retry_after` (remaining cooldown) and the provider is
111
+ never touched. After `cooldown` a half-open trial closes the circuit on
112
+ success or reopens it on failure.
113
+ - **`timeout`** — per-attempt ceiling (default 30s), counted as a retryable
114
+ failure.
115
+
116
+ Absent `reliability` = the plain single attempt, byte-for-byte today's
117
+ behavior.
118
+
119
+ ### Intent routing — classify before you answer
120
+
121
+ For a store that must tell "shopping" from "order" from "human" up front,
122
+ routing is data on the profile:
123
+
124
+ ```ruby
125
+ routes "shopping" => "the customer wants to browse products",
126
+ "order" => { "description" => "asks about an existing order",
127
+ "delegate" => "order-agent" },
128
+ "human" => { "description" => "the customer asks for a person",
129
+ "stuck" => true, "message" => "A person will help you." },
130
+ "default" => "shopping",
131
+ "model" => "deepseek-v4-flash" # the cheap classifier (absent = the agent's own)
132
+ ```
133
+
134
+ - **Classification** — when `routes:` is present, the message is classified into
135
+ one route with the configured model BEFORE the agent chat is assembled, from
136
+ a prompt auto-generated out of the descriptions (no per-route prompt file).
137
+ The route rides the turn: `state.route`, the `:route_classified` event, and
138
+ the terminal event additively.
139
+ - **Deterministic default** — the model's answer must be a route name; prose,
140
+ an unknown name, or an empty answer falls back to `default`, never invents.
141
+ A classifier call that FAILS leaves the turn unrouted (routing is additive —
142
+ it must not break the turn).
143
+ - **Cost** — the classification is an extra provider call, counted in the
144
+ turn's usage (the trace, the token ceiling and the budget all see it).
145
+ - **Actions** — a route value may be a description string, or a Hash:
146
+ `delegate: "<agent-id>"` hands the turn to that existing agent and its
147
+ answer becomes the parent's; `stuck: true` ends the turn with the [stuck
148
+ outcome](AGENTS.md#the-stuck-signal--i-cannot-proceed) and the route's `message`
149
+ (or description) as the lead-in — the consumer interprets it. A route with
150
+ neither is just a label. A delegation counts against the same delegation
151
+ depth cap as a subagent (`INSIKA_SUBAGENT_DEPTH_CAP`, default 5), so a pair
152
+ of agents routing to each other stops instead of looping.
153
+
154
+ Absent `routes` = no classification, no extra call, byte-identical turn.
155
+
156
+ ### Operator alerts — the webhook
157
+
158
+ Three operational events — `budget_warning`, `breaker_open`, `delivery_failed` —
159
+ can be answered per agent with a webhook:
160
+
161
+ ```ruby
162
+ alerts webhook: "https://ops.example.com/insika-alerts"
163
+ ```
164
+
165
+ When present, each such event is POSTed to the URL as JSON (the event's
166
+ type/data/meta, plus the agent). Delivery rides the same outbox + claim +
167
+ bounded-retry pipeline as channel answers — at-most-once, crashed deliveries
168
+ recovered at boot. The engine transports the event and does not interpret it: a
169
+ Slack/CRM adapter is the consumer's. Absent `alerts` = nothing is sent.
170
+
171
+ Separately, with `INSIKA_TURN_TIMING`, the provider's **live TTFB** is carried in
172
+ the streaming envelope: the first content chunk emits an `insika.ttft` frame
173
+ (`ttft_ms`) on `/v1/responses`, alongside the per-turn `timing` breakdown on the
174
+ final `response.completed`. Additive and opt-in — absent by default.
175
+
176
+ ## Layer 5: Reasoning (thinking)
177
+
178
+ Controls the model's thinking budget, resolved by precedence
179
+ **Chat > Agent > Model > Global** (first non-blank wins):
180
+
181
+ | Scope | Where |
182
+ |-------|-------|
183
+ | Chat | session var `__llm__.thinking` |
184
+ | Agent | `profile.params["thinking"]` |
185
+ | Model | platform `model_params[<ref>].thinking` |
186
+ | Global | platform `thinking` |
187
+
188
+ Values: `off | on | low | medium | high`. `off`/`on` toggle thinking; the effort
189
+ levels map to the provider's thinking-effort parameter. This is a control
190
+ primitive, not a latency lever — turning reasoning off does not necessarily speed
191
+ up a turn, because most of a turn's latency is the provider itself, not thinking.
192
+
193
+ Whether the reasoning ever reaches the **customer** is a separate switch, off by
194
+ default:
195
+
196
+ ```ruby
197
+ edge_stream thinking: true, intermediate: false
198
+ ```
199
+
200
+ `thinking` is the provider's reasoning; `intermediate` is the model narrating its
201
+ own tool loop ("let me look that up"). Both are always on the event stream for the
202
+ Studio and the trace — this decides only whether `/v1/responses` translates them,
203
+ and each opted-in channel gets its own frame type, never the answer's. See
204
+ [Architecture](ARCHITECTURE.md#what-crosses-the-edge).
205
+
206
+ > ⚠️ Turn it on knowing your consumer. One that concatenates every text delta into
207
+ > a single message — a WhatsApp adapter — will only be affected once it learns to
208
+ > read the new frames, and when it does, the deliberation is what the customer
209
+ > reads. That is the operator's call, which is why it is neither a default nor a
210
+ > global.
211
+
212
+ ## See also
213
+
214
+ - [Agents](AGENTS.md) — the profile these keys live on.
215
+ - [Tools](TOOLS.md) — how a tool gets defined, registered and allowed.
216
+ - [Security](SECURITY.md) — the deployment-side counterpart: sandbox, egress, secrets.
data/docs/REFINEMENT.md CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  title: Refinement
3
- parent: Operate & prove it
4
- nav_order: 5
3
+ parent: Improve
4
+ nav_order: 2
5
5
  permalink: /refinement/
6
6
  ---
7
7
 
@@ -51,11 +51,14 @@ Or from **Studio → Refinement**: pick the agent, press Run, and the same repor
51
51
  renders with each session id linking to its transcript. Every finding is a claim
52
52
  you can go and check.
53
53
 
54
- There is no scheduler in the engine, by design. A run is one command
55
- (`run_refinement`), and the CLI and the button are the two ways to fire it. If you
56
- want it on a timer, point your own cron at the authenticated route the button uses
57
- that keeps the engine free of a background ticker and of the single-node
58
- assumption one would bring.
54
+ A run is one command (`run_refinement`), and the CLI and the button are the
55
+ two ways to fire it. There is still no built-in timer *for the report itself*,
56
+ but there is one for the *agent*: fine-grained recurring turns live in
57
+ [Schedules](SCHEDULING.md), and a refinement run scheduled like any other turn
58
+ is just a message the agent's schedule sends it. If you prefer to stay outside
59
+ the engine, pointing your own cron at the authenticated route the button uses
60
+ works just the same — both paths are supported, the engine's trigger is the
61
+ built-in one.
59
62
 
60
63
  ```
61
64
  insika refine ─┐
@@ -466,8 +469,10 @@ prompt edits have real leverage, and it is also where they do damage.
466
469
 
467
470
  ## What this is not
468
471
 
469
- It has no scheduler: a run happens because a person or a cron asked for one, never
470
- because a timer inside the engine went off. A proposal is written when you ask for
472
+ A run happens because a person or a cron asked for one — the refinement
473
+ pipeline has no timer of its own, and if you want one, a schedule
474
+ ([Schedules](SCHEDULING.md)) makes a run a turn any agent can be scheduled to
475
+ send. A proposal is written when you ask for
471
476
  one, and — unless you turned on [`auto_apply`](#applying-without-a-human), which is
472
477
  off until you do and bounded when you do — applied when you approve it. And it cannot
473
478
  touch your guardrails, tools, policies, model pins or limits, and not because a prompt
data/docs/RELEASING.md CHANGED
@@ -59,10 +59,10 @@ GEM_HOME="$T/gemhome" GEM_PATH="$T/gemhome" ruby -e '
59
59
  puts "clean"'
60
60
  ```
61
61
 
62
- ### The 1.0 release gate — the RFC-0036 install proof (E1)
62
+ ### The 1.0 release gate — the clean-install proof
63
63
 
64
64
  For the 1.0 release the proof above is scripted and its **installed-bytes**
65
- half is asserted by the domain-boundary suite (C1) on the artifact, not the
65
+ half is asserted by the domain-boundary suite on the artifact, not the
66
66
  repo. Run the runbook, with a key (the smoke turn is one `reply` through the
67
67
  installed gem):
68
68
 
@@ -77,12 +77,12 @@ only the public docs. Archive the PASS output with the release notes — it is
77
77
  the 1.0 exit criterion "install proof by the docs alone".
78
78
 
79
79
  The same gate writes the freeze date: a breaking `/v1` change needs a new
80
- `Insika-Version` entry (server/app.rb), a compatibility branch (RFC-0016 A5)
80
+ `Insika-Version` entry (server/app.rb), a compatibility branch
81
81
  and a rewritten `**Frozen as of:**` line in `docs/API.md` — the version-gate
82
82
  spec pins the two together, and the 1.0 release writes the date at release
83
83
  time.
84
84
 
85
- Catalog submission checklist (RFC §6): verify the best-of-Agent-Harnesses
85
+ Catalog submission checklist: verify the best-of-Agent-Harnesses
86
86
  catalog size at submission time (161 vs 154 — the counts diverge across the
87
87
  catalog's own pages) and cite the conformance suite as the `durable` evidence.
88
88
 
data/docs/ROUTER.md ADDED
@@ -0,0 +1,213 @@
1
+ ---
2
+ title: Router
3
+ parent: Ship it
4
+ nav_order: 4
5
+ permalink: /router/
6
+ ---
7
+
8
+ # Session-sticky router
9
+
10
+ `insika-router` is a standalone proxy that lets you run **N engine backends**
11
+ (`WEB_CONCURRENCY=1` each) and get the same per-session guarantees a single
12
+ worker gives you today — FIFO ordering, `collect`/`steer`, the SSE watch (see
13
+ [DEPLOY.md "The process model"](DEPLOY.md#the-process-model)) — at N>1
14
+ capacity. It is **entirely opt-in**: if one worker is enough for you, ignore
15
+ this file, run the engine exactly as DEPLOY.md already describes, and nothing
16
+ changes. Reach for the router only when you outgrow one worker and want to
17
+ scale up.
18
+
19
+ It changes nothing about the engine itself — no code in `SessionActor` or
20
+ `Executor` is aware the router exists. It solves routing, and routing only: a
21
+ given session's requests always land on the same backend, so that backend's
22
+ in-memory session state is always the one being read and written.
23
+
24
+ ## Why this exists
25
+
26
+ `WEB_CONCURRENCY>1` without sticky routing in front is not "reduced
27
+ guarantees" — it is a correctness bug (a reply from one session can leak into
28
+ another's transcript; `insika doctor`'s `web-concurrency` check exists because
29
+ this happened in staging). Sticky routing is the documented escape hatch, but
30
+ neither deploy target the engine ships for has it built in:
31
+
32
+ - **Railway** does not support sticky sessions at all — traffic is randomly
33
+ distributed across replicas, with no configuration that changes that.
34
+ - **Kubernetes** `Service` load-balances with no session notion, and
35
+ ingress-nginx's `upstream-hash-by` (the usual sticky mechanism) hashes on
36
+ nginx *variables* — headers, cookies, the URL — never a field parsed out of
37
+ a POST body. The session id here is exactly that: the `user` field inside
38
+ `POST /v1/responses`'s JSON body.
39
+
40
+ So this is a small piece of new infrastructure, not a config flag.
41
+
42
+ ## How it decides where a request goes
43
+
44
+ Per request, in order:
45
+
46
+ 1. `GET /up` → answered directly by the router (its own liveness), never
47
+ proxied.
48
+ 2. `POST /v1/responses` or `POST /v1/messages` → the session key is the
49
+ `user` field of the JSON body.
50
+ 3. `POST /channels/:id/messages` or `POST /channels/:id/events` (the web
51
+ widget and the relay channel) → the session key is the `session_id` field
52
+ of the JSON body.
53
+ 4. Everything else (health checks, `/studio/*`, onboarding, minting a new
54
+ channel session) → no session key, plain round-robin. None of these depend
55
+ on a worker's in-memory `SessionActor` — a Studio read hits the durable
56
+ store, and minting a session has no existing state to be sticky about.
57
+
58
+ A request with a session key is routed by a ketama-style **consistent hash
59
+ ring** over the backend list: the same key always reaches the same backend,
60
+ and adding or removing one backend remaps only ~1/N of the key space, not the
61
+ whole ring — a rolling deploy does not bounce every live session to a new
62
+ owner at once. A request whose key isn't found (a corner-case route) or whose
63
+ key extraction is skipped (see body size cap below) round-robins across all
64
+ backends.
65
+
66
+ The whole request body is always read and forwarded byte-for-byte —
67
+ `INSIKA_ROUTER_BODY_MAX_BYTES` (default 256 KiB) only bounds how much of it
68
+ the router will attempt to parse as JSON while looking for a session key; a
69
+ body over that cap round-robins instead of erroring, and the router logs it.
70
+
71
+ **A request whose chosen backend is unreachable is never retried against a
72
+ different backend** — that backend may already hold a durable, at-most-once
73
+ claim on the task the request names, and retrying elsewhere could
74
+ double-process it. It answers the same retry envelope a single overloaded
75
+ backend would:
76
+
77
+ ```json
78
+ {"error": {"class": "Insika::Router::BackendUnavailable", "message": "no backend reachable",
79
+ "retryable": true, "retry_after": 1}}
80
+ ```
81
+
82
+ SSE responses stream through the router with no added buffering — a client
83
+ watching a long turn sees the same chunks, in the same order, as if it had
84
+ hit the backend directly.
85
+
86
+ ## Deploy shape 1 — Railway (N local workers, one replica)
87
+
88
+ Railway's replica load balancer has no sticky option, full stop — this shape
89
+ does not attempt to fix that. What it fixes is the *unsafe* alternative
90
+ (`WEB_CONCURRENCY=N` Falcon workers behind Railway's own port, which is
91
+ exactly the leak `insika doctor` errors on). Instead, run N engine processes
92
+ on different local ports and put the router in front of them, all inside the
93
+ one container Railway load-balances to:
94
+
95
+ ```bash
96
+ # three engine workers, WEB_CONCURRENCY=1 each (the entrypoint's own
97
+ # `falcon serve --count 1`), on different local ports — never `--count 3` on
98
+ # one port, which is exactly the unsafe fan-out this replaces
99
+ bundle exec falcon serve --bind http://127.0.0.1:9292 --count 1 config.ru &
100
+ bundle exec falcon serve --bind http://127.0.0.1:9293 --count 1 config.ru &
101
+ bundle exec falcon serve --bind http://127.0.0.1:9294 --count 1 config.ru &
102
+
103
+ # the router, bound to the port Railway actually forwards
104
+ INSIKA_ROUTER_PORT=$PORT \
105
+ INSIKA_ROUTER_BACKENDS=http://127.0.0.1:9292,http://127.0.0.1:9293,http://127.0.0.1:9294 \
106
+ bundle exec insika-router
107
+ ```
108
+
109
+ `insika doctor` treats `WEB_CONCURRENCY>1` as `ok` (not `error`/`warn`) once
110
+ it sees `INSIKA_ROUTER_BACKENDS` or `INSIKA_ROUTER_BACKENDS_DNS` set — it
111
+ cannot verify a router process is actually running at those addresses, only
112
+ that one was configured, same as every other env-based capability check in
113
+ `insika doctor`.
114
+
115
+ ## Deploy shape 2 — Kubernetes (a headless Service)
116
+
117
+ Run N engine pods (`WEB_CONCURRENCY=1` each) behind a **headless** Service
118
+ (`clusterIP: None` — this is what makes DNS resolve to one A/AAAA record per
119
+ ready pod instead of a single virtual IP), and the router as its own
120
+ Deployment in front:
121
+
122
+ ```yaml
123
+ apiVersion: v1
124
+ kind: Service
125
+ metadata:
126
+ name: insika-headless
127
+ spec:
128
+ clusterIP: None
129
+ selector: { app: insika }
130
+ ports: [{ port: 9292 }]
131
+ ---
132
+ # insika-router Deployment env:
133
+ env:
134
+ - name: INSIKA_ROUTER_BACKENDS_DNS
135
+ value: insika-headless.default.svc.cluster.local
136
+ - name: INSIKA_ROUTER_BACKEND_PORT
137
+ value: "9292"
138
+ - name: INSIKA_ROUTER_DNS_INTERVAL
139
+ value: "15"
140
+ ```
141
+
142
+ The router holds no session state itself, so it needs no sticky routing in
143
+ front of *itself* — it scales trivially (1-2 replicas behind an ordinary
144
+ `Service`). It re-resolves the headless Service on `INSIKA_ROUTER_DNS_INTERVAL`
145
+ (default 15s) and rebuilds its hash ring only when the resolved pod set
146
+ actually changed. A pod that just became ready is invisible to the router
147
+ until the next resolve — capacity added a few seconds late, never wrong.
148
+
149
+ ## Environment variables
150
+
151
+ | Variable | Default | Meaning |
152
+ |---|---|---|
153
+ | `INSIKA_ROUTER_BACKENDS` | — | Comma-separated backend URLs (static mode). Exactly one of this or the DNS var below. |
154
+ | `INSIKA_ROUTER_BACKENDS_DNS` | — | A headless-Service hostname to re-resolve (DNS mode). Requires `INSIKA_ROUTER_BACKEND_PORT`. |
155
+ | `INSIKA_ROUTER_BACKEND_PORT` | — | The engine port on every DNS-resolved pod. |
156
+ | `INSIKA_ROUTER_DNS_INTERVAL` | `15` | Seconds between DNS re-resolves. |
157
+ | `INSIKA_ROUTER_BODY_MAX_BYTES` | `262144` | Size cap on the session-key JSON peek (never on what is forwarded). |
158
+ | `INSIKA_ROUTER_BACKEND_TIMEOUT` | `10` | Connect/read timeout to a backend, in seconds. |
159
+ | `INSIKA_ROUTER_HOST` | `0.0.0.0` | Bind address for the router itself. |
160
+ | `INSIKA_ROUTER_PORT` | `9090` | Listen port for the router itself. |
161
+
162
+ ## A runnable smoke test
163
+
164
+ The shape below is what the router's acceptance criteria were verified
165
+ against: two fake backends and the router in front, run entirely in-process.
166
+
167
+ ```ruby
168
+ require "async"; require "async/http/server"; require "async/http/client"
169
+ require "async/http/endpoint"; require "protocol/rack"; require "insika/router"
170
+
171
+ Async do |task|
172
+ echo = ->(name) { ->(env) { [200, {}, ["#{name} #{Rack::Request.new(env).path_info}"]] } }
173
+ %w[9292 9293].each_with_index do |port, i|
174
+ endpoint = Async::HTTP::Endpoint.parse("http://127.0.0.1:#{port}")
175
+ task.async { Async::HTTP::Server.new(Protocol::Rack::Adapter.new(echo["backend-#{i}"]), endpoint).run }
176
+ end
177
+ task.sleep(0.2)
178
+
179
+ pool = Insika::Router::BackendPool.new(static: %w[http://127.0.0.1:9292 http://127.0.0.1:9293])
180
+ app = Insika::Router::App.new(pool: pool)
181
+ endpoint = Async::HTTP::Endpoint.parse("http://127.0.0.1:9090")
182
+ task.async { Async::HTTP::Server.new(Protocol::Rack::Adapter.new(app), endpoint).run }
183
+ task.sleep(0.2)
184
+
185
+ client = Async::HTTP::Client.new(Async::HTTP::Endpoint.parse("http://127.0.0.1:9090"))
186
+ 3.times { |i| puts client.post("/v1/responses", {}, [%({"user":"sess-1","i":#{i}})]).read }
187
+ # -> the same "backend-N" answers all three times, even though two backends are up.
188
+ ensure
189
+ task.stop
190
+ end
191
+ ```
192
+
193
+ ## What this deliberately does not attempt
194
+
195
+ - **Railway cross-*replica* routing.** Railway's replica load balancer itself
196
+ has no sticky option and this router cannot sit in front of Railway's own
197
+ edge. Railway stays at one replica; this only raises the ceiling of that one
198
+ replica (N local workers instead of N=1).
199
+ - **A distributed `SessionActor`.** The alternative design — making any
200
+ worker able to safely pick up any session, removing the need for sticky
201
+ routing entirely — is a much larger rewrite (debounce windows, steer
202
+ mailboxes, and SSE fan-out would all have to move into the shared store with
203
+ lease semantics) for the same outcome this router reaches with an unchanged
204
+ engine. Worth revisiting only if this approach turns out not to scale far
205
+ enough.
206
+ - Native WhatsApp/Slack channel routing — those channels are shelved; the
207
+ relay channel rides the same `/v1/responses`-shaped call this router already
208
+ covers.
209
+
210
+ ## See also
211
+
212
+ - [DEPLOY.md "The process model"](DEPLOY.md#the-process-model) — the contract
213
+ this router satisfies (FIFO/`collect`/`steer` guarantees, recovery, drain).
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  title: Running locally
3
- parent: Build an agent
4
- nav_order: 8
3
+ parent: Start here
4
+ nav_order: 2
5
5
  permalink: /running-local/
6
6
  ---
7
7
 
@@ -122,7 +122,7 @@ prompt files, skills, and one data-tool per file:
122
122
  > **Data-tool URLs must be literal on the pack path.** The pack import does not
123
123
  > resolve `{{env.*}}` — bake the backend base URL into each `tools/*.json` at
124
124
  > generation time. (Only the *manifest* path resolves `{{env.*}}`.) See
125
- > [Tools](TOOLS.md#the-one-gotcha-env-templating-is-manifest-only).
125
+ > [Tools](TOOLS.md#the-one-gotcha-envsecret-templating-is-manifest-only).
126
126
 
127
127
  Provision it (runs as a client against the live server; the internal token comes
128
128
  from the environment, never disk):