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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +361 -0
- data/LICENSE +21 -0
- data/README.md +136 -2
- data/bin/insika +366 -0
- data/docs/AGENTS.md +618 -0
- data/docs/ARCHITECTURE.md +333 -0
- data/docs/BENCHMARK.md +114 -0
- data/docs/CHANNELS.md +453 -0
- data/docs/CONTEXT.md +117 -0
- data/docs/DEPLOY.md +354 -0
- data/docs/EMBEDDING.md +198 -0
- data/docs/EVALS.md +273 -0
- data/docs/LOADTEST.md +232 -0
- data/docs/OBSERVABILITY.md +374 -0
- data/docs/PLUGINS.md +211 -0
- data/docs/REFINEMENT.md +477 -0
- data/docs/RELEASING.md +70 -0
- data/docs/RUNNING-LOCAL.md +153 -0
- data/docs/SANDBOX.md +114 -0
- data/docs/SECURITY.md +375 -0
- data/docs/SKILLS.md +284 -0
- data/docs/TOOLS.md +302 -0
- data/docs/WHY.md +137 -0
- data/docs/WORKFLOWS.md +225 -0
- data/docs/build.md +14 -0
- data/docs/index.md +68 -0
- data/docs/onboarding/start.md +126 -0
- data/docs/operate.md +12 -0
- data/docs/ship.md +10 -0
- data/docs/understand.md +10 -0
- data/lib/insika/agent_file_store.rb +125 -0
- data/lib/insika/agent_profile.rb +255 -0
- data/lib/insika/alert_dispatcher.rb +139 -0
- data/lib/insika/allowlist.rb +28 -0
- data/lib/insika/baseline_store.rb +74 -0
- data/lib/insika/budget_ledger.rb +135 -0
- data/lib/insika/capability/resolved_tool.rb +34 -0
- data/lib/insika/capability_registry.rb +112 -0
- data/lib/insika/channel_delivery.rb +153 -0
- data/lib/insika/channel_registry.rb +30 -0
- data/lib/insika/channels/relay.rb +178 -0
- data/lib/insika/channels/web/widget.js +283 -0
- data/lib/insika/channels/web.rb +211 -0
- data/lib/insika/channels/webhook.rb +58 -0
- data/lib/insika/chat_builder.rb +303 -0
- data/lib/insika/checkpoint.rb +13 -0
- data/lib/insika/checkpoint_store.rb +153 -0
- data/lib/insika/circuit_state.rb +114 -0
- data/lib/insika/coercion.rb +58 -0
- data/lib/insika/command.rb +32 -0
- data/lib/insika/command_bus.rb +39 -0
- data/lib/insika/commands/agent_payload.rb +43 -0
- data/lib/insika/commands/approve_action.rb +46 -0
- data/lib/insika/commands/cancel_task.rb +33 -0
- data/lib/insika/commands/create_agent.rb +54 -0
- data/lib/insika/commands/create_session.rb +67 -0
- data/lib/insika/commands/delete_agent.rb +33 -0
- data/lib/insika/commands/delete_agent_file.rb +50 -0
- data/lib/insika/commands/delete_data_tool.rb +33 -0
- data/lib/insika/commands/delete_llm_provider.rb +36 -0
- data/lib/insika/commands/delete_mcp.rb +30 -0
- data/lib/insika/commands/delete_skill.rb +43 -0
- data/lib/insika/commands/delete_system_file.rb +29 -0
- data/lib/insika/commands/gate_refinement.rb +245 -0
- data/lib/insika/commands/import_mcp_tools.rb +48 -0
- data/lib/insika/commands/import_tools.rb +81 -0
- data/lib/insika/commands/issue_tenant_token.rb +41 -0
- data/lib/insika/commands/memory_add_note.rb +32 -0
- data/lib/insika/commands/memory_forget_fact.rb +32 -0
- data/lib/insika/commands/memory_put_fact.rb +35 -0
- data/lib/insika/commands/pause_task.rb +29 -0
- data/lib/insika/commands/resolve_refinement.rb +126 -0
- data/lib/insika/commands/restore_agent_file.rb +36 -0
- data/lib/insika/commands/restore_data_tool.rb +34 -0
- data/lib/insika/commands/restore_system_file.rb +31 -0
- data/lib/insika/commands/resume_task.rb +85 -0
- data/lib/insika/commands/revoke_token.rb +39 -0
- data/lib/insika/commands/rotate_tenant_token.rb +43 -0
- data/lib/insika/commands/run_refinement.rb +133 -0
- data/lib/insika/commands/send_message.rb +150 -0
- data/lib/insika/commands/set_agent_tools.rb +39 -0
- data/lib/insika/commands/set_skill_agents.rb +112 -0
- data/lib/insika/commands/trigger_workflow.rb +80 -0
- data/lib/insika/commands/update_agent.rb +49 -0
- data/lib/insika/commands/update_settings.rb +33 -0
- data/lib/insika/commands/upsert_llm_provider.rb +34 -0
- data/lib/insika/commands/upsert_mcp.rb +32 -0
- data/lib/insika/commands/write_agent_file.rb +57 -0
- data/lib/insika/commands/write_data_tool.rb +43 -0
- data/lib/insika/commands/write_golden.rb +58 -0
- data/lib/insika/commands/write_skill.rb +60 -0
- data/lib/insika/commands/write_system_file.rb +31 -0
- data/lib/insika/config_store.rb +89 -0
- data/lib/insika/context/builder.rb +166 -0
- data/lib/insika/context/catalog_provider.rb +23 -0
- data/lib/insika/context/fragment.rb +43 -0
- data/lib/insika/context/priority.rb +30 -0
- data/lib/insika/context/provider.rb +19 -0
- data/lib/insika/context/providers/memory.rb +60 -0
- data/lib/insika/context/providers/prompt.rb +105 -0
- data/lib/insika/context/providers/request.rb +32 -0
- data/lib/insika/context/providers/session.rb +123 -0
- data/lib/insika/context/providers/skill.rb +24 -0
- data/lib/insika/context/providers/skill_trigger.rb +128 -0
- data/lib/insika/context/providers/tool_search.rb +20 -0
- data/lib/insika/context_trace_store.rb +92 -0
- data/lib/insika/delegation_store.rb +153 -0
- data/lib/insika/doctor.rb +539 -0
- data/lib/insika/dsl/definition.rb +55 -0
- data/lib/insika/dsl/runtime.rb +382 -0
- data/lib/insika/dsl/server_boot.rb +98 -0
- data/lib/insika/dsl/system.rb +93 -0
- data/lib/insika/dsl/workflow_adapter.rb +59 -0
- data/lib/insika/dsl.rb +364 -0
- data/lib/insika/edge_limiter.rb +268 -0
- data/lib/insika/egress_guard.rb +75 -0
- data/lib/insika/env_schema.rb +249 -0
- data/lib/insika/errors.rb +201 -0
- data/lib/insika/evals/assertions.rb +247 -0
- data/lib/insika/evals/baseline.rb +69 -0
- data/lib/insika/evals/golden.rb +172 -0
- data/lib/insika/evals/judge.rb +225 -0
- data/lib/insika/evals/pairwise.rb +178 -0
- data/lib/insika/evals/report.rb +115 -0
- data/lib/insika/evals/runner.rb +141 -0
- data/lib/insika/evals/transport.rb +178 -0
- data/lib/insika/event.rb +18 -0
- data/lib/insika/event_stream.rb +132 -0
- data/lib/insika/executor.rb +1995 -0
- data/lib/insika/frontmatter.rb +42 -0
- data/lib/insika/golden_store.rb +145 -0
- data/lib/insika/hooks.rb +48 -0
- data/lib/insika/http_client.rb +63 -0
- data/lib/insika/inbound_log.rb +84 -0
- data/lib/insika/llm_configurator.rb +99 -0
- data/lib/insika/llm_provider_store.rb +83 -0
- data/lib/insika/loop_detector.rb +143 -0
- data/lib/insika/mcp_http_client.rb +67 -0
- data/lib/insika/mcp_store.rb +115 -0
- data/lib/insika/mcp_tool_ingestor.rb +143 -0
- data/lib/insika/memory_store.rb +93 -0
- data/lib/insika/message_origin.rb +76 -0
- data/lib/insika/middleware.rb +36 -0
- data/lib/insika/model_policy.rb +52 -0
- data/lib/insika/model_resolver.rb +176 -0
- data/lib/insika/model_selection.rb +115 -0
- data/lib/insika/onboarding.rb +208 -0
- data/lib/insika/outbox_store.rb +166 -0
- data/lib/insika/overlay_tool_registry.rb +102 -0
- data/lib/insika/pack.rb +102 -0
- data/lib/insika/pack_importer.rb +123 -0
- data/lib/insika/pending_action_store.rb +120 -0
- data/lib/insika/plugin/loader.rb +356 -0
- data/lib/insika/plugin.rb +35 -0
- data/lib/insika/policy/engine.rb +83 -0
- data/lib/insika/policy/policy.rb +120 -0
- data/lib/insika/policy_registry.rb +23 -0
- data/lib/insika/profile_source.rb +143 -0
- data/lib/insika/prompt_catalog.rb +61 -0
- data/lib/insika/provider_error_classifier.rb +160 -0
- data/lib/insika/queue_policy.rb +167 -0
- data/lib/insika/recovery.rb +168 -0
- data/lib/insika/refinement/candidate.rb +159 -0
- data/lib/insika/refinement/evidence_collector.rb +371 -0
- data/lib/insika/refinement/gate.rb +234 -0
- data/lib/insika/refinement/panel.rb +222 -0
- data/lib/insika/refinement/proposer.rb +262 -0
- data/lib/insika/refinement_store.rb +295 -0
- data/lib/insika/registry.rb +59 -0
- data/lib/insika/reliability.rb +185 -0
- data/lib/insika/safety/config.rb +109 -0
- data/lib/insika/safety/detectors.rb +176 -0
- data/lib/insika/safety/factory.rb +102 -0
- data/lib/insika/safety/input_guardrail.rb +102 -0
- data/lib/insika/safety/moderator.rb +94 -0
- data/lib/insika/safety/output_filter.rb +79 -0
- data/lib/insika/safety/output_validator.rb +101 -0
- data/lib/insika/safety/safe_responses.rb +47 -0
- data/lib/insika/sandbox/boundary.rb +93 -0
- data/lib/insika/sandbox/docker.rb +74 -0
- data/lib/insika/sandbox/local.rb +33 -0
- data/lib/insika/sandbox/runner.rb +80 -0
- data/lib/insika/sandbox.rb +85 -0
- data/lib/insika/schema_guard.rb +147 -0
- data/lib/insika/secret_masking.rb +34 -0
- data/lib/insika/server/a2a/agent_card.rb +27 -0
- data/lib/insika/server/a2a/app.rb +112 -0
- data/lib/insika/server/a2a/client.rb +101 -0
- data/lib/insika/server/a2a/errors.rb +32 -0
- data/lib/insika/server/a2a/http.rb +42 -0
- data/lib/insika/server/a2a/message.rb +27 -0
- data/lib/insika/server/a2a/protocol.rb +45 -0
- data/lib/insika/server/a2a/remotes.rb +25 -0
- data/lib/insika/server/a2a/task_projection.rb +40 -0
- data/lib/insika/server/app.rb +1022 -0
- data/lib/insika/server/boot.rb +119 -0
- data/lib/insika/server/rack_app.rb +118 -0
- data/lib/insika/server/responses.rb +165 -0
- data/lib/insika/server/sse_body.rb +96 -0
- data/lib/insika/server/tenant_auth.rb +61 -0
- data/lib/insika/session_actor.rb +162 -0
- data/lib/insika/session_store.rb +143 -0
- data/lib/insika/settings_store.rb +154 -0
- data/lib/insika/shutdown.rb +125 -0
- data/lib/insika/skill_catalog.rb +220 -0
- data/lib/insika/skill_store.rb +127 -0
- data/lib/insika/steer_injector.rb +110 -0
- data/lib/insika/store.rb +52 -0
- data/lib/insika/stores/memory.rb +123 -0
- data/lib/insika/stores/sqlite.rb +183 -0
- data/lib/insika/studio/app.rb +1693 -0
- data/lib/insika/studio/assets/dist/application.css +1 -0
- data/lib/insika/studio/assets/dist/application.js +70 -0
- data/lib/insika/studio/forms.rb +335 -0
- data/lib/insika/studio/nav_icons.rb +31 -0
- data/lib/insika/studio/views/_message.erb +44 -0
- data/lib/insika/studio/views/agent_detail.erb +285 -0
- data/lib/insika/studio/views/agents.erb +63 -0
- data/lib/insika/studio/views/approvals.erb +41 -0
- data/lib/insika/studio/views/chats.erb +34 -0
- data/lib/insika/studio/views/evals.erb +83 -0
- data/lib/insika/studio/views/home.erb +72 -0
- data/lib/insika/studio/views/layout.erb +94 -0
- data/lib/insika/studio/views/login.erb +17 -0
- data/lib/insika/studio/views/mcp.erb +91 -0
- data/lib/insika/studio/views/not_found.erb +5 -0
- data/lib/insika/studio/views/playground.erb +47 -0
- data/lib/insika/studio/views/refinement.erb +234 -0
- data/lib/insika/studio/views/session.erb +137 -0
- data/lib/insika/studio/views/settings.erb +168 -0
- data/lib/insika/studio/views/skills.erb +141 -0
- data/lib/insika/studio/views/system_files.erb +65 -0
- data/lib/insika/studio/views/task.erb +105 -0
- data/lib/insika/studio/views/tasks.erb +33 -0
- data/lib/insika/studio/views/tool_edit.erb +107 -0
- data/lib/insika/studio/views/tools.erb +89 -0
- data/lib/insika/subagent_graph.rb +96 -0
- data/lib/insika/system_file_store.rb +96 -0
- data/lib/insika/task_actor.rb +128 -0
- data/lib/insika/task_store.rb +250 -0
- data/lib/insika/telemetry/pricing.rb +104 -0
- data/lib/insika/telemetry/recorder.rb +228 -0
- data/lib/insika/telemetry.rb +127 -0
- data/lib/insika/testing/store_contract.rb +270 -0
- data/lib/insika/tick.rb +122 -0
- data/lib/insika/token_estimator.rb +16 -0
- data/lib/insika/token_store.rb +168 -0
- data/lib/insika/tool_assembly.rb +140 -0
- data/lib/insika/tool_catalog.rb +89 -0
- data/lib/insika/tool_definition.rb +518 -0
- data/lib/insika/tool_envelope.rb +140 -0
- data/lib/insika/tool_manifest.rb +218 -0
- data/lib/insika/tool_output_compressor.rb +100 -0
- data/lib/insika/tool_registry.rb +21 -0
- data/lib/insika/tool_store.rb +135 -0
- data/lib/insika/tool_trace_store.rb +92 -0
- data/lib/insika/tools/a2a_remote.rb +48 -0
- data/lib/insika/tools/agent_enum.rb +68 -0
- data/lib/insika/tools/concurrency.rb +54 -0
- data/lib/insika/tools/data_defined_tool.rb +219 -0
- data/lib/insika/tools/load_skill.rb +99 -0
- data/lib/insika/tools/remember.rb +53 -0
- data/lib/insika/tools/stuck_signal.rb +44 -0
- data/lib/insika/tools/subagent.rb +75 -0
- data/lib/insika/tools/subagents.rb +77 -0
- data/lib/insika/tools/tool_search.rb +94 -0
- data/lib/insika/turn_output.rb +139 -0
- data/lib/insika/turn_state.rb +162 -0
- data/lib/insika/turn_timing.rb +56 -0
- data/lib/insika/usage_ledger.rb +47 -0
- data/lib/insika/version.rb +3 -1
- data/lib/insika/wiring/graph.rb +249 -0
- data/lib/insika/workflow.rb +185 -0
- data/lib/insika/workflow_registry.rb +33 -0
- data/lib/insika.rb +220 -4
- 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.
|