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,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ module Commands
5
+ # WS1: issues a PER-TENANT token (multi_tenant mode). Operator-only BY
6
+ # CONSTRUCTION: the edge refuses a tenant principal on POST /v1/commands
7
+ # (403), and an internal command stamped with a tenant is refused here —
8
+ # a tenant can never mint credentials. The plaintext token is the response
9
+ # and exists nowhere else; the store keeps only its hash. -> Issue.to_h.
10
+ class IssueTenantToken
11
+ def initialize(token_store:, event_stream:)
12
+ @token_store = token_store
13
+ @event_stream = event_stream
14
+ end
15
+
16
+ def call(command)
17
+ raise Insika::ValidationError, "token commands are operator-only" if command.meta[:tenant]
18
+
19
+ tenant_id = Insika::Coercion.presence(
20
+ command.payload[:tenant_id] || command.payload["tenant_id"]
21
+ )
22
+ raise Insika::ValidationError, "tenant_id is required" if tenant_id.nil?
23
+
24
+ label = command.payload[:label] || command.payload["label"] || "default"
25
+ issue = @token_store.issue(tenant_id: tenant_id, label: label)
26
+ emit(issue.id, tenant_id)
27
+ { token: issue.token, id: issue.id, tenant_id: tenant_id, label: label.to_s }
28
+ end
29
+
30
+ private
31
+
32
+ def emit(token_id, tenant_id)
33
+ @event_stream.emit(Insika::Event.new(
34
+ type: :tenant_token_issued,
35
+ data: { token_id: token_id, tenant_id: tenant_id },
36
+ meta: { at: Time.now.utc.iso8601 }
37
+ ))
38
+ end
39
+ end
40
+ end
41
+ end
@@ -4,7 +4,7 @@ require "time"
4
4
 
5
5
  module Insika
6
6
  module Commands
7
- # Control command (RFC-0013 §3.6): the human's answer to a gated proposal.
7
+ # Control command: the human's answer to a gated proposal.
8
8
  #
9
9
  # approve -> each edit is written through `AgentFileStore#write`, which pushes
10
10
  # the previous content into `history`. Rollback is therefore already
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ module Commands
5
+ # WS1: revokes ONE token by id. The edge resolves tokens by hash, so the
6
+ # plaintext is not needed to kill a credential. Idempotent: revoking an
7
+ # already-revoked or unknown id -> { revoked: false }, never an error.
8
+ # -> { id:, revoked: bool }.
9
+ class RevokeToken
10
+ def initialize(token_store:, event_stream:)
11
+ @token_store = token_store
12
+ @event_stream = event_stream
13
+ end
14
+
15
+ def call(command)
16
+ raise Insika::ValidationError, "token commands are operator-only" if command.meta[:tenant]
17
+
18
+ id = Insika::Coercion.presence(
19
+ command.payload[:token_id] || command.payload["token_id"]
20
+ )
21
+ raise Insika::ValidationError, "token_id is required" if id.nil?
22
+
23
+ revoked = @token_store.revoke(id)
24
+ emit(id, revoked)
25
+ { id: id, revoked: revoked }
26
+ end
27
+
28
+ private
29
+
30
+ def emit(token_id, revoked)
31
+ @event_stream.emit(Insika::Event.new(
32
+ type: :tenant_token_revoked,
33
+ data: { token_id: token_id, revoked: revoked },
34
+ meta: { at: Time.now.utc.iso8601 }
35
+ ))
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ module Commands
5
+ # WS1: rotates a tenant's credential — revokes every active token of the
6
+ # tenant and issues a fresh one, in ONE transaction (a crashed half-rotation
7
+ # must never leave the tenant with nothing valid). Only the tenant's own
8
+ # tokens are touched; the operator token and other tenants are untouched.
9
+ # -> { revoked: n, token: Issue.to_h }.
10
+ class RotateTenantToken
11
+ def initialize(token_store:, event_stream:)
12
+ @token_store = token_store
13
+ @event_stream = event_stream
14
+ end
15
+
16
+ def call(command)
17
+ raise Insika::ValidationError, "token commands are operator-only" if command.meta[:tenant]
18
+
19
+ tenant_id = Insika::Coercion.presence(
20
+ command.payload[:tenant_id] || command.payload["tenant_id"]
21
+ )
22
+ raise Insika::ValidationError, "tenant_id is required" if tenant_id.nil?
23
+
24
+ label = command.payload[:label] || command.payload["label"] || "default"
25
+ result = @token_store.rotate(tenant_id: tenant_id, label: label)
26
+ issue = result[:issue]
27
+ emit(issue.id, tenant_id, result[:revoked])
28
+ { revoked: result[:revoked],
29
+ token: { token: issue.token, id: issue.id, tenant_id: tenant_id, label: label.to_s } }
30
+ end
31
+
32
+ private
33
+
34
+ def emit(token_id, tenant_id, revoked)
35
+ @event_stream.emit(Insika::Event.new(
36
+ type: :tenant_token_rotated,
37
+ data: { token_id: token_id, tenant_id: tenant_id, revoked: revoked },
38
+ meta: { at: Time.now.utc.iso8601 }
39
+ ))
40
+ end
41
+ end
42
+ end
43
+ end
@@ -4,11 +4,11 @@ require "time"
4
4
 
5
5
  module Insika
6
6
  module Commands
7
- # Control command (RFC-0013 phase A): reads a window of the agent's real traffic
7
+ # Control command: reads a window of the agent's real traffic
8
8
  # and records a ranked failure REPORT. Synchronous — it only scans durable stores
9
9
  # (no provider call, no fiber), so it answers with the Run and does not create a
10
10
  # Task. It is the ONLY way a refinement run starts: the CLI, the Studio button
11
- # and any external cron all dispatch this one command (§3.10 — there is no
11
+ # and any external cron all dispatch this one command (there is no
12
12
  # scheduler in the engine).
13
13
  #
14
14
  # Payload:
@@ -23,8 +23,8 @@ module Insika
23
23
  # the previous run for this agent, unless `full`) -> the agent's configured
24
24
  # `refinement.window` -> the collector's default.
25
25
  #
26
- # Phase A writes NOTHING to the agent, so it needs no opt-in: an absent
27
- # `refinement` config reads as report-only. Only `propose`/`auto_apply` (phase C)
26
+ # writes NOTHING to the agent, so it needs no opt-in: an absent
27
+ # `refinement` config reads as report-only. Only `propose`/`auto_apply`
28
28
  # require the operator to enable them explicitly.
29
29
  class RunRefinement
30
30
  READ_ONLY_MODES = %w[report propose auto_apply].freeze
@@ -77,7 +77,7 @@ module Insika
77
77
 
78
78
  # An unknown mode is a config typo, and a typo that silently degrades to
79
79
  # "report" would be the kind of quiet wrong the strict-config rule exists to
80
- # prevent (item 23).
80
+ # prevent.
81
81
  def validate_mode!(config)
82
82
  mode = AgentPayload.presence(config["mode"]) || "report"
83
83
  return if READ_ONLY_MODES.include?(mode)
@@ -7,16 +7,16 @@ module Insika
7
7
  # Stream. Validations that fail do NOT create a Task
8
8
  # (ValidationError/NotFoundError -> direct HTTP response).
9
9
  class SendMessage
10
- # RFC-0015 §5.5 — surfaces whose response can carry the "you do not own the reply"
10
+ # surfaces whose response can carry the "you do not own the reply"
11
11
  # verdict (`merged` for `collect`, `steered` for `steer`), and therefore the only
12
12
  # ones where a message may join another turn. `/v1/responses` is NOT here: its body
13
13
  # is OpenAI-shaped SSE with nowhere to put the field, and it is frozen because a
14
14
  # live consumer speaks it. Joining a caller that cannot hear the verdict makes it
15
15
  # deliver the same answer once per message, which is worse than not joining at all.
16
- # Channels declare themselves by `channel:<id>` once RFC-0011 §6 lands.
16
+ # Channels declare themselves by `channel:<id>` once lands.
17
17
  COALESCABLE_TRANSPORTS = %i[http:json].freeze
18
18
 
19
- # RFC-0011 §6.4 — `inbound_log` is the retry window for channel event ids.
19
+ # `inbound_log` is the retry window for channel event ids.
20
20
  # nil = no dedup (every surface that does not send an `event_id`, which is all
21
21
  # of them today), and a caller that cannot supply a stable id gets
22
22
  # at-least-once turns rather than a content hash pretending to be dedup.
@@ -42,7 +42,7 @@ module Insika
42
42
 
43
43
  # session_id XOR history (both -> error; neither -> one-shot).
44
44
  if p[:session_id] && p[:history]
45
- raise Insika::ValidationError, "session_id and history are mutually exclusive (D2)"
45
+ raise Insika::ValidationError, "session_id and history are mutually exclusive"
46
46
  end
47
47
 
48
48
  validate_history!(p[:history]) if p[:history]
@@ -56,7 +56,7 @@ module Insika
56
56
  (raise Insika::NotFoundError, "session '#{p[:session_id]}' not found")
57
57
  end
58
58
 
59
- # RFC-0011 §6.4 — the platform retried a webhook it already delivered. Answer
59
+ # the platform retried a webhook it already delivered. Answer
60
60
  # with the turn it ALREADY produced and run nothing: without this, one flaky
61
61
  # ack costs a second LLM turn and sends the customer the same answer twice.
62
62
  # Checked before the queue doors on purpose — a duplicate is not a fragment to
@@ -77,18 +77,18 @@ module Insika
77
77
  def start_turn(command, p, profile)
78
78
  message = p[:message]
79
79
 
80
- # RFC-0015 §5.3 — a fragment for a session whose turn is still at the door
80
+ # a fragment for a session whose turn is still at the door
81
81
  # joins it instead of becoming a turn of its own. Asked BEFORE `create` so a
82
82
  # merge leaves no orphan :queued task behind (Recovery replays :queued at
83
83
  # boot). Only offered on a surface that can report the verdict back —
84
- # §5.5: coalescing a caller that cannot hear `merged` makes it deliver the
84
+ # coalescing a caller that cannot hear `merged` makes it deliver the
85
85
  # same answer twice.
86
86
  if coalescable?(command)
87
87
  if (joined = @executor.collect_into_pending(p[:session_id], message, profile: profile))
88
88
  return { task_id: joined, merged: true }
89
89
  end
90
90
 
91
- # RFC-0015 §5.1 — the turn is already RUNNING: the message is appended to it at
91
+ # the turn is already RUNNING: the message is appended to it at
92
92
  # the next tool-batch boundary. Same verdict as a merge, different word: the
93
93
  # answer comes out of `task_id`, which is not this call's to deliver. Asked
94
94
  # after `collect` because the two cannot both apply — a turn is either still at
@@ -101,7 +101,7 @@ module Insika
101
101
  # command.to_h persists the entire Command in the Task;
102
102
  # ResumeTask re-reads payload.message from there.
103
103
  task = @task_store.create(command: command.to_h, session_id: p[:session_id])
104
- # RFC-0015 §6.4 — `interrupt` mode: the turn in flight is now answering the wrong
104
+ # `interrupt` mode: the turn in flight is now answering the wrong
105
105
  # question, so it is abandoned at its next boundary. This message keeps its OWN
106
106
  # task and its own reply (that is why it needs no verdict and no surface gate), and
107
107
  # the cancel is posted after `create` so the event can name what replaced what.
@@ -6,7 +6,7 @@ module Insika
6
6
  module Commands
7
7
  # Control command: adjusts an agent's tools allow/denylist.
8
8
  # `allow` nil = all (AgentProfile rule); `deny` always
9
- # wins. `allow_groups` (Phase 7/D4/F5, Step C): per-group allowlist, only
9
+ # wins. `allow_groups`: per-group allowlist, only
10
10
  # overwritten if the key comes in the payload (otherwise preserved). Takes effect on the next
11
11
  # dispatch (hot). -> AgentProfile.
12
12
  class SetAgentTools
@@ -5,8 +5,15 @@ require "time"
5
5
  module Insika
6
6
  module Commands
7
7
  # Control command: enables/disables a skill on N
8
- # agents at once, adjusting each one's `profile.skills` allowlist.
9
- # Takes effect on the next dispatch (hot via ProfileSource). -> { name, enabled_for }.
8
+ # agents at once, adjusting each one's `profile.skills` allowlist — and, with
9
+ # `eager_ids`, which of them keep the body in the prompt on EVERY turn
10
+ # (`profile.skills_eager`). Takes effect on the next dispatch (hot via
11
+ # ProfileSource). -> { name, enabled_for, eager_for, skipped_all, skipped_eager_all }.
12
+ #
13
+ # Both are per-agent decisions about the same skill, which is why they are one
14
+ # command: eagerness lives on the agent precisely BECAUSE the skill is shared, so
15
+ # the screen that says "which agents can load this" is the screen that says "and
16
+ # which of them always have it".
10
17
  #
11
18
  # Allowlist semantics (AgentProfile): nil = ALL, [] = none,
12
19
  # [names] = subset. Important and deliberate consequence:
@@ -16,6 +23,11 @@ module Insika
16
23
  # allowlist (destructive/surprising). These agents are left intact and
17
24
  # go into `skipped_all`. To restrict, use an explicit :set_agent_tools/allowlist
18
25
  # first.
26
+ #
27
+ # `skills_eager` is NOT allowlist semantics (nil = NONE, see
28
+ # SkillCatalog#eager_for), so the two fields are adjusted by different rules: nil
29
+ # there is an empty set that can simply be added to, and only the BLANKET `true`
30
+ # has the same "cannot remove one name from all" problem (-> skipped_eager_all).
19
31
  class SetSkillAgents
20
32
  def initialize(profile_source:, event_stream:)
21
33
  @profile_source = profile_source
@@ -27,35 +39,46 @@ module Insika
27
39
  name = AgentPayload.presence(p[:name])
28
40
  raise Insika::ValidationError, "name is required" if name.nil?
29
41
  raise Insika::ValidationError, "agent_ids must be a list" unless p[:agent_ids].nil? || p[:agent_ids].is_a?(Array)
42
+ raise Insika::ValidationError, "eager_ids must be a list" unless p[:eager_ids].nil? || p[:eager_ids].is_a?(Array)
30
43
 
31
44
  wanted = Array(p[:agent_ids]).map(&:to_s)
32
- enabled_for = []
33
- skipped_all = []
34
-
35
- @profile_source.all.each do |profile|
36
- want = wanted.include?(profile.id)
37
- new_skills = next_skills(profile.skills, name, want)
38
-
39
- if new_skills == :skip
40
- skipped_all << profile.id
41
- next
42
- end
43
- next if new_skills == profile.skills # no change
45
+ # eager_ids ABSENT means "this form does not manage eagerness" — leave every
46
+ # profile's setting alone. An empty ARRAY means "none of them", which is a real
47
+ # instruction and must be applied. `nil` and `[]` are not the same request.
48
+ eager_wanted = p[:eager_ids].nil? ? nil : Array(p[:eager_ids]).map(&:to_s)
49
+ result = { enabled_for: [], eager_for: [], skipped_all: [], skipped_eager_all: [] }
44
50
 
45
- @profile_source.put(Insika::AgentProfile.build(**profile.to_h.merge(skills: new_skills)))
46
- enabled_for << profile.id if want
47
- end
51
+ @profile_source.all.each { |profile| apply(profile, name, wanted, eager_wanted, result) }
48
52
 
49
53
  @event_stream.emit(Insika::Event.new(
50
54
  type: :skill_agents_set,
51
- data: { name: name, agent_ids: wanted, skipped_all: skipped_all },
55
+ data: { name: name, agent_ids: wanted, eager_ids: eager_wanted,
56
+ skipped_all: result[:skipped_all] }.compact,
52
57
  meta: { at: Time.now.utc.iso8601 }
53
58
  ))
54
- { name: name, enabled_for: enabled_for, skipped_all: skipped_all }
59
+ { name: name }.merge(result)
55
60
  end
56
61
 
57
62
  private
58
63
 
64
+ def apply(profile, name, wanted, eager_wanted, result)
65
+ want = wanted.include?(profile.id)
66
+ new_skills = next_skills(profile.skills, name, want)
67
+ new_eager = next_eager(profile, name, eager_wanted, result)
68
+
69
+ if new_skills == :skip
70
+ result[:skipped_all] << profile.id
71
+ new_skills = profile.skills
72
+ end
73
+ result[:enabled_for] << profile.id if want
74
+ result[:eager_for] << profile.id if eager_wanted&.include?(profile.id)
75
+ return if new_skills == profile.skills && new_eager == profile.skills_eager
76
+
77
+ @profile_source.put(Insika::AgentProfile.build(
78
+ **profile.to_h.merge(skills: new_skills, skills_eager: new_eager)
79
+ ))
80
+ end
81
+
59
82
  # -> new allowlist | :skip (agent with nil that requested disable).
60
83
  def next_skills(current, name, want)
61
84
  if want
@@ -66,6 +89,24 @@ module Insika
66
89
  Array(current).map(&:to_s) - [name]
67
90
  end
68
91
  end
92
+
93
+ # -> new skills_eager. nil eager_wanted = not managed by this call.
94
+ def next_eager(profile, name, eager_wanted, result)
95
+ current = profile.skills_eager
96
+ return current if eager_wanted.nil?
97
+
98
+ want = eager_wanted.include?(profile.id)
99
+ # Blanket `true` already includes every skill; removing ONE name from it would
100
+ # mean materializing the whole catalog as a list, the same destructive surprise
101
+ # `skipped_all` exists for.
102
+ if Coercion.truthy?(current)
103
+ result[:skipped_eager_all] << profile.id unless want
104
+ return current
105
+ end
106
+
107
+ list = current.nil? || current == false ? [] : Array(current).map(&:to_s)
108
+ want ? (list | [name]) : (list - [name])
109
+ end
69
110
  end
70
111
  end
71
112
  end
@@ -45,7 +45,7 @@ module Insika
45
45
  raise Insika::NotFoundError, "workflow '#{workflow}' not registered"
46
46
  end
47
47
 
48
- # I/O by schema (item 22 / §4.4): the INPUT is validated SYNCHRONOUSLY —
48
+ # I/O by schema: the INPUT is validated SYNCHRONOUSLY —
49
49
  # a non-conforming input is a WorkflowSchemaError (< ValidationError -> 422)
50
50
  # with NO run created. `definition` reads the schema metadata without
51
51
  # resolving the factory (stays out of the fiber). No-op without an input_schema.
@@ -25,7 +25,7 @@ module Insika
25
25
  # what was sent. id never changes (rename = create+delete, out of scope).
26
26
  merged = existing.to_h.merge(patch).merge(id: id)
27
27
  profile = Insika::AgentProfile.build(**merged)
28
- # RFC-0010 §4.4: definition-time cycle + depth check (an update may ADD
28
+ # definition-time cycle + depth check (an update may ADD
29
29
  # subagents to an existing agent). Raises SubagentError before persisting.
30
30
  validate_subagent_graph!(profile)
31
31
  @profile_source.put(profile)
@@ -5,7 +5,7 @@ require "time"
5
5
  module Insika
6
6
  module Commands
7
7
  # Writes (upserts) a DATA-defined tool into the ToolStore and RELOADS the overlay +
8
- # catalog — takes effect without a restart (F5). Rejects a name that collides with a
8
+ # catalog — takes effect without a restart. Rejects a name that collides with a
9
9
  # code tool (R3). Validating the definition (format, method/url, types,
10
10
  # placeholders) is the ToolStore/ToolDefinition's job. Secrets (headers) reconcile
11
11
  # in the store; the event carries only the name (0 leakage). -> { name, definition }.
@@ -4,7 +4,7 @@ require "time"
4
4
 
5
5
  module Insika
6
6
  module Commands
7
- # Control command: writes an eval case (RFC-0008 §3.1) into the GoldenStore. This
7
+ # Control command: writes an eval case into the GoldenStore. This
8
8
  # is what lets a domain owner add a case — above all its RUBRIC, the part of an eval
9
9
  # that is plain language — without a checkout and a text editor.
10
10
  #
@@ -5,10 +5,15 @@ require "yaml"
5
5
 
6
6
  module Insika
7
7
  module Commands
8
- # Control command: writes a shared skill
9
- # (complete SKILL.md) into the SkillStore and RELOADS the catalog — takes effect
10
- # without a restart (hot). Validates the frontmatter (name required) before writing.
11
- # -> { name, updated_at }.
8
+ # Control command: writes a skill (complete SKILL.md) into
9
+ # the SkillStore and RELOADS the catalog — takes effect without a restart (hot).
10
+ # Validates the frontmatter (name required) before writing. -> { name, agent, updated_at }.
11
+ #
12
+ # `agent` (optional) writes into that agent's scope instead of the shared one: the
13
+ # SPECIALIZATION of a shared skill, or a skill private to one agent. Same `name`
14
+ # either way — the store position is the identity, so the frontmatter inside an
15
+ # override keeps saying the bare shared name and the level-1 catalog, load_skill,
16
+ # the labels and the events all keep displaying it.
12
17
  class WriteSkill
13
18
  def initialize(skill_store:, skill_catalog:, event_stream:)
14
19
  @skill_store = skill_store
@@ -19,21 +24,26 @@ module Insika
19
24
  def call(command)
20
25
  p = AgentPayload.symbolize(command.payload)
21
26
  name = AgentPayload.presence(p[:name])
27
+ agent = AgentPayload.presence(p[:agent])
22
28
  content = p[:content].to_s
23
29
  raise Insika::ValidationError, "name is required" if name.nil?
24
30
  validate_frontmatter!(content)
25
31
 
26
- rec = @skill_store.write(name, content, create_only: !!p[:create_only])
32
+ rec = @skill_store.write(name, content, agent: agent, create_only: !!p[:create_only])
27
33
  @skill_catalog.reload
34
+ emit(:skill_written, name, agent)
35
+ { name: name, agent: agent, updated_at: rec["updated_at"] }
36
+ end
37
+
38
+ private
39
+
40
+ def emit(type, name, agent)
28
41
  @event_stream.emit(Insika::Event.new(
29
- type: :skill_written, data: { name: name },
42
+ type: type, data: { name: name, agent: agent }.compact,
30
43
  meta: { at: Time.now.utc.iso8601 }
31
44
  ))
32
- { name: name, updated_at: rec["updated_at"] }
33
45
  end
34
46
 
35
- private
36
-
37
47
  # Mirrors the SkillCatalog parse: without YAML frontmatter with `name`, the skill
38
48
  # would be silently ignored on reload — fail early, in the Command.
39
49
  def validate_frontmatter!(content)
@@ -18,15 +18,19 @@ module Insika
18
18
  # agent_files/skills: the CONTENT of per-agent prompts
19
19
  # and shared skills lives in the Store (single source of truth,
20
20
  # a SQLite backup), not on disk — disk becomes only seed/import.
21
- # goldens: authored eval cases (RFC-0008 §3.1 / RFC-0013 §3.7) — config, like the
21
+ # goldens: authored eval cases — config, like the
22
22
  # prompts and skills: the corpus on disk is seed and export, the store is what a
23
23
  # deployment runs and what the Studio edits.
24
- # baselines: the ACCEPTED state of each agent's golden set (RFC-0013 §3.7.3).
24
+ # baselines: the ACCEPTED state of each agent's golden set.
25
25
  # Config for the same reason: it is a curated decision ("this is the bar"), the
26
26
  # file is its export, and the refinement gate reads it from inside a deployment
27
27
  # that has no checkout.
28
- SCOPES = %w[agents settings llm_providers mcp agent_files skills system_files tools
29
- goldens baselines].freeze
28
+ # agent_skills: the per-agent SPECIALIZATIONS of a shared skill (and
29
+ # agent-private skills). A second scope rather than a composite key in `skills`,
30
+ # so the shared records are untouched by the agent dimension arriving — no
31
+ # migration, and a live deployment keeps serving exactly what it served.
32
+ SCOPES = %w[agents settings llm_providers mcp agent_files skills agent_skills
33
+ system_files tools goldens baselines].freeze
30
34
 
31
35
  class UnknownScope < Insika::Error; end
32
36
 
@@ -19,7 +19,7 @@ module Insika
19
19
  def initialize(providers:, event_stream:, hooks: Hooks.new, estimator: TokenEstimator)
20
20
  @providers = providers
21
21
  @event_stream = event_stream
22
- @hooks = hooks # :prompt pair (task 16); empty Hooks = no-op
22
+ @hooks = hooks # prompt pair; empty Hooks = no-op
23
23
  @estimator = estimator
24
24
  end
25
25
 
@@ -97,7 +97,7 @@ module Insika
97
97
  def estimable_text(content)
98
98
  case content
99
99
  when String then content
100
- # An eviction unit (§11 R1): a cycle of message Hashes -> sum their text.
100
+ # An eviction unit (R1): a cycle of message Hashes -> sum their text.
101
101
  when Array then content.map { |c| estimable_text(c) }.join(" ")
102
102
  when Hash then content.values.map(&:to_s).join(" ")
103
103
  else content.to_s
@@ -8,12 +8,36 @@ module Insika
8
8
  # tokens: Integer | nil; estimated by the Builder when nil
9
9
  # source: String — provider id (audit)
10
10
  # pinned: true -> uncuttable in the budget (e.g. identity)
11
+ # labels: [{ "name" =>, "reason" => }] — WHAT this fragment carries and WHY,
12
+ # as ids. Content-FREE by contract, so the context trace can report
13
+ # which skills a turn injected without storing a byte of the bodies.
14
+ # [] = nothing to name (the default for every provider that has no
15
+ # natural id, e.g. the identity prompt).
16
+ #
17
+ # The REASON is the point. A name alone answers "was something
18
+ # injected"; the operator's actual question is "which skill did I
19
+ # trigger, and why is it here" — `eager` (the agent always wants it),
20
+ # `trigger:<matched phrase>` (this message asked for it), or absent
21
+ # for a body a plugin supplied. String keys because these labels are
22
+ # written to the context trace and to events as JSON: the round-trip
23
+ # is then the identity, and no reader has to defend against both.
11
24
  ContextFragment = Data.define(:content, :placement, :priority, :tokens,
12
- :source, :pinned) do
25
+ :source, :pinned, :labels) do
13
26
  def self.build(content:, placement:, source:, priority: 50, tokens: nil,
14
- pinned: false)
27
+ pinned: false, labels: [])
15
28
  new(content: content, placement: placement, priority: priority,
16
- tokens: tokens, source: source, pinned: pinned)
29
+ tokens: tokens, source: source, pinned: pinned,
30
+ labels: Array(labels).map { |l| label(l) })
31
+ end
32
+
33
+ # A bare String is still a valid label (a provider that has an id but no reason
34
+ # to give) — it normalizes to a reason-less entry rather than being rejected.
35
+ def self.label(raw)
36
+ return { "name" => raw.to_s }.freeze unless raw.is_a?(Hash)
37
+
38
+ name = (raw[:name] || raw["name"]).to_s
39
+ reason = raw[:reason] || raw["reason"]
40
+ { "name" => name, "reason" => reason&.to_s }.compact.freeze
17
41
  end
18
42
  end
19
43
  end
@@ -3,12 +3,12 @@
3
3
  module Insika
4
4
  module Context
5
5
  # Precedence ladder for context fragments — SINGLE SOURCE of the order
6
- # (Phase 6/D5, trust boundary). Higher = more authority: appears earlier
6
+ # (trust boundary). Higher = more authority: appears earlier
7
7
  # in the system prompt and survives budget cuts. Providers reference these
8
8
  # constants instead of loose numbers, so the boundary is an auditable
9
9
  # contract in a single place (trust_boundary_spec locks the order).
10
10
  #
11
- # Contract (NF3/D5): identity and guardrails go PINNED at the top; TURN
11
+ # Contract: identity and guardrails go PINNED at the top; TURN
12
12
  # injections (request_context — the consumer's tenant/vars) sit at the
13
13
  # BOTTOM and are sacrificed FIRST under budget. Identity (pinned) is NEVER
14
14
  # truncated. A prompt injection riding in via turn data is DATA, not
@@ -18,6 +18,7 @@ module Insika
18
18
  module Priority
19
19
  IDENTITY = 100 # IDENTITY/SOUL (Prompt) — pinned
20
20
  PROMPT_REF = 90 # Prompt Catalog guardrails/refs (Prompt) — pinned
21
+ SKILL_BODY = 85 # <active_skill> trigger-matched body (SkillTrigger)
21
22
  SKILL = 80 # <available_skills> level 1 (Skill)
22
23
  MEMORY = 75 # <memory> read path (Memory)
23
24
  TOOL_SEARCH = 70 # <available_tools> level 1 (ToolSearch)
@@ -33,7 +33,7 @@ module Insika
33
33
 
34
34
  private
35
35
 
36
- # Engine memory scope (D3): an EXPLICIT tenant from the Command wins
36
+ # Engine memory scope: an EXPLICIT tenant from the Command wins
37
37
  # (multi-merchant override); otherwise the SESSION (=chat) — engine-owner
38
38
  # memory is per-chat. No session (one-shot) and no tenant -> nil (MemoryStore
39
39
  # applies _default). Symmetric to the write path (`state.tenant` in the Executor).
@@ -13,7 +13,7 @@ module Insika
13
13
  # "history" is transcript (consumed by the Session provider), not turn
14
14
  # metadata — it does not leak into the system's request_context. Keys
15
15
  # prefixed with "__" are INTERNAL slots (e.g. the per-chat model pin
16
- # "__llm__", §10) — reserved, never rendered to the model.
16
+ # "__llm__") — reserved, never rendered to the model.
17
17
  request.vars.to_h.each do |k, v|
18
18
  next if k.to_s == "history" || k.to_s.start_with?("__")
19
19
 
@@ -17,7 +17,13 @@ module Insika
17
17
  messages = transcript_for(request)
18
18
  return [] if messages.nil? || messages.empty?
19
19
 
20
- # 1 fragment per EVICTION UNIT (§11 R1): a plain message, OR an
20
+ # A3/C3 opt-in: identical tool results in the transcript collapse to a
21
+ # back-reference (the cheap half of compaction). CHANGES WHAT THE MODEL
22
+ # SEES — hence the profile flag, never a default. Applied BEFORE the
23
+ # eviction-unit grouping so a cycle's results are already slim.
24
+ messages = compress_history(messages, request)
25
+
26
+ # 1 fragment per EVICTION UNIT (R1): a plain message, OR an
21
27
  # assistant-with-tool_calls together with its tool results. Grouping at
22
28
  # the fragment level means the budget cut (apply_budget) drops a whole
23
29
  # tool cycle atomically — a tool_use is NEVER seeded without its result
@@ -72,7 +78,16 @@ module Insika
72
78
  h
73
79
  end
74
80
 
75
- def tool_calls?(msg) = msg[:tool_calls] && !Array(msg[:tool_calls]).empty?
81
+ def tool_calls?(msg) = msg[:tool_calls] && !Array(msg[:tool_calls]).empty?
82
+
83
+ # The compression is opt-in per agent (profile data, config-over-code):
84
+ # absent/off -> the transcript passes through byte-identical (parity).
85
+ def compress_history(messages, request)
86
+ profile = request.respond_to?(:profile) ? request.profile : nil
87
+ return messages unless profile&.tool_output_compression
88
+
89
+ ToolOutputCompressor.compress_transcript(messages)
90
+ end
76
91
 
77
92
  # Precedence: checkpoint -> explicit history -> store.
78
93
  # The first present source wins; no merge.
@@ -13,7 +13,11 @@ module Insika
13
13
 
14
14
  private
15
15
 
16
- def entries(request) = @catalog.effective(request.profile.skills)
16
+ # Only the skills the model still has to ASK for. An eager skill is already in
17
+ # the prompt in full, so advertising it here would invite a `load_skill` call
18
+ # that buys a duplicate — and the catalog's whole job is to describe what is
19
+ # NOT yet loaded. Nothing lazy left -> CatalogProvider emits no fragment.
20
+ def entries(request) = @catalog.lazy_for(request.profile)
17
21
  end
18
22
  end
19
23
  end