insika 0.1.0 → 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 (182) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +69 -3
  3. data/README.md +1 -1
  4. data/bin/insika +22 -7
  5. data/docs/AGENTS.md +129 -5
  6. data/docs/CHANNELS.md +1 -1
  7. data/docs/CONTEXT.md +22 -5
  8. data/docs/DEPLOY.md +30 -10
  9. data/docs/EMBEDDING.md +11 -7
  10. data/docs/EVALS.md +1 -1
  11. data/docs/LOADTEST.md +3 -2
  12. data/docs/OBSERVABILITY.md +11 -2
  13. data/docs/REFINEMENT.md +6 -6
  14. data/docs/RELEASING.md +7 -7
  15. data/docs/RUNNING-LOCAL.md +1 -1
  16. data/docs/SECURITY.md +24 -11
  17. data/docs/SKILLS.md +189 -3
  18. data/docs/WHY.md +1 -1
  19. data/docs/WORKFLOWS.md +2 -2
  20. data/docs/index.md +1 -1
  21. data/docs/onboarding/start.md +1 -1
  22. data/lib/insika/agent_profile.rb +89 -22
  23. data/lib/insika/alert_dispatcher.rb +139 -0
  24. data/lib/insika/baseline_store.rb +2 -2
  25. data/lib/insika/budget_ledger.rb +135 -0
  26. data/lib/insika/channel_delivery.rb +14 -11
  27. data/lib/insika/channel_registry.rb +1 -1
  28. data/lib/insika/channels/relay.rb +3 -3
  29. data/lib/insika/channels/web/widget.js +2 -2
  30. data/lib/insika/channels/web.rb +7 -7
  31. data/lib/insika/channels/webhook.rb +58 -0
  32. data/lib/insika/chat_builder.rb +62 -13
  33. data/lib/insika/circuit_state.rb +114 -0
  34. data/lib/insika/coercion.rb +8 -0
  35. data/lib/insika/commands/agent_payload.rb +5 -3
  36. data/lib/insika/commands/create_agent.rb +2 -2
  37. data/lib/insika/commands/create_session.rb +1 -1
  38. data/lib/insika/commands/delete_llm_provider.rb +1 -1
  39. data/lib/insika/commands/delete_skill.rb +43 -0
  40. data/lib/insika/commands/gate_refinement.rb +12 -12
  41. data/lib/insika/commands/import_mcp_tools.rb +1 -1
  42. data/lib/insika/commands/import_tools.rb +4 -4
  43. data/lib/insika/commands/issue_tenant_token.rb +41 -0
  44. data/lib/insika/commands/resolve_refinement.rb +1 -1
  45. data/lib/insika/commands/revoke_token.rb +39 -0
  46. data/lib/insika/commands/rotate_tenant_token.rb +43 -0
  47. data/lib/insika/commands/run_refinement.rb +5 -5
  48. data/lib/insika/commands/send_message.rb +9 -9
  49. data/lib/insika/commands/set_agent_tools.rb +1 -1
  50. data/lib/insika/commands/set_skill_agents.rb +60 -19
  51. data/lib/insika/commands/trigger_workflow.rb +1 -1
  52. data/lib/insika/commands/update_agent.rb +1 -1
  53. data/lib/insika/commands/write_data_tool.rb +1 -1
  54. data/lib/insika/commands/write_golden.rb +1 -1
  55. data/lib/insika/commands/write_skill.rb +19 -9
  56. data/lib/insika/config_store.rb +8 -4
  57. data/lib/insika/context/builder.rb +2 -2
  58. data/lib/insika/context/fragment.rb +27 -3
  59. data/lib/insika/context/priority.rb +3 -2
  60. data/lib/insika/context/providers/memory.rb +1 -1
  61. data/lib/insika/context/providers/request.rb +1 -1
  62. data/lib/insika/context/providers/session.rb +17 -2
  63. data/lib/insika/context/providers/skill.rb +5 -1
  64. data/lib/insika/context/providers/skill_trigger.rb +128 -0
  65. data/lib/insika/context_trace_store.rb +92 -0
  66. data/lib/insika/delegation_store.rb +2 -2
  67. data/lib/insika/doctor.rb +250 -5
  68. data/lib/insika/dsl/runtime.rb +12 -9
  69. data/lib/insika/dsl/server_boot.rb +4 -3
  70. data/lib/insika/dsl/system.rb +1 -1
  71. data/lib/insika/dsl.rb +72 -15
  72. data/lib/insika/edge_limiter.rb +144 -6
  73. data/lib/insika/egress_guard.rb +3 -3
  74. data/lib/insika/env_schema.rb +13 -10
  75. data/lib/insika/errors.rb +61 -5
  76. data/lib/insika/evals/assertions.rb +12 -12
  77. data/lib/insika/evals/baseline.rb +3 -3
  78. data/lib/insika/evals/golden.rb +8 -8
  79. data/lib/insika/evals/judge.rb +7 -7
  80. data/lib/insika/evals/pairwise.rb +3 -3
  81. data/lib/insika/evals/report.rb +2 -2
  82. data/lib/insika/evals/runner.rb +6 -6
  83. data/lib/insika/evals/transport.rb +2 -2
  84. data/lib/insika/event_stream.rb +23 -5
  85. data/lib/insika/executor.rb +423 -108
  86. data/lib/insika/frontmatter.rb +1 -1
  87. data/lib/insika/golden_store.rb +2 -2
  88. data/lib/insika/http_client.rb +3 -3
  89. data/lib/insika/inbound_log.rb +1 -1
  90. data/lib/insika/llm_configurator.rb +3 -3
  91. data/lib/insika/loop_detector.rb +143 -0
  92. data/lib/insika/mcp_http_client.rb +4 -4
  93. data/lib/insika/mcp_tool_ingestor.rb +6 -6
  94. data/lib/insika/message_origin.rb +2 -2
  95. data/lib/insika/model_resolver.rb +1 -1
  96. data/lib/insika/model_selection.rb +5 -4
  97. data/lib/insika/onboarding.rb +2 -2
  98. data/lib/insika/outbox_store.rb +2 -2
  99. data/lib/insika/overlay_tool_registry.rb +3 -4
  100. data/lib/insika/pack.rb +3 -3
  101. data/lib/insika/pack_importer.rb +17 -15
  102. data/lib/insika/pending_action_store.rb +1 -1
  103. data/lib/insika/plugin/loader.rb +2 -2
  104. data/lib/insika/policy/policy.rb +1 -1
  105. data/lib/insika/profile_source.rb +12 -6
  106. data/lib/insika/provider_error_classifier.rb +160 -0
  107. data/lib/insika/queue_policy.rb +2 -2
  108. data/lib/insika/recovery.rb +47 -6
  109. data/lib/insika/refinement/candidate.rb +4 -4
  110. data/lib/insika/refinement/evidence_collector.rb +6 -6
  111. data/lib/insika/refinement/gate.rb +7 -7
  112. data/lib/insika/refinement/panel.rb +7 -7
  113. data/lib/insika/refinement/proposer.rb +9 -9
  114. data/lib/insika/refinement_store.rb +12 -12
  115. data/lib/insika/reliability.rb +185 -0
  116. data/lib/insika/safety/config.rb +2 -2
  117. data/lib/insika/safety/detectors.rb +5 -5
  118. data/lib/insika/safety/factory.rb +3 -3
  119. data/lib/insika/safety/input_guardrail.rb +19 -4
  120. data/lib/insika/safety/moderator.rb +19 -11
  121. data/lib/insika/safety/output_filter.rb +2 -2
  122. data/lib/insika/safety/output_validator.rb +2 -2
  123. data/lib/insika/safety/safe_responses.rb +1 -1
  124. data/lib/insika/sandbox/boundary.rb +2 -2
  125. data/lib/insika/sandbox.rb +1 -1
  126. data/lib/insika/server/app.rb +223 -51
  127. data/lib/insika/server/boot.rb +4 -4
  128. data/lib/insika/server/rack_app.rb +15 -7
  129. data/lib/insika/server/responses.rb +18 -8
  130. data/lib/insika/server/tenant_auth.rb +61 -0
  131. data/lib/insika/session_actor.rb +3 -3
  132. data/lib/insika/session_store.rb +1 -1
  133. data/lib/insika/settings_store.rb +5 -5
  134. data/lib/insika/shutdown.rb +4 -4
  135. data/lib/insika/skill_catalog.rb +127 -20
  136. data/lib/insika/skill_store.rb +70 -22
  137. data/lib/insika/steer_injector.rb +1 -1
  138. data/lib/insika/store.rb +1 -1
  139. data/lib/insika/studio/app.rb +183 -61
  140. data/lib/insika/studio/assets/dist/application.js +25 -24
  141. data/lib/insika/studio/forms.rb +13 -18
  142. data/lib/insika/studio/nav_icons.rb +1 -1
  143. data/lib/insika/studio/views/_message.erb +2 -2
  144. data/lib/insika/studio/views/agent_detail.erb +2 -2
  145. data/lib/insika/studio/views/agents.erb +1 -1
  146. data/lib/insika/studio/views/refinement.erb +4 -4
  147. data/lib/insika/studio/views/session.erb +78 -3
  148. data/lib/insika/studio/views/settings.erb +7 -12
  149. data/lib/insika/studio/views/skills.erb +67 -12
  150. data/lib/insika/subagent_graph.rb +3 -3
  151. data/lib/insika/task_actor.rb +3 -3
  152. data/lib/insika/task_store.rb +1 -1
  153. data/lib/insika/telemetry/pricing.rb +3 -3
  154. data/lib/insika/telemetry/recorder.rb +1 -1
  155. data/lib/insika/telemetry.rb +2 -2
  156. data/lib/insika/testing/store_contract.rb +27 -27
  157. data/lib/insika/tick.rb +122 -0
  158. data/lib/insika/token_store.rb +168 -0
  159. data/lib/insika/tool_assembly.rb +5 -5
  160. data/lib/insika/tool_definition.rb +8 -8
  161. data/lib/insika/tool_envelope.rb +1 -1
  162. data/lib/insika/tool_manifest.rb +6 -6
  163. data/lib/insika/tool_output_compressor.rb +100 -0
  164. data/lib/insika/tool_store.rb +1 -1
  165. data/lib/insika/tool_trace_store.rb +1 -1
  166. data/lib/insika/tools/concurrency.rb +2 -2
  167. data/lib/insika/tools/data_defined_tool.rb +4 -5
  168. data/lib/insika/tools/load_skill.rb +61 -3
  169. data/lib/insika/tools/stuck_signal.rb +44 -0
  170. data/lib/insika/tools/subagent.rb +4 -4
  171. data/lib/insika/tools/subagents.rb +1 -1
  172. data/lib/insika/turn_output.rb +2 -2
  173. data/lib/insika/turn_state.rb +17 -13
  174. data/lib/insika/turn_timing.rb +2 -2
  175. data/lib/insika/usage_ledger.rb +1 -1
  176. data/lib/insika/version.rb +1 -1
  177. data/lib/insika/wiring/graph.rb +77 -26
  178. data/lib/insika/workflow.rb +1 -1
  179. data/lib/insika/workflow_registry.rb +1 -1
  180. data/lib/insika.rb +32 -15
  181. metadata +19 -2
  182. data/lib/insika/server/admin_auth.rb +0 -29
@@ -6,7 +6,7 @@ require "securerandom"
6
6
 
7
7
  module Insika
8
8
  module Channels
9
- # The web widget (RFC-0011 §5) — the FIRST Shape A channel, and the adoption
9
+ # The web widget — the FIRST Shape A channel, and the adoption
10
10
  # claim behind the whole RFC: an adopter pastes one tag into their site and has
11
11
  # a working agent, with no backend of their own and no build step.
12
12
  #
@@ -17,7 +17,7 @@ module Insika
17
17
  # no outbox, no claim and no `deliver` here — the three routes and a static
18
18
  # asset are the entire surface:
19
19
  #
20
- # POST /channels/web/sessions mint an opaque session id (the engine's, §4.3)
20
+ # POST /channels/web/sessions mint an opaque session id (the engine's)
21
21
  # POST /channels/web/messages the turn, answered as SSE on this connection
22
22
  # GET /channels/web/asset/widget.js
23
23
  #
@@ -29,7 +29,7 @@ module Insika
29
29
  # · an AGENT allowlist — a visitor addresses the agents the operator published
30
30
  # to the widget, not every agent in the deployment;
31
31
  # · an ORIGIN allowlist — exact match, no wildcards, and no "allow all" value;
32
- # · a mandatory chat RATE LIMIT (§5.4) — the channel answers `:disabled`
32
+ # · a mandatory chat RATE LIMIT — the channel answers `:disabled`
33
33
  # until one is configured, because a public endpoint with an LLM behind it
34
34
  # and no ceiling is an unmetered bill waiting to happen.
35
35
  #
@@ -49,7 +49,7 @@ module Insika
49
49
  # An unversioned URL cannot be cached for a year — the next release would
50
50
  # never reach a browser that already has it. Short max-age + an ETag instead:
51
51
  # the common case is a 304 with no body, and an upgrade lands within minutes.
52
- # (A deliberate narrowing of §5.2's "long-cache versioned URL": the install
52
+ # (A deliberate narrowing of's "long-cache versioned URL": the install
53
53
  # snippet in the RFC has no version in it, so there is nothing to bust.)
54
54
  ASSET_CACHE_CONTROL = "public, max-age=300"
55
55
 
@@ -67,7 +67,7 @@ module Insika
67
67
  new(origins: origins, agents: agents, id: id, chat_rate_limit: chat_rate_limit)
68
68
  end
69
69
 
70
- # The probe §5.4's gate needs, asked exactly the way `EdgeLimiter` asks it at
70
+ # The probe's gate needs, asked exactly the way `EdgeLimiter` asks it at
71
71
  # turn time: the per-agent override first, the platform default second. Built
72
72
  # here so both composition roots wire the gate identically, and returned as a
73
73
  # lambda so the channel itself stays store-free.
@@ -149,7 +149,7 @@ module Insika
149
149
  { agent: agent, session_id: session_id, message: message }
150
150
  end
151
151
 
152
- # §4.3's hard rule for a public channel: the ENGINE issues the id and the
152
+ #'s hard rule for a public channel: the ENGINE issues the id and the
153
153
  # client never proposes one. A visitor-supplied session id on an anonymous
154
154
  # endpoint is session hijacking by enumeration, so there is no create-on-write
155
155
  # path — `POST /messages` with an unknown id is a 404, not a new conversation.
@@ -159,7 +159,7 @@ module Insika
159
159
  # protocol: what to type, what to say while a tool runs, and how it ended.
160
160
  #
161
161
  # `:intermediate` and `:thinking` are deliberately absent. `:content` is the
162
- # ANSWER (P19) — the model's narration on the way there is internal, and a
162
+ # ANSWER — the model's narration on the way there is internal, and a
163
163
  # widget that rendered it would show the customer the engine thinking out loud.
164
164
  def frame_for(event)
165
165
  case event.type
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Insika
6
+ module Channels
7
+ # A webhook as a Shape B "channel": the recipient of operator ALERTS
8
+ # (WS6). One instance per configured URL, registered in the ChannelRegistry
9
+ # so ChannelDelivery's outbox+claim+retry pipeline delivers the alert the
10
+ # same way it delivers a chat answer — at-most-once, bounded retry, boot
11
+ # sweep. Deliberately NOT Slack/CRM/anything: it POSTs the event as JSON and
12
+ # the consumer interprets it (the engine transports, it does not integrate).
13
+ #
14
+ # The target URL is operator configuration, so the POST crosses the SAME
15
+ # egress guard the Relay applies: https-only (fails closed), private/
16
+ # loopback/metadata targets and DNS-rebindable hosts blocked. Without it the
17
+ # `alerts.webhook` URL is an SSRF vector — a URL pointed at cloud metadata
18
+ # or an internal API exfiltrates alert events out of the boundary (WS6).
19
+ class Webhook
20
+ def initialize(url, http:, allow_http: false, allow_private: false)
21
+ @http = http
22
+ @allow_http = allow_http
23
+ @allow_private = allow_private
24
+ end
25
+
26
+ # The ChannelDelivery contract: -> HTTP status (200..299 = delivered).
27
+ # Every failure becomes a DeliveryError so the bounded retry records it.
28
+ def deliver(payload, to:, delivery_id: nil)
29
+ if (reason = egress_violation(to))
30
+ raise Insika::DeliveryError, "webhook egress blocked for #{to}: #{reason}"
31
+ end
32
+
33
+ result = @http.request(
34
+ method: :post, url: to,
35
+ headers: { "content-type" => "application/json" },
36
+ body: JSON.generate(payload)
37
+ )
38
+ result[:status].to_i
39
+ rescue Insika::DeliveryError
40
+ raise
41
+ rescue StandardError => e
42
+ raise Insika::DeliveryError, "webhook: #{e.message}"
43
+ end
44
+
45
+ private
46
+
47
+ # Resolved on EVERY delivery, not once at registration: a host that
48
+ # answered a public address yesterday can answer 169.254.169.254 today,
49
+ # and this POST carries the operator's alerts.
50
+ def egress_violation(url)
51
+ Insika::EgressGuard.violation(url, allow_http: @allow_http,
52
+ allow_private: @allow_private)
53
+ rescue URI::InvalidURIError
54
+ "invalid URL"
55
+ end
56
+ end
57
+ end
58
+ end
@@ -13,7 +13,8 @@ module Insika
13
13
  # Executor, injected as the `emit` callable.
14
14
  class ChatBuilder
15
15
  def initialize(tool_registry:, skill_catalog:, checkpoint_store:, event_stream:,
16
- hooks:, tool_catalog: nil, memory_store: nil, subagent_runner: nil)
16
+ hooks:, tool_catalog: nil, memory_store: nil, subagent_runner: nil,
17
+ tool_trace_store: nil)
17
18
  @tool_registry = tool_registry
18
19
  @skill_catalog = skill_catalog
19
20
  @checkpoint_store = checkpoint_store
@@ -21,7 +22,9 @@ module Insika
21
22
  @hooks = hooks
22
23
  @tool_catalog = tool_catalog
23
24
  @memory_store = memory_store
24
- # RFC-0010: the object exposing #run_subagent (the Executor). nil = the
25
+ # only to trace load_skill, which is not enveloped — nil = no trace (parity).
26
+ @tool_trace_store = tool_trace_store
27
+ # the object exposing #run_subagent (the Executor). nil = the
25
28
  # spawn_subagent system tool is never wired (parity for a builder used
26
29
  # without delegation, e.g. some unit stubs).
27
30
  @subagent_runner = subagent_runner
@@ -69,9 +72,16 @@ module Insika
69
72
 
70
73
  # load_skill is a system default (outside the allowlist), otherwise
71
74
  # progressive disclosure breaks. allowed_skills comes from the Resolution
72
- # (policy).
73
- skill_names = Array(state.allowed_skills).map { |s| s.respond_to?(:name) ? s.name : s.to_s }
74
- tools << Tools::LoadSkill.new(@skill_catalog, skill_names) unless skill_names.empty?
75
+ # (policy), minus the EAGER ones: their bodies are already in the prompt, so a
76
+ # call could only pay for a duplicate. Nothing lazy left -> the tool is not
77
+ # wired at all. Keeping it for the discretionary skills is deliberate: that
78
+ # call is the only record of which skill the model actually reached for.
79
+ skill_names = lazy_skill_names(state)
80
+ unless skill_names.empty?
81
+ tools << Tools::LoadSkill.new(@skill_catalog, skill_names,
82
+ trace_recorder: @tool_trace_store, state: state,
83
+ agent: state.profile.id)
84
+ end
75
85
 
76
86
  # remember is the memory-write system tool — wired only with
77
87
  # @memory_store present AND profile.memory (a double gate). Never enveloped.
@@ -80,24 +90,32 @@ module Insika
80
90
  event_stream: @event_stream, state: state)
81
91
  end
82
92
 
83
- # spawn_subagent is the delegation system tool (RFC-0010) — wired only with a
93
+ # signal_stuck is the "I cannot proceed" system tool (WS5) — wired only
94
+ # when the agent opted in (`profile.stuck_signal`), never enveloped. It is a
95
+ # deterministic signal; the consumer decides what "stuck" means. Defensive
96
+ # read: a minimal profile double without the reader means off (nil = parity).
97
+ if state.profile.respond_to?(:stuck_signal) && state.profile.stuck_signal
98
+ tools << Tools::StuckSignal.new(state: state)
99
+ end
100
+
101
+ # spawn_subagent is the delegation system tool — wired only with a
84
102
  # runner present AND profile.subagents non-empty (a double gate, like
85
103
  # remember). Never enveloped: in the synchronous mode the child lives in the
86
104
  # parent's envelope. The runtime gate on WHICH agent is spawnable is the
87
105
  # parent's subagents allowlist, enforced in Executor#run_subagent.
88
106
  if @subagent_runner && !Array(state.profile.subagents).empty?
89
107
  tools << Tools::Subagent.new(runner: @subagent_runner, state: state)
90
- # ...and its parallel sibling (RFC-0010 §A): fan-out N children at once.
108
+ # and its parallel sibling: fan-out N children at once.
91
109
  tools << Tools::Subagents.new(runner: @subagent_runner, state: state)
92
110
  end
93
111
 
94
112
  unless tools.empty?
95
- # Item 30: the ONLY place the gem is told to run tool calls in parallel.
113
+ # the ONLY place the gem is told to run tool calls in parallel.
96
114
  # `:fibers` is not a preference but the only admissible mode — `:threads`
97
115
  # breaks ToolEnvelope's `Async::Task.current.with_timeout`, the SQLite
98
116
  # store's fiber semaphore, and the turn's own durability (mailbox,
99
117
  # approvals, cancellation are all expressed in fiber terms). The number
100
- # the operator configured is OUR cap (D4, ToolAssembly#install_tool_gate);
118
+ # the operator configured is OUR cap (ToolAssembly#install_tool_gate);
101
119
  # the gem has none.
102
120
  if tool_concurrency_for(state)
103
121
  chat.with_tools(*tools, concurrency: :fibers)
@@ -109,9 +127,21 @@ module Insika
109
127
  chat
110
128
  end
111
129
 
130
+ # The skills the model still has to ask for: the Resolution's set minus the eager
131
+ # ones. Intersected by NAME against the catalog's own verdict (SkillCatalog#eager_for)
132
+ # so the tool and the level-1 list can never disagree about who is eager. A catalog
133
+ # without the reader (a unit stub) falls back to the whole set — parity.
134
+ def lazy_skill_names(state)
135
+ names = Array(state.allowed_skills).map { |s| s.respond_to?(:name) ? s.name : s.to_s }
136
+ return names unless @skill_catalog.respond_to?(:eager_for)
137
+
138
+ eager = @skill_catalog.eager_for(state.profile).map(&:name)
139
+ names - eager
140
+ end
141
+
112
142
  # The turn's effective tool concurrency (nil = serial), plus the ONE thing the
113
143
  # gate owes the operator: when the profile asked for parallel tool calls and
114
- # this turn silently cannot have them (D3 — an approval-required tool would
144
+ # this turn silently cannot have them (an approval-required tool would
115
145
  # deadlock two fibers on the single per-task mailbox), say so once. Otherwise
116
146
  # the speedup just vanishes with no reason given. The rule itself lives in
117
147
  # TurnState; a state predating those readers (a unit stub) means off.
@@ -137,7 +167,7 @@ module Insika
137
167
  ))
138
168
  end
139
169
 
140
- # §11 R3: opt-in Anthropic prompt caching. When the agent enables
170
+ # R3: opt-in Anthropic prompt caching. When the agent enables
141
171
  # prompt_caching AND the resolved provider is Anthropic, wrap the system in
142
172
  # the provider's native Content helper with cache: true — ONE breakpoint at
143
173
  # the END of the system block. By Anthropic's prefix order
@@ -176,7 +206,7 @@ module Insika
176
206
  # History comes from the context/checkpoint. The {role:, content:} shape
177
207
  # tolerates string keys (JSON from the stores). `flatten(1)` dissolves the
178
208
  # Session provider's "eviction units" (an assistant+tool_results cycle grouped
179
- # as one Array, §11 R1) back into a flat message stream.
209
+ # as one Array, R1) back into a flat message stream.
180
210
  #
181
211
  # tool_calls / tool_call_id are rehydrated ONLY when present, so a message
182
212
  # without them keeps the 2-arg shape the specs' FakeChat expects (no unknown
@@ -216,6 +246,14 @@ module Insika
216
246
  tool_calls = 0
217
247
  max_tool_calls = state.profile.limits[:max_tool_calls] || 50
218
248
 
249
+ # the loop detector. Needs #after_message + #add_message for the
250
+ # batch-boundary intervention; a chat without them (smoke shim, minimal
251
+ # double) stays bounded by max_tool_calls alone — never half-wired.
252
+ detector = if %i[after_message add_message].all? { |m| chat.respond_to?(m) }
253
+ repeat = state.profile.limits[:max_tool_repeat] || Insika::AgentProfile::DEFAULT_LIMITS[:max_tool_repeat]
254
+ Insika::LoopDetector.new(chat: chat, limit: repeat, emit: emit) if repeat >= 2
255
+ end
256
+
219
257
  chat.before_tool_call do |tool_call|
220
258
  # call<->decorator correlation (side-effects/skip) — 1st line.
221
259
  state.current_tool_call = tool_call
@@ -224,9 +262,13 @@ module Insika
224
262
  tool_calls += 1
225
263
  if tool_calls > max_tool_calls
226
264
  raise Insika::TimeoutError.new("tool call limit exceeded (#{max_tool_calls})",
227
- stage: :tool_limit)
265
+ stage: :tool_limit)
228
266
  end
229
267
 
268
+ # AFTER the count, BEFORE the call runs — a post-warning
269
+ # repeat raises here, so the stubborn loop pays for no extra call.
270
+ detector&.tool_call(tool_call.name, tool_call.arguments)
271
+
230
272
  # :tool pair: RubyLLM's callbacks are additive — the altered subject
231
273
  # feeds later hooks and the events, but does not rewrite the call the
232
274
  # model executes. A hook exception here aborts the turn.
@@ -246,9 +288,16 @@ module Insika
246
288
  end
247
289
 
248
290
  chat.after_tool_result do |result|
291
+ # the RAW result — the only place a Tool::Halt (halt_when) is
292
+ # still recognizable, and a halted batch must receive no intervention.
293
+ detector&.tool_result(result)
249
294
  result = @hooks.run_after(:tool, result)
250
295
  emit.call(:tool_result, { name: state.current_tool_name, result: result.to_s })
251
296
  end
297
+
298
+ # the intervention appends at the batch boundary (the Nth tool
299
+ # result closing) — never between two tool results of one batch.
300
+ chat.after_message { |message| detector.message_ended(message) } if detector
252
301
  end
253
302
  end
254
303
  end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ # The circuit breaker's durable state (WS3): one cell per
7
+ # (tenant, provider/model ref) holding the recent failures and when the
8
+ # circuit opened. The read-modify-write rides `@store.transaction` — the same
9
+ # discipline as the dispatch claim (A1): two concurrent failures of the same
10
+ # cell serialize, so the "10 failures in 60s" count is never lost to a race.
11
+ #
12
+ # States, standard semantics:
13
+ # :closed fewer than `after` failures in the `within` window — attempts flow.
14
+ # :open the window count is met AND the cooldown hasn't elapsed — the
15
+ # edge FAIL-FASTS (no provider call) with retry_after = remaining
16
+ # cooldown. Reached at the `after`-th failure, which stamps
17
+ # opened_at.
18
+ # :half_open the cooldown elapsed — the next attempt is the TRIAL: a success
19
+ # closes (record_success clears the cell), a failure reopens
20
+ # (a new failure count / opened_at).
21
+ class CircuitState
22
+ SCOPE = "circuit_state"
23
+
24
+ # Timestamps are pruned to COUNT_LIMIT — a pathological loop cannot grow the
25
+ # cell unboundedly (the window is 60s-wide; 100 reads and writes bounded).
26
+ COUNT_LIMIT = 100
27
+
28
+ Record = Data.define(:failures, :opened_at)
29
+
30
+ def initialize(store:)
31
+ @store = store
32
+ end
33
+
34
+ # Records ONE failure for (tenant, ref). If this failure makes the window
35
+ # reach `after` and the circuit is not already open, it stamps opened_at
36
+ # (the instant the breaker trips). -> :closed (still closed) | :open (JUST
37
+ # tripped — closed->open, the ONLY transition that alerts) | :reopened (an
38
+ # already-tripped cell re-stamped: a half-open trial failed; the circuit is
39
+ # open again but the node already told the operator it is sick — no NEW
40
+ # alert (WS3)).
41
+ def record_failure(tenant:, ref:, after: 10, within: 60, now: Time.now)
42
+ key = key_for(tenant, ref)
43
+ @store.transaction do
44
+ record = load(key)
45
+ cutoff = (now.to_i - within)
46
+ retained = record.failures.select { |t| t > cutoff }.last(COUNT_LIMIT)
47
+ failures = (retained + [now.to_i]).last(COUNT_LIMIT)
48
+ opened_at = record.opened_at
49
+ tripped = :closed
50
+ if opened_at.nil?
51
+ if failures.size >= after
52
+ opened_at = now.to_i
53
+ tripped = :open
54
+ end
55
+ else
56
+ # the circuit was tripped before; a failure here can only be a HALF-OPEN
57
+ # trial that failed. Re-stamp opened_at so it RE-OPENS — a breaker that
58
+ # never restamps is one-shot: after the first cooldown every later turn
59
+ # is an unlocked half-open trial (WS3).
60
+ opened_at = now.to_i
61
+ tripped = :reopened
62
+ end
63
+ @store.set(SCOPE, key, { "failures" => failures, "opened_at" => opened_at })
64
+ tripped
65
+ end
66
+ end
67
+
68
+ # A successful attempt CLOSES the circuit: the cell is cleared so the
69
+ # failure window starts fresh (half-open trial success included).
70
+ def record_success(tenant:, ref:)
71
+ @store.delete(SCOPE, key_for(tenant, ref))
72
+ end
73
+
74
+ # -> :closed | :open | :half_open
75
+ def state(tenant:, ref:, after: 10, within: 60, cooldown: 300, now: Time.now)
76
+ record = load(key_for(tenant, ref))
77
+ opened = record.opened_at.to_i
78
+ # :closed needs BOTH doors shut: the window count is under `after` AND the
79
+ # circuit never tripped. A surviving opened_at tombstone keeps the circuit
80
+ # governed by the cooldown (open, then half-open) even after its original
81
+ # failures age out of the rolling window — only a SUCCESS clears it (WS3:
82
+ # a failed half-open trial must reopen, not silently close).
83
+ return :closed if record.failures.size < after && opened.zero?
84
+
85
+ return :open if opened.zero? || (now.to_i - opened) < cooldown
86
+
87
+ :half_open
88
+ end
89
+
90
+ # Seconds until the circuit can be retried (the remaining cooldown). nil
91
+ # while closed.
92
+ def retry_after(tenant:, ref:, cooldown: 300, now: Time.now)
93
+ record = load(key_for(tenant, ref))
94
+ return nil if record.failures.empty?
95
+
96
+ opened = record.opened_at.to_i
97
+ return nil if opened.zero?
98
+
99
+ remaining = cooldown - (now.to_i - opened)
100
+ remaining.positive? ? remaining : nil
101
+ end
102
+
103
+ private
104
+
105
+ def key_for(tenant, ref) = "#{tenant || 'platform'}:#{ref}"
106
+
107
+ def load(key)
108
+ record = @store.get(SCOPE, key)
109
+ return Record.new([], nil) if record.nil?
110
+
111
+ Record.new(Array(record["failures"]), record["opened_at"])
112
+ end
113
+ end
114
+ end
@@ -19,6 +19,14 @@ module Insika
19
19
  # Inverse of blank? — a usable, non-empty value.
20
20
  def present?(value) = !blank?(value)
21
21
 
22
+ # The operator-facing truthy: what a form checkbox ("1"), a JSON round-trip
23
+ # ("true") or the DSL (true) produce for an opt-in. One list, one reading —
24
+ # AgentProfile#stream_public?, SkillCatalog#blanket? and the Studio all consult
25
+ # it, so a value that opts in on one surface cannot opt out on another.
26
+ TRUTHY = [true, "true", "1", "yes", "on"].freeze
27
+
28
+ def truthy?(value) = TRUTHY.include?(value)
29
+
22
30
  # Bytes that come from outside the engine (sockets, pipes, subprocesses)
23
31
  # arrive tagged BINARY, or as UTF-8 carrying invalid sequences. Both break
24
32
  # `JSON.generate` the moment the text has an accent or an emoji — today a
@@ -10,9 +10,11 @@ module Insika
10
10
  module AgentPayload
11
11
  # Fields accepted by AgentProfile.build (order irrelevant).
12
12
  FIELDS = %i[id model provider base_prompt prompt_files tools_allow tools_deny
13
- tools_allow_groups skills context_providers workflows_allow policies
13
+ tools_allow_groups skills skills_eager context_providers workflows_allow policies
14
14
  prompt_refs limits approvals_required capabilities subagents tools_deferred
15
- memory params model_policy guardrails refinement capabilities_declared
15
+ memory prompt_caching tool_output_compression budget reliability alerts
16
+ stuck_signal
17
+ params model_policy guardrails refinement capabilities_declared
16
18
  edge_stream metadata].freeze
17
19
 
18
20
  module_function
@@ -25,7 +27,7 @@ module Insika
25
27
  out[:provider] = presence(out[:provider])&.to_sym if out.key?(:provider)
26
28
  out[:policies] = Array(out[:policies]).map(&:to_sym) if out.key?(:policies)
27
29
  out[:limits] = out[:limits].transform_keys(&:to_sym) if out[:limits].is_a?(Hash)
28
- # generation params (v2, §10) consumed by symbol key (temperature/max_tokens/thinking).
30
+ # generation params consumed by symbol key (temperature/max_tokens/thinking).
29
31
  out[:params] = out[:params].transform_keys(&:to_sym) if out[:params].is_a?(Hash)
30
32
  out
31
33
  end
@@ -17,13 +17,13 @@ module Insika
17
17
  attrs = AgentPayload.attrs(command.payload)
18
18
  id = AgentPayload.presence(attrs[:id])
19
19
  raise Insika::ValidationError, "id is required" if id.nil?
20
- # model is OPTIONAL as of v2 (§10): omitted -> resolves the platform
20
+ # model is OPTIONAL as of v2: omitted -> resolves the platform
21
21
  # default_model (Settings) at turn start. An agent with neither its own
22
22
  # model nor a platform default fails clearly at the first turn.
23
23
  raise Insika::ValidationError, "agent '#{id}' already exists" if @profile_source.fetch(id)
24
24
 
25
25
  profile = Insika::AgentProfile.build(**attrs)
26
- # RFC-0010 §4.4: definition-time cycle + depth check. Only a profile that
26
+ # definition-time cycle + depth check. Only a profile that
27
27
  # introduces edges (non-empty subagents) can create a violation — a
28
28
  # childless agent is always a safe leaf. Raises SubagentError (a
29
29
  # ValidationError) BEFORE persisting, so a bad graph never lands.
@@ -21,7 +21,7 @@ module Insika
21
21
  vars = command.payload[:vars] || command.payload["vars"] || {}
22
22
  raise Insika::ValidationError, "vars must be a Hash" unless vars.is_a?(Hash)
23
23
 
24
- # Per-chat model pin (v2, §10): `model`/`provider` on the payload become a
24
+ # Per-chat model pin: `model`/`provider` on the payload become a
25
25
  # reserved, collision-safe slot in vars (never rendered in the prompt — the
26
26
  # Request provider skips "__"-prefixed vars). The ModelResolver reads it as
27
27
  # the highest-precedence layer (Chat > Agent > platform default).
@@ -6,7 +6,7 @@ module Insika
6
6
  module Commands
7
7
  # Control command: removes an LLM provider from the
8
8
  # LLMProviderStore. Idempotent (`existed: false` if there was none). UNDOES the
9
- # config in RubyLLM at runtime (§9.5): when the provider existed, calls
9
+ # config in RubyLLM at runtime: when the provider existed, calls
10
10
  # `configurator.unapply(api)` clearing the global key/base — without a restart. A provider
11
11
  # that RubyLLM does not recognize degrades gracefully (unapply: false, nothing to
12
12
  # undo). -> { existed: bool }.
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ module Commands
7
+ # Control command: removes an authored skill from the SkillStore and RELOADS the
8
+ # catalog. -> { name, agent, deleted }.
9
+ #
10
+ # With `agent`, it deletes only that agent's SPECIALIZATION — the shared skill
11
+ # stays exactly where it was, and the agent falls back to it on the next turn.
12
+ # "Stop specializing this" is the inverse of the specialize action, and it must not
13
+ # be expressible as "delete the skill".
14
+ #
15
+ # Deleting a shared skill does NOT touch any allowlist: an agent left naming a
16
+ # skill that no longer exists sees nothing (the catalog resolves nothing), which is
17
+ # the same outcome as never having allowed it.
18
+ class DeleteSkill
19
+ def initialize(skill_store:, skill_catalog:, event_stream:)
20
+ @skill_store = skill_store
21
+ @skill_catalog = skill_catalog
22
+ @event_stream = event_stream
23
+ end
24
+
25
+ def call(command)
26
+ p = AgentPayload.symbolize(command.payload)
27
+ name = AgentPayload.presence(p[:name])
28
+ agent = AgentPayload.presence(p[:agent])
29
+ raise Insika::ValidationError, "name is required" if name.nil?
30
+
31
+ deleted = @skill_store.delete(name, agent: agent)
32
+ raise Insika::NotFoundError, "skill '#{name}' not found#{" for agent '#{agent}'" if agent}" unless deleted
33
+
34
+ @skill_catalog.reload
35
+ @event_stream.emit(Insika::Event.new(
36
+ type: :skill_deleted, data: { name: name, agent: agent }.compact,
37
+ meta: { at: Time.now.utc.iso8601 }
38
+ ))
39
+ { name: name, agent: agent, deleted: true }
40
+ end
41
+ end
42
+ end
43
+ end
@@ -4,21 +4,21 @@ require "time"
4
4
 
5
5
  module Insika
6
6
  module Commands
7
- # Control command (RFC-0013 phase C): takes a CANDIDATE for a completed
7
+ # Control command: takes a CANDIDATE for a completed
8
8
  # refinement run, validates it against the agent's write allowlist, and scores it
9
9
  # by actually running it — clone the agent, apply the edits to the clone, replay
10
- # the golden set, compare to the accepted baseline (§3.5).
10
+ # the golden set, compare to the accepted baseline.
11
11
  #
12
12
  # Synchronous like `run_refinement`, and for the same reason: it is operator-paced
13
13
  # work with a human waiting on the answer. It is NOT cheap — the replay is a real
14
14
  # conversation per golden case — so it is fired deliberately, never on a timer
15
- # (D8: the engine has no scheduler and this RFC does not add one).
15
+ # (the engine has no scheduler and this RFC does not add one).
16
16
  #
17
17
  # Payload:
18
18
  # run_id (required) a run in :completed — the evidence the candidate answers
19
19
  # candidate { proposer?, rationale?, edits: [ {file, op, anchor?, before, after,
20
20
  # addresses?} ] } — or omit it and pass `propose: true` to have the
21
- # configured model(s) write one from the run's findings (§3.4).
21
+ # configured model(s) write one from the run's findings.
22
22
  # propose truthy — write the candidate with the agent's proposer PANEL.
23
23
  # tolerance Float — overrides the configured judge-score tolerance for this gate
24
24
  #
@@ -30,14 +30,14 @@ module Insika
30
30
  # forgot the candidate should get an error, not a bill.
31
31
  #
32
32
  # Two refusals happen BEFORE anything is cloned, because both mean the operator
33
- # has not actually enabled this: an agent still in `mode: report` (§3.8 — writing
33
+ # has not actually enabled this: an agent still in `mode: report` (writing
34
34
  # is opt-in and an absent config is report-only), and a candidate whose every edit
35
35
  # was dropped (stale, off-allowlist, over budget). Neither is worth a provider bill.
36
36
  #
37
- # Phase D (§3.9) makes the proposal a PANEL and the run's spend a BUDGET. The
37
+ # makes the proposal a PANEL and the run's spend a BUDGET. The
38
38
  # command's shape does not change: N models write N candidates, the gate scores
39
39
  # each, and the best survivor is the one proposal a human is shown. A deployment
40
- # with one proposer and no budget behaves exactly as phase C did.
40
+ # with one proposer and no budget behaves exactly as did.
41
41
  class GateRefinement
42
42
  WRITE_MODES = %w[propose auto_apply].freeze
43
43
 
@@ -49,7 +49,7 @@ module Insika
49
49
 
50
50
  # proposer_factory: ->(refinement_config) { [Refinement::Proposer] | Proposer | nil }.
51
51
  # Optional — a deployment with none can still gate a candidate that arrives from
52
- # the API, which is exactly what phase C shipped before the proposer existed.
52
+ # the API, which is exactly what shipped before the proposer existed.
53
53
  # resolver: the :resolve_refinement handler, used ONLY for `mode: auto_apply`.
54
54
  # Reusing it rather than writing files here is what keeps auto-apply honest: it
55
55
  # goes through the same staleness re-check, the same versioned write and the
@@ -125,7 +125,7 @@ module Insika
125
125
  end
126
126
 
127
127
  # Editing an agent's instructions is opt-in, explicitly, per agent — the whole
128
- # point of §3.1 is that the reachable surface is small and declared. An absent
128
+ # point of is that the reachable surface is small and declared. An absent
129
129
  # or `report` config is not "not configured yet", it is a NO.
130
130
  def require_write_mode!(config, agent_id)
131
131
  mode = Coercion.presence(config["mode"]) || "report"
@@ -148,7 +148,7 @@ module Insika
148
148
  raise ArgumentError, "run #{run.id} is #{run.status}, expected completed"
149
149
  end
150
150
 
151
- # The model(s) write the candidates (§3.4/§3.9). Each is shown the run's findings
151
+ # The model(s) write the candidates. Each is shown the run's findings
152
152
  # and the CURRENT content of the allowlisted files only — the same allowlist the
153
153
  # builder then enforces, so a proposal cannot even name a file it may not touch.
154
154
  #
@@ -194,7 +194,7 @@ module Insika
194
194
  allow
195
195
  end
196
196
 
197
- # `mode: auto_apply` (§3.6, D2) — the ONE path where a prompt changes with no
197
+ # `mode: auto_apply` — the ONE path where a prompt changes with no
198
198
  # human in the loop. Off by default and deliberately narrow: it needs the mode,
199
199
  # a gate PASS with zero regressions, and a diff under `auto_apply_max_edits`.
200
200
  # Everything else parks at :awaiting_approval, which is the product.
@@ -232,7 +232,7 @@ module Insika
232
232
  end
233
233
 
234
234
  # Counts and ids only, never file content: these events reach the operator
235
- # stream and the same rule the findings follow applies here (§5).
235
+ # stream and the same rule the findings follow applies here.
236
236
  def emit(type, run, **data)
237
237
  @event_stream.emit(Insika::Event.new(
238
238
  type: type,
@@ -4,7 +4,7 @@ require "time"
4
4
 
5
5
  module Insika
6
6
  module Commands
7
- # Control command: LIVE MCP ingestion (Phase 7, Step E). Receives the NAME of
7
+ # Control command: LIVE MCP ingestion. Receives the NAME of
8
8
  # an MCP instance, delegates to McpToolIngestor (discovers via an injectable client
9
9
  # -> builds the manifest -> reuses :import_tools: batch upsert + hot reload)
10
10
  # and returns the per-tool report in the shape of :import_tools, plus
@@ -4,18 +4,18 @@ require "time"
4
4
 
5
5
  module Insika
6
6
  module Commands
7
- # BATCH ingestion of data-tools from a manifest (Phase 7, Step B).
7
+ # BATCH ingestion of data-tools from a manifest.
8
8
  # Normalizes each tool (defaults + envelope adapter + endpoint→url + secret/
9
9
  # env) via ToolManifest, UPSERTs it into the ToolStore, and RELOADS the overlay +
10
- # catalog ONCE at the end — takes effect without a restart (NF3). Idempotent
10
+ # catalog ONCE at the end — takes effect without a restart. Idempotent
11
11
  # (re-importing reconciles). Injects the deployment's `{{secret.*}}`/`{{env.*}}`
12
- # resolvers (D6/open-q2): the secret NEVER comes in the manifest.
12
+ # resolvers: the secret NEVER comes in the manifest.
13
13
  #
14
14
  # ISOLATED PARTIAL FAILURE (R4): a malformed tool (invalid envelope, missing
15
15
  # endpoint, unconfigured secret, collision with a code tool, invalid url)
16
16
  # does NOT bring down the batch — it becomes an entry in `errors[]`. Only a STRUCTURAL error in the
17
17
  # manifest (defaults/tools of the wrong type) raises (transport -> 422).
18
- # Per-tool report in the shape of the Phase 6 pack importer.
18
+ # Per-tool report in the shape of the pack importer.
19
19
  # -> { version, created: [names], updated: [names], errors: [{tool,error}] }
20
20
  class ImportTools
21
21
  def initialize(tool_store:, registry:, tool_catalog:, event_stream:, secrets: ENV, env: ENV)