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
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Insika
6
+ # Mechanical (no-LLM) compression of tool output in the replayed transcript
7
+ # (A3/C3, hermes phase 1): identical tool results are stored ONCE — the first
8
+ # occurrence full — and every byte-identical repeat is replaced with a compact
9
+ # back-reference to it plus the original's first line. The cheap half of
10
+ # compaction: a search tool that returns the same catalog page over N turns
11
+ # currently pays for the full body N times, and that accumulation is what
12
+ # blows the budget first.
13
+ #
14
+ # Deliberately crude on purpose: SHA-256 digest, so ONLY exact matches dedupe —
15
+ # a differing detail is kept, never "close enough" dropped. Deterministic per
16
+ # input (the same transcript compresses identically every turn, prompt-cache
17
+ # friendly). Never touches user/assistant content and never mutates the input
18
+ # (symbol or string keys are preserved per message).
19
+ #
20
+ # Consumer: the history path of Context::Providers::Session, opt-in via
21
+ # AgentProfile#tool_output_compression. The LLM half of compaction (real
22
+ # summarization) remains F5 — decided with data, not by matrix.
23
+ class ToolOutputCompressor
24
+ # Below this a back-reference can never cost less than the body it
25
+ # replaces (an early bail; the REAL gate is the length check per repeat).
26
+ MIN_LENGTH = 200
27
+ # The mechanical "1-line summary": the original's first line, capped here
28
+ # (160 chars ≈ ~40 tokens — a bounded, informative stub).
29
+ SUMMARY_LIMIT = 160
30
+
31
+ # The boilerplate deliberately carries NO positional pointer ("see above"):
32
+ # the budget cut evicts the OLDEST unit first, which is the original this
33
+ # back-reference points at — a "↑" that survives its target leaves a dangle
34
+ # in the model's context (C3). The summary is the content; it stands alone.
35
+ BOILERPLATE = "[repeated tool output — byte-identical to an earlier result " \
36
+ "in this conversation; One-line summary: "
37
+
38
+ class << self
39
+ # [message, ...] with repeated tool results back-referenced. A non-tool
40
+ # message or a tool result that is not a String passes through untouched.
41
+ # A repeat is replaced ONLY when the back-reference is strictly shorter
42
+ # than the content it would replace (in the 200–~260-char band a
43
+ # back-reference is LONGER than the original — replacing there would
44
+ # GROW the transcript, C3).
45
+ def compress_transcript(messages)
46
+ seen = {}
47
+ Array(messages).map do |m|
48
+ content = text_of(m)
49
+ next m unless content && content.length >= MIN_LENGTH
50
+
51
+ digest = Digest::SHA256.hexdigest(content)
52
+ if seen.key?(digest)
53
+ replacement = back_reference(seen[digest][:summary])
54
+ replacement.length < content.length ? replace_content(m, replacement) : m
55
+ else
56
+ seen[digest] = { summary: summarize(content) }
57
+ m
58
+ end
59
+ end
60
+ end
61
+
62
+ private
63
+
64
+ # -> String | nil: the content of a role:tool message, nil otherwise
65
+ # (compression is for tool RESULTS only; a person's words are never touched).
66
+ def text_of(m)
67
+ return nil unless m.is_a?(Hash)
68
+ return nil unless (m[:role] || m["role"]).to_s == "tool"
69
+
70
+ c = m[:content] || m["content"]
71
+ c.is_a?(String) ? c : nil
72
+ end
73
+
74
+ def back_reference(summary)
75
+ "#{BOILERPLATE}#{summary}]"
76
+ end
77
+
78
+ # A dup with the content replaced under the SAME key style as the original
79
+ # (symbol-keyed message -> symbol content, string-keyed -> string content).
80
+ def replace_content(m, text)
81
+ out = m.dup
82
+ if m.key?(:content)
83
+ out[:content] = text
84
+ else
85
+ out["content"] = text
86
+ end
87
+ out
88
+ end
89
+
90
+ # The mechanical one-line summary: the first line, whitespace-collapsed and
91
+ # capped. For a multi-line JSON tool body the first line is a real head; for
92
+ # a single-line blob it is a truncated head — never the whole body.
93
+ def summarize(content)
94
+ first = content.to_s.split(/\r?\n/).first.to_s
95
+ collapsed = first.gsub(/\s+/, " ").strip
96
+ collapsed.length <= SUMMARY_LIMIT ? collapsed : "#{collapsed[0, SUMMARY_LIMIT]}…"
97
+ end
98
+ end
99
+ end
100
+ end
@@ -9,7 +9,7 @@ module Insika
9
9
  # McpStore masks the env): headers whose name is in `secret_headers` never
10
10
  # leave in plaintext to the UI — they become the sentinel `__OCULTO__`. Only `get_raw`/
11
11
  # `all_raw` (consumed by DataDefinedTool/overlay, never the screen) return the
12
- # real values. Phase 5, Step A.
12
+ # real values.,.
13
13
  #
14
14
  # Record in the ConfigStore:
15
15
  # { "definition" => { ...ToolDefinition#to_h... },
@@ -3,7 +3,7 @@
3
3
  require "json"
4
4
 
5
5
  module Insika
6
- # Per-session TOOL-CALL trace, for debugging in the Studio (FOLLOWUP §3.1). One
6
+ # Per-session TOOL-CALL trace, for debugging in the Studio. One
7
7
  # record per session in the raw backend (scope "tool_traces") — RUNTIME data, next
8
8
  # to sessions/tasks, NOT config (hence the raw `store:`, like SessionStore,
9
9
  # not the ConfigStore). A capped LIST of entries; ToolEnvelope writes one
@@ -5,7 +5,7 @@ require "async/semaphore"
5
5
 
6
6
  module Insika
7
7
  module Tools
8
- # Fan-out INSIDE a single tool (RFC-0010 §B1): when a tool must gather N
8
+ # Fan-out INSIDE a single tool (§): when a tool must gather N
9
9
  # independent I/O calls (stock + price + promo + delivery) into ONE result,
10
10
  # `gather` runs them CONCURRENTLY on the turn's reactor and returns their values
11
11
  # IN ORDER. Because a data-tool spends its time on the HTTP wait, those waits
@@ -13,7 +13,7 @@ module Insika
13
13
  #
14
14
  # This is the "aggregator tool" pattern: the model makes ONE tool call and the
15
15
  # concurrency is an implementation detail of that tool — no change to the agent
16
- # loop, no dependency on parallel tool execution (item 30). Use it in a custom
16
+ # loop, no dependency on parallel tool execution. Use it in a custom
17
17
  # Ruby tool's #execute:
18
18
  #
19
19
  # def execute(store_id:)
@@ -10,7 +10,6 @@ module Insika
10
10
  # ToolDefinition (the same pattern as A2ARemote). It makes an HTTP call described
11
11
  # in config — no Ruby code per tool. Since it inherits RubyLLM::Tool (pulls in the gem),
12
12
  # it is NOT required in lib/insika.rb; the overlay loads it lazily at registration
13
- # (Step B). Phase 5, Step A.
14
13
  #
15
14
  # Contract preserved by duck-typing: it overrides name/description/parameters/
16
15
  # execute; RubyLLM's params_schema derives from #parameters automatically.
@@ -28,7 +27,7 @@ module Insika
28
27
  super()
29
28
  end
30
29
 
31
- # Turn context (Phase 6/D2/G3): the registry tool does NOT receive TurnState,
30
+ # Turn context: the registry tool does NOT receive TurnState,
32
31
  # so the Executor DEPOSITS the turn ids here, per-turn
33
32
  # (chat/agent/tenant/store). They resolve {{ctx.*}} — SEPARATE from the model's
34
33
  # {{param}} — to emit X-Chat-Id/X-Store-Id/X-Agent-Id. They come from the TURN, never from
@@ -46,8 +45,8 @@ module Insika
46
45
 
47
46
  # FULL (nested) JSON Schema straight into RubyLLM's params_schema — it is what
48
47
  # the providers serialize (OpenAI/Anthropic/Gemini/Bedrock prefer
49
- # params_schema; parameters is just a fallback). Provider-agnostic (Phase 7/F6) and
50
- # the only form that expresses nesting (object/array/enum). Phase 7, Step A.
48
+ # params_schema; parameters is just a fallback). Provider-agnostic and
49
+ # the only form that expresses nesting (object/array/enum).,.
51
50
  def params_schema = @definition.parameters
52
51
 
53
52
  # FLAT top-level view for discovery (tool_search calls #parameters on the resolved
@@ -121,7 +120,7 @@ module Insika
121
120
  end
122
121
 
123
122
  # ctx.* -> TURN context (deposited by the Executor); the rest -> MODEL args
124
- # (kwargs). The split is the D2/R2 trust boundary: the model does
123
+ # (kwargs). The split is the/R2 trust boundary: the model does
125
124
  # not choose which chat/store the tool accesses.
126
125
  def resolve(name, kwargs)
127
126
  prefix = Insika::ToolDefinition::CTX_PREFIX
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "ruby_llm"
4
+ require "time"
4
5
 
5
6
  module Insika
6
7
  module Tools
@@ -22,19 +23,76 @@ module Insika
22
23
  # `param :name` (verified: the param is still present).
23
24
  def name = "load_skill"
24
25
 
25
- def initialize(catalog, allowed_names)
26
+ # trace_recorder/state are OPTIONAL (nil = no trace, parity): this tool is
27
+ # deliberately NOT enveloped (ToolAssembly#wrap_tools), and the envelope is
28
+ # what records the tool trace — so without recording HERE, the one call an
29
+ # operator most needs to audit is the only one missing from the Studio's
30
+ # trace. Same shape as ToolSearch, which also emits its own event.
31
+ # `agent` selects WHICH body a name resolves to: an agent that specialized a
32
+ # shared skill must be served its own version, under the same bare name. nil =
33
+ # the shared scope only (parity).
34
+ def initialize(catalog, allowed_names, trace_recorder: nil, state: nil, agent: nil)
26
35
  @catalog = catalog
27
36
  @allowed = Array(allowed_names).map(&:to_s)
37
+ @trace_recorder = trace_recorder
38
+ @state = state
39
+ @agent = agent
28
40
  super()
29
41
  end
30
42
 
31
43
  def execute(name:)
44
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
45
+ result = load(name)
46
+ trace(name, result, started)
47
+ result
48
+ end
49
+
50
+ private
51
+
52
+ def load(name)
32
53
  return { error: "skill '#{name}' not available for this agent" } unless @allowed.include?(name.to_s)
33
54
 
34
- skill = @catalog.find(name)
55
+ skill = @catalog.find(name, agent: @agent)
35
56
  return { error: "skill '#{name}' not found" } unless skill
36
57
 
37
- skill.body
58
+ with_companions(skill)
59
+ end
60
+
61
+ # A skill's declared `companions:` come back in the SAME call, so the model
62
+ # cannot end up holding half a recipe (the reference table without the procedure
63
+ # that reads it — measured on a real pack, and the searches came out malformed).
64
+ #
65
+ # A lone skill returns its bare body, byte for byte as before: only a skill that
66
+ # actually declares companions pays the wrapper. Restricted to `@allowed`, which
67
+ # is the LAZY allowed set — a companion that is eager is already in the prompt in
68
+ # full, so fetching it again would only buy a duplicate.
69
+ def with_companions(skill)
70
+ extras = Array(skill.companions).filter_map do |name|
71
+ next unless @allowed.include?(name.to_s) && name.to_s != skill.name
72
+
73
+ @catalog.find(name, agent: @agent)
74
+ end
75
+ return skill.body if extras.empty?
76
+
77
+ ([skill] + extras).uniq(&:name)
78
+ .map { |s| %(<skill name="#{s.name}">\n#{s.body}\n</skill>) }.join("\n\n")
79
+ end
80
+
81
+ # Mirrors ToolEnvelope#trace (same entry shape, so the Studio renders it
82
+ # like any other call). Clipping/masking is the ToolTraceStore's job.
83
+ # NEVER breaks the turn — the trace is observability.
84
+ def trace(name, result, started)
85
+ return unless @trace_recorder && @state&.task&.session_id
86
+
87
+ @trace_recorder.record(
88
+ session_id: @state.task.session_id,
89
+ entry: { "turn" => @state.turn, "tool" => "load_skill", "call_id" => "",
90
+ "args" => { "name" => name.to_s }, "result" => result,
91
+ "ms" => ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round,
92
+ "at" => Time.now.utc.iso8601 }
93
+ )
94
+ rescue StandardError
95
+ nil
38
96
  end
39
97
  end
40
98
  end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_llm"
4
+
5
+ module Insika
6
+ module Tools
7
+ # The agent's deterministic "I cannot proceed" signal (WS5). A
8
+ # system builtin like remember: the model calls it when it determines it cannot
9
+ # continue (out of scope, missing data, a case a human must take over). It ends
10
+ # the turn with `outcome: :stuck` recorded in the contract (the executor reads
11
+ # `state.stuck_outcome` at stage 8/9 and tags the terminal event) and a final
12
+ # message: the model's lead-in when it wrote one, else this tool's `message`.
13
+ #
14
+ # The ENGINE does not decide what "stuck" means — the consumer does. This tool
15
+ # is the deterministic signal the Agent.Shop subscribes to (`:turn_stuck` /
16
+ # `outcome: "stuck"`) to run its escalation (CRM/operator, which answers with
17
+ # `MessageOrigin::OPERATOR`). Nothing about handoff, pause or resume lives here.
18
+ class StuckSignal < RubyLLM::Tool
19
+ description "Signal that you cannot proceed and end the turn. Use when the " \
20
+ "request is out of your scope, you lack the data to help, or a human " \
21
+ "must take over. Write your final sentence to the customer first."
22
+ param :reason, desc: "Why you cannot proceed (goes to the operator, not the customer)"
23
+ param :message, desc: "Optional final message if you wrote none", required: false
24
+
25
+ def name = "signal_stuck"
26
+
27
+ def initialize(state:, **)
28
+ @state = state
29
+ super()
30
+ end
31
+
32
+ def execute(reason:, message: nil)
33
+ @state.stuck_outcome = { reason: reason.to_s, message: message.to_s }
34
+ # A Halt ends the tool loop here, so the turn cannot continue after declaring
35
+ # stuck. The payload's `say` is the fallback final message when the model
36
+ # wrote no lead-in (the executor's halt_answer already prefers the lead-in).
37
+ RubyLLM::Tool::Halt.new(Insika::ToolDefinition.wrap_halt(
38
+ { "reason" => reason.to_s },
39
+ message.to_s
40
+ ))
41
+ end
42
+ end
43
+ end
44
+ end
@@ -5,18 +5,18 @@ require_relative "agent_enum"
5
5
 
6
6
  module Insika
7
7
  module Tools
8
- # In-process delegation to a CHILD agent (RFC-0010, item 21) — the Flue
8
+ # In-process delegation to a CHILD agent — the Flue
9
9
  # `session.task()` primitive. A system tool (like remember/load_skill): wired
10
10
  # by the ChatBuilder ONLY when `profile.subagents` is present, so `require
11
11
  # "ruby_llm"` stays in this file (loaded lazily in create_chat). NOT enveloped
12
12
  # (system tool) — in the synchronous mode the child lives in the parent's
13
- # envelope and re-runs on the parent's resume (RFC-0010 §6).
13
+ # envelope and re-runs on the parent's resume.
14
14
  #
15
15
  # It holds no delegation logic itself: `execute` reads the parent TurnState
16
16
  # (the subagents allowlist + resolved model for inheritance + depth) and hands
17
17
  # off to `Executor#run_subagent`, which spawns the isolated child turn and
18
18
  # returns its result. The child result = its text + the linked child session
19
- # id (§4.3 R3).
19
+ # id (R3).
20
20
  class Subagent < RubyLLM::Tool
21
21
  description "Delegates a self-contained task to a specialized child agent. " \
22
22
  "The child runs in an ISOLATED context (it does not see this " \
@@ -67,7 +67,7 @@ module Insika
67
67
  return { dispatched: true, agent: result[:agent], session_id: result[:session_id] } if result[:dispatched]
68
68
 
69
69
  # sync: link the child session id alongside the text so a multi-step parent
70
- # can reference it and the transcript stays auditable (§4.3 R3 / §7).
70
+ # can reference it and the transcript stays auditable (R3).
71
71
  { text: result[:text], session_id: result[:session_id] }
72
72
  end
73
73
  end
@@ -5,7 +5,7 @@ require_relative "agent_enum"
5
5
 
6
6
  module Insika
7
7
  module Tools
8
- # PARALLEL fan-out of child agents (RFC-0010 §A): delegate N self-contained
8
+ # PARALLEL fan-out of child agents: delegate N self-contained
9
9
  # tasks at once and get all answers back together, in ONE parent turn. Sibling
10
10
  # of `spawn_subagent` (single); this one always sync-joins — the children run
11
11
  # concurrently (their provider waits overlap on the reactor) and the combined
@@ -25,7 +25,7 @@ module Insika
25
25
  # deliberate:
26
26
  #
27
27
  # · The customer-visible stream is per MESSAGE, not per token. `ttft_ms` still
28
- # measures the provider's first token, so item 34's baselines stay comparable;
28
+ # measures the provider's first token, so's baselines stay comparable;
29
29
  # what moved is when the customer can read it. For the WhatsApp edge this
30
30
  # changes nothing — the dispatcher accumulated the deltas into one message
31
31
  # anyway — and the Studio keeps its live typing off `:intermediate`.
@@ -44,7 +44,7 @@ module Insika
44
44
  # decides and publishes once, at the end of the stage.
45
45
  attr_reader :candidate
46
46
 
47
- # filter: Safety::OutputFilter | nil (RFC-0009 §3.2 — nil = stream untouched).
47
+ # filter: Safety::OutputFilter | nil (nil = stream untouched).
48
48
  # emit: ->(type, data) — the Executor's emitter, already bound to the task.
49
49
  # public_intermediate: the agent opted this channel in (`edge_stream`), so the
50
50
  # narration is TAGGED and `/v1/responses` gives it its own frame. Default false:
@@ -11,11 +11,11 @@ module Insika
11
11
  :allowed_skills,
12
12
  :chat, # the turn's RubyLLM::Chat instance
13
13
  :session, # the turn's SessionStore::Session | nil (set at stage 2;
14
- # read by create_chat for the per-chat model pin, §10)
15
- :model_selection, # resolved ModelSelection (v2, §10): model/provider/source/
14
+ # read by create_chat for the per-chat model pin)
15
+ :model_selection, # resolved ModelSelection: model/provider/source/
16
16
  # pinned/params/fallbacks. Set at stage 5; surfaced in usage.
17
17
  :halt_reason, # set by Middleware when short-circuiting (halt-as-FAILURE)
18
- :halt_response, # set by a Middleware for the GRACEFUL halt (RFC-0009 §3.1):
18
+ :halt_response, # set by a Middleware for the GRACEFUL halt:
19
19
  # the safe reply the turn completes with, WITHOUT touching the
20
20
  # LLM. Distinct from halt_reason — a completion, not a failure.
21
21
  :guardrail_block, # audit metadata the guardrail sets alongside halt_response
@@ -25,7 +25,11 @@ module Insika
25
25
  # (after_task); the Executor emits one :guardrail_flagged each.
26
26
  :response_content, # the turn's final assistant text, set at stage 6/on halt so the
27
27
  # after_task validator can inspect it.
28
- :output_filter # per-turn Safety::OutputFilter (nil = off); redacts the stream.
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.
29
33
 
30
34
  # Internal (not part of the contract): per-CALL correlation between RubyLLM's
31
35
  # tool callbacks and the tool decorators — `current_tool_call` keys the
@@ -34,7 +38,7 @@ module Insika
34
38
  #
35
39
  # They live in FIBER STORAGE, not in ivars, and that is the whole point:
36
40
  # `before_tool_call` → `tool.call` → `after_tool_result` all run in the SAME
37
- # fiber, and with `ToolConcurrency` (item 30) there is one fiber PER CALL. A
41
+ # fiber, and with `ToolConcurrency` there is one fiber PER CALL. A
38
42
  # single slot on this shared object would let one in-flight call overwrite
39
43
  # another's — a side-effect recorded under the wrong id (so a resume skips the
40
44
  # wrong tool, or re-runs a non-idempotent one) and a mislabelled event. Both
@@ -56,7 +60,7 @@ module Insika
56
60
  Fiber[NAME_KEY] = name
57
61
  end
58
62
 
59
- # Internal (§11 R1): the chat's message count RIGHT AFTER `assemble` (seeded
63
+ # Internal (R1): the chat's message count RIGHT AFTER `assemble` (seeded
60
64
  # history) and BEFORE `ask`. persist_turn slices `chat.messages.drop(baseline)`
61
65
  # to serialize the turn's real exchange — user + assistant(tool_calls) + tool
62
66
  # results + final assistant — into the transcript. nil = no chat recorded
@@ -70,13 +74,13 @@ module Insika
70
74
  # capability_registry or empty profile.capabilities (parity).
71
75
  attr_accessor :capability_names
72
76
 
73
- # Internal (RFC-0015): the turn's resolved QueuePolicy. Read at stage 6 to decide
77
+ # Internal: the turn's resolved QueuePolicy. Read at stage 6 to decide
74
78
  # whether this run accepts steered messages, and how they are worded. Resolved once
75
79
  # per turn, in build_turn_state — an edit to the agent mid-run does not change the
76
80
  # rules the run started under.
77
81
  attr_accessor :queue_policy
78
82
 
79
- # Internal (item 33): true when this turn re-enters the pipeline via
83
+ # Internal: true when this turn re-enters the pipeline via
80
84
  # resume_task/recovery. The EdgeLimiter reads it to NEVER re-count or block a
81
85
  # turn that was already admitted — a crash/pause under a saturated window must
82
86
  # not swallow a legitimate message with the rate-limit reply.
@@ -86,13 +90,13 @@ module Insika
86
90
  # (`remember` tool). Set in run_pipeline; nil = DEFAULT_TENANT in the MemoryStore.
87
91
  attr_accessor :tenant
88
92
 
89
- # Internal (Phase 6/D2/G4): turn context deposited into the data-tools to
93
+ # Internal: turn context deposited into the data-tools to
90
94
  # resolve {{ctx.*}} (chat_id/agent_id/tenant/store_id) and emit
91
95
  # X-Chat-Id/X-Store-Id/X-Agent-Id. A Hash of symbols, set in run_pipeline.
92
96
  # Comes from the TURN, never from the model's args (R2). Distinct from `tenant` (memory).
93
97
  attr_accessor :turn_context
94
98
 
95
- # Internal (Phase 6, observability): the turn's token usage (input/output/
99
+ # Internal (observability): the turn's token usage (input/output/
96
100
  # total/cached + model), captured from the provider's response at stage 6. Goes
97
101
  # to the terminal event (:task_completed) — feeds the usage of
98
102
  # /v1/responses and the Telemetry (OTEL). nil = turn with no model response
@@ -111,19 +115,19 @@ module Insika
111
115
  # by the coordinator for await(:approval)).
112
116
  attr_accessor :requires_approval, :approval_coordinator, :actor
113
117
 
114
- # Internal (item 30 / D4): the turn's shared in-flight cap for tool calls —
118
+ # Internal: the turn's shared in-flight cap for tool calls —
115
119
  # ONE Async::Semaphore(tool_concurrency), installed by ToolAssembly#wrap_tools
116
120
  # and acquired by every ToolEnvelope, INCLUDING the ones tool_search promotes
117
121
  # mid-turn (they read it off the state, so the cap survives promotion).
118
122
  # nil = concurrency off: no gate, no overhead, serial execution unchanged.
119
123
  attr_accessor :tool_gate
120
124
 
121
- # Item 30: parallel tool calls, resolved PER TURN and read by ChatBuilder
125
+ # parallel tool calls, resolved PER TURN and read by ChatBuilder
122
126
  # (whether to hand the gem `concurrency:`) and ToolAssembly (the gate's size).
123
127
  #
124
128
  # `requested_tool_concurrency` is what the operator configured;
125
129
  # `tool_concurrency` is what this turn actually gets. They differ for exactly
126
- # one reason — D3: `Executor#request_approval` blocks on `actor.await(:approval)`,
130
+ # one reason —: `Executor#request_approval` blocks on `actor.await(approval)`,
127
131
  # and the mailbox is one queue per TASK. Two fibers waiting there share it,
128
132
  # `dequeue` wakes exactly one, the message is consumed, and the other fiber
129
133
  # hangs until `approval_timeout` (~1h). So a turn that can suspend for a human
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Insika
4
- # Opt-in per-turn latency breakdown (item 34 / §13.1, action 1: locate the
4
+ # Opt-in per-turn latency breakdown (locate the
5
5
  # TTFB cost with real-turn data). OFF unless INSIKA_TURN_TIMING is set — when
6
6
  # off the Executor never allocates one and the hot path pays only `nil&.mark`.
7
7
  #
@@ -16,7 +16,7 @@ module Insika
16
16
  #
17
17
  # `ttft_ms` is the PROVIDER's first token, not the first byte the customer can
18
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 — item 34's
19
+ # answer lands inside `gen_ms`. Measuring the provider is the point —'s
20
20
  # baselines (~720 ms, provider-bound) stay comparable across that change.
21
21
  class TurnTiming
22
22
  # EnvSchema owns "is this flag on?" (1/true/yes/on) — the same predicate that
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Insika
4
- # Fixed-window counters on the KV Store (item 33 / §12 G7): the durable side of
4
+ # Fixed-window counters on the KV Store: the durable side of
5
5
  # the edge limits. One scope, keys shaped "kind:id:window_start" — the window
6
6
  # start bucketed on the epoch keeps every process/worker on the SAME bucket
7
7
  # without coordination (the Store's transaction serializes the read-modify-write,
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Insika
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end