insika 0.0.1 → 0.2.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 (277) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +361 -0
  3. data/LICENSE +21 -0
  4. data/README.md +136 -2
  5. data/bin/insika +366 -0
  6. data/docs/AGENTS.md +618 -0
  7. data/docs/ARCHITECTURE.md +333 -0
  8. data/docs/BENCHMARK.md +114 -0
  9. data/docs/CHANNELS.md +453 -0
  10. data/docs/CONTEXT.md +117 -0
  11. data/docs/DEPLOY.md +354 -0
  12. data/docs/EMBEDDING.md +198 -0
  13. data/docs/EVALS.md +273 -0
  14. data/docs/LOADTEST.md +232 -0
  15. data/docs/OBSERVABILITY.md +374 -0
  16. data/docs/PLUGINS.md +211 -0
  17. data/docs/REFINEMENT.md +477 -0
  18. data/docs/RELEASING.md +70 -0
  19. data/docs/RUNNING-LOCAL.md +153 -0
  20. data/docs/SANDBOX.md +114 -0
  21. data/docs/SECURITY.md +375 -0
  22. data/docs/SKILLS.md +284 -0
  23. data/docs/TOOLS.md +302 -0
  24. data/docs/WHY.md +137 -0
  25. data/docs/WORKFLOWS.md +225 -0
  26. data/docs/build.md +14 -0
  27. data/docs/index.md +68 -0
  28. data/docs/onboarding/start.md +126 -0
  29. data/docs/operate.md +12 -0
  30. data/docs/ship.md +10 -0
  31. data/docs/understand.md +10 -0
  32. data/lib/insika/agent_file_store.rb +125 -0
  33. data/lib/insika/agent_profile.rb +255 -0
  34. data/lib/insika/alert_dispatcher.rb +139 -0
  35. data/lib/insika/allowlist.rb +28 -0
  36. data/lib/insika/baseline_store.rb +74 -0
  37. data/lib/insika/budget_ledger.rb +135 -0
  38. data/lib/insika/capability/resolved_tool.rb +34 -0
  39. data/lib/insika/capability_registry.rb +112 -0
  40. data/lib/insika/channel_delivery.rb +153 -0
  41. data/lib/insika/channel_registry.rb +30 -0
  42. data/lib/insika/channels/relay.rb +178 -0
  43. data/lib/insika/channels/web/widget.js +283 -0
  44. data/lib/insika/channels/web.rb +211 -0
  45. data/lib/insika/channels/webhook.rb +58 -0
  46. data/lib/insika/chat_builder.rb +303 -0
  47. data/lib/insika/checkpoint.rb +13 -0
  48. data/lib/insika/checkpoint_store.rb +153 -0
  49. data/lib/insika/circuit_state.rb +114 -0
  50. data/lib/insika/coercion.rb +58 -0
  51. data/lib/insika/command.rb +32 -0
  52. data/lib/insika/command_bus.rb +39 -0
  53. data/lib/insika/commands/agent_payload.rb +43 -0
  54. data/lib/insika/commands/approve_action.rb +46 -0
  55. data/lib/insika/commands/cancel_task.rb +33 -0
  56. data/lib/insika/commands/create_agent.rb +54 -0
  57. data/lib/insika/commands/create_session.rb +67 -0
  58. data/lib/insika/commands/delete_agent.rb +33 -0
  59. data/lib/insika/commands/delete_agent_file.rb +50 -0
  60. data/lib/insika/commands/delete_data_tool.rb +33 -0
  61. data/lib/insika/commands/delete_llm_provider.rb +36 -0
  62. data/lib/insika/commands/delete_mcp.rb +30 -0
  63. data/lib/insika/commands/delete_skill.rb +43 -0
  64. data/lib/insika/commands/delete_system_file.rb +29 -0
  65. data/lib/insika/commands/gate_refinement.rb +245 -0
  66. data/lib/insika/commands/import_mcp_tools.rb +48 -0
  67. data/lib/insika/commands/import_tools.rb +81 -0
  68. data/lib/insika/commands/issue_tenant_token.rb +41 -0
  69. data/lib/insika/commands/memory_add_note.rb +32 -0
  70. data/lib/insika/commands/memory_forget_fact.rb +32 -0
  71. data/lib/insika/commands/memory_put_fact.rb +35 -0
  72. data/lib/insika/commands/pause_task.rb +29 -0
  73. data/lib/insika/commands/resolve_refinement.rb +126 -0
  74. data/lib/insika/commands/restore_agent_file.rb +36 -0
  75. data/lib/insika/commands/restore_data_tool.rb +34 -0
  76. data/lib/insika/commands/restore_system_file.rb +31 -0
  77. data/lib/insika/commands/resume_task.rb +85 -0
  78. data/lib/insika/commands/revoke_token.rb +39 -0
  79. data/lib/insika/commands/rotate_tenant_token.rb +43 -0
  80. data/lib/insika/commands/run_refinement.rb +133 -0
  81. data/lib/insika/commands/send_message.rb +150 -0
  82. data/lib/insika/commands/set_agent_tools.rb +39 -0
  83. data/lib/insika/commands/set_skill_agents.rb +112 -0
  84. data/lib/insika/commands/trigger_workflow.rb +80 -0
  85. data/lib/insika/commands/update_agent.rb +49 -0
  86. data/lib/insika/commands/update_settings.rb +33 -0
  87. data/lib/insika/commands/upsert_llm_provider.rb +34 -0
  88. data/lib/insika/commands/upsert_mcp.rb +32 -0
  89. data/lib/insika/commands/write_agent_file.rb +57 -0
  90. data/lib/insika/commands/write_data_tool.rb +43 -0
  91. data/lib/insika/commands/write_golden.rb +58 -0
  92. data/lib/insika/commands/write_skill.rb +60 -0
  93. data/lib/insika/commands/write_system_file.rb +31 -0
  94. data/lib/insika/config_store.rb +89 -0
  95. data/lib/insika/context/builder.rb +166 -0
  96. data/lib/insika/context/catalog_provider.rb +23 -0
  97. data/lib/insika/context/fragment.rb +43 -0
  98. data/lib/insika/context/priority.rb +30 -0
  99. data/lib/insika/context/provider.rb +19 -0
  100. data/lib/insika/context/providers/memory.rb +60 -0
  101. data/lib/insika/context/providers/prompt.rb +105 -0
  102. data/lib/insika/context/providers/request.rb +32 -0
  103. data/lib/insika/context/providers/session.rb +123 -0
  104. data/lib/insika/context/providers/skill.rb +24 -0
  105. data/lib/insika/context/providers/skill_trigger.rb +128 -0
  106. data/lib/insika/context/providers/tool_search.rb +20 -0
  107. data/lib/insika/context_trace_store.rb +92 -0
  108. data/lib/insika/delegation_store.rb +153 -0
  109. data/lib/insika/doctor.rb +539 -0
  110. data/lib/insika/dsl/definition.rb +55 -0
  111. data/lib/insika/dsl/runtime.rb +382 -0
  112. data/lib/insika/dsl/server_boot.rb +98 -0
  113. data/lib/insika/dsl/system.rb +93 -0
  114. data/lib/insika/dsl/workflow_adapter.rb +59 -0
  115. data/lib/insika/dsl.rb +364 -0
  116. data/lib/insika/edge_limiter.rb +268 -0
  117. data/lib/insika/egress_guard.rb +75 -0
  118. data/lib/insika/env_schema.rb +249 -0
  119. data/lib/insika/errors.rb +201 -0
  120. data/lib/insika/evals/assertions.rb +247 -0
  121. data/lib/insika/evals/baseline.rb +69 -0
  122. data/lib/insika/evals/golden.rb +172 -0
  123. data/lib/insika/evals/judge.rb +225 -0
  124. data/lib/insika/evals/pairwise.rb +178 -0
  125. data/lib/insika/evals/report.rb +115 -0
  126. data/lib/insika/evals/runner.rb +141 -0
  127. data/lib/insika/evals/transport.rb +178 -0
  128. data/lib/insika/event.rb +18 -0
  129. data/lib/insika/event_stream.rb +132 -0
  130. data/lib/insika/executor.rb +1995 -0
  131. data/lib/insika/frontmatter.rb +42 -0
  132. data/lib/insika/golden_store.rb +145 -0
  133. data/lib/insika/hooks.rb +48 -0
  134. data/lib/insika/http_client.rb +63 -0
  135. data/lib/insika/inbound_log.rb +84 -0
  136. data/lib/insika/llm_configurator.rb +99 -0
  137. data/lib/insika/llm_provider_store.rb +83 -0
  138. data/lib/insika/loop_detector.rb +143 -0
  139. data/lib/insika/mcp_http_client.rb +67 -0
  140. data/lib/insika/mcp_store.rb +115 -0
  141. data/lib/insika/mcp_tool_ingestor.rb +143 -0
  142. data/lib/insika/memory_store.rb +93 -0
  143. data/lib/insika/message_origin.rb +76 -0
  144. data/lib/insika/middleware.rb +36 -0
  145. data/lib/insika/model_policy.rb +52 -0
  146. data/lib/insika/model_resolver.rb +176 -0
  147. data/lib/insika/model_selection.rb +115 -0
  148. data/lib/insika/onboarding.rb +208 -0
  149. data/lib/insika/outbox_store.rb +166 -0
  150. data/lib/insika/overlay_tool_registry.rb +102 -0
  151. data/lib/insika/pack.rb +102 -0
  152. data/lib/insika/pack_importer.rb +123 -0
  153. data/lib/insika/pending_action_store.rb +120 -0
  154. data/lib/insika/plugin/loader.rb +356 -0
  155. data/lib/insika/plugin.rb +35 -0
  156. data/lib/insika/policy/engine.rb +83 -0
  157. data/lib/insika/policy/policy.rb +120 -0
  158. data/lib/insika/policy_registry.rb +23 -0
  159. data/lib/insika/profile_source.rb +143 -0
  160. data/lib/insika/prompt_catalog.rb +61 -0
  161. data/lib/insika/provider_error_classifier.rb +160 -0
  162. data/lib/insika/queue_policy.rb +167 -0
  163. data/lib/insika/recovery.rb +168 -0
  164. data/lib/insika/refinement/candidate.rb +159 -0
  165. data/lib/insika/refinement/evidence_collector.rb +371 -0
  166. data/lib/insika/refinement/gate.rb +234 -0
  167. data/lib/insika/refinement/panel.rb +222 -0
  168. data/lib/insika/refinement/proposer.rb +262 -0
  169. data/lib/insika/refinement_store.rb +295 -0
  170. data/lib/insika/registry.rb +59 -0
  171. data/lib/insika/reliability.rb +185 -0
  172. data/lib/insika/safety/config.rb +109 -0
  173. data/lib/insika/safety/detectors.rb +176 -0
  174. data/lib/insika/safety/factory.rb +102 -0
  175. data/lib/insika/safety/input_guardrail.rb +102 -0
  176. data/lib/insika/safety/moderator.rb +94 -0
  177. data/lib/insika/safety/output_filter.rb +79 -0
  178. data/lib/insika/safety/output_validator.rb +101 -0
  179. data/lib/insika/safety/safe_responses.rb +47 -0
  180. data/lib/insika/sandbox/boundary.rb +93 -0
  181. data/lib/insika/sandbox/docker.rb +74 -0
  182. data/lib/insika/sandbox/local.rb +33 -0
  183. data/lib/insika/sandbox/runner.rb +80 -0
  184. data/lib/insika/sandbox.rb +85 -0
  185. data/lib/insika/schema_guard.rb +147 -0
  186. data/lib/insika/secret_masking.rb +34 -0
  187. data/lib/insika/server/a2a/agent_card.rb +27 -0
  188. data/lib/insika/server/a2a/app.rb +112 -0
  189. data/lib/insika/server/a2a/client.rb +101 -0
  190. data/lib/insika/server/a2a/errors.rb +32 -0
  191. data/lib/insika/server/a2a/http.rb +42 -0
  192. data/lib/insika/server/a2a/message.rb +27 -0
  193. data/lib/insika/server/a2a/protocol.rb +45 -0
  194. data/lib/insika/server/a2a/remotes.rb +25 -0
  195. data/lib/insika/server/a2a/task_projection.rb +40 -0
  196. data/lib/insika/server/app.rb +1022 -0
  197. data/lib/insika/server/boot.rb +119 -0
  198. data/lib/insika/server/rack_app.rb +118 -0
  199. data/lib/insika/server/responses.rb +165 -0
  200. data/lib/insika/server/sse_body.rb +96 -0
  201. data/lib/insika/server/tenant_auth.rb +61 -0
  202. data/lib/insika/session_actor.rb +162 -0
  203. data/lib/insika/session_store.rb +143 -0
  204. data/lib/insika/settings_store.rb +154 -0
  205. data/lib/insika/shutdown.rb +125 -0
  206. data/lib/insika/skill_catalog.rb +220 -0
  207. data/lib/insika/skill_store.rb +127 -0
  208. data/lib/insika/steer_injector.rb +110 -0
  209. data/lib/insika/store.rb +52 -0
  210. data/lib/insika/stores/memory.rb +123 -0
  211. data/lib/insika/stores/sqlite.rb +183 -0
  212. data/lib/insika/studio/app.rb +1693 -0
  213. data/lib/insika/studio/assets/dist/application.css +1 -0
  214. data/lib/insika/studio/assets/dist/application.js +70 -0
  215. data/lib/insika/studio/forms.rb +335 -0
  216. data/lib/insika/studio/nav_icons.rb +31 -0
  217. data/lib/insika/studio/views/_message.erb +44 -0
  218. data/lib/insika/studio/views/agent_detail.erb +285 -0
  219. data/lib/insika/studio/views/agents.erb +63 -0
  220. data/lib/insika/studio/views/approvals.erb +41 -0
  221. data/lib/insika/studio/views/chats.erb +34 -0
  222. data/lib/insika/studio/views/evals.erb +83 -0
  223. data/lib/insika/studio/views/home.erb +72 -0
  224. data/lib/insika/studio/views/layout.erb +94 -0
  225. data/lib/insika/studio/views/login.erb +17 -0
  226. data/lib/insika/studio/views/mcp.erb +91 -0
  227. data/lib/insika/studio/views/not_found.erb +5 -0
  228. data/lib/insika/studio/views/playground.erb +47 -0
  229. data/lib/insika/studio/views/refinement.erb +234 -0
  230. data/lib/insika/studio/views/session.erb +137 -0
  231. data/lib/insika/studio/views/settings.erb +168 -0
  232. data/lib/insika/studio/views/skills.erb +141 -0
  233. data/lib/insika/studio/views/system_files.erb +65 -0
  234. data/lib/insika/studio/views/task.erb +105 -0
  235. data/lib/insika/studio/views/tasks.erb +33 -0
  236. data/lib/insika/studio/views/tool_edit.erb +107 -0
  237. data/lib/insika/studio/views/tools.erb +89 -0
  238. data/lib/insika/subagent_graph.rb +96 -0
  239. data/lib/insika/system_file_store.rb +96 -0
  240. data/lib/insika/task_actor.rb +128 -0
  241. data/lib/insika/task_store.rb +250 -0
  242. data/lib/insika/telemetry/pricing.rb +104 -0
  243. data/lib/insika/telemetry/recorder.rb +228 -0
  244. data/lib/insika/telemetry.rb +127 -0
  245. data/lib/insika/testing/store_contract.rb +270 -0
  246. data/lib/insika/tick.rb +122 -0
  247. data/lib/insika/token_estimator.rb +16 -0
  248. data/lib/insika/token_store.rb +168 -0
  249. data/lib/insika/tool_assembly.rb +140 -0
  250. data/lib/insika/tool_catalog.rb +89 -0
  251. data/lib/insika/tool_definition.rb +518 -0
  252. data/lib/insika/tool_envelope.rb +140 -0
  253. data/lib/insika/tool_manifest.rb +218 -0
  254. data/lib/insika/tool_output_compressor.rb +100 -0
  255. data/lib/insika/tool_registry.rb +21 -0
  256. data/lib/insika/tool_store.rb +135 -0
  257. data/lib/insika/tool_trace_store.rb +92 -0
  258. data/lib/insika/tools/a2a_remote.rb +48 -0
  259. data/lib/insika/tools/agent_enum.rb +68 -0
  260. data/lib/insika/tools/concurrency.rb +54 -0
  261. data/lib/insika/tools/data_defined_tool.rb +219 -0
  262. data/lib/insika/tools/load_skill.rb +99 -0
  263. data/lib/insika/tools/remember.rb +53 -0
  264. data/lib/insika/tools/stuck_signal.rb +44 -0
  265. data/lib/insika/tools/subagent.rb +75 -0
  266. data/lib/insika/tools/subagents.rb +77 -0
  267. data/lib/insika/tools/tool_search.rb +94 -0
  268. data/lib/insika/turn_output.rb +139 -0
  269. data/lib/insika/turn_state.rb +162 -0
  270. data/lib/insika/turn_timing.rb +56 -0
  271. data/lib/insika/usage_ledger.rb +47 -0
  272. data/lib/insika/version.rb +3 -1
  273. data/lib/insika/wiring/graph.rb +249 -0
  274. data/lib/insika/workflow.rb +185 -0
  275. data/lib/insika/workflow_registry.rb +33 -0
  276. data/lib/insika.rb +220 -4
  277. metadata +412 -8
data/docs/AGENTS.md ADDED
@@ -0,0 +1,618 @@
1
+ ---
2
+ title: Agents
3
+ parent: Build an agent
4
+ nav_order: 1
5
+ permalink: /agents/
6
+ ---
7
+
8
+ # Agents
9
+
10
+ An **agent** is the unit you configure and address. It is an immutable
11
+ `AgentProfile` value object — an identity (the system prompt), a model, and a set
12
+ of layered access controls — stored as a row in SQLite. Everything about an agent
13
+ is **data**: created and edited at runtime through the DSL, the API, or the
14
+ Studio, and every edit is **hot** — no restart, no redeploy. An in-flight turn
15
+ keeps the profile it captured when it started; the next turn sees the new one.
16
+
17
+ > Smallest possible agent — see [`examples/hello-agent/`](https://github.com/guizaols/insika/tree/main/examples/hello-agent/):
18
+ >
19
+ > ```ruby
20
+ > agent = Insika.agent("assistant") do
21
+ > model "deepseek-v4-flash"
22
+ > provider :deepseek
23
+ > instructions "You are a concise, friendly assistant."
24
+ > end
25
+ >
26
+ > puts agent.reply("hi") # => one turn, in-process
27
+ > ```
28
+
29
+ ## Three ways to create an agent
30
+
31
+ All three land on the **same** config-over-code import path — they differ only in
32
+ ergonomics, not in what they produce.
33
+
34
+ - **DSL** — `Insika.agent("id") { … }` builds an agent definition and imports it
35
+ into the durable store. Best for code-defined agents and examples. The DSL
36
+ auto-enables the tool- and skill-allowlist policies. `model` is optional — a nil
37
+ model resolves the platform `default_model` at turn start.
38
+ - **API** — `POST /v1/agents` with a definition (a "pack": an agent config plus its
39
+ prompt files, skills, and data-tools). The import is **idempotent** and
40
+ **authoritative** — what leaves the definition leaves the agent, so a
41
+ re-import that drops a tool or skill also removes it. `DELETE /v1/agents/:id`
42
+ removes an agent.
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.
45
+
46
+ Creating an agent validates its id (required, must be unique) and its subagent
47
+ graph (cycle/depth — see [subagents](#delegation-subagents)) **before**
48
+ persisting anything.
49
+
50
+ ### Addressing an agent
51
+
52
+ Once it exists, an agent is addressable by id as the `model` on the
53
+ OpenAI-Responses-compatible endpoint:
54
+
55
+ ```jsonc
56
+ POST /v1/responses
57
+ { "model": "<agent-id>", "user": "<session-id>", "stream": true, "input": "hello" }
58
+ ```
59
+
60
+ `user` is the session id (see [Context](CONTEXT.md)); `stream: true` streams the
61
+ turn as Server-Sent Events.
62
+
63
+ An optional `"origin"` declares **who wrote the input**. Omit it and it means what
64
+ it always meant: a customer typed this. Send `"engine"` when your consumer composed
65
+ the message out of context blocks (`<memoria> …`) rather than relaying something a
66
+ person said — the transcript then records it, and a report stops counting your own
67
+ injected text as the customer repeating themselves. See
68
+ [Refinement](REFINEMENT.md#who-wrote-a-message).
69
+
70
+ ## The AgentProfile
71
+
72
+ A profile is built through one front door — `AgentProfile.build(id:, model: nil, …)`.
73
+ A prompt file is **text**. Passing a structured value where the markdown belongs —
74
+ a `{"content": …}` wrapper in a pack, or a store entry read and written back — is
75
+ rejected, not coerced: `to_s` on a Hash produces Ruby's `#inspect`, and a prompt made
76
+ of that is served on every turn while looking healthy.
77
+
78
+ Its free-form hashes (`params`, `guardrails`, `sandbox`, `metadata`, …) are
79
+ normalized to string keys once, at build time; no reader downstream does dual-key
80
+ lookups.
81
+
82
+ ### Default limits
83
+
84
+ ```ruby
85
+ DEFAULT_LIMITS = {
86
+ turn_timeout: 300, tool_timeout: 60, provider_timeout: 5,
87
+ context_budget: 8_000, max_tool_calls: 50, max_tool_repeat: 3,
88
+ approval_timeout: 3_600, tool_concurrency: 1
89
+ }
90
+ ```
91
+
92
+ `build` merges your overrides over these — you set only the deltas.
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
+ ### Why some limits are missing from that list
101
+
102
+ `chat_rate_limit`, `agent_token_ceiling`, `queue_mode`, `debounce_ms`,
103
+ `debounce_max_ms`, `steer_max_messages` and `steer_join` are real limits, and none
104
+ of them appears above. That is the rule, not an oversight:
105
+
106
+ > **A limit that has a platform-wide layer is absent from `DEFAULT_LIMITS`.**
107
+
108
+ Those limits resolve **agent → platform (Studio settings) → off**, and the agent
109
+ layer wins whenever the key is *present* — including when you set it to `nil` or
110
+ `0`, which means *off for this agent*, never *inherit the platform value*. A
111
+ default baked into every profile would make the key present on every agent, and
112
+ the platform layer would then apply to nobody.
113
+
114
+ So the two groups read differently on purpose:
115
+
116
+ | | In `DEFAULT_LIMITS` | Absent |
117
+ |---|---|---|
118
+ | Examples | `turn_timeout`, `tool_concurrency`, `context_budget` | `chat_rate_limit`, `queue_mode`, `debounce_ms` |
119
+ | Absent from your profile means | the constant above | ask the platform, then off |
120
+ | You set it to `nil`/`0` | back to the constant | **off**, platform ignored |
121
+
122
+ ### `tool_concurrency` — parallel tool calls
123
+
124
+ When the model asks for several tools in one step, they run **one at a time by
125
+ default**. Raise `tool_concurrency` and they run together, capped at that number
126
+ in flight:
127
+
128
+ ```ruby
129
+ limit :tool_concurrency, 4 # nil / 0 / 1 = serial (the default); N = at most N at once
130
+ ```
131
+
132
+ One number is both the switch and the cap. It pays off only when a turn issues
133
+ several **slow, independent** calls (data tools waiting on HTTP) — the wall-clock
134
+ becomes the slowest call instead of the sum. It buys nothing for fast in-process
135
+ tools, and it is the *model* that decides the fan-out, which is why the cap is not
136
+ optional: an uncapped batch of 15 data tools is 15 simultaneous requests to the
137
+ same backend.
138
+
139
+ > ⚠️ **It is silently disabled for any turn that has an approval-required tool.**
140
+ > The approval wait is one mailbox per task, so two tool calls suspended for an
141
+ > operator would deadlock — the turn runs serially instead. The downgrade is
142
+ > per *turn*, not per agent (an agent that lists approvals still gets parallelism
143
+ > on turns where none of the allowed tools require one), and it emits one
144
+ > `provider_warning` event so the lost speedup is never a mystery.
145
+
146
+ Two behaviours change once it is on — see [Tools](TOOLS.md#parallel-tool-calls):
147
+ `max_tool_calls` becomes approximate, and the transcript records tool results in
148
+ completion order.
149
+
150
+ ### `queue_mode` — when a message arrives while the agent is busy
151
+
152
+ A person on WhatsApp rarely writes one message. They write three:
153
+
154
+ ```
155
+ 14:02:31 "oi"
156
+ 14:02:33 "queria saber do pedido"
157
+ 14:02:36 "1234567"
158
+ ```
159
+
160
+ By default each one is a turn, and they run one at a time. So the agent answers
161
+ `"oi"` with a greeting the customer has already moved past, and may go looking for
162
+ an order before the number arrives three seconds later.
163
+
164
+ Which mode you want depends on **when** the message arrives:
165
+
166
+ | `queue_mode` | The message arrives… | What happens |
167
+ |---|---|---|
168
+ | `followup` (default) | any time | it waits its turn in the queue — today's behavior, named |
169
+ | `collect` | before the turn starts | the fragments merge into ONE turn |
170
+ | `steer` | while the turn is running tools | it is appended to the run in flight |
171
+ | `interrupt` | while a turn is running that is now **wrong** | that turn is abandoned; this message becomes its own turn |
172
+
173
+ #### `collect` — the fragments become one turn
174
+
175
+ `collect` merges the fragments that land **before the turn starts** into a single
176
+ turn:
177
+
178
+ ```ruby
179
+ limit :queue_mode, "collect" # "followup" (the default) = one turn per message
180
+ limit :debounce_ms, 2_000 # 0 (the default) = no waiting; N = the quiet window
181
+ limit :debounce_max_ms, 10_000 # ceiling on the total wait, so typing forever
182
+ # cannot postpone the answer forever
183
+ ```
184
+
185
+ With those settings the three fragments above become one turn carrying
186
+ `"oi\nqueria saber do pedido\n1234567"`, released 2 s after the last one.
187
+
188
+ All three follow the platform-layer rule above, with one extra rung on top:
189
+ **session vars → this agent's limits → the platform default (Studio, `queue.*`) →
190
+ off**. Pinning `queue_mode` in a session's vars is how an operator takes one
191
+ difficult conversation off `collect` without touching the agent.
192
+
193
+ > ⚠️ **Your caller has to know it was merged.** When the engine coalesces, only
194
+ > one of the three calls owns the reply; the other two answer
195
+ > `200 {"task_id": "…", "merged": true}` and stream nothing. A caller that
196
+ > delivers a `merged` response anyway sends the same answer to the customer three
197
+ > times.
198
+ >
199
+ > Because of that, `collect` works **only on surfaces that can report the
200
+ > verdict**: `POST /v1/messages?stream=false` and channel endpoints. On
201
+ > `/v1/responses` and on any open stream it is refused and the agent falls back to
202
+ > `followup` — the response body there is fixed by someone else's wire format and
203
+ > has nowhere to put the field.
204
+
205
+ Waiting happens inside the engine, not in your request: the POST is acked
206
+ immediately with its `task_id`. Debouncing costs one thing — a customer who sends
207
+ a *single* message still waits out the window before their turn starts, which is
208
+ why 2 s is a sane value and 10 s is not.
209
+
210
+ A merged fragment creates no task of its own, so the record that it arrived
211
+ separately lives in one event, emitted when the window closes:
212
+
213
+ ```jsonc
214
+ { "type": "turn_coalesced",
215
+ "data": { "task_id": "…", "merged": 3,
216
+ "arrivals": ["2026-08-07T14:02:31Z", "…:33Z", "…:36Z"] } }
217
+ ```
218
+
219
+ Times and counts, never content. That is what answers "the customer says they
220
+ sent the order number" without keeping a throwaway task per fragment.
221
+
222
+ #### `steer` — the message arrives while the turn is already running
223
+
224
+ `collect` only ever touches a turn that has **not started**. Once the agent is
225
+ running tools, the customer's next message has nowhere to go but the back of the
226
+ queue — so a correction that arrives three seconds into a fifteen-second run is
227
+ answered after the run that did not know about it.
228
+
229
+ `steer` appends it to the run in flight instead:
230
+
231
+ ```ruby
232
+ limit :queue_mode, "steer"
233
+ limit :steer_max_messages, 5 # how many one run may absorb; the 6th becomes its own turn
234
+ limit :steer_join, nil # nil = the raw text; a template frames it (below)
235
+ ```
236
+
237
+ Where it lands is the whole design: **at a tool-batch boundary, appended at the
238
+ tail.** After the last result of a batch and before the model's next step — never
239
+ between two tool results (Anthropic rejects that outright, OpenAI merely tolerates
240
+ it), and never rewriting a message already sent, which is what keeps the prompt
241
+ cache valid. So the model sees the correction on its very next step, with the full
242
+ context of what it has already found.
243
+
244
+ Reach for `steer` when turns are long **because they call tools**. If your turns
245
+ are one provider round-trip, `collect` is the mode that helps and `steer` has no
246
+ boundary to use.
247
+
248
+ Four cases where the run cannot absorb the message. In every one it becomes the
249
+ next turn on the session instead — `followup`, arrived at late, reported as
250
+ `turn_steer_released`:
251
+
252
+ | The run… | Why |
253
+ |---|---|
254
+ | never calls a tool | there is no batch boundary to append at |
255
+ | ends in [`halt_when`](TOOLS.md#halt_when-when-the-answer-is-already-out) | there is no next model step; the message would sit unanswered forever |
256
+ | is a [workflow](WORKFLOWS.md) | a workflow orchestrates the model itself and has no chat to append to |
257
+ | already absorbed `steer_max_messages` | the bound exists so a tail cannot grow without one |
258
+
259
+ `steer_join` is for an agent that needs the model to *know* the text arrived
260
+ mid-run. It must contain `%{message}`, or the config is refused:
261
+
262
+ ```ruby
263
+ limit :steer_join, "the customer just added: %{message}"
264
+ ```
265
+
266
+ Default `nil` appends exactly what the person typed — and a steered message is a
267
+ first-class transcript message, with no origin, because a person wrote it. The
268
+ Studio marks it `steered` in the transcript, derived from its position (a `user`
269
+ message right after a tool result); nothing else in the engine puts one there.
270
+
271
+ > ⚠️ **Same verdict rule as `collect`, different word.** The reply comes out of the
272
+ > turn the message joined, so the steered caller is told it does not own it:
273
+ > `200 {"task_id": "<the running turn>", "steered": true}`, no stream opened. Only
274
+ > surfaces that can carry that verdict may steer — `/v1/messages?stream=false` and
275
+ > channel endpoints, never `/v1/responses` or an open stream.
276
+ >
277
+ > One consequence worth knowing before you turn it on: when the run *cannot* absorb
278
+ > the message, the follow-up turn's reply belongs to no caller. It travels the event
279
+ > stream like any engine-initiated turn (an async subagent's delivery has the same
280
+ > shape). `steer` therefore fits a consumer that reads replies off the stream or off
281
+ > a channel delivery — not one that only reads its own POST response.
282
+ >
283
+ > A steered message also lives **in memory** until a boundary writes it to the
284
+ > transcript. A hard stop inside that window loses it; a merged fragment, by
285
+ > contrast, is persisted before the window opens.
286
+
287
+ #### `interrupt` — the turn in flight is answering the wrong question
288
+
289
+ `steer` assumes the run is still worth finishing. Sometimes it is not: the customer
290
+ says "não, esquece isso" while the agent is three tool calls into the wrong order.
291
+
292
+ ```ruby
293
+ limit :queue_mode, "interrupt" # no other knob: see below
294
+ ```
295
+
296
+ The running turn is abandoned and the new message becomes an **ordinary turn** — its
297
+ own `task_id`, its own reply. That is why `interrupt` needs no verdict field and works
298
+ on **every** surface, `/v1/responses` included: nothing joins anything.
299
+
300
+ What "abandoned" means, exactly:
301
+
302
+ - The turn terminates `:cancelled` and **publishes nothing**. The answer to the
303
+ question the customer already replaced never reaches them, and nothing is written to
304
+ the transcript — so what they read and what the session holds still agree.
305
+ - **A tool call in flight runs to completion** and its result is recorded on the
306
+ stream. The batch is one unit of work: cancelling the calls that had not started
307
+ would leave it half applied, and fabricating failure results would teach the model
308
+ that tools failed when they did not. The same boundary bounds `turn_timeout`.
309
+ - The next turn starts from the last **committed** state. The abandoned attempt is
310
+ visible to an *operator* (its `tool_call`/`tool_result` events and the trace), not to
311
+ the model — a half batch in the history would be an invalid prompt.
312
+
313
+ > **No grace knob.** An `interrupt_grace_ms` was sketched; it is not
314
+ > implemented, and would buy nothing here. The new turn is queued behind the abandoned
315
+ > one either way (one turn at a time per session is the invariant), and waiting for a
316
+ > boundary inside the request would break the ack-fast rule that put the debounce
317
+ > window on the session's fiber in the first place.
318
+
319
+ > ⚠️ **`context_budget` defaults to 8000 tokens.** A large system prompt (a rich
320
+ > persona can run tens of thousands of tokens) exceeds it, and a pinned identity
321
+ > that overflows the budget fails the turn rather than truncating the identity.
322
+ > If a freshly created agent returns empty turns, raise `context_budget` first.
323
+ > See [Context](CONTEXT.md).
324
+
325
+ ### The allowlist convention
326
+
327
+ The same three-state rule governs tools, skills, context providers, and workflows
328
+ — learn it once:
329
+
330
+ - `nil` (or absent) = **all** (opt-in capabilities aside);
331
+ - `[]` = **none**;
332
+ - `[names]` = **exactly** those.
333
+
334
+ For tools, a paired deny list (`tools_deny`) **always wins**, and
335
+ `tools_allow_groups` unions a per-group allowlist on top of `tools_allow`.
336
+
337
+ Three capabilities invert the default — `nil`/absent means **OFF**, not "all":
338
+ `subagents`, `memory`, and `guardrails` (each defaults to off or a conservative
339
+ setting, never "everything on"). `tool_output_compression` is a fourth: opt-in
340
+ mechanical dedupe of repeated tool results in the history (see
341
+ [Context](CONTEXT.md#compaction-is-not-wired--except-the-mechanical-dedupe)),
342
+ off by default because it changes what the model sees.
343
+
344
+ ### Declaring what this deployment has
345
+
346
+ `declares "promotions", "human_handoff"` records facts about the deployment that
347
+ are not tools. It decides **nothing** at runtime — it exists so an eval case can
348
+ say what it needs and be *skipped* where it is absent instead of failing for the
349
+ wrong reason (see [Evals](EVALS.md)). A flat list you write: inferring "this store
350
+ has promotions" from data is how a test suite starts lying.
351
+
352
+ ## The five access layers
353
+
354
+ What an agent may do is layered. Each layer is independent, opt-in where it
355
+ matters, and editable hot.
356
+
357
+ ### Layer 1: Tools (what it can call)
358
+
359
+ `tools_allow` / `tools_deny` / `tools_allow_groups` decide which tools enter the
360
+ turn's tool-loop, enforced by the tool-allowlist policy. See [Tools](TOOLS.md)
361
+ for how tools are defined and registered, and [`examples/data-tool/`](https://github.com/guizaols/insika/tree/main/examples/data-tool/).
362
+
363
+ ### Layer 2: Policies and approvals
364
+
365
+ Policies are named entries evaluated before the turn runs. Builtins cover
366
+ tool-, skill-, and workflow-allowlisting, plus **`ApprovalRequired`** — which
367
+ does not allow or deny but *tags* a tool as needing human approval. Set
368
+ `approvals_required: [tool names]`; the gate then fires when the model tries to
369
+ call that tool, suspending the turn until an operator approves it in the Studio.
370
+ See [Security](SECURITY.md#human-approval).
371
+
372
+ ### Layer 3: Guardrails (content safety)
373
+
374
+ `guardrails` configures input/output content safety per agent — **opt-in**, so an
375
+ agent that says nothing gets a conservative default (deterministic detectors on,
376
+ LLM moderator off). See [Security](SECURITY.md#guardrails) and
377
+ [`examples/guardrails/`](https://github.com/guizaols/insika/tree/main/examples/guardrails/).
378
+
379
+ ### Layer 4: Edge limits (flood and spend control)
380
+
381
+ Two independent, opt-in ceilings, enforced *before* the model is ever called —
382
+ opt-in everywhere except on a public channel, where `chat_rate_limit` is
383
+ [required](CHANNELS.md#a-rate-limit-is-required-not-suggested) and the
384
+ [web widget](CHANNELS.md#the-web-widget) refuses to serve without one:
385
+
386
+ - **`chat_rate_limit`** — turn attempts per session per `chat_rate_window`.
387
+ - **`agent_token_ceiling`** — total tokens per agent per `agent_token_window`.
388
+
389
+ On breach the turn halts gracefully with a configurable `limit_response` and
390
+ **zero LLM calls**. Windows are set at the platform level; the ceilings can be set
391
+ per agent (blank inherits the platform value, `0` explicitly disables it).
392
+
393
+ > ⚠️ The token window default is **86400 (daily)**. To express "500k tokens per
394
+ > **hour**", set `agent_token_window = 3600` explicitly. A per-agent key that is
395
+ > *present but nil* reads as OFF for that agent — leave the key **absent** to
396
+ > inherit. See [Security](SECURITY.md#edge-limits).
397
+
398
+ #### Calendar budgets — the daily/monthly cost wall
399
+
400
+ A third, opt-in ceiling for the *billing* shape the windows above cannot express:
401
+ a spend cap over a CALENDAR day or month, per `(tenant, agent)` when
402
+ multi-tenant. Data on the profile (DSL `budget` or the pack's `budget` key):
403
+
404
+ ```ruby
405
+ budget daily: 100_000, monthly: 2_000_000, soft: false # or soft: true
406
+ ```
407
+
408
+ - Tokens count the **billed** spend — `input + output + cached + cache_creation`
409
+ (the cached prefix is the bulk of the bill, not an afterthought).
410
+ - **Hard** (the default, `soft` absent/false): a turn that arrives with the
411
+ window's spend already at/over the cap **fails** with the typed
412
+ `Insika::BudgetExceeded` — the envelope reads `budget_exceeded` +
413
+ `retry_after` (seconds until the window rolls). It is NOT a customer reply; it
414
+ is an operator signal.
415
+ - **Soft** (`soft: true`): the same turn RUNS — crossing the cap emits one
416
+ `budget_warning` event per window and injects a note into the context (the
417
+ model sees it, the transcript does not).
418
+ - Either way, crossing `alert_at` (default `0.8` of the cap) fires the same
419
+ warning **before** the wall, once per window.
420
+
421
+ > ⚠️ Unlike the ceilings above, the cap that counts is per **calendar** window —
422
+ > a `daily` budget rolls at UTC midnight, a `monthly` one on the 1st, whatever
423
+ > the sun. `agent_token_window` is a fixed seconds window and cannot express
424
+ > "the day resets at midnight".
425
+
426
+ #### Reliability — retries, fallback, circuit breaker (WS3)
427
+
428
+ The provider interaction is a single attempt by default (RubyLLM's own 2
429
+ transport retries aside). For a store that cannot have a dead model take the
430
+ chat down, the reliability policy is DATA on the profile:
431
+
432
+ ```ruby
433
+ reliability retries: 2, backoff: "exponential",
434
+ fallback: ["openai/gpt-4o-mini"],
435
+ circuit_breaker: { after: 10, within: 60, cooldown: 300 }
436
+ ```
437
+
438
+ - **Retries** — transient failures (`:retryable` / `:rate_limited_*` per the
439
+ error classification) retry with exponential backoff, up to `retries`.
440
+ A `:fatal` (auth, billing, bad request) is NEVER retried or rotated. Each
441
+ attempt runs on a fresh chat — the customer-visible answer comes only from
442
+ the attempt that returns.
443
+ - **Fallback** — after a node's retries, the turn ROTATES to the next model in
444
+ the chain: the profile's `fallback` refs first, then the platform
445
+ `fallback_models`. The turn's usage is attributed to the model that actually
446
+ spoke (`model_source: "fallback"`).
447
+ - **Circuit breaker** — per `(tenant, provider/model)`: `after` failures within
448
+ `within` seconds open the circuit; while open, the turn fail-fasts with the
449
+ typed `circuit_open` + `retry_after` (remaining cooldown) and the provider is
450
+ never touched. After `cooldown` a half-open trial closes the circuit on
451
+ success or reopens it on failure.
452
+ - **`timeout`** — per-attempt ceiling (default 30s), counted as a retryable
453
+ failure.
454
+
455
+ Absent `reliability` = the plain single attempt, byte-for-byte today's
456
+ behavior.
457
+
458
+ #### Operator alerts — the webhook (WS6)
459
+
460
+ Three operational events — `budget_warning`, `breaker_open`, `delivery_failed` —
461
+ can be answered per agent with a webhook:
462
+
463
+ ```ruby
464
+ alerts webhook: "https://ops.example.com/insika-alerts"
465
+ ```
466
+
467
+ When present, each such event is POSTed to the URL as JSON (the event's
468
+ type/data/meta, plus the agent). Delivery rides the same outbox + claim +
469
+ bounded-retry pipeline as channel answers — at-most-once, crashed deliveries
470
+ recovered at boot. The engine transports the event and does not interpret it: a
471
+ Slack/CRM adapter is the consumer's. Absent `alerts` = nothing is sent.
472
+
473
+ Separately, with `INSIKA_TURN_TIMING`, the provider's **live TTFB** is carried in
474
+ the streaming envelope: the first content chunk emits an `insika.ttft` frame
475
+ (`ttft_ms`) on `/v1/responses`, alongside the per-turn `timing` breakdown on the
476
+ final `response.completed`. Additive and opt-in — absent by default.
477
+
478
+ ### Layer 5: Reasoning (thinking)
479
+
480
+ Controls the model's thinking budget, resolved by precedence
481
+ **Chat > Agent > Model > Global** (first non-blank wins):
482
+
483
+ | Scope | Where |
484
+ |-------|-------|
485
+ | Chat | session var `__llm__.thinking` |
486
+ | Agent | `profile.params["thinking"]` |
487
+ | Model | platform `model_params[<ref>].thinking` |
488
+ | Global | platform `thinking` |
489
+
490
+ Values: `off | on | low | medium | high`. `off`/`on` toggle thinking; the effort
491
+ levels map to the provider's thinking-effort parameter. This is a control
492
+ primitive, not a latency lever — turning reasoning off does not necessarily speed
493
+ up a turn, because most of a turn's latency is the provider itself, not thinking.
494
+
495
+ Whether the reasoning ever reaches the **customer** is a separate switch, off by
496
+ default:
497
+
498
+ ```ruby
499
+ edge_stream thinking: true, intermediate: false
500
+ ```
501
+
502
+ `thinking` is the provider's reasoning; `intermediate` is the model narrating its
503
+ own tool loop ("let me look that up"). Both are always on the event stream for the
504
+ Studio and the trace — this decides only whether `/v1/responses` translates them,
505
+ and each opted-in channel gets its own frame type, never the answer's. See
506
+ [Architecture](ARCHITECTURE.md#what-crosses-the-edge).
507
+
508
+ > ⚠️ Turn it on knowing your consumer. One that concatenates every text delta into
509
+ > a single message — a WhatsApp adapter — will only be affected once it learns to
510
+ > read the new frames, and when it does, the deliberation is what the customer
511
+ > reads. That is the operator's call, which is why it is neither a default nor a
512
+ > global.
513
+
514
+ ## The stuck signal — "I cannot proceed" (WS5)
515
+
516
+ The engine doesn't decide what "I can't help you" means — the consumer does. What
517
+ the engine provides is the deterministic signal, so that a product wanting
518
+ **human escalation** can act on it instead of regexing the answer text:
519
+
520
+ ```ruby
521
+ stuck_signal true
522
+ ```
523
+
524
+ With `stuck_signal` on, the model may call `signal_stuck(reason:, message:)` when it
525
+ determines it cannot proceed (out of scope, missing data, a case a human must take
526
+ over). The turn then **ends** — a final message is published (the model's lead-in,
527
+ or the tool's `message` when it wrote none) — and the contract carries the signal
528
+ twice:
529
+
530
+ - the terminal event `task_completed` gains an additive sibling
531
+ `"outcome": "stuck"` (and the OpenAI `response.completed` frame too), so a
532
+ consumer that only reads the response can react;
533
+ - a dedicated `:turn_stuck` event is published with the `agent`, the `reason`, and
534
+ the final `message` — the subscription point for an operator inbox.
535
+
536
+ ```jsonc
537
+ { "type": "turn_stuck",
538
+ "data": { "agent": "store-support", "reason": "order outside my scope",
539
+ "message": "I'll transfer you to our human team." } }
540
+ ```
541
+
542
+ Nothing about handing off, pausing, or resuming is in scope here — escalation is a
543
+ consumer concern. How a human joins the conversation is exactly what
544
+ `MessageOrigin.operator` ([Refinement](REFINEMENT.md#who-wrote-a-message)) stamps
545
+ an imported transcript with; this workstream provides the point at which that
546
+ handoff is *triggered*. Off by default (parity): without `stuck_signal`, the tool
547
+ is not wired and the outcome never appears.
548
+
549
+ ## Refinement
550
+
551
+ `refinement` configures how an agent's own traffic is read back as a report — what
552
+ broke, how often, in which conversations. Unlike the layers above it grants
553
+ nothing: a run calls no model and edits nothing, so it needs no opt-in and an
554
+ absent key still reports. See [Refinement](REFINEMENT.md).
555
+
556
+ ```ruby
557
+ refine window: { last_sessions: 200 }, max_findings: 20
558
+ ```
559
+
560
+ Editing the agent from that report is a separate, explicit `mode` — with a write
561
+ allowlist, one or more `proposers`, a token `budget`, and a gate that replays the
562
+ golden set before anything reaches a human. All of it is in
563
+ [Refinement](REFINEMENT.md); none of it is on until you name it.
564
+
565
+ ## Delegation (subagents)
566
+
567
+ An agent can delegate to **subagents**: named child agents it may invoke as a
568
+ tool, fanning work out and collecting results. Subagents are **opt-in**
569
+ (`subagents` defaults to none) and the graph is validated for cycles and depth at
570
+ create time. This is off by default because it multiplies model calls — enable it
571
+ deliberately.
572
+
573
+ Delegation only means something when the children are resolvable in the same
574
+ graph, which is what `Insika.system` is for — several agents, one runtime:
575
+
576
+ ```ruby
577
+ system = Insika.system do
578
+ agent("security") { instructions "Review code for security issues." }
579
+ agent("performance") { instructions "Review code for performance issues." }
580
+
581
+ agent "reviewer" do
582
+ instructions "Delegate to the specialists, then synthesize their reports."
583
+ subagents "security", "performance"
584
+ end
585
+ end
586
+
587
+ system.reply("reviewer", code) # one turn; the parent fans out and synthesizes
588
+ system.serve # all three on /studio + /v1 (each id is a `model`)
589
+ ```
590
+
591
+ When the *shape* of the work is known in advance — draft then edit, classify then
592
+ answer, three reviewers then a summary — put the choice in Ruby instead: see
593
+ [Workflows](WORKFLOWS.md).
594
+
595
+ The parent gets two system tools: `spawn_subagent` (one child) and
596
+ `spawn_subagents` (**N children in parallel**, one combined result — wall-clock
597
+ is the slowest child, not the sum, capped by `INSIKA_SUBAGENT_FANOUT_CAP`,
598
+ default 8). A child inherits the *environment* (model, thinking) as a default and
599
+ **never** inherits capability: its tools, skills and own subagents come from its
600
+ own profile.
601
+
602
+ ## Where agent data lives
603
+
604
+ Every agent is a row in one SQLite key-value table (WAL mode), namespaced under
605
+ `config:agents`. The database file is `INSIKA_DB`. The profile source reads
606
+ **fresh** on each dispatch, which is why Studio and API edits take effect on the
607
+ next turn with no restart. See [Deploy](DEPLOY.md) for the durable-volume setup and
608
+ [Context](CONTEXT.md#the-volume) for why editing a committed file does *not* change
609
+ a running agent.
610
+
611
+ ## See also
612
+
613
+ - [Tools](TOOLS.md) — define, register, and troubleshoot tools.
614
+ - [Skills](SKILLS.md) — progressive playbooks an agent loads on demand.
615
+ - [Context](CONTEXT.md) — what fills a turn's prompt, and memory.
616
+ - [Security](SECURITY.md) — guardrails, sandbox, approvals, edge limits.
617
+ - [Architecture](ARCHITECTURE.md) — how a turn actually runs.
618
+ - [`examples/`](https://github.com/guizaols/insika/tree/main/examples/) — one runnable project per capability.