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
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.
|