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
@@ -0,0 +1,333 @@
1
+ ---
2
+ title: Architecture
3
+ parent: Understand the idea
4
+ nav_order: 2
5
+ permalink: /architecture/
6
+ ---
7
+
8
+ # Architecture
9
+
10
+ This is the engineering reference — how a turn actually runs, why the pieces are
11
+ shaped the way they are, and where to look in the code. It is deliberately
12
+ separate from the [capability guides](AGENTS.md); those tell you *how to use* the
13
+ runtime, this tells you *how it works*.
14
+
15
+ Five principles run through everything below:
16
+
17
+ 1. **RubyLLM does the model work.** Chat, streaming, the tool-loop, and provider
18
+ retries are never reimplemented — the engine is the operational shell around
19
+ them.
20
+ 2. **One pipeline.** Every conversational turn goes through the *same* ordered
21
+ stages. There is no fast path that skips policy or persistence.
22
+ 3. **Everything is a Command.** Every state-changing interaction is a typed
23
+ command through one bus; reads go straight to the stores, never through the
24
+ runtime.
25
+ 4. **Durable by default.** Every turn checkpoints; a killed process resumes from
26
+ the last checkpoint on reboot.
27
+ 5. **Config over code.** Agents, tools, skills, and policies are data in stores,
28
+ editable hot — not classes you redeploy.
29
+
30
+ ## The engine at a glance
31
+
32
+ A request enters as a Command, becomes a Task running on its own fiber, and streams
33
+ its progress out through the Event Stream as it moves down the pipeline.
34
+
35
+ ```mermaid
36
+ flowchart TD
37
+ client([HTTP client]) -->|"POST /v1/responses"| bus[Command Bus]
38
+ bus -->|turn command| task[Task actor<br/>Async fiber]
39
+ task --> cb[Context Builder]
40
+ cb --> pol[Policy Engine]
41
+ pol --> mw[Middleware<br/>edge limit · input guardrail]
42
+ mw --> ex[Executor<br/>chat + tool-loop]
43
+ ex --> rll[(RubyLLM ⇄ provider)]
44
+ ex --> persist[Persistence<br/>checkpoint · session · task]
45
+ task -.emits.-> es[Event Stream]
46
+ es -->|SSE| client
47
+ cb -.reads.-> stores[(SQLite stores)]
48
+ persist -.writes.-> stores
49
+ ```
50
+
51
+ - **Command Bus** validates and dispatches. A *turn* command (`send_message`,
52
+ `trigger_workflow`) creates a Task and returns its id immediately; the result
53
+ flows out on the Event Stream. A *control* command (`create_session`,
54
+ `cancel_task`, `pause_task`, `approve_action`, `resume_task`) acts on stores
55
+ synchronously. Queries are **not** commands — they read the stores directly.
56
+ - **Task actor** is an `Async` fiber with a minimal mailbox (`cancel`,
57
+ `user_message`, pause). Because an LLM turn is almost all *waiting* on the
58
+ provider, one process runs many concurrent turns on a fiber scheduler instead of
59
+ pinning a thread per call.
60
+ - **Event Stream** is in-process pub/sub. Every event carries `task_id` and a
61
+ monotonic `seq`, so one stream multiplexes many turns and replays reliably. Not
62
+ every event is for the end user — see the edge contract below.
63
+
64
+ *In-process* is a real boundary, not a detail: per-session FIFO ordering,
65
+ `steer`, `interrupt`, `pause`/`cancel` and the SSE watch all act on the worker
66
+ that holds the session's actor, and the engine does **not** promise them across
67
+ worker processes. What *is* cross-process is everything durable — sessions,
68
+ tasks, checkpoints, outbox, delegations — behind transactional claims on the
69
+ shared store. The deploy-side consequences (and the sticky-routing escape
70
+ hatch) are written in [Deploy → The process model](DEPLOY.md#the-process-model).
71
+
72
+ The mirror image of that question — not N workers of one deployment, but N
73
+ **graphs** inside one process, which is what happens when you mount Insika into
74
+ an app you already have — is the [embed contract](EMBEDDING.md). Short version: a
75
+ graph owns its store and its LLM credentials, so two graphs no longer swap keys
76
+ or read each other's sessions; the process keeps owning signals, the reactor and
77
+ the Studio, which is why the host installs the drain itself.
78
+
79
+ ### What crosses the edge
80
+
81
+ A turn is not one assistant message. Between the user's message and the answer the
82
+ model may narrate the tool loop ("let me look that up"), apologise for a tool that
83
+ failed, or — when it has no tool to call — reason in prose. All of it arrives as
84
+ ordinary content chunks, indistinguishable at the token level from the answer.
85
+
86
+ So the engine publishes rather than relays: **`:content` carries the answer — the
87
+ text of the assistant message that *ends* the turn**. It is emitted once, whole,
88
+ when that message ends. Everything else the model says rides `:intermediate`, and
89
+ the provider's own reasoning channel rides `:thinking`. Both are real events —
90
+ the Studio renders them and the trace keeps them, which is how an operator sees
91
+ what the model was doing — and `/v1/responses` deliberately translates neither.
92
+
93
+ Two consequences worth knowing before you build on it:
94
+
95
+ - The customer-visible stream is per **message**, not per token. A consumer that
96
+ accumulates deltas gets the same text; one that renders them live gets it in one
97
+ piece. Watch `:intermediate` if you want the keystrokes. Measured on a real agent,
98
+ the answer's frames span 0 ms — which is why a
99
+ [relay costs the customer nothing](CHANNELS.md#relay-or-the-drop-in-api) next to
100
+ holding an SSE connection for the whole turn.
101
+ - A turn that dies mid-message publishes nothing. Half a sentence was never an
102
+ answer; the fragment is still on the stream for whoever is debugging it.
103
+
104
+ That contract is what makes a channel possible at all. A
105
+ [channel](CHANNELS.md) whose recipient is not on the connection — a WhatsApp
106
+ number, your own callback URL — cannot stream anything: it needs one message it
107
+ can send. `:content` is that message, which is why a channel delivers exactly it
108
+ and nothing else.
109
+
110
+ The exception is `halt_when`: a tool that ends the turn has already answered the
111
+ customer, so the model's lead-in before that call *is* the turn, and it is
112
+ published as the answer.
113
+
114
+ **Neither default is a law.** A product with a "thinking" panel wants the
115
+ reasoning, and a chat UI may want the progress line. An agent opts a channel in:
116
+
117
+ ```ruby
118
+ edge_stream thinking: true, intermediate: false
119
+ ```
120
+
121
+ Two things keep that from re-opening the hole. Nothing crosses unless someone
122
+ opted in, per agent. And what crosses gets its **own frame type**, never the
123
+ answer's — `response.reasoning_summary_text.delta` for reasoning, and a namespaced
124
+ `insika.intermediate.delta` for the narration, because the Responses protocol has
125
+ no honest event for "assistant text that is not the answer" (there, that text *is*
126
+ `output_text.delta`, told apart only by an output-item index this adapter does not
127
+ carry). So a consumer that accumulates `output_text` deltas into one message —
128
+ WhatsApp — is unaffected by the switch, and one that renders reasoning has
129
+ something to render.
130
+
131
+ ## A turn, end to end
132
+
133
+ The Executor runs a fixed sequence of stages. Each stage boundary drains the
134
+ mailbox, so a cancel or pause is honored at a safe point — never mid-write. One of
135
+ those boundaries sits **between the provider's last word and publishing the answer**:
136
+ a turn cancelled while the model was working publishes nothing, so what the customer
137
+ read and what the transcript holds never disagree.
138
+
139
+ The numbers are the engine's own — the same stage numbers `executor.rb` uses. The
140
+ sequence has no stage 7; the numbering is kept as the code has it rather than
141
+ renumbered here.
142
+
143
+ ```mermaid
144
+ flowchart TD
145
+ s1["1 · Command Bus<br/>send_message → Task on a fiber, task_id returned"]
146
+ s2["2 · Context Builder<br/>providers, budget, pinned"]
147
+ ck["initial checkpoint<br/>the state at the START of the turn"]
148
+ s3["3 · Policy Engine<br/>allowed tools/skills, approval tags"]
149
+ s1 --> s2 --> ck --> s3 --> mw
150
+
151
+ subgraph mw ["4 · Middleware wraps everything below — edge limit → input guardrail"]
152
+ s5["5 · assemble chat"]
153
+ s6["6 · agent interaction<br/>chat.ask + tool-loop"]
154
+ s8["8 · Persistence<br/>checkpoint → session → task"]
155
+ s9["9 · Response<br/>task_completed + usage"]
156
+ s5 --> s6 --> s8 --> s9
157
+ end
158
+
159
+ s9 --> hook["after-task hook<br/>output guardrail"]
160
+ ```
161
+
162
+ The order is not arbitrary:
163
+
164
+ - **Context before policy.** The prompt is assembled first so policy can see what
165
+ the turn will actually contain (candidate skills come from the catalog, tools
166
+ from the registry).
167
+ - **The initial checkpoint is written before the model call.** "The checkpoint of
168
+ turn *n* holds the state at the *start* of turn *n*." Without it, a crash during
169
+ the model call would orphan the task with no checkpoint — unrecoverable.
170
+ - **Middleware wraps the model-facing stages**, so the edge limiter and input
171
+ guardrail run *before* the provider is ever touched — a flood or an injection is
172
+ refused without a paid model call.
173
+ - **Persistence is a fixed order** (checkpoint → session → task) and a pure drain
174
+ point: the last stage never suspends, so a checkpoint is never left half-written.
175
+ - **Output validation** runs as an after-task hook on the produced content
176
+ (the output guardrail).
177
+
178
+ ## The tool-loop
179
+
180
+ Stage 6 is the single agent interaction. RubyLLM owns the reason→act→observe loop;
181
+ the engine wraps each tool the model may call in a **ToolEnvelope** that enforces
182
+ the per-tool timeout, records side-effects for checkpointing, skips
183
+ already-completed side-effects on resume, and fires the approval gate.
184
+
185
+ When a step contains several tool calls they are executed **one at a time**, unless
186
+ the agent raised `limits[:tool_concurrency]` — then the batch runs on the turn's
187
+ reactor with at most that many in flight, one fiber per call, and the envelope's
188
+ shared semaphore is the cap. A turn with an approval-required tool always runs
189
+ serially. See [Tools](TOOLS.md#parallel-tool-calls) for what that changes.
190
+
191
+ ```mermaid
192
+ flowchart TD
193
+ ask[chat.ask -> model] --> dec{tool call?}
194
+ dec -->|no| done[final content]
195
+ dec -->|yes| env[ToolEnvelope]
196
+ env --> appr{approval<br/>required?}
197
+ appr -->|yes| suspend[[suspend turn<br/>await operator]]
198
+ suspend --> appr
199
+ appr -->|no / approved| kind{tool kind}
200
+ kind -->|code| ruby[Ruby class<br/>in-process / sandbox]
201
+ kind -->|data / MCP| egress[EgressGuard] --> http[(external HTTP)]
202
+ ruby --> obs[result -> back to model]
203
+ http --> obs
204
+ obs --> ask
205
+ ```
206
+
207
+ A **data tool** is config, not code (see [Tools](TOOLS.md)): its result comes back
208
+ to the model exactly like a code tool's, but it went out over HTTP through the
209
+ egress guard. A tool exception is caught and returned *to the model* as an error
210
+ result — it does not crash the turn. Side-effecting tools (POST and friends) are
211
+ recorded in the checkpoint so a resume does not re-run them.
212
+
213
+ ## Ingesting tools: manifest and MCP
214
+
215
+ Tools become data in the store through two runtime paths, both hot (no restart):
216
+
217
+ ```mermaid
218
+ flowchart TD
219
+ subgraph manifest [Manifest path]
220
+ m["POST /v1/tools/manifest"] --> sub["substitute<br/>{{env.*}} / {{secret.*}}"]
221
+ sub --> val1[validate each tool]
222
+ end
223
+ subgraph mcp [MCP path]
224
+ srv[(MCP server<br/>HTTP transport)] --> ing[MCP ingestor]
225
+ ing --> conv[each tool → HTTP data tool<br/>JSON-RPC tools/call]
226
+ conv --> val2[validate]
227
+ end
228
+ val1 --> store[(ToolStore)]
229
+ val2 --> store
230
+ store --> cat[reload catalog + registry]
231
+ cat --> loop[available in the tool-loop]
232
+ ```
233
+
234
+ The manifest path is the **only** one that resolves `{{env.*}}` (at ingestion);
235
+ every other write path requires a literal URL. Partial failure on the manifest
236
+ path is isolated — one malformed tool is reported in `errors[]` while the rest
237
+ import. See [Tools](TOOLS.md#registering-a-tool).
238
+
239
+ ## Durability: checkpoints and resume
240
+
241
+ The runtime has no external job queue. Durability is stores plus **boot recovery**:
242
+ at startup the recovery scan finds tasks that were mid-flight and resumes each from
243
+ its last valid checkpoint — the *same* code path a `resume_task` command uses.
244
+
245
+ ```mermaid
246
+ flowchart LR
247
+ start(( )) -->|send_message| running[running]
248
+ running -->|turn persisted| checkpointed[checkpointed]
249
+ checkpointed -->|next turn| running
250
+ running -->|approval required| waiting[waiting]
251
+ waiting -->|approve_action| running
252
+ running -->|process killed| crashed[crashed]
253
+ crashed -->|boot recovery / resume_task| running
254
+ checkpointed -->|task_completed| completed[completed]
255
+ completed --> done((( )))
256
+ ```
257
+
258
+ Resume always replays from the *start of the last checkpointed turn*. Tool calls
259
+ that already completed in the interrupted turn are recorded in the checkpoint and
260
+ **not** re-executed, so a non-idempotent side-effect fires at most once. A resumed
261
+ turn is also never re-counted against edge-limit ledgers. Cancellation is
262
+ cooperative — checked at stage boundaries, never in the middle of a store write.
263
+
264
+ ## Composition root
265
+
266
+ The whole graph is wired in one place — `Insika::Wiring::Graph` — in two phases,
267
+ so the two deployment roots (a minimal in-process wiring and the full server
268
+ deployment) share the parts that are identical and layer on only what genuinely
269
+ differs.
270
+
271
+ ```mermaid
272
+ flowchart TD
273
+ root1[minimal wiring] --> phase1
274
+ root2[server deployment] --> phase1
275
+
276
+ subgraph phase1 [Phase 1 · spine — infra, identical across roots]
277
+ backend["backend<br/>SQLite (INSIKA_DB) or Memory"] --> dstores[domain stores<br/>session · task · checkpoint · memory]
278
+ reg[registries<br/>tools · workflows · policies]
279
+ caps[capability registry]
280
+ es2[event stream]
281
+ hk[hooks]
282
+ end
283
+
284
+ phase1 ==>|"Graph.build(spine:)"| phase2
285
+
286
+ subgraph phase2 [Phase 2 · build — assembled on the spine]
287
+ cbz[Context Builder] --> exz[Executor]
288
+ pez[Policy Engine] --> exz
289
+ mwz["Middleware<br/>edge limiter → input guardrail"] --> exz
290
+ exz --> busz[Command Bus<br/>6 core commands]
291
+ end
292
+ ```
293
+
294
+ Everything in phase 1 is passed into phase 2: the domain stores, registries,
295
+ capability registry, event stream and hooks are all constructor arguments of the
296
+ Executor and the Command Bus (`spine.*` throughout `Graph.build`). The arrow is one
297
+ call, not one wire.
298
+
299
+ `backend_from_env` picks the backend: `INSIKA_DB` set → durable SQLite (the
300
+ prerequisite for recovery); unset → ephemeral in-memory (dev/demo). Registering the
301
+ operator commands (`pause_task`, `approve_action`) in the shared core is what lets
302
+ both roots expose the Studio's controls without a per-root patch. The guardrails
303
+ factory contributes the input guardrail as the single middleware and the output
304
+ validator as the after-task hook, so both roots enforce content safety identically.
305
+
306
+ ## Where the code lives
307
+
308
+ | Concern | Code |
309
+ |---------|------|
310
+ | Composition root | `lib/insika/wiring/graph.rb` |
311
+ | Command bus + handlers | `lib/insika/command_bus.rb`, `lib/insika/commands/*` |
312
+ | Turn pipeline | `lib/insika/executor.rb` |
313
+ | Context assembly | `lib/insika/context/*` |
314
+ | Policy | `lib/insika/policy/*` |
315
+ | Stores | `lib/insika/stores/*`, `lib/insika/*_store.rb` |
316
+ | Recovery | `lib/insika/recovery.rb` |
317
+ | Inbound queue (one turn at a time per session, and what happens to a message that arrives while one is running) | `lib/insika/session_actor.rb`, `lib/insika/queue_policy.rb`, `lib/insika/steer_injector.rb` |
318
+ | Channels (a way in and out for people; the reply that travels after the turn ends) | `lib/insika/channel_registry.rb`, `lib/insika/channels/*`, `lib/insika/channel_delivery.rb`, `lib/insika/outbox_store.rb`, `lib/insika/inbound_log.rb` |
319
+ | Tools (data/manifest/MCP) | `lib/insika/tool_definition.rb`, `tool_manifest.rb`, `mcp_tool_ingestor.rb` |
320
+ | Plugin loading (boot) | `lib/insika/plugin.rb`, `lib/insika/plugin/loader.rb` |
321
+ | Refinement (traffic → report) | `lib/insika/refinement/*`, `lib/insika/refinement_store.rb` |
322
+ | Evals (cases, judges, gate) | `lib/insika/evals/*`, `lib/insika/golden_store.rb`; `evals/run.rb` is the CLI |
323
+ | HTTP/SSE surface | `lib/insika/server/*` |
324
+
325
+ ## See also
326
+
327
+ - [Agents](AGENTS.md) · [Tools](TOOLS.md) · [Skills](SKILLS.md) ·
328
+ [Context](CONTEXT.md) · [Channels](CHANNELS.md) · [Plugins](PLUGINS.md) ·
329
+ [Security](SECURITY.md) — the capability guides.
330
+ - [Evals](EVALS.md) — the cases that grade an agent, and the pre-merge gate.
331
+ - [Refinement](REFINEMENT.md) — reading a live agent's own traffic back as a report.
332
+ - [Deploy](DEPLOY.md) — running the engine durably.
333
+ - [Benchmark](BENCHMARK.md) — the per-turn engine overhead, reproducible.
data/docs/BENCHMARK.md ADDED
@@ -0,0 +1,114 @@
1
+ ---
2
+ title: Benchmark
3
+ parent: Operate & prove it
4
+ nav_order: 2
5
+ permalink: /benchmark/
6
+ ---
7
+
8
+ # Benchmark — engine overhead, neutral & reproducible
9
+
10
+ This is the engine's public performance benchmark. It is **neutral** (no
11
+ competitor, no baseline, no product-specific deployment appears), **reproducible**
12
+ (one command, no API key), and **provider-free** by design. It measures the one
13
+ thing the engine actually controls: the overhead the engine adds around the
14
+ model on every turn.
15
+
16
+ Run it:
17
+
18
+ ```bash
19
+ bundle exec ruby scripts/bench.rb
20
+ ```
21
+
22
+ No key, no service, no network. It forces the in-memory backend, so it never
23
+ touches a real deployment's data.
24
+
25
+ ## What it measures — and what it does not
26
+
27
+ A turn's wall-clock time is dominated by the **provider round-trip** — the LLM
28
+ generating tokens — which the engine does not control and cannot speed up.
29
+ Profiling a real turn put the engine's own local assembly at well under a
30
+ millisecond and time-to-first-token entirely bounded by the provider. A
31
+ benchmark that called a provider would therefore:
32
+
33
+ - require an API key — **not reproducible** by a third party;
34
+ - name a model/endpoint as its baseline — **not neutral**;
35
+ - bury the engine signal under provider and network noise.
36
+
37
+ So this suite replaces the model with a **deterministic in-process stub** and
38
+ reports only the engine's contribution:
39
+
40
+ | Metric | Meaning |
41
+ |---|---|
42
+ | **total** (p50/p95) | per-turn engine latency — all engine work, no model call |
43
+ | **prep** (p50/p95) | context build + policy + guardrail detectors + chat assembly |
44
+ | **ttft** (p50/p95) | assembly → first streamed token, engine-side |
45
+ | **gen** (p50/p95) | streaming the rest through the pipeline (filter/emit/event stream) |
46
+ | **throughput** | turns/s a single process sustains at a given concurrency |
47
+ | **pipeline overhead** | engine work per streamed token (µs) |
48
+
49
+ **Out of scope, on purpose:** end-to-end latency, time-to-first-token *against a
50
+ provider*, and tokens/s of *model generation*. Those are provider-bound — the
51
+ Insika has no lever on them — so this suite makes no claim about them.
52
+
53
+ The stub implements exactly the chat surface the executor touches, and each turn
54
+ runs the full engine path: context build, policy resolution, guardrail
55
+ detectors, chat assembly, the tool-call round-trip, streamed output, persistence,
56
+ checkpointing, and the event stream. Only the network call is removed.
57
+
58
+ ## Scenarios
59
+
60
+ The agents are synthetic — built through the public `Insika.agent { … }` DSL and
61
+ imported the same way any pack is, so the measured path is the real one.
62
+
63
+ - **greeting** — a minimal turn: a short system prompt, no tools. Baseline
64
+ engine overhead.
65
+ - **tool_call** — a turn where the agent calls one tool; exercises tool
66
+ assembly, the wrap/dispatch path, and the call→result round-trip.
67
+ - **multi_turn** — a one-shot carrying prior conversation messages; shows how
68
+ overhead moves as the context the engine assembles grows.
69
+
70
+ ## Options
71
+
72
+ | Flag | Default | Meaning |
73
+ |---|---|---|
74
+ | `--scenario NAME` | `all` | `greeting`, `tool_call`, `multi_turn`, or `all` |
75
+ | `--iterations N` | `200` | measured turns in the latency pass |
76
+ | `--warmup N` | `20` | unmeasured warmup turns |
77
+ | `--concurrency N` | `8` | concurrent turns per wave (throughput pass) |
78
+ | `--waves N` | `5` | waves in the throughput pass |
79
+ | `--identity-tokens N` | `2000` | approximate size of the agent's system prompt |
80
+ | `--history-turns N` | `10` | prior messages for the `multi_turn` scenario |
81
+ | `--output-tokens N` | `48` | tokens the stub streams per turn |
82
+ | `--json` | off | emit results as JSON (for regression gating) |
83
+
84
+ `--json` prints the engine version, Ruby/YJIT status, the full config, and every
85
+ metric — a stable shape to diff across commits.
86
+
87
+ ## Reference numbers
88
+
89
+ A reference run. **The absolute milliseconds are machine-specific** — reproduce
90
+ them on your own hardware with the command below; what travels across machines is
91
+ the shape (sub-millisecond overhead, flat p95, thousands of turns/s per process).
92
+
93
+ ```
94
+ insika 0.1.0 · ruby 4.0.6 (YJIT) · Apple Silicon (arm64-darwin)
95
+ bundle exec ruby scripts/bench.rb --iterations 300 --warmup 30 --concurrency 16 --waves 20
96
+ ```
97
+
98
+ | Scenario | total p50 | total p95 | prep p50 | throughput @16 | µs/token |
99
+ |---|---|---|---|---|---|
100
+ | greeting | 0.40 ms | 0.63 ms | 0.17 ms | ~1900 turns/s | 4.8 |
101
+ | tool_call | 0.39 ms | 0.69 ms | 0.16 ms | ~1670 turns/s | 4.6 |
102
+ | multi_turn | 0.38 ms | 0.64 ms | 0.16 ms | ~1700 turns/s | 4.4 |
103
+
104
+ Reading: the engine adds **well under a millisecond per turn** (p50 ≈ 0.4 ms,
105
+ p95 < 0.7 ms), and that overhead stays flat with a tool round-trip and with
106
+ accumulated context. A single process sustains ~1.7–1.9k turns/s of pure engine
107
+ work. The rest of any real turn's latency is the provider.
108
+
109
+ ## Publication rule
110
+
111
+ **Any public claim about the engine's performance must reference this suite.**
112
+ Numbers produced against a specific provider, deployment, or competitor are not
113
+ publishable — they are neither neutral nor reproducible. If a claim cannot be
114
+ reproduced by running `scripts/bench.rb`, it does not go in public materials.