insika 0.0.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (277) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +361 -0
  3. data/LICENSE +21 -0
  4. data/README.md +136 -2
  5. data/bin/insika +366 -0
  6. data/docs/AGENTS.md +618 -0
  7. data/docs/ARCHITECTURE.md +333 -0
  8. data/docs/BENCHMARK.md +114 -0
  9. data/docs/CHANNELS.md +453 -0
  10. data/docs/CONTEXT.md +117 -0
  11. data/docs/DEPLOY.md +354 -0
  12. data/docs/EMBEDDING.md +198 -0
  13. data/docs/EVALS.md +273 -0
  14. data/docs/LOADTEST.md +232 -0
  15. data/docs/OBSERVABILITY.md +374 -0
  16. data/docs/PLUGINS.md +211 -0
  17. data/docs/REFINEMENT.md +477 -0
  18. data/docs/RELEASING.md +70 -0
  19. data/docs/RUNNING-LOCAL.md +153 -0
  20. data/docs/SANDBOX.md +114 -0
  21. data/docs/SECURITY.md +375 -0
  22. data/docs/SKILLS.md +284 -0
  23. data/docs/TOOLS.md +302 -0
  24. data/docs/WHY.md +137 -0
  25. data/docs/WORKFLOWS.md +225 -0
  26. data/docs/build.md +14 -0
  27. data/docs/index.md +68 -0
  28. data/docs/onboarding/start.md +126 -0
  29. data/docs/operate.md +12 -0
  30. data/docs/ship.md +10 -0
  31. data/docs/understand.md +10 -0
  32. data/lib/insika/agent_file_store.rb +125 -0
  33. data/lib/insika/agent_profile.rb +255 -0
  34. data/lib/insika/alert_dispatcher.rb +139 -0
  35. data/lib/insika/allowlist.rb +28 -0
  36. data/lib/insika/baseline_store.rb +74 -0
  37. data/lib/insika/budget_ledger.rb +135 -0
  38. data/lib/insika/capability/resolved_tool.rb +34 -0
  39. data/lib/insika/capability_registry.rb +112 -0
  40. data/lib/insika/channel_delivery.rb +153 -0
  41. data/lib/insika/channel_registry.rb +30 -0
  42. data/lib/insika/channels/relay.rb +178 -0
  43. data/lib/insika/channels/web/widget.js +283 -0
  44. data/lib/insika/channels/web.rb +211 -0
  45. data/lib/insika/channels/webhook.rb +58 -0
  46. data/lib/insika/chat_builder.rb +303 -0
  47. data/lib/insika/checkpoint.rb +13 -0
  48. data/lib/insika/checkpoint_store.rb +153 -0
  49. data/lib/insika/circuit_state.rb +114 -0
  50. data/lib/insika/coercion.rb +58 -0
  51. data/lib/insika/command.rb +32 -0
  52. data/lib/insika/command_bus.rb +39 -0
  53. data/lib/insika/commands/agent_payload.rb +43 -0
  54. data/lib/insika/commands/approve_action.rb +46 -0
  55. data/lib/insika/commands/cancel_task.rb +33 -0
  56. data/lib/insika/commands/create_agent.rb +54 -0
  57. data/lib/insika/commands/create_session.rb +67 -0
  58. data/lib/insika/commands/delete_agent.rb +33 -0
  59. data/lib/insika/commands/delete_agent_file.rb +50 -0
  60. data/lib/insika/commands/delete_data_tool.rb +33 -0
  61. data/lib/insika/commands/delete_llm_provider.rb +36 -0
  62. data/lib/insika/commands/delete_mcp.rb +30 -0
  63. data/lib/insika/commands/delete_skill.rb +43 -0
  64. data/lib/insika/commands/delete_system_file.rb +29 -0
  65. data/lib/insika/commands/gate_refinement.rb +245 -0
  66. data/lib/insika/commands/import_mcp_tools.rb +48 -0
  67. data/lib/insika/commands/import_tools.rb +81 -0
  68. data/lib/insika/commands/issue_tenant_token.rb +41 -0
  69. data/lib/insika/commands/memory_add_note.rb +32 -0
  70. data/lib/insika/commands/memory_forget_fact.rb +32 -0
  71. data/lib/insika/commands/memory_put_fact.rb +35 -0
  72. data/lib/insika/commands/pause_task.rb +29 -0
  73. data/lib/insika/commands/resolve_refinement.rb +126 -0
  74. data/lib/insika/commands/restore_agent_file.rb +36 -0
  75. data/lib/insika/commands/restore_data_tool.rb +34 -0
  76. data/lib/insika/commands/restore_system_file.rb +31 -0
  77. data/lib/insika/commands/resume_task.rb +85 -0
  78. data/lib/insika/commands/revoke_token.rb +39 -0
  79. data/lib/insika/commands/rotate_tenant_token.rb +43 -0
  80. data/lib/insika/commands/run_refinement.rb +133 -0
  81. data/lib/insika/commands/send_message.rb +150 -0
  82. data/lib/insika/commands/set_agent_tools.rb +39 -0
  83. data/lib/insika/commands/set_skill_agents.rb +112 -0
  84. data/lib/insika/commands/trigger_workflow.rb +80 -0
  85. data/lib/insika/commands/update_agent.rb +49 -0
  86. data/lib/insika/commands/update_settings.rb +33 -0
  87. data/lib/insika/commands/upsert_llm_provider.rb +34 -0
  88. data/lib/insika/commands/upsert_mcp.rb +32 -0
  89. data/lib/insika/commands/write_agent_file.rb +57 -0
  90. data/lib/insika/commands/write_data_tool.rb +43 -0
  91. data/lib/insika/commands/write_golden.rb +58 -0
  92. data/lib/insika/commands/write_skill.rb +60 -0
  93. data/lib/insika/commands/write_system_file.rb +31 -0
  94. data/lib/insika/config_store.rb +89 -0
  95. data/lib/insika/context/builder.rb +166 -0
  96. data/lib/insika/context/catalog_provider.rb +23 -0
  97. data/lib/insika/context/fragment.rb +43 -0
  98. data/lib/insika/context/priority.rb +30 -0
  99. data/lib/insika/context/provider.rb +19 -0
  100. data/lib/insika/context/providers/memory.rb +60 -0
  101. data/lib/insika/context/providers/prompt.rb +105 -0
  102. data/lib/insika/context/providers/request.rb +32 -0
  103. data/lib/insika/context/providers/session.rb +123 -0
  104. data/lib/insika/context/providers/skill.rb +24 -0
  105. data/lib/insika/context/providers/skill_trigger.rb +128 -0
  106. data/lib/insika/context/providers/tool_search.rb +20 -0
  107. data/lib/insika/context_trace_store.rb +92 -0
  108. data/lib/insika/delegation_store.rb +153 -0
  109. data/lib/insika/doctor.rb +539 -0
  110. data/lib/insika/dsl/definition.rb +55 -0
  111. data/lib/insika/dsl/runtime.rb +382 -0
  112. data/lib/insika/dsl/server_boot.rb +98 -0
  113. data/lib/insika/dsl/system.rb +93 -0
  114. data/lib/insika/dsl/workflow_adapter.rb +59 -0
  115. data/lib/insika/dsl.rb +364 -0
  116. data/lib/insika/edge_limiter.rb +268 -0
  117. data/lib/insika/egress_guard.rb +75 -0
  118. data/lib/insika/env_schema.rb +249 -0
  119. data/lib/insika/errors.rb +201 -0
  120. data/lib/insika/evals/assertions.rb +247 -0
  121. data/lib/insika/evals/baseline.rb +69 -0
  122. data/lib/insika/evals/golden.rb +172 -0
  123. data/lib/insika/evals/judge.rb +225 -0
  124. data/lib/insika/evals/pairwise.rb +178 -0
  125. data/lib/insika/evals/report.rb +115 -0
  126. data/lib/insika/evals/runner.rb +141 -0
  127. data/lib/insika/evals/transport.rb +178 -0
  128. data/lib/insika/event.rb +18 -0
  129. data/lib/insika/event_stream.rb +132 -0
  130. data/lib/insika/executor.rb +1995 -0
  131. data/lib/insika/frontmatter.rb +42 -0
  132. data/lib/insika/golden_store.rb +145 -0
  133. data/lib/insika/hooks.rb +48 -0
  134. data/lib/insika/http_client.rb +63 -0
  135. data/lib/insika/inbound_log.rb +84 -0
  136. data/lib/insika/llm_configurator.rb +99 -0
  137. data/lib/insika/llm_provider_store.rb +83 -0
  138. data/lib/insika/loop_detector.rb +143 -0
  139. data/lib/insika/mcp_http_client.rb +67 -0
  140. data/lib/insika/mcp_store.rb +115 -0
  141. data/lib/insika/mcp_tool_ingestor.rb +143 -0
  142. data/lib/insika/memory_store.rb +93 -0
  143. data/lib/insika/message_origin.rb +76 -0
  144. data/lib/insika/middleware.rb +36 -0
  145. data/lib/insika/model_policy.rb +52 -0
  146. data/lib/insika/model_resolver.rb +176 -0
  147. data/lib/insika/model_selection.rb +115 -0
  148. data/lib/insika/onboarding.rb +208 -0
  149. data/lib/insika/outbox_store.rb +166 -0
  150. data/lib/insika/overlay_tool_registry.rb +102 -0
  151. data/lib/insika/pack.rb +102 -0
  152. data/lib/insika/pack_importer.rb +123 -0
  153. data/lib/insika/pending_action_store.rb +120 -0
  154. data/lib/insika/plugin/loader.rb +356 -0
  155. data/lib/insika/plugin.rb +35 -0
  156. data/lib/insika/policy/engine.rb +83 -0
  157. data/lib/insika/policy/policy.rb +120 -0
  158. data/lib/insika/policy_registry.rb +23 -0
  159. data/lib/insika/profile_source.rb +143 -0
  160. data/lib/insika/prompt_catalog.rb +61 -0
  161. data/lib/insika/provider_error_classifier.rb +160 -0
  162. data/lib/insika/queue_policy.rb +167 -0
  163. data/lib/insika/recovery.rb +168 -0
  164. data/lib/insika/refinement/candidate.rb +159 -0
  165. data/lib/insika/refinement/evidence_collector.rb +371 -0
  166. data/lib/insika/refinement/gate.rb +234 -0
  167. data/lib/insika/refinement/panel.rb +222 -0
  168. data/lib/insika/refinement/proposer.rb +262 -0
  169. data/lib/insika/refinement_store.rb +295 -0
  170. data/lib/insika/registry.rb +59 -0
  171. data/lib/insika/reliability.rb +185 -0
  172. data/lib/insika/safety/config.rb +109 -0
  173. data/lib/insika/safety/detectors.rb +176 -0
  174. data/lib/insika/safety/factory.rb +102 -0
  175. data/lib/insika/safety/input_guardrail.rb +102 -0
  176. data/lib/insika/safety/moderator.rb +94 -0
  177. data/lib/insika/safety/output_filter.rb +79 -0
  178. data/lib/insika/safety/output_validator.rb +101 -0
  179. data/lib/insika/safety/safe_responses.rb +47 -0
  180. data/lib/insika/sandbox/boundary.rb +93 -0
  181. data/lib/insika/sandbox/docker.rb +74 -0
  182. data/lib/insika/sandbox/local.rb +33 -0
  183. data/lib/insika/sandbox/runner.rb +80 -0
  184. data/lib/insika/sandbox.rb +85 -0
  185. data/lib/insika/schema_guard.rb +147 -0
  186. data/lib/insika/secret_masking.rb +34 -0
  187. data/lib/insika/server/a2a/agent_card.rb +27 -0
  188. data/lib/insika/server/a2a/app.rb +112 -0
  189. data/lib/insika/server/a2a/client.rb +101 -0
  190. data/lib/insika/server/a2a/errors.rb +32 -0
  191. data/lib/insika/server/a2a/http.rb +42 -0
  192. data/lib/insika/server/a2a/message.rb +27 -0
  193. data/lib/insika/server/a2a/protocol.rb +45 -0
  194. data/lib/insika/server/a2a/remotes.rb +25 -0
  195. data/lib/insika/server/a2a/task_projection.rb +40 -0
  196. data/lib/insika/server/app.rb +1022 -0
  197. data/lib/insika/server/boot.rb +119 -0
  198. data/lib/insika/server/rack_app.rb +118 -0
  199. data/lib/insika/server/responses.rb +165 -0
  200. data/lib/insika/server/sse_body.rb +96 -0
  201. data/lib/insika/server/tenant_auth.rb +61 -0
  202. data/lib/insika/session_actor.rb +162 -0
  203. data/lib/insika/session_store.rb +143 -0
  204. data/lib/insika/settings_store.rb +154 -0
  205. data/lib/insika/shutdown.rb +125 -0
  206. data/lib/insika/skill_catalog.rb +220 -0
  207. data/lib/insika/skill_store.rb +127 -0
  208. data/lib/insika/steer_injector.rb +110 -0
  209. data/lib/insika/store.rb +52 -0
  210. data/lib/insika/stores/memory.rb +123 -0
  211. data/lib/insika/stores/sqlite.rb +183 -0
  212. data/lib/insika/studio/app.rb +1693 -0
  213. data/lib/insika/studio/assets/dist/application.css +1 -0
  214. data/lib/insika/studio/assets/dist/application.js +70 -0
  215. data/lib/insika/studio/forms.rb +335 -0
  216. data/lib/insika/studio/nav_icons.rb +31 -0
  217. data/lib/insika/studio/views/_message.erb +44 -0
  218. data/lib/insika/studio/views/agent_detail.erb +285 -0
  219. data/lib/insika/studio/views/agents.erb +63 -0
  220. data/lib/insika/studio/views/approvals.erb +41 -0
  221. data/lib/insika/studio/views/chats.erb +34 -0
  222. data/lib/insika/studio/views/evals.erb +83 -0
  223. data/lib/insika/studio/views/home.erb +72 -0
  224. data/lib/insika/studio/views/layout.erb +94 -0
  225. data/lib/insika/studio/views/login.erb +17 -0
  226. data/lib/insika/studio/views/mcp.erb +91 -0
  227. data/lib/insika/studio/views/not_found.erb +5 -0
  228. data/lib/insika/studio/views/playground.erb +47 -0
  229. data/lib/insika/studio/views/refinement.erb +234 -0
  230. data/lib/insika/studio/views/session.erb +137 -0
  231. data/lib/insika/studio/views/settings.erb +168 -0
  232. data/lib/insika/studio/views/skills.erb +141 -0
  233. data/lib/insika/studio/views/system_files.erb +65 -0
  234. data/lib/insika/studio/views/task.erb +105 -0
  235. data/lib/insika/studio/views/tasks.erb +33 -0
  236. data/lib/insika/studio/views/tool_edit.erb +107 -0
  237. data/lib/insika/studio/views/tools.erb +89 -0
  238. data/lib/insika/subagent_graph.rb +96 -0
  239. data/lib/insika/system_file_store.rb +96 -0
  240. data/lib/insika/task_actor.rb +128 -0
  241. data/lib/insika/task_store.rb +250 -0
  242. data/lib/insika/telemetry/pricing.rb +104 -0
  243. data/lib/insika/telemetry/recorder.rb +228 -0
  244. data/lib/insika/telemetry.rb +127 -0
  245. data/lib/insika/testing/store_contract.rb +270 -0
  246. data/lib/insika/tick.rb +122 -0
  247. data/lib/insika/token_estimator.rb +16 -0
  248. data/lib/insika/token_store.rb +168 -0
  249. data/lib/insika/tool_assembly.rb +140 -0
  250. data/lib/insika/tool_catalog.rb +89 -0
  251. data/lib/insika/tool_definition.rb +518 -0
  252. data/lib/insika/tool_envelope.rb +140 -0
  253. data/lib/insika/tool_manifest.rb +218 -0
  254. data/lib/insika/tool_output_compressor.rb +100 -0
  255. data/lib/insika/tool_registry.rb +21 -0
  256. data/lib/insika/tool_store.rb +135 -0
  257. data/lib/insika/tool_trace_store.rb +92 -0
  258. data/lib/insika/tools/a2a_remote.rb +48 -0
  259. data/lib/insika/tools/agent_enum.rb +68 -0
  260. data/lib/insika/tools/concurrency.rb +54 -0
  261. data/lib/insika/tools/data_defined_tool.rb +219 -0
  262. data/lib/insika/tools/load_skill.rb +99 -0
  263. data/lib/insika/tools/remember.rb +53 -0
  264. data/lib/insika/tools/stuck_signal.rb +44 -0
  265. data/lib/insika/tools/subagent.rb +75 -0
  266. data/lib/insika/tools/subagents.rb +77 -0
  267. data/lib/insika/tools/tool_search.rb +94 -0
  268. data/lib/insika/turn_output.rb +139 -0
  269. data/lib/insika/turn_state.rb +162 -0
  270. data/lib/insika/turn_timing.rb +56 -0
  271. data/lib/insika/usage_ledger.rb +47 -0
  272. data/lib/insika/version.rb +3 -1
  273. data/lib/insika/wiring/graph.rb +249 -0
  274. data/lib/insika/workflow.rb +185 -0
  275. data/lib/insika/workflow_registry.rb +33 -0
  276. data/lib/insika.rb +220 -4
  277. metadata +412 -8
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_llm"
4
+
5
+ module Insika
6
+ module Tools
7
+ # Level 2 of TOOLS progressive disclosure (analog of LoadSkill):
8
+ # searches the deferred catalog and PROMOTES the relevant ones into the live chat via
9
+ # chat.with_tools (verified to propagate on the next round of the same `ask`
10
+ # in ruby_llm 1.16). `require "ruby_llm"` stays in THIS file (it inherits from
11
+ # RubyLLM::Tool) — it does not enter lib/insika.rb; the Executor loads it lazily in
12
+ # configure_chat (like LoadSkill).
13
+ class ToolSearch < RubyLLM::Tool
14
+ description "Searches and enables additional tools by describing the need"
15
+ param :query, desc: "What you need to do (e.g.: 'send email', 'generate invoice')"
16
+
17
+ # RubyLLM::Tool#name derives from self.class.name — for a nested class
18
+ # (Insika::Tools::ToolSearch) it produces "insika--tools--tool_search", not
19
+ # "tool_search". Explicit override: the name the model calls must match
20
+ # the catalog/docs/tests.
21
+ def name = "tool_search"
22
+
23
+ def initialize(catalog, deferred_allowed, chat, tool_registry:, event_stream:,
24
+ checkpoint_store:, state:)
25
+ @catalog = catalog
26
+ @deferred_allowed = Array(deferred_allowed).map(&:to_s)
27
+ @chat = chat
28
+ @tool_registry = tool_registry
29
+ @event_stream = event_stream
30
+ @checkpoint_store = checkpoint_store
31
+ @state = state
32
+ @promoted = [] # names already promoted IN THIS chat — idempotency
33
+ super()
34
+ end
35
+
36
+ def execute(query:)
37
+ matches = @catalog.search(query, within: @deferred_allowed)
38
+ emit_tool_search(query, matches.map(&:name))
39
+ if matches.empty?
40
+ return { matched: [], message: "no tool found for '#{query}'" }
41
+ end
42
+
43
+ new_matches = matches.reject { |m| @promoted.include?(m.name) }
44
+ promote(new_matches) unless new_matches.empty?
45
+
46
+ { matched: matches.map { |m| describe(m) } }
47
+ end
48
+
49
+ private
50
+
51
+ # Instantiates (via tool_registry), wraps in the SAME ToolEnvelope as the eager
52
+ # ones (profile timeout + state's skip_side_effects) and promotes via
53
+ # chat.with_tools. A NotFoundError (misaligned catalog) drops only
54
+ # that match — the search does not break.
55
+ def promote(entries)
56
+ timeout = @state.profile.limits[:tool_timeout] || 60
57
+ wrapped = entries.filter_map do |entry|
58
+ tool = @tool_registry.resolve(entry.name)
59
+ @promoted << entry.name
60
+ ToolEnvelope.new(tool, state: @state, checkpoint_store: @checkpoint_store,
61
+ tool_registry: @tool_registry, timeout: timeout,
62
+ skip_side_effects: Array(@state.skip_side_effects))
63
+ rescue Insika::NotFoundError
64
+ nil
65
+ end
66
+ @chat.with_tools(*wrapped) unless wrapped.empty?
67
+ end
68
+
69
+ # Mirrors :skill_activated, but emitted by the tool itself (it has event_stream/
70
+ # state in the constructor). Without a monotonic `seq` (private to the Executor) — a
71
+ # documented gap, not a blocker.
72
+ def emit_tool_search(query, matched_names)
73
+ @event_stream.emit(Insika::Event.new(
74
+ type: :tool_search,
75
+ data: { query: query, matched: matched_names },
76
+ meta: { task_id: @state.task.id, session_id: @state.task.session_id }
77
+ ))
78
+ end
79
+
80
+ def describe(entry)
81
+ tool = @tool_registry.resolve(entry.name)
82
+ {
83
+ name: entry.name,
84
+ description: entry.description,
85
+ parameters: tool.parameters.transform_values do |p|
86
+ { type: p.type, description: p.description, required: p.required }
87
+ end
88
+ }
89
+ rescue Insika::NotFoundError
90
+ { name: entry.name, description: entry.description, parameters: {} }
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # WHAT REACHES THE CUSTOMER — the turn's publishing rule.
5
+ #
6
+ # A turn is not one assistant message. Between the user's message and the answer
7
+ # the model may narrate the loop ("deixa eu buscar isso pra você"), apologise for
8
+ # a tool that failed, or — when it has no tool to call — reason in prose. All of
9
+ # it arrives as ordinary `content` chunks, indistinguishable at the token level
10
+ # from the answer, so streaming every chunk straight to `:content` published all
11
+ # of it. Running a real store's prompt proved what that costs: 132 deltas of an
12
+ # English monologue ("Let me check the tools I actually have… Actually, let me
13
+ # reconsider.") went out as `response.output_text.delta` — the bytes a WhatsApp
14
+ # customer would have read.
15
+ #
16
+ # The rule, enforced here: **`:content` carries the ANSWER — the text of the
17
+ # assistant message that ENDS the turn.** Everything else rides `:intermediate`,
18
+ # which the Studio and the trace render (that is the operator's window into what
19
+ # the model narrated) and which `/v1/responses` deliberately does not translate,
20
+ # exactly like `:thinking`.
21
+ #
22
+ # Which message is the last one is only knowable when it ends — a message that
23
+ # carries tool calls is never the answer — so text is buffered per message and
24
+ # published at the boundary (RubyLLM's `after_message`). Two consequences, both
25
+ # deliberate:
26
+ #
27
+ # · The customer-visible stream is per MESSAGE, not per token. `ttft_ms` still
28
+ # measures the provider's first token, so's baselines stay comparable;
29
+ # what moved is when the customer can read it. For the WhatsApp edge this
30
+ # changes nothing — the dispatcher accumulated the deltas into one message
31
+ # anyway — and the Studio keeps its live typing off `:intermediate`.
32
+ # · A turn that dies mid-message publishes nothing. Half a sentence was never an
33
+ # answer; the fragment is still on the stream as `:intermediate` for whoever is
34
+ # debugging it.
35
+ #
36
+ # `halt_when` (PR #130) is the one case where narration IS the turn: the tool
37
+ # already answered the customer, and the model's lead-in ("vou te inscrever
38
+ # agora") is all the turn is worth. That text is retained and published by the
39
+ # Executor's halt branch — see #halt_text.
40
+ class TurnOutput
41
+ # The text of the message that ended the turn, or nil if no boundary said so.
42
+ # It is a CANDIDATE, not the published answer: the `:agent` after-hook runs
43
+ # after the message ends and may replace the whole response, so the Executor
44
+ # decides and publishes once, at the end of the stage.
45
+ attr_reader :candidate
46
+
47
+ # filter: Safety::OutputFilter | nil (nil = stream untouched).
48
+ # emit: ->(type, data) — the Executor's emitter, already bound to the task.
49
+ # public_intermediate: the agent opted this channel in (`edge_stream`), so the
50
+ # narration is TAGGED and `/v1/responses` gives it its own frame. Default false:
51
+ # an internal event stays internal unless someone said otherwise.
52
+ def initialize(filter:, emit:, public_intermediate: false)
53
+ @filter = filter
54
+ @emit = emit
55
+ @public_intermediate = public_intermediate
56
+ @pending = +"" # text of the message currently streaming
57
+ @last_intermediate = +"" # text of the last message that turned out NOT to be the answer
58
+ @candidate = nil
59
+ end
60
+
61
+ # One provider chunk. Publishes the redacted slice as `:intermediate` — live,
62
+ # chunk by chunk, because the operator surfaces want to watch it happen — and
63
+ # holds it until the message boundary decides what it was.
64
+ def push(text)
65
+ slice = @filter ? @filter.push(text) : text.to_s
66
+ return if slice.empty?
67
+
68
+ @pending << slice
69
+ emit_intermediate(slice)
70
+ end
71
+
72
+ # A message ended (RubyLLM `after_message`). Only an assistant message decides
73
+ # anything — a `role: tool` result is the gem's bookkeeping. A message carrying
74
+ # tool calls is intermediate by definition: the model asked for something, so
75
+ # it was not done talking.
76
+ def message_ended(message)
77
+ return unless assistant?(message)
78
+
79
+ flush
80
+ text = @pending
81
+ @pending = +""
82
+ tool_calls?(message) ? @last_intermediate = text : @candidate = text
83
+ end
84
+
85
+ # Releases the redactor's retained tail (the sliding buffer holds back a value
86
+ # that might still be growing into a match) into the current message.
87
+ def flush
88
+ return unless @filter
89
+
90
+ tail = @filter.flush.to_s
91
+ return if tail.empty?
92
+
93
+ @pending << tail
94
+ emit_intermediate(tail)
95
+ end
96
+
97
+ # Emits the answer and returns it. Empty text emits no event — an empty turn is
98
+ # what the consumer suppresses.
99
+ def publish(text)
100
+ answer = text.to_s
101
+ @emit.call(:content, { delta: answer }) unless answer.empty?
102
+ answer
103
+ end
104
+
105
+ # The narration of the message that halted the turn. Normally the message
106
+ # boundary already moved it aside (`@last_intermediate`); a transport that does
107
+ # not report boundaries leaves it in `@pending`, and the lead-in is worth the
108
+ # same either way.
109
+ def halt_text = @last_intermediate.empty? ? @pending : @last_intermediate
110
+
111
+ private
112
+
113
+ # `public: true` is the whole difference between an event the edge drops and one
114
+ # it translates. The flag travels on the EVENT because `frame_for` is a pure
115
+ # static mapper with no agent in scope — the profile is read once, here.
116
+ def emit_intermediate(text)
117
+ data = { delta: text }
118
+ data[:public] = true if @public_intermediate
119
+ @emit.call(:intermediate, data)
120
+ end
121
+
122
+ def assistant?(message) = field(message, :role).to_s == "assistant"
123
+
124
+ # RubyLLM::Message answers `tool_call?`; a double may only carry the field.
125
+ def tool_calls?(message)
126
+ return !!message.tool_call? if message.respond_to?(:tool_call?)
127
+
128
+ calls = field(message, :tool_calls)
129
+ calls.respond_to?(:empty?) ? !calls.empty? : !calls.nil?
130
+ end
131
+
132
+ def field(message, name)
133
+ return message.public_send(name) if message.respond_to?(name)
134
+ return message[name] || message[name.to_s] if message.respond_to?(:[])
135
+
136
+ nil
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # MUTABLE on purpose (the only exception to the Data types):
5
+ # the Middleware MODIFIES the execution — the links write into these fields.
6
+ class TurnState
7
+ attr_reader :task, :profile, :turn # turn identity (1-based)
8
+ attr_accessor :message, # input (Middleware may rewrite)
9
+ :context, # ContextPackage from the Builder
10
+ :allowed_tools, # Resolution from the Policy Engine
11
+ :allowed_skills,
12
+ :chat, # the turn's RubyLLM::Chat instance
13
+ :session, # the turn's SessionStore::Session | nil (set at stage 2;
14
+ # read by create_chat for the per-chat model pin)
15
+ :model_selection, # resolved ModelSelection: model/provider/source/
16
+ # pinned/params/fallbacks. Set at stage 5; surfaced in usage.
17
+ :halt_reason, # set by Middleware when short-circuiting (halt-as-FAILURE)
18
+ :halt_response, # set by a Middleware for the GRACEFUL halt:
19
+ # the safe reply the turn completes with, WITHOUT touching the
20
+ # LLM. Distinct from halt_reason — a completion, not a failure.
21
+ :guardrail_block, # audit metadata the guardrail sets alongside halt_response
22
+ # ({category:, source:, action:, detail:}); the Executor (single
23
+ # emitter) turns it into :guardrail_blocked.
24
+ :guardrail_flags, # [{category:, source:, detail:}] appended by the OutputValidator
25
+ # (after_task); the Executor emits one :guardrail_flagged each.
26
+ :response_content, # the turn's final assistant text, set at stage 6/on halt so the
27
+ # after_task validator can inspect it.
28
+ :output_filter, # per-turn Safety::OutputFilter (nil = off); redacts the stream.
29
+ :stuck_outcome # set by the signal_stuck system tool (WS5):
30
+ # { reason:, message: } when the agent declared it cannot
31
+ # proceed. The Executor tags the terminal event with
32
+ # outcome: "stuck" and emits :turn_stuck. nil = normal turn.
33
+
34
+ # Internal (not part of the contract): per-CALL correlation between RubyLLM's
35
+ # tool callbacks and the tool decorators — `current_tool_call` keys the
36
+ # side-effect checkpoint / resume skip / trace, `current_tool_name` labels the
37
+ # :tool_result event.
38
+ #
39
+ # They live in FIBER STORAGE, not in ivars, and that is the whole point:
40
+ # `before_tool_call` → `tool.call` → `after_tool_result` all run in the SAME
41
+ # fiber, and with `ToolConcurrency` there is one fiber PER CALL. A
42
+ # single slot on this shared object would let one in-flight call overwrite
43
+ # another's — a side-effect recorded under the wrong id (so a resume skips the
44
+ # wrong tool, or re-runs a non-idempotent one) and a mislabelled event. Both
45
+ # silent. One writer per fiber needs no lock; serial execution is unchanged,
46
+ # since a lone fiber writes and reads its own storage.
47
+ #
48
+ # Read/written ONLY through here so the rule has one home.
49
+ CALL_KEY = :insika_tool_call
50
+ NAME_KEY = :insika_tool_name
51
+
52
+ def current_tool_call = Fiber[CALL_KEY]
53
+ def current_tool_name = Fiber[NAME_KEY]
54
+
55
+ def current_tool_call=(call)
56
+ Fiber[CALL_KEY] = call
57
+ end
58
+
59
+ def current_tool_name=(name)
60
+ Fiber[NAME_KEY] = name
61
+ end
62
+
63
+ # Internal (R1): the chat's message count RIGHT AFTER `assemble` (seeded
64
+ # history) and BEFORE `ask`. persist_turn slices `chat.messages.drop(baseline)`
65
+ # to serialize the turn's real exchange — user + assistant(tool_calls) + tool
66
+ # results + final assistant — into the transcript. nil = no chat recorded
67
+ # (workflow/halt) → persist_turn falls back to the {user, assistant} pair.
68
+ attr_accessor :chat_baseline
69
+
70
+ # Internal: impl_name(String) -> STABLE name of the capability that
71
+ # resolved it, computed by resolve_capabilities BEFORE the policy_request and
72
+ # consulted AFTER @policy_engine.decide, at the post-Policy junction, to
73
+ # decide which impls enter as Capability::ResolvedTool. {} = no
74
+ # capability_registry or empty profile.capabilities (parity).
75
+ attr_accessor :capability_names
76
+
77
+ # Internal: the turn's resolved QueuePolicy. Read at stage 6 to decide
78
+ # whether this run accepts steered messages, and how they are worded. Resolved once
79
+ # per turn, in build_turn_state — an edit to the agent mid-run does not change the
80
+ # rules the run started under.
81
+ attr_accessor :queue_policy
82
+
83
+ # Internal: true when this turn re-enters the pipeline via
84
+ # resume_task/recovery. The EdgeLimiter reads it to NEVER re-count or block a
85
+ # turn that was already admitted — a crash/pause under a saturated window must
86
+ # not swallow a legitimate message with the rate-limit reply.
87
+ attr_accessor :resumed
88
+
89
+ # Internal (memory): the turn's tenant (from the Command), scope of the write path
90
+ # (`remember` tool). Set in run_pipeline; nil = DEFAULT_TENANT in the MemoryStore.
91
+ attr_accessor :tenant
92
+
93
+ # Internal: turn context deposited into the data-tools to
94
+ # resolve {{ctx.*}} (chat_id/agent_id/tenant/store_id) and emit
95
+ # X-Chat-Id/X-Store-Id/X-Agent-Id. A Hash of symbols, set in run_pipeline.
96
+ # Comes from the TURN, never from the model's args (R2). Distinct from `tenant` (memory).
97
+ attr_accessor :turn_context
98
+
99
+ # Internal (observability): the turn's token usage (input/output/
100
+ # total/cached + model), captured from the provider's response at stage 6. Goes
101
+ # to the terminal event (:task_completed) — feeds the usage of
102
+ # /v1/responses and the Telemetry (OTEL). nil = turn with no model response
103
+ # (workflow) or provider without counts.
104
+ attr_accessor :usage
105
+
106
+ # Internal (Tool Search): ids of side-effects already completed in the
107
+ # interrupted turn, propagated to the tools PROMOTED by tool_search (the same `skip`
108
+ # that the eager tools' wrap_tools receives). Set in run_pipeline;
109
+ # nil = new turn (Array(nil) => []).
110
+ attr_accessor :skip_side_effects
111
+
112
+ # Approval gate. `requires_approval` = names of tools that require
113
+ # approval (Resolution); `approval_coordinator` = object (the Executor) that
114
+ # creates the PendingAction/suspends/waits; `actor` = the turn's mailbox (used
115
+ # by the coordinator for await(:approval)).
116
+ attr_accessor :requires_approval, :approval_coordinator, :actor
117
+
118
+ # Internal: the turn's shared in-flight cap for tool calls —
119
+ # ONE Async::Semaphore(tool_concurrency), installed by ToolAssembly#wrap_tools
120
+ # and acquired by every ToolEnvelope, INCLUDING the ones tool_search promotes
121
+ # mid-turn (they read it off the state, so the cap survives promotion).
122
+ # nil = concurrency off: no gate, no overhead, serial execution unchanged.
123
+ attr_accessor :tool_gate
124
+
125
+ # parallel tool calls, resolved PER TURN and read by ChatBuilder
126
+ # (whether to hand the gem `concurrency:`) and ToolAssembly (the gate's size).
127
+ #
128
+ # `requested_tool_concurrency` is what the operator configured;
129
+ # `tool_concurrency` is what this turn actually gets. They differ for exactly
130
+ # one reason —: `Executor#request_approval` blocks on `actor.await(approval)`,
131
+ # and the mailbox is one queue per TASK. Two fibers waiting there share it,
132
+ # `dequeue` wakes exactly one, the message is consumed, and the other fiber
133
+ # hangs until `approval_timeout` (~1h). So a turn that can suspend for a human
134
+ # runs its tools serially. Per-TURN and not per-profile because
135
+ # `requires_approval` comes from the Resolution: it can be empty on a turn
136
+ # whose profile does list approvals.
137
+ def requested_tool_concurrency
138
+ n = ((profile.respond_to?(:limits) && profile.limits) || {})[:tool_concurrency].to_i
139
+ n > 1 ? n : nil
140
+ end
141
+
142
+ def tool_concurrency
143
+ return nil unless Array(requires_approval).empty?
144
+
145
+ requested_tool_concurrency
146
+ end
147
+
148
+ def initialize(task:, profile:, turn:, message:)
149
+ @task = task
150
+ @profile = profile
151
+ @turn = turn
152
+ @message = message
153
+ @capability_names = {}
154
+ # Fiber storage is INHERITED by fibers created later, so a turn spawned from
155
+ # inside a tool call (a subagent child) would start out carrying its
156
+ # parent's correlation. Clearing at turn start keeps a child from keying its
157
+ # own side-effects under the parent's tool_call id.
158
+ self.current_tool_call = nil
159
+ self.current_tool_name = nil
160
+ end
161
+ end
162
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # Opt-in per-turn latency breakdown (locate the
5
+ # TTFB cost with real-turn data). OFF unless INSIKA_TURN_TIMING is set — when
6
+ # off the Executor never allocates one and the hot path pays only `nil&.mark`.
7
+ #
8
+ # Splits a turn into the three windows that answer "is TTFB local or provider?":
9
+ # prep_ms — prep_start -> ask: ALL local work before the provider call
10
+ # (context build, policy, guardrail detectors, chat assembly).
11
+ # ttft_ms — ask -> first_token: the provider round-trip to the 1st token.
12
+ # gen_ms — first_token -> done: streaming the rest of the response.
13
+ #
14
+ # Marks are monotonic; `mark` is first-write-wins so `first_token` records the
15
+ # FIRST content chunk even though it is called on every chunk.
16
+ #
17
+ # `ttft_ms` is the PROVIDER's first token, not the first byte the customer can
18
+ # read: TurnOutput publishes a message once it ends, so the customer-visible
19
+ # answer lands inside `gen_ms`. Measuring the provider is the point —'s
20
+ # baselines (~720 ms, provider-bound) stay comparable across that change.
21
+ class TurnTiming
22
+ # EnvSchema owns "is this flag on?" (1/true/yes/on) — the same predicate that
23
+ # validates the :boolean keys, so a spelling `insika env` accepts is a spelling
24
+ # the reader honours.
25
+ def self.enabled?(env = ENV)
26
+ Insika::EnvSchema.truthy?(Insika::EnvSchema.read("INSIKA_TURN_TIMING", env))
27
+ end
28
+
29
+ def initialize
30
+ @marks = {}
31
+ end
32
+
33
+ def mark(name)
34
+ @marks[name] ||= Process.clock_gettime(Process::CLOCK_MONOTONIC)
35
+ end
36
+
37
+ # -> Hash of phase deltas in ms (only the windows whose endpoints both fired;
38
+ # a workflow turn has no ask/first_token, so those are simply absent).
39
+ def to_h
40
+ {
41
+ prep_ms: delta(:prep_start, :ask),
42
+ ttft_ms: delta(:ask, :first_token),
43
+ gen_ms: delta(:first_token, :done),
44
+ total_ms: delta(:prep_start, :done)
45
+ }.compact
46
+ end
47
+
48
+ private
49
+
50
+ def delta(from, to)
51
+ return nil unless @marks[from] && @marks[to]
52
+
53
+ ((@marks[to] - @marks[from]) * 1000).round(2)
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # Fixed-window counters on the KV Store: the durable side of
5
+ # the edge limits. One scope, keys shaped "kind:id:window_start" — the window
6
+ # start bucketed on the epoch keeps every process/worker on the SAME bucket
7
+ # without coordination (the Store's transaction serializes the read-modify-write,
8
+ # in-process via the fiber semaphore and cross-process via BEGIN IMMEDIATE).
9
+ #
10
+ # Growth is bounded: each `add` garbage-collects the (kind, id) pair's PREVIOUS
11
+ # window key, so an active entity holds at most 2 keys at any time and an idle
12
+ # one converges to 1.
13
+ class UsageLedger
14
+ SCOPE = "usage_counters"
15
+
16
+ def initialize(store:)
17
+ @store = store
18
+ end
19
+
20
+ # Adds `by` to the current window's counter of (kind, id) -> the NEW total.
21
+ def add(kind, id, window:, by: 1, now: Time.now)
22
+ start = window_start(now, window)
23
+ key = key_for(kind, id, start)
24
+ @store.transaction do
25
+ total = @store.get(SCOPE, key).to_i + by
26
+ @store.set(SCOPE, key, total)
27
+ @store.delete(SCOPE, key_for(kind, id, start - window))
28
+ total
29
+ end
30
+ end
31
+
32
+ # Current window's total for (kind, id). Missing/expired -> 0.
33
+ def count(kind, id, window:, now: Time.now)
34
+ @store.get(SCOPE, key_for(kind, id, window_start(now, window))).to_i
35
+ end
36
+
37
+ private
38
+
39
+ def window_start(now, window)
40
+ (now.to_i / window) * window
41
+ end
42
+
43
+ def key_for(kind, id, start)
44
+ "#{kind}:#{id}:#{start}"
45
+ end
46
+ end
47
+ end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Insika
2
- VERSION = "0.0.1"
4
+ VERSION = "0.2.0"
3
5
  end