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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +69 -3
- data/README.md +1 -1
- data/bin/insika +22 -7
- data/docs/AGENTS.md +129 -5
- data/docs/CHANNELS.md +1 -1
- data/docs/CONTEXT.md +22 -5
- data/docs/DEPLOY.md +30 -10
- data/docs/EMBEDDING.md +11 -7
- data/docs/EVALS.md +1 -1
- data/docs/LOADTEST.md +3 -2
- data/docs/OBSERVABILITY.md +11 -2
- data/docs/REFINEMENT.md +6 -6
- data/docs/RELEASING.md +7 -7
- data/docs/RUNNING-LOCAL.md +1 -1
- data/docs/SECURITY.md +24 -11
- data/docs/SKILLS.md +189 -3
- data/docs/WHY.md +1 -1
- data/docs/WORKFLOWS.md +2 -2
- data/docs/index.md +1 -1
- data/docs/onboarding/start.md +1 -1
- data/lib/insika/agent_profile.rb +89 -22
- data/lib/insika/alert_dispatcher.rb +139 -0
- data/lib/insika/baseline_store.rb +2 -2
- data/lib/insika/budget_ledger.rb +135 -0
- data/lib/insika/channel_delivery.rb +14 -11
- data/lib/insika/channel_registry.rb +1 -1
- data/lib/insika/channels/relay.rb +3 -3
- data/lib/insika/channels/web/widget.js +2 -2
- data/lib/insika/channels/web.rb +7 -7
- data/lib/insika/channels/webhook.rb +58 -0
- data/lib/insika/chat_builder.rb +62 -13
- data/lib/insika/circuit_state.rb +114 -0
- data/lib/insika/coercion.rb +8 -0
- data/lib/insika/commands/agent_payload.rb +5 -3
- data/lib/insika/commands/create_agent.rb +2 -2
- data/lib/insika/commands/create_session.rb +1 -1
- data/lib/insika/commands/delete_llm_provider.rb +1 -1
- data/lib/insika/commands/delete_skill.rb +43 -0
- data/lib/insika/commands/gate_refinement.rb +12 -12
- data/lib/insika/commands/import_mcp_tools.rb +1 -1
- data/lib/insika/commands/import_tools.rb +4 -4
- data/lib/insika/commands/issue_tenant_token.rb +41 -0
- data/lib/insika/commands/resolve_refinement.rb +1 -1
- data/lib/insika/commands/revoke_token.rb +39 -0
- data/lib/insika/commands/rotate_tenant_token.rb +43 -0
- data/lib/insika/commands/run_refinement.rb +5 -5
- data/lib/insika/commands/send_message.rb +9 -9
- data/lib/insika/commands/set_agent_tools.rb +1 -1
- data/lib/insika/commands/set_skill_agents.rb +60 -19
- data/lib/insika/commands/trigger_workflow.rb +1 -1
- data/lib/insika/commands/update_agent.rb +1 -1
- data/lib/insika/commands/write_data_tool.rb +1 -1
- data/lib/insika/commands/write_golden.rb +1 -1
- data/lib/insika/commands/write_skill.rb +19 -9
- data/lib/insika/config_store.rb +8 -4
- data/lib/insika/context/builder.rb +2 -2
- data/lib/insika/context/fragment.rb +27 -3
- data/lib/insika/context/priority.rb +3 -2
- data/lib/insika/context/providers/memory.rb +1 -1
- data/lib/insika/context/providers/request.rb +1 -1
- data/lib/insika/context/providers/session.rb +17 -2
- data/lib/insika/context/providers/skill.rb +5 -1
- data/lib/insika/context/providers/skill_trigger.rb +128 -0
- data/lib/insika/context_trace_store.rb +92 -0
- data/lib/insika/delegation_store.rb +2 -2
- data/lib/insika/doctor.rb +250 -5
- data/lib/insika/dsl/runtime.rb +12 -9
- data/lib/insika/dsl/server_boot.rb +4 -3
- data/lib/insika/dsl/system.rb +1 -1
- data/lib/insika/dsl.rb +72 -15
- data/lib/insika/edge_limiter.rb +144 -6
- data/lib/insika/egress_guard.rb +3 -3
- data/lib/insika/env_schema.rb +13 -10
- data/lib/insika/errors.rb +61 -5
- data/lib/insika/evals/assertions.rb +12 -12
- data/lib/insika/evals/baseline.rb +3 -3
- data/lib/insika/evals/golden.rb +8 -8
- data/lib/insika/evals/judge.rb +7 -7
- data/lib/insika/evals/pairwise.rb +3 -3
- data/lib/insika/evals/report.rb +2 -2
- data/lib/insika/evals/runner.rb +6 -6
- data/lib/insika/evals/transport.rb +2 -2
- data/lib/insika/event_stream.rb +23 -5
- data/lib/insika/executor.rb +423 -108
- data/lib/insika/frontmatter.rb +1 -1
- data/lib/insika/golden_store.rb +2 -2
- data/lib/insika/http_client.rb +3 -3
- data/lib/insika/inbound_log.rb +1 -1
- data/lib/insika/llm_configurator.rb +3 -3
- data/lib/insika/loop_detector.rb +143 -0
- data/lib/insika/mcp_http_client.rb +4 -4
- data/lib/insika/mcp_tool_ingestor.rb +6 -6
- data/lib/insika/message_origin.rb +2 -2
- data/lib/insika/model_resolver.rb +1 -1
- data/lib/insika/model_selection.rb +5 -4
- data/lib/insika/onboarding.rb +2 -2
- data/lib/insika/outbox_store.rb +2 -2
- data/lib/insika/overlay_tool_registry.rb +3 -4
- data/lib/insika/pack.rb +3 -3
- data/lib/insika/pack_importer.rb +17 -15
- data/lib/insika/pending_action_store.rb +1 -1
- data/lib/insika/plugin/loader.rb +2 -2
- data/lib/insika/policy/policy.rb +1 -1
- data/lib/insika/profile_source.rb +12 -6
- data/lib/insika/provider_error_classifier.rb +160 -0
- data/lib/insika/queue_policy.rb +2 -2
- data/lib/insika/recovery.rb +47 -6
- data/lib/insika/refinement/candidate.rb +4 -4
- data/lib/insika/refinement/evidence_collector.rb +6 -6
- data/lib/insika/refinement/gate.rb +7 -7
- data/lib/insika/refinement/panel.rb +7 -7
- data/lib/insika/refinement/proposer.rb +9 -9
- data/lib/insika/refinement_store.rb +12 -12
- data/lib/insika/reliability.rb +185 -0
- data/lib/insika/safety/config.rb +2 -2
- data/lib/insika/safety/detectors.rb +5 -5
- data/lib/insika/safety/factory.rb +3 -3
- data/lib/insika/safety/input_guardrail.rb +19 -4
- data/lib/insika/safety/moderator.rb +19 -11
- data/lib/insika/safety/output_filter.rb +2 -2
- data/lib/insika/safety/output_validator.rb +2 -2
- data/lib/insika/safety/safe_responses.rb +1 -1
- data/lib/insika/sandbox/boundary.rb +2 -2
- data/lib/insika/sandbox.rb +1 -1
- data/lib/insika/server/app.rb +223 -51
- data/lib/insika/server/boot.rb +4 -4
- data/lib/insika/server/rack_app.rb +15 -7
- data/lib/insika/server/responses.rb +18 -8
- data/lib/insika/server/tenant_auth.rb +61 -0
- data/lib/insika/session_actor.rb +3 -3
- data/lib/insika/session_store.rb +1 -1
- data/lib/insika/settings_store.rb +5 -5
- data/lib/insika/shutdown.rb +4 -4
- data/lib/insika/skill_catalog.rb +127 -20
- data/lib/insika/skill_store.rb +70 -22
- data/lib/insika/steer_injector.rb +1 -1
- data/lib/insika/store.rb +1 -1
- data/lib/insika/studio/app.rb +183 -61
- data/lib/insika/studio/assets/dist/application.js +25 -24
- data/lib/insika/studio/forms.rb +13 -18
- data/lib/insika/studio/nav_icons.rb +1 -1
- data/lib/insika/studio/views/_message.erb +2 -2
- data/lib/insika/studio/views/agent_detail.erb +2 -2
- data/lib/insika/studio/views/agents.erb +1 -1
- data/lib/insika/studio/views/refinement.erb +4 -4
- data/lib/insika/studio/views/session.erb +78 -3
- data/lib/insika/studio/views/settings.erb +7 -12
- data/lib/insika/studio/views/skills.erb +67 -12
- data/lib/insika/subagent_graph.rb +3 -3
- data/lib/insika/task_actor.rb +3 -3
- data/lib/insika/task_store.rb +1 -1
- data/lib/insika/telemetry/pricing.rb +3 -3
- data/lib/insika/telemetry/recorder.rb +1 -1
- data/lib/insika/telemetry.rb +2 -2
- data/lib/insika/testing/store_contract.rb +27 -27
- data/lib/insika/tick.rb +122 -0
- data/lib/insika/token_store.rb +168 -0
- data/lib/insika/tool_assembly.rb +5 -5
- data/lib/insika/tool_definition.rb +8 -8
- data/lib/insika/tool_envelope.rb +1 -1
- data/lib/insika/tool_manifest.rb +6 -6
- data/lib/insika/tool_output_compressor.rb +100 -0
- data/lib/insika/tool_store.rb +1 -1
- data/lib/insika/tool_trace_store.rb +1 -1
- data/lib/insika/tools/concurrency.rb +2 -2
- data/lib/insika/tools/data_defined_tool.rb +4 -5
- data/lib/insika/tools/load_skill.rb +61 -3
- data/lib/insika/tools/stuck_signal.rb +44 -0
- data/lib/insika/tools/subagent.rb +4 -4
- data/lib/insika/tools/subagents.rb +1 -1
- data/lib/insika/turn_output.rb +2 -2
- data/lib/insika/turn_state.rb +17 -13
- data/lib/insika/turn_timing.rb +2 -2
- data/lib/insika/usage_ledger.rb +1 -1
- data/lib/insika/version.rb +1 -1
- data/lib/insika/wiring/graph.rb +77 -26
- data/lib/insika/workflow.rb +1 -1
- data/lib/insika/workflow_registry.rb +1 -1
- data/lib/insika.rb +32 -15
- metadata +19 -2
- data/lib/insika/server/admin_auth.rb +0 -29
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# Contract suite for Insika::Store
|
|
4
|
-
# backends
|
|
3
|
+
# Contract suite for Insika::Store, exported for third-party
|
|
4
|
+
# backends — a gem like `insika-pg` writes its spec against THIS
|
|
5
5
|
# file, not against a read of stores/sqlite.rb:
|
|
6
6
|
#
|
|
7
7
|
# require "insika/testing/store_contract"
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
# it_behaves_like "an Insika store"
|
|
12
12
|
# end
|
|
13
13
|
#
|
|
14
|
-
# Two groups, on purpose
|
|
14
|
+
# Two groups, on purpose:
|
|
15
15
|
#
|
|
16
16
|
# - "an Insika store" — universal; every backend passes EXACTLY it (L2: the
|
|
17
17
|
# suite is honest — a test that passes on Memory passes on SQLite). The
|
|
@@ -27,89 +27,89 @@
|
|
|
27
27
|
# races) — those belong to the backend's own spec.
|
|
28
28
|
RSpec.shared_examples "an Insika store" do
|
|
29
29
|
describe "#get / #set (round-trip)" do
|
|
30
|
-
it "
|
|
30
|
+
it " preserves Hash with string keys" do #
|
|
31
31
|
store.set("s", "k", { "a" => 1, "b" => [1, 2] })
|
|
32
32
|
expect(store.get("s", "k")).to eq({ "a" => 1, "b" => [1, 2] })
|
|
33
33
|
end
|
|
34
34
|
|
|
35
|
-
it "
|
|
35
|
+
it " preserves Array" do #
|
|
36
36
|
store.set("s", "k", [1, "x", true, nil])
|
|
37
37
|
expect(store.get("s", "k")).to eq([1, "x", true, nil])
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
-
it "
|
|
40
|
+
it " preserves String" do #
|
|
41
41
|
store.set("s", "k", "texto")
|
|
42
42
|
expect(store.get("s", "k")).to eq("texto")
|
|
43
43
|
end
|
|
44
44
|
|
|
45
|
-
it "
|
|
45
|
+
it " preserves Integer" do #
|
|
46
46
|
store.set("s", "k", 42)
|
|
47
47
|
value = store.get("s", "k")
|
|
48
48
|
expect(value).to eq(42)
|
|
49
49
|
expect(value).to be_a(Integer)
|
|
50
50
|
end
|
|
51
51
|
|
|
52
|
-
it "
|
|
52
|
+
it " preserves Float" do #
|
|
53
53
|
store.set("s", "k", 3.14)
|
|
54
54
|
value = store.get("s", "k")
|
|
55
55
|
expect(value).to eq(3.14)
|
|
56
56
|
expect(value).to be_a(Float)
|
|
57
57
|
end
|
|
58
58
|
|
|
59
|
-
it "
|
|
59
|
+
it " preserves booleans" do #
|
|
60
60
|
store.set("s", "t", true)
|
|
61
61
|
store.set("s", "f", false)
|
|
62
62
|
expect(store.get("s", "t")).to be(true)
|
|
63
63
|
expect(store.get("s", "f")).to be(false)
|
|
64
64
|
end
|
|
65
65
|
|
|
66
|
-
it "
|
|
66
|
+
it " preserves nil written without exception" do #
|
|
67
67
|
store.set("s", "k", nil)
|
|
68
68
|
expect(store.get("s", "k")).to be_nil
|
|
69
69
|
end
|
|
70
70
|
|
|
71
|
-
it "
|
|
71
|
+
it " converts Symbols (keys and values) to Strings" do #
|
|
72
72
|
store.set("s", "k", { chave: :valor })
|
|
73
73
|
expect(store.get("s", "k")).to eq({ "chave" => "valor" })
|
|
74
74
|
end
|
|
75
75
|
|
|
76
|
-
it "
|
|
76
|
+
it " returns nil for an absent key, never an exception" do #
|
|
77
77
|
expect(store.get("s", "nao-existe")).to be_nil
|
|
78
78
|
end
|
|
79
79
|
|
|
80
|
-
it "
|
|
80
|
+
it " overwrites silently (last-write-wins)" do #
|
|
81
81
|
store.set("s", "k", "primeiro")
|
|
82
82
|
store.set("s", "k", "segundo")
|
|
83
83
|
expect(store.get("s", "k")).to eq("segundo")
|
|
84
84
|
end
|
|
85
85
|
|
|
86
|
-
it "
|
|
86
|
+
it " set returns the same object passed in (not the round-trip)" do #
|
|
87
87
|
obj = { "a" => 1 }
|
|
88
88
|
expect(store.set("s", "k", obj)).to equal(obj)
|
|
89
89
|
end
|
|
90
90
|
end
|
|
91
91
|
|
|
92
92
|
describe "#delete" do
|
|
93
|
-
it "
|
|
93
|
+
it " removes existing and returns true" do #
|
|
94
94
|
store.set("s", "k", 1)
|
|
95
95
|
expect(store.delete("s", "k")).to be(true)
|
|
96
96
|
expect(store.get("s", "k")).to be_nil
|
|
97
97
|
end
|
|
98
98
|
|
|
99
|
-
it "
|
|
99
|
+
it " returns false for a nonexistent key" do #
|
|
100
100
|
expect(store.delete("s", "k")).to be(false)
|
|
101
101
|
end
|
|
102
102
|
end
|
|
103
103
|
|
|
104
104
|
describe "#list" do
|
|
105
|
-
it "
|
|
105
|
+
it " returns scope keys sorted lexicographically" do #
|
|
106
106
|
store.set("s", "b", 1)
|
|
107
107
|
store.set("s", "a", 1)
|
|
108
108
|
store.set("s", "c", 1)
|
|
109
109
|
expect(store.list("s")).to eq(%w[a b c])
|
|
110
110
|
end
|
|
111
111
|
|
|
112
|
-
it "
|
|
112
|
+
it " filters by prefix with start_with? (not include?)" do #
|
|
113
113
|
store.set("s", "task:1", 1)
|
|
114
114
|
store.set("s", "task:2", 1)
|
|
115
115
|
store.set("s", "checkpoint:1", 1)
|
|
@@ -117,7 +117,7 @@ RSpec.shared_examples "an Insika store" do
|
|
|
117
117
|
expect(store.list("s", "task:")).to eq(%w[task:1 task:2])
|
|
118
118
|
end
|
|
119
119
|
|
|
120
|
-
it "
|
|
120
|
+
it " returns [] for an empty scope" do #
|
|
121
121
|
expect(store.list("s")).to eq([])
|
|
122
122
|
end
|
|
123
123
|
|
|
@@ -129,7 +129,7 @@ RSpec.shared_examples "an Insika store" do
|
|
|
129
129
|
end
|
|
130
130
|
|
|
131
131
|
describe "scope isolation" do
|
|
132
|
-
it "
|
|
132
|
+
it " keeps scopes independent in get/list/delete" do #
|
|
133
133
|
store.set("s1", "k", 1)
|
|
134
134
|
store.set("s2", "k", 2)
|
|
135
135
|
|
|
@@ -143,16 +143,16 @@ RSpec.shared_examples "an Insika store" do
|
|
|
143
143
|
end
|
|
144
144
|
|
|
145
145
|
describe "#transaction" do
|
|
146
|
-
it "
|
|
146
|
+
it " returns the block's value" do #
|
|
147
147
|
expect(store.transaction { 42 }).to eq(42)
|
|
148
148
|
end
|
|
149
149
|
|
|
150
|
-
it "
|
|
150
|
+
it " commits the block's writes" do #
|
|
151
151
|
store.transaction { store.set("s", "k", "commitado") }
|
|
152
152
|
expect(store.get("s", "k")).to eq("commitado")
|
|
153
153
|
end
|
|
154
154
|
|
|
155
|
-
it "
|
|
155
|
+
it " does a real rollback of set AND delete when the block raises" do #
|
|
156
156
|
store.set("s", "manter", "antigo")
|
|
157
157
|
store.set("s", "apagar", "existe")
|
|
158
158
|
|
|
@@ -169,7 +169,7 @@ RSpec.shared_examples "an Insika store" do
|
|
|
169
169
|
expect(store.get("s", "apagar")).to eq("existe")
|
|
170
170
|
end
|
|
171
171
|
|
|
172
|
-
it "
|
|
172
|
+
it " reuses the outer transaction when nested" do #
|
|
173
173
|
store.set("s", "k", "antigo")
|
|
174
174
|
|
|
175
175
|
expect do
|
|
@@ -185,7 +185,7 @@ RSpec.shared_examples "an Insika store" do
|
|
|
185
185
|
end
|
|
186
186
|
|
|
187
187
|
describe "serialization errors" do
|
|
188
|
-
it "
|
|
188
|
+
it " raises StoreError and does not write a non-serializable value" do #
|
|
189
189
|
expect do
|
|
190
190
|
store.set("s", "k", Object.new)
|
|
191
191
|
end.to raise_error(Insika::StoreError)
|
|
@@ -194,7 +194,7 @@ RSpec.shared_examples "an Insika store" do
|
|
|
194
194
|
end
|
|
195
195
|
end
|
|
196
196
|
|
|
197
|
-
# Multi-worker safety
|
|
197
|
+
# Multi-worker safety. Every claim (outbox, delegation
|
|
198
198
|
# sweep, recovery) is a read-check-write inside `transaction` — on SQLite that is
|
|
199
199
|
# atomic because the backend opens BEGIN IMMEDIATE; a backend whose transaction
|
|
200
200
|
# only yields passes all 22 cases above and still double-claims under two
|
|
@@ -204,7 +204,7 @@ end
|
|
|
204
204
|
# The `sleep` inside each transaction is deliberate: it holds the read state
|
|
205
205
|
# open long enough that a backend without isolation ALWAYS lets a second
|
|
206
206
|
# connection through, while a correct backend serializes the writers. Without
|
|
207
|
-
# it a wrong backend could pass by scheduling luck — the failure
|
|
207
|
+
# it a wrong backend could pass by scheduling luck — the failure relies on.
|
|
208
208
|
RSpec.shared_examples "an Insika store safe for N workers" do
|
|
209
209
|
# Starts N threads, each holding its OWN connection to the same backend,
|
|
210
210
|
# releases them together, and returns each block's value. Connections are
|
data/lib/insika/tick.rb
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
# The periodic tick: durability stops waiting for a reboot. One
|
|
7
|
+
# pass does two things, in this order:
|
|
8
|
+
#
|
|
9
|
+
# 1. DRAIN the outbox (`ChannelDelivery#sweep`) — replies a previous pass
|
|
10
|
+
# (or process) recorded and never claimed. Ungated: every record carries
|
|
11
|
+
# its own transactional claim, so N workers draining is safe.
|
|
12
|
+
# 2. SWEEP stale orphaned tasks (`Recovery#run(stale_after:)`) — gated by a
|
|
13
|
+
# bucketed claim (`Recovery.claim_sweep` on "tick:<epoch/interval>"), so
|
|
14
|
+
# exactly one worker per window sweeps. The staleness threshold is the
|
|
15
|
+
# liveness gate: a live :running turn is bounded by turn_timeout, so
|
|
16
|
+
# anything untouched past it cannot be alive.
|
|
17
|
+
#
|
|
18
|
+
# It is NOT a job queue: no schedules, no priorities, no fan-out. The
|
|
19
|
+
# refinement hook once pictured here is dropped by merit —
|
|
20
|
+
# docs/REFINEMENT.md's "no scheduler in the engine" stands.
|
|
21
|
+
class Tick
|
|
22
|
+
# 60s: a customer waiting on WhatsApp is the deadline. 900s = 3x the
|
|
23
|
+
# default turn_timeout (300s) — the rule, not the number: the threshold
|
|
24
|
+
# must exceed the deployment's largest turn_timeout, or the sweep would
|
|
25
|
+
# judge live turns orphaned.
|
|
26
|
+
DEFAULT_INTERVAL = 60
|
|
27
|
+
DEFAULT_STALE_AFTER = 900
|
|
28
|
+
|
|
29
|
+
SCOPE = "tick"
|
|
30
|
+
KEY = "claim"
|
|
31
|
+
|
|
32
|
+
def initialize(store:, recovery:, channel_delivery:, logger: nil,
|
|
33
|
+
interval: DEFAULT_INTERVAL, stale_after: DEFAULT_STALE_AFTER,
|
|
34
|
+
sleeper: nil)
|
|
35
|
+
@store = store
|
|
36
|
+
@recovery = recovery
|
|
37
|
+
@channel_delivery = channel_delivery
|
|
38
|
+
@logger = logger
|
|
39
|
+
@interval = interval.to_i
|
|
40
|
+
@stale_after = stale_after.to_i
|
|
41
|
+
@sleeper = sleeper || method(:default_sleep)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def enabled? = @interval.positive?
|
|
45
|
+
|
|
46
|
+
# One pass, pure (no reactor needed): the serving loop calls it on a timer,
|
|
47
|
+
# specs call it directly. A StoreError propagates to the loop, which logs
|
|
48
|
+
# and keeps ticking.
|
|
49
|
+
def run_once
|
|
50
|
+
drained = @channel_delivery ? @channel_delivery.sweep : { dispatched: [] }
|
|
51
|
+
summary = { dispatched: drained[:dispatched], resumed: [], failed: [] }
|
|
52
|
+
return summary unless claim_window
|
|
53
|
+
|
|
54
|
+
result = @recovery.run(stale_after: @stale_after)
|
|
55
|
+
summary.merge(resumed: result[:resumed], failed: result[:failed])
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The loop, spawned as a child of the turn supervisor (the
|
|
59
|
+
# tick lives on the supervisor fiber — every serving arm gets it the moment
|
|
60
|
+
# `supervised = true` matters, with no arm edits). A failing pass logs and
|
|
61
|
+
# the loop continues: a sweeper that dies silently is the outage it exists
|
|
62
|
+
# to prevent. Restartable: when the supervisor is recreated its children
|
|
63
|
+
# died with it, so a stopped task is not a running one.
|
|
64
|
+
def start(parent:)
|
|
65
|
+
return false unless enabled?
|
|
66
|
+
return true if @task&.running?
|
|
67
|
+
|
|
68
|
+
@task = parent.async do |t|
|
|
69
|
+
t.annotate("insika-tick")
|
|
70
|
+
loop do
|
|
71
|
+
@sleeper.call(@interval)
|
|
72
|
+
run_once
|
|
73
|
+
rescue StandardError => e
|
|
74
|
+
log(:warn, "tick failed: #{e.class}: #{e.message}")
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
true
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
# One sweeper per window across N workers. Unlike the boot
|
|
83
|
+
# claim (one key per generation), the tick reuses a SINGLE key with a
|
|
84
|
+
# timestamp — a key per minute would be a slow leak in the store. The
|
|
85
|
+
# read-check-write rides Store#transaction like every claim:
|
|
86
|
+
# two workers racing the window serialize on the backend's lock and exactly
|
|
87
|
+
# one sweeps.
|
|
88
|
+
def claim_window
|
|
89
|
+
now = Time.now.utc
|
|
90
|
+
@store.transaction do
|
|
91
|
+
current = @store.get(SCOPE, KEY)
|
|
92
|
+
last = current && begin
|
|
93
|
+
Time.iso8601(current["claimed_at"].to_s)
|
|
94
|
+
rescue ArgumentError
|
|
95
|
+
nil # a corrupted claim is not a claim — take the window
|
|
96
|
+
end
|
|
97
|
+
if last.nil? || (now - last) >= @interval
|
|
98
|
+
@store.set(SCOPE, KEY, { "claimed_at" => now.iso8601 })
|
|
99
|
+
true
|
|
100
|
+
else
|
|
101
|
+
false
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Async when there is a reactor (production: the interval must not block
|
|
107
|
+
# the worker), plain sleep otherwise (specs driving the loop by hand use an
|
|
108
|
+
# injected sleeper anyway).
|
|
109
|
+
def default_sleep(seconds)
|
|
110
|
+
task = defined?(Async::Task) ? Async::Task.current? : nil
|
|
111
|
+
task ? task.sleep(seconds) : sleep(seconds)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Same contract as Recovery's: logging is pure observability and must never
|
|
115
|
+
# alter the flow.
|
|
116
|
+
def log(level, message)
|
|
117
|
+
@logger&.public_send(level, "[tick] #{message}")
|
|
118
|
+
rescue StandardError
|
|
119
|
+
nil
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "digest"
|
|
5
|
+
require "time"
|
|
6
|
+
|
|
7
|
+
module Insika
|
|
8
|
+
# Multi-tenant credentials (WS1): per-tenant tokens plus the operator token,
|
|
9
|
+
# stored ONLY as SHA-256 hashes — the plaintext is returned once at issue time
|
|
10
|
+
# and is never persisted, logged or evented. Every resolution is a hash lookup,
|
|
11
|
+
# so reading the store yields nothing usable. Behind any Insika::Store, like
|
|
12
|
+
# the other domain stores.
|
|
13
|
+
#
|
|
14
|
+
# A record describes ONE principal the edge can resolve a Bearer to:
|
|
15
|
+
# role: "operator" (tenant_id nil — the historical single credential)
|
|
16
|
+
# "tenant" (scoped to tenant_id)
|
|
17
|
+
# status: "active" | "revoked"
|
|
18
|
+
# A revoked record stops resolving immediately; a tenant's rotation is
|
|
19
|
+
# revoke-all + issue (the spec's rule — revoking one tenant's token never
|
|
20
|
+
# touches another tenant's: every cell is (id)- or (hash)-scoped cells).
|
|
21
|
+
class TokenStore
|
|
22
|
+
SCOPE = "tenant_tokens"
|
|
23
|
+
|
|
24
|
+
Record = Data.define(:id, :token_hash, :role, :tenant_id, :label,
|
|
25
|
+
:status, :created_at, :revoked_at) do
|
|
26
|
+
def active? = status.to_s == "active"
|
|
27
|
+
def tenant? = role.to_s == "tenant"
|
|
28
|
+
def to_h
|
|
29
|
+
{ id: id, role: role, tenant_id: tenant_id, label: label,
|
|
30
|
+
status: status, created_at: created_at, revoked_at: revoked_at }
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# The token a caller receives from a successful issue/rotate — the ONLY
|
|
35
|
+
# moment the plaintext exists in the process. `token` is never stored.
|
|
36
|
+
Issue = Data.define(:id, :token)
|
|
37
|
+
|
|
38
|
+
def initialize(store:)
|
|
39
|
+
@store = store
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Issues a token for tenant_id (nil = an OPERATOR token). -> Issue. The
|
|
43
|
+
# token is shown exactly once; there is no `get_token` — lost = rotate.
|
|
44
|
+
def issue(tenant_id: nil, label: "default")
|
|
45
|
+
validate_tenant_id!(tenant_id)
|
|
46
|
+
token = SecureRandom.hex(32)
|
|
47
|
+
hash = digest(token)
|
|
48
|
+
id = SecureRandom.uuid
|
|
49
|
+
@store.transaction do
|
|
50
|
+
@store.set(SCOPE, record_key(id), {
|
|
51
|
+
"id" => id, "token_hash" => hash,
|
|
52
|
+
"role" => tenant_id ? "tenant" : "operator",
|
|
53
|
+
"tenant_id" => tenant_id, "label" => label.to_s,
|
|
54
|
+
"status" => "active",
|
|
55
|
+
"created_at" => Time.now.utc.iso8601, "revoked_at" => nil
|
|
56
|
+
})
|
|
57
|
+
@store.set(SCOPE, hash_key(hash), id)
|
|
58
|
+
end
|
|
59
|
+
Issue.new(id: id, token: token)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Active-token record for token_id; a revoked one reads WHO it was but not
|
|
63
|
+
# as resolvable. -> Record | nil.
|
|
64
|
+
def find(id)
|
|
65
|
+
return nil if id.to_s.empty?
|
|
66
|
+
|
|
67
|
+
record = @store.get(SCOPE, record_key(id.to_s))
|
|
68
|
+
record && to_record(record)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The edge resolution: -> Record (active) | nil. A revoked token is
|
|
72
|
+
# indistinguishable from a missing one (fail-closed: the Bearer just 401s).
|
|
73
|
+
def resolve(token)
|
|
74
|
+
return nil if token.to_s.empty?
|
|
75
|
+
|
|
76
|
+
id = @store.get(SCOPE, hash_key(digest(token)))
|
|
77
|
+
return nil if id.nil?
|
|
78
|
+
|
|
79
|
+
record = to_record(@store.get(SCOPE, record_key(id)))
|
|
80
|
+
record&.active? ? record : nil
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# -> bool: true only for an ACTIVE record (revoking an already-revoked/unknown
|
|
84
|
+
# id is false — a no-op, never an error). The read-modify-write rides
|
|
85
|
+
# `@store.transaction` (the repo's rule — WS1): `next false`, NOT `return
|
|
86
|
+
# false`, or the non-local return would skip the COMMIT and leak the open
|
|
87
|
+
# transaction (the same trap as the budget ledger's mark_alert).
|
|
88
|
+
def revoke(id)
|
|
89
|
+
@store.transaction do
|
|
90
|
+
record = find(id)
|
|
91
|
+
next false unless record&.active?
|
|
92
|
+
|
|
93
|
+
flipped = record.to_h.merge(status: "revoked", revoked_at: Time.now.utc.iso8601)
|
|
94
|
+
@store.set(SCOPE, record_key(id), stringify(flipped))
|
|
95
|
+
true
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Revokes every ACTIVE token of a tenant; the hash-index cells stay (they
|
|
100
|
+
# resolve to a revoked record -> nil). -> count of records revoked. Does NOT
|
|
101
|
+
# touch the operator token or any other tenant.
|
|
102
|
+
def revoke_all(tenant_id:)
|
|
103
|
+
ids = active_token_ids.select do |id|
|
|
104
|
+
record = to_record(@store.get(SCOPE, record_key(id)))
|
|
105
|
+
record&.tenant? && record.tenant_id.to_s == tenant_id.to_s
|
|
106
|
+
end
|
|
107
|
+
ids.count { |id| revoke(id) }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Rotation: revoke the tenant's active tokens, issue a fresh one. Both in
|
|
111
|
+
# one transaction -> a crashed half-rotation never leaves the tenant with
|
|
112
|
+
# NOTHING valid. -> { revoked: n, issue: Issue }.
|
|
113
|
+
def rotate(tenant_id:, label: "default", now: Time.now)
|
|
114
|
+
@store.transaction do
|
|
115
|
+
revoked = revoke_all(tenant_id: tenant_id)
|
|
116
|
+
{ revoked: revoked, issue: issue(tenant_id: tenant_id, label: label) }
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Every active token id (used by revoke_all). Reads the id-keyed cells.
|
|
121
|
+
def active_token_ids
|
|
122
|
+
@store.list(SCOPE, RECORD_PREFIX).filter_map do |key|
|
|
123
|
+
id = key.delete_prefix(RECORD_PREFIX)
|
|
124
|
+
record = @store.get(SCOPE, record_key(id))
|
|
125
|
+
record && record["status"] == "active" ? id : nil
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
private
|
|
130
|
+
|
|
131
|
+
# The tenant id IS the session/task namespace prefix ("<tenant>:<id>" — WS1).
|
|
132
|
+
# A ":" lets a second tenant forge an id that lands on the FIRST tenant's
|
|
133
|
+
# namespace: T1="loja" + session id "adma:x" => "loja:adma:x", which is the
|
|
134
|
+
# cell T2="loja:adma" would claim with id "x" (WS1). Guarded at the single
|
|
135
|
+
# chokepoint both issue and rotate flow through.
|
|
136
|
+
def validate_tenant_id!(tenant_id)
|
|
137
|
+
return if tenant_id.nil?
|
|
138
|
+
|
|
139
|
+
raise Insika::ValidationError, "tenant_id is required" if Insika::Coercion.blank?(tenant_id)
|
|
140
|
+
raise Insika::ValidationError, "tenant_id must not contain ':'" if tenant_id.to_s.include?(":")
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def digest(token)
|
|
144
|
+
Digest::SHA256.hexdigest(token)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
RECORD_PREFIX = "r:"
|
|
148
|
+
HASH_PREFIX = "h:"
|
|
149
|
+
|
|
150
|
+
def record_key(id) = "#{RECORD_PREFIX}#{id}"
|
|
151
|
+
def hash_key(hash) = "#{HASH_PREFIX}#{hash}"
|
|
152
|
+
|
|
153
|
+
def to_record(record)
|
|
154
|
+
return nil if record.nil?
|
|
155
|
+
|
|
156
|
+
Record.new(
|
|
157
|
+
id: record["id"], token_hash: record["token_hash"],
|
|
158
|
+
role: record["role"], tenant_id: record["tenant_id"],
|
|
159
|
+
label: record["label"], status: record["status"],
|
|
160
|
+
created_at: record["created_at"], revoked_at: record["revoked_at"]
|
|
161
|
+
)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def stringify(hash)
|
|
165
|
+
hash.each_with_object({}) { |(k, v), acc| acc[k.to_s] = v }
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
data/lib/insika/tool_assembly.rb
CHANGED
|
@@ -6,10 +6,10 @@ require "async/semaphore"
|
|
|
6
6
|
module Insika
|
|
7
7
|
# Turn-scoped assembly of the agent's tool instances (pipeline stage 3 tail):
|
|
8
8
|
# capability resolution, instantiation (Entry#factory | ready instance),
|
|
9
|
-
# turn-context
|
|
9
|
+
# turn-context injection, the capability<->direct dedup join, and the
|
|
10
10
|
# ToolEnvelope wrap (stage 7 seam: per-call timeout + side-effect recording).
|
|
11
11
|
#
|
|
12
|
-
# Extracted from the Executor
|
|
12
|
+
# Extracted from the Executor to keep the hot-path file smaller. It is
|
|
13
13
|
# a pure collaborator — it holds only the injected registries/stores and no
|
|
14
14
|
# per-turn state; everything turn-specific arrives via `state`/`turn_context`.
|
|
15
15
|
# The Executor keeps thin delegators (resolve_capabilities/assemble_tool_instances/
|
|
@@ -84,7 +84,7 @@ module Insika
|
|
|
84
84
|
|
|
85
85
|
private
|
|
86
86
|
|
|
87
|
-
#
|
|
87
|
+
# the model decides the fan-out, so without a cap a batch of 15
|
|
88
88
|
# data-tools is 15 simultaneous requests to the same upstream — which is how
|
|
89
89
|
# one turn earns a 429 for every other turn in the process. Note the contrast
|
|
90
90
|
# with the primitives that already shipped: `spawn_subagents` caps at 8
|
|
@@ -103,7 +103,7 @@ module Insika
|
|
|
103
103
|
end
|
|
104
104
|
|
|
105
105
|
# Real Engine -> Entries (respond to factory); fakes -> ready instances.
|
|
106
|
-
# `turn_context`
|
|
106
|
+
# `turn_context` is deposited into the instances that expose it
|
|
107
107
|
# (data-tools); the rest ignore it (parity).
|
|
108
108
|
def instantiate_tools(allowed, turn_context = nil)
|
|
109
109
|
Array(allowed).map do |t|
|
|
@@ -113,7 +113,7 @@ module Insika
|
|
|
113
113
|
end
|
|
114
114
|
end
|
|
115
115
|
|
|
116
|
-
#
|
|
116
|
+
# seam: deposits the turn context into the freshly created instance
|
|
117
117
|
# (same idea as `remember`, which receives tenant/state) BEFORE the
|
|
118
118
|
# ToolEnvelope. Duck-typed: only what exposes `turn_context=` (DataDefinedTool)
|
|
119
119
|
# receives it. nil (a state with no turn_context, e.g. a test stub) -> no-op.
|
|
@@ -7,8 +7,8 @@ module Insika
|
|
|
7
7
|
# Definition of a DATA-DEFINED TOOL (no Ruby code): name, description, parameters
|
|
8
8
|
# and an HTTP call. Immutable value object, persisted by ToolStore and
|
|
9
9
|
# materialized at runtime by Tools::DataDefinedTool (one class, N instances —
|
|
10
|
-
# the same pattern as A2ARemote)
|
|
11
|
-
# Schema in
|
|
10
|
+
# the same pattern as A2ARemote).,; parameters migrated to JSON
|
|
11
|
+
# Schema in,.
|
|
12
12
|
#
|
|
13
13
|
# Persisted form (JSON-serializable Hash; ConfigStore stringifies the keys):
|
|
14
14
|
# { "name", "description",
|
|
@@ -17,9 +17,9 @@ module Insika
|
|
|
17
17
|
# "response" => { "extract","path" },
|
|
18
18
|
# "secret_headers" => [ "Authorization", ... ],
|
|
19
19
|
# "side_effect" => bool, "timeout" => int|nil,
|
|
20
|
-
# "group" => string|nil, "tags" => [
|
|
20
|
+
# "group" => string|nil, "tags" => ["b2b",...] } #//
|
|
21
21
|
#
|
|
22
|
-
# `parameters` is **JSON Schema** (the interlingua of OpenAI/Anthropic/MCP
|
|
22
|
+
# `parameters` is **JSON Schema** (the interlingua of OpenAI/Anthropic/MCP):
|
|
23
23
|
# a nestable object, fed straight into RubyLLM's `params_schema` (provider-
|
|
24
24
|
# agnostic). The **flat array** (`[{name,type,required}]`) is SUGAR for the simple
|
|
25
25
|
# case: it is lifted to JSON Schema at build time. The sugar covers scalars and
|
|
@@ -30,7 +30,7 @@ module Insika
|
|
|
30
30
|
# Validation lives HERE (single source): `build`/`from_h` raise ValidationError
|
|
31
31
|
# on malformed input. Name uniqueness and collision with a code tool are NOT
|
|
32
32
|
# validated here (the value object does not know the registry) — that belongs to the
|
|
33
|
-
# overlay
|
|
33
|
+
# overlay. Secrets (credential headers) are the ToolStore's responsibility
|
|
34
34
|
# (masks/reconciles); the definition itself is agnostic to masking.
|
|
35
35
|
ToolDefinition = Data.define(
|
|
36
36
|
:name, :description, :parameters, :request, :response,
|
|
@@ -49,7 +49,7 @@ module Insika
|
|
|
49
49
|
EXTRACTS = %w[body_raw status json_path].freeze
|
|
50
50
|
NAME_RE = /\A[a-z][a-z0-9_]*\z/ # identifier for the model
|
|
51
51
|
# A `.` in the placeholder enables the turn-context namespace `{{ctx.*}}`
|
|
52
|
-
#
|
|
52
|
+
# separate from the model's `{{param}}`. Params follow NAME_RE (no
|
|
53
53
|
# dot) -> a placeholder with a dot can only be a ctx ref.
|
|
54
54
|
PLACEHOLDER_RE = /\{\{\s*([a-zA-Z0-9_.]+)\s*\}\}/
|
|
55
55
|
# Turn-context namespace: values coming from the TURN (not the model),
|
|
@@ -108,7 +108,7 @@ module Insika
|
|
|
108
108
|
)
|
|
109
109
|
end
|
|
110
110
|
|
|
111
|
-
# Group
|
|
111
|
+
# Group: enablement label by DATA (not name convention),
|
|
112
112
|
# target of AgentProfile's `tools_allow_groups`. Trimmed; empty/nil -> nil.
|
|
113
113
|
def self.normalize_group(group)
|
|
114
114
|
g = group.to_s.strip
|
|
@@ -145,7 +145,7 @@ module Insika
|
|
|
145
145
|
def self.empty_schema = { "type" => "object", "properties" => {}, "required" => [] }
|
|
146
146
|
private_class_method :empty_schema
|
|
147
147
|
|
|
148
|
-
# Flat sugar -> JSON Schema. Preserves
|
|
148
|
+
# Flat sugar -> JSON Schema. Preserves validation (NAME_RE, duplicates,
|
|
149
149
|
# PARAM_TYPES) and lifts `array:<scalar>` into proper `items`.
|
|
150
150
|
#
|
|
151
151
|
# A bare `array` is REFUSED. It used to lift to `items: {type:"string"}` — the
|
data/lib/insika/tool_envelope.rb
CHANGED
|
@@ -69,7 +69,7 @@ module Insika
|
|
|
69
69
|
|
|
70
70
|
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
71
71
|
|
|
72
|
-
#
|
|
72
|
+
# with parallel tool calls on, the turn's shared semaphore
|
|
73
73
|
# (TurnState#tool_gate, sized by `limits[:tool_concurrency]`) caps how many run
|
|
74
74
|
# at once. Wraps the REAL call ONLY — the approval wait and the skip check are
|
|
75
75
|
# outside it, so a call blocked on a human never holds a slot, and the per-call
|
data/lib/insika/tool_manifest.rb
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Insika
|
|
4
|
-
# Tool manifest
|
|
4
|
+
# Tool manifest: the BATCH, DATA-driven, industry-STANDARD
|
|
5
5
|
# (JSON Schema) form of the data-tools. A `defaults` (common binding:
|
|
6
6
|
# base_url/path_template/method/headers/secret_headers/response) + a list of
|
|
7
7
|
# `tools`. Each tool is normalized — inheriting the defaults, applying the envelope
|
|
8
8
|
# adapter (OpenAI/MCP/raw) and resolving `endpoint`→url + `{{secret.*}}`/
|
|
9
9
|
# `{{env.*}}` — into a ToolDefinition Hash consumable by the ToolStore.
|
|
10
10
|
#
|
|
11
|
-
# GENERIC
|
|
11
|
+
# GENERIC: nothing here mentions a consumer/gateway — the manifest IS the contract. It
|
|
12
12
|
# only NORMALIZES/RESOLVES; the final validation (method/url/types, model placeholders
|
|
13
13
|
# `{{param}}`/turn `{{ctx.*}}`, safe subset of JSON Schema) belongs to
|
|
14
14
|
# ToolDefinition (single source). The batch upsert + hot reload belongs to the
|
|
@@ -18,12 +18,12 @@ module Insika
|
|
|
18
18
|
# - `{{param}}`/`{{ctx.*}}` : resolved AT THE TURN (by DataDefinedTool) —
|
|
19
19
|
# they stay INTACT here; ToolDefinition validates them.
|
|
20
20
|
# - `{{secret.*}}`/`{{env.*}}` : resolved AT INGESTION (here) — from the deployment,
|
|
21
|
-
# never from the manifest (
|
|
21
|
+
# never from the manifest (R3). `{{env.*}}` is non-secret config (any
|
|
22
22
|
# template); `{{secret.*}}` is a credential and is ONLY allowed in a header
|
|
23
23
|
# declared in `secret_headers`, with value === `{{secret.X}}` (nothing literal,
|
|
24
24
|
# nothing outside a header — otherwise the secret would leak without masking).
|
|
25
25
|
#
|
|
26
|
-
# Interface envelope (what the model sees), by adapter
|
|
26
|
+
# Interface envelope (what the model sees), by adapter:
|
|
27
27
|
# - `parameters` (raw JSON Schema), OR
|
|
28
28
|
# - `{ "function": { name, description, parameters } }` (OpenAI/Anthropic), OR
|
|
29
29
|
# - `{ "inputSchema": {...} }` (MCP).
|
|
@@ -73,7 +73,7 @@ module Insika
|
|
|
73
73
|
"response" => binding[:response],
|
|
74
74
|
"secret_headers" => binding[:secret_headers],
|
|
75
75
|
"side_effect" => t["side_effect"],
|
|
76
|
-
"group" => t["group"] || defaults["group"], #
|
|
76
|
+
"group" => t["group"] || defaults["group"], # per-group allowlist:
|
|
77
77
|
"tags" => (Array(defaults["tags"]) | Array(t["tags"])), # inherited default; tags unioned
|
|
78
78
|
# Per-tool only: "this result ends the turn" is a property of THIS backend's
|
|
79
79
|
# response, never something a manifest default should hand to its siblings.
|
|
@@ -83,7 +83,7 @@ module Insika
|
|
|
83
83
|
|
|
84
84
|
private
|
|
85
85
|
|
|
86
|
-
# Envelope adapter
|
|
86
|
+
# Envelope adapter: extracts the INTERFACE (name/description/parameters) from
|
|
87
87
|
# any standard form. name/description fall back to the tool's top level when the
|
|
88
88
|
# envelope does not carry them.
|
|
89
89
|
def extract_interface(t)
|