insika 0.0.1 → 0.1.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 +295 -0
- data/LICENSE +21 -0
- data/README.md +136 -2
- data/bin/insika +351 -0
- data/docs/AGENTS.md +494 -0
- data/docs/ARCHITECTURE.md +333 -0
- data/docs/BENCHMARK.md +114 -0
- data/docs/CHANNELS.md +453 -0
- data/docs/CONTEXT.md +100 -0
- data/docs/DEPLOY.md +334 -0
- data/docs/EMBEDDING.md +194 -0
- data/docs/EVALS.md +273 -0
- data/docs/LOADTEST.md +231 -0
- data/docs/OBSERVABILITY.md +365 -0
- data/docs/PLUGINS.md +211 -0
- data/docs/REFINEMENT.md +477 -0
- data/docs/RELEASING.md +70 -0
- data/docs/RUNNING-LOCAL.md +153 -0
- data/docs/SANDBOX.md +114 -0
- data/docs/SECURITY.md +362 -0
- data/docs/SKILLS.md +98 -0
- data/docs/TOOLS.md +302 -0
- data/docs/WHY.md +137 -0
- data/docs/WORKFLOWS.md +225 -0
- data/docs/build.md +14 -0
- data/docs/index.md +68 -0
- data/docs/onboarding/start.md +126 -0
- data/docs/operate.md +12 -0
- data/docs/ship.md +10 -0
- data/docs/understand.md +10 -0
- data/lib/insika/agent_file_store.rb +125 -0
- data/lib/insika/agent_profile.rb +188 -0
- data/lib/insika/allowlist.rb +28 -0
- data/lib/insika/baseline_store.rb +74 -0
- data/lib/insika/capability/resolved_tool.rb +34 -0
- data/lib/insika/capability_registry.rb +112 -0
- data/lib/insika/channel_delivery.rb +150 -0
- data/lib/insika/channel_registry.rb +30 -0
- data/lib/insika/channels/relay.rb +178 -0
- data/lib/insika/channels/web/widget.js +283 -0
- data/lib/insika/channels/web.rb +211 -0
- data/lib/insika/chat_builder.rb +254 -0
- data/lib/insika/checkpoint.rb +13 -0
- data/lib/insika/checkpoint_store.rb +153 -0
- data/lib/insika/coercion.rb +50 -0
- data/lib/insika/command.rb +32 -0
- data/lib/insika/command_bus.rb +39 -0
- data/lib/insika/commands/agent_payload.rb +41 -0
- data/lib/insika/commands/approve_action.rb +46 -0
- data/lib/insika/commands/cancel_task.rb +33 -0
- data/lib/insika/commands/create_agent.rb +54 -0
- data/lib/insika/commands/create_session.rb +67 -0
- data/lib/insika/commands/delete_agent.rb +33 -0
- data/lib/insika/commands/delete_agent_file.rb +50 -0
- data/lib/insika/commands/delete_data_tool.rb +33 -0
- data/lib/insika/commands/delete_llm_provider.rb +36 -0
- data/lib/insika/commands/delete_mcp.rb +30 -0
- data/lib/insika/commands/delete_system_file.rb +29 -0
- data/lib/insika/commands/gate_refinement.rb +245 -0
- data/lib/insika/commands/import_mcp_tools.rb +48 -0
- data/lib/insika/commands/import_tools.rb +81 -0
- data/lib/insika/commands/memory_add_note.rb +32 -0
- data/lib/insika/commands/memory_forget_fact.rb +32 -0
- data/lib/insika/commands/memory_put_fact.rb +35 -0
- data/lib/insika/commands/pause_task.rb +29 -0
- data/lib/insika/commands/resolve_refinement.rb +126 -0
- data/lib/insika/commands/restore_agent_file.rb +36 -0
- data/lib/insika/commands/restore_data_tool.rb +34 -0
- data/lib/insika/commands/restore_system_file.rb +31 -0
- data/lib/insika/commands/resume_task.rb +85 -0
- data/lib/insika/commands/run_refinement.rb +133 -0
- data/lib/insika/commands/send_message.rb +150 -0
- data/lib/insika/commands/set_agent_tools.rb +39 -0
- data/lib/insika/commands/set_skill_agents.rb +71 -0
- data/lib/insika/commands/trigger_workflow.rb +80 -0
- data/lib/insika/commands/update_agent.rb +49 -0
- data/lib/insika/commands/update_settings.rb +33 -0
- data/lib/insika/commands/upsert_llm_provider.rb +34 -0
- data/lib/insika/commands/upsert_mcp.rb +32 -0
- data/lib/insika/commands/write_agent_file.rb +57 -0
- data/lib/insika/commands/write_data_tool.rb +43 -0
- data/lib/insika/commands/write_golden.rb +58 -0
- data/lib/insika/commands/write_skill.rb +50 -0
- data/lib/insika/commands/write_system_file.rb +31 -0
- data/lib/insika/config_store.rb +85 -0
- data/lib/insika/context/builder.rb +166 -0
- data/lib/insika/context/catalog_provider.rb +23 -0
- data/lib/insika/context/fragment.rb +19 -0
- data/lib/insika/context/priority.rb +29 -0
- data/lib/insika/context/provider.rb +19 -0
- data/lib/insika/context/providers/memory.rb +60 -0
- data/lib/insika/context/providers/prompt.rb +105 -0
- data/lib/insika/context/providers/request.rb +32 -0
- data/lib/insika/context/providers/session.rb +108 -0
- data/lib/insika/context/providers/skill.rb +20 -0
- data/lib/insika/context/providers/tool_search.rb +20 -0
- data/lib/insika/delegation_store.rb +153 -0
- data/lib/insika/doctor.rb +294 -0
- data/lib/insika/dsl/definition.rb +55 -0
- data/lib/insika/dsl/runtime.rb +379 -0
- data/lib/insika/dsl/server_boot.rb +97 -0
- data/lib/insika/dsl/system.rb +93 -0
- data/lib/insika/dsl/workflow_adapter.rb +59 -0
- data/lib/insika/dsl.rb +307 -0
- data/lib/insika/edge_limiter.rb +130 -0
- data/lib/insika/egress_guard.rb +75 -0
- data/lib/insika/env_schema.rb +246 -0
- data/lib/insika/errors.rb +145 -0
- data/lib/insika/evals/assertions.rb +247 -0
- data/lib/insika/evals/baseline.rb +69 -0
- data/lib/insika/evals/golden.rb +172 -0
- data/lib/insika/evals/judge.rb +225 -0
- data/lib/insika/evals/pairwise.rb +178 -0
- data/lib/insika/evals/report.rb +115 -0
- data/lib/insika/evals/runner.rb +141 -0
- data/lib/insika/evals/transport.rb +178 -0
- data/lib/insika/event.rb +18 -0
- data/lib/insika/event_stream.rb +114 -0
- data/lib/insika/executor.rb +1680 -0
- data/lib/insika/frontmatter.rb +42 -0
- data/lib/insika/golden_store.rb +145 -0
- data/lib/insika/hooks.rb +48 -0
- data/lib/insika/http_client.rb +63 -0
- data/lib/insika/inbound_log.rb +84 -0
- data/lib/insika/llm_configurator.rb +99 -0
- data/lib/insika/llm_provider_store.rb +83 -0
- data/lib/insika/mcp_http_client.rb +67 -0
- data/lib/insika/mcp_store.rb +115 -0
- data/lib/insika/mcp_tool_ingestor.rb +143 -0
- data/lib/insika/memory_store.rb +93 -0
- data/lib/insika/message_origin.rb +76 -0
- data/lib/insika/middleware.rb +36 -0
- data/lib/insika/model_policy.rb +52 -0
- data/lib/insika/model_resolver.rb +176 -0
- data/lib/insika/model_selection.rb +114 -0
- data/lib/insika/onboarding.rb +208 -0
- data/lib/insika/outbox_store.rb +166 -0
- data/lib/insika/overlay_tool_registry.rb +103 -0
- data/lib/insika/pack.rb +102 -0
- data/lib/insika/pack_importer.rb +121 -0
- data/lib/insika/pending_action_store.rb +120 -0
- data/lib/insika/plugin/loader.rb +356 -0
- data/lib/insika/plugin.rb +35 -0
- data/lib/insika/policy/engine.rb +83 -0
- data/lib/insika/policy/policy.rb +120 -0
- data/lib/insika/policy_registry.rb +23 -0
- data/lib/insika/profile_source.rb +137 -0
- data/lib/insika/prompt_catalog.rb +61 -0
- data/lib/insika/queue_policy.rb +167 -0
- data/lib/insika/recovery.rb +127 -0
- data/lib/insika/refinement/candidate.rb +159 -0
- data/lib/insika/refinement/evidence_collector.rb +371 -0
- data/lib/insika/refinement/gate.rb +234 -0
- data/lib/insika/refinement/panel.rb +222 -0
- data/lib/insika/refinement/proposer.rb +262 -0
- data/lib/insika/refinement_store.rb +295 -0
- data/lib/insika/registry.rb +59 -0
- data/lib/insika/safety/config.rb +109 -0
- data/lib/insika/safety/detectors.rb +176 -0
- data/lib/insika/safety/factory.rb +102 -0
- data/lib/insika/safety/input_guardrail.rb +87 -0
- data/lib/insika/safety/moderator.rb +86 -0
- data/lib/insika/safety/output_filter.rb +79 -0
- data/lib/insika/safety/output_validator.rb +101 -0
- data/lib/insika/safety/safe_responses.rb +47 -0
- data/lib/insika/sandbox/boundary.rb +93 -0
- data/lib/insika/sandbox/docker.rb +74 -0
- data/lib/insika/sandbox/local.rb +33 -0
- data/lib/insika/sandbox/runner.rb +80 -0
- data/lib/insika/sandbox.rb +85 -0
- data/lib/insika/schema_guard.rb +147 -0
- data/lib/insika/secret_masking.rb +34 -0
- data/lib/insika/server/a2a/agent_card.rb +27 -0
- data/lib/insika/server/a2a/app.rb +112 -0
- data/lib/insika/server/a2a/client.rb +101 -0
- data/lib/insika/server/a2a/errors.rb +32 -0
- data/lib/insika/server/a2a/http.rb +42 -0
- data/lib/insika/server/a2a/message.rb +27 -0
- data/lib/insika/server/a2a/protocol.rb +45 -0
- data/lib/insika/server/a2a/remotes.rb +25 -0
- data/lib/insika/server/a2a/task_projection.rb +40 -0
- data/lib/insika/server/admin_auth.rb +29 -0
- data/lib/insika/server/app.rb +850 -0
- data/lib/insika/server/boot.rb +119 -0
- data/lib/insika/server/rack_app.rb +110 -0
- data/lib/insika/server/responses.rb +155 -0
- data/lib/insika/server/sse_body.rb +96 -0
- data/lib/insika/session_actor.rb +162 -0
- data/lib/insika/session_store.rb +143 -0
- data/lib/insika/settings_store.rb +154 -0
- data/lib/insika/shutdown.rb +125 -0
- data/lib/insika/skill_catalog.rb +113 -0
- data/lib/insika/skill_store.rb +79 -0
- data/lib/insika/steer_injector.rb +110 -0
- data/lib/insika/store.rb +52 -0
- data/lib/insika/stores/memory.rb +123 -0
- data/lib/insika/stores/sqlite.rb +183 -0
- data/lib/insika/studio/app.rb +1571 -0
- data/lib/insika/studio/assets/dist/application.css +1 -0
- data/lib/insika/studio/assets/dist/application.js +69 -0
- data/lib/insika/studio/forms.rb +340 -0
- data/lib/insika/studio/nav_icons.rb +31 -0
- data/lib/insika/studio/views/_message.erb +44 -0
- data/lib/insika/studio/views/agent_detail.erb +285 -0
- data/lib/insika/studio/views/agents.erb +63 -0
- data/lib/insika/studio/views/approvals.erb +41 -0
- data/lib/insika/studio/views/chats.erb +34 -0
- data/lib/insika/studio/views/evals.erb +83 -0
- data/lib/insika/studio/views/home.erb +72 -0
- data/lib/insika/studio/views/layout.erb +94 -0
- data/lib/insika/studio/views/login.erb +17 -0
- data/lib/insika/studio/views/mcp.erb +91 -0
- data/lib/insika/studio/views/not_found.erb +5 -0
- data/lib/insika/studio/views/playground.erb +47 -0
- data/lib/insika/studio/views/refinement.erb +234 -0
- data/lib/insika/studio/views/session.erb +62 -0
- data/lib/insika/studio/views/settings.erb +173 -0
- data/lib/insika/studio/views/skills.erb +86 -0
- data/lib/insika/studio/views/system_files.erb +65 -0
- data/lib/insika/studio/views/task.erb +105 -0
- data/lib/insika/studio/views/tasks.erb +33 -0
- data/lib/insika/studio/views/tool_edit.erb +107 -0
- data/lib/insika/studio/views/tools.erb +89 -0
- data/lib/insika/subagent_graph.rb +96 -0
- data/lib/insika/system_file_store.rb +96 -0
- data/lib/insika/task_actor.rb +128 -0
- data/lib/insika/task_store.rb +250 -0
- data/lib/insika/telemetry/pricing.rb +104 -0
- data/lib/insika/telemetry/recorder.rb +228 -0
- data/lib/insika/telemetry.rb +127 -0
- data/lib/insika/testing/store_contract.rb +270 -0
- data/lib/insika/token_estimator.rb +16 -0
- data/lib/insika/tool_assembly.rb +140 -0
- data/lib/insika/tool_catalog.rb +89 -0
- data/lib/insika/tool_definition.rb +518 -0
- data/lib/insika/tool_envelope.rb +140 -0
- data/lib/insika/tool_manifest.rb +218 -0
- data/lib/insika/tool_registry.rb +21 -0
- data/lib/insika/tool_store.rb +135 -0
- data/lib/insika/tool_trace_store.rb +92 -0
- data/lib/insika/tools/a2a_remote.rb +48 -0
- data/lib/insika/tools/agent_enum.rb +68 -0
- data/lib/insika/tools/concurrency.rb +54 -0
- data/lib/insika/tools/data_defined_tool.rb +220 -0
- data/lib/insika/tools/load_skill.rb +41 -0
- data/lib/insika/tools/remember.rb +53 -0
- data/lib/insika/tools/subagent.rb +75 -0
- data/lib/insika/tools/subagents.rb +77 -0
- data/lib/insika/tools/tool_search.rb +94 -0
- data/lib/insika/turn_output.rb +139 -0
- data/lib/insika/turn_state.rb +158 -0
- data/lib/insika/turn_timing.rb +56 -0
- data/lib/insika/usage_ledger.rb +47 -0
- data/lib/insika/version.rb +3 -1
- data/lib/insika/wiring/graph.rb +198 -0
- data/lib/insika/workflow.rb +185 -0
- data/lib/insika/workflow_registry.rb +33 -0
- data/lib/insika.rb +203 -4
- metadata +395 -8
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Contract suite for Insika::Store (doc 01 §7), exported for third-party
|
|
4
|
+
# backends (RFC-0018 A4) — a gem like `insika-pg` writes its spec against THIS
|
|
5
|
+
# file, not against a read of stores/sqlite.rb:
|
|
6
|
+
#
|
|
7
|
+
# require "insika/testing/store_contract"
|
|
8
|
+
#
|
|
9
|
+
# RSpec.describe Insika::Stores::PG do
|
|
10
|
+
# subject(:store) { described_class.new(url:) }
|
|
11
|
+
# it_behaves_like "an Insika store"
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# Two groups, on purpose (RFC-0018 §4 item 4):
|
|
15
|
+
#
|
|
16
|
+
# - "an Insika store" — universal; every backend passes EXACTLY it (L2: the
|
|
17
|
+
# suite is honest — a test that passes on Memory passes on SQLite). The
|
|
18
|
+
# including group defines `store` (an empty, ready backend).
|
|
19
|
+
# - "an Insika store safe for N workers" — OPT-IN; the multi-worker half. The
|
|
20
|
+
# including group ALSO defines `store_factory`, a callable returning ANOTHER
|
|
21
|
+
# connection to the SAME underlying backend. A backend that cannot serialize
|
|
22
|
+
# transactions across connections must not include it — Memory is the
|
|
23
|
+
# canonical example, and a single-connection backend has nothing to prove.
|
|
24
|
+
# Anything that wants to sit under `WEB_CONCURRENCY > 1` has to.
|
|
25
|
+
#
|
|
26
|
+
# Do not include backend-specific cases here (file durability, WAL, boot
|
|
27
|
+
# races) — those belong to the backend's own spec.
|
|
28
|
+
RSpec.shared_examples "an Insika store" do
|
|
29
|
+
describe "#get / #set (round-trip)" do
|
|
30
|
+
it "C1 preserves Hash with string keys" do # C1
|
|
31
|
+
store.set("s", "k", { "a" => 1, "b" => [1, 2] })
|
|
32
|
+
expect(store.get("s", "k")).to eq({ "a" => 1, "b" => [1, 2] })
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
it "C2 preserves Array" do # C2
|
|
36
|
+
store.set("s", "k", [1, "x", true, nil])
|
|
37
|
+
expect(store.get("s", "k")).to eq([1, "x", true, nil])
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
it "C3 preserves String" do # C3
|
|
41
|
+
store.set("s", "k", "texto")
|
|
42
|
+
expect(store.get("s", "k")).to eq("texto")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
it "C4 preserves Integer" do # C4
|
|
46
|
+
store.set("s", "k", 42)
|
|
47
|
+
value = store.get("s", "k")
|
|
48
|
+
expect(value).to eq(42)
|
|
49
|
+
expect(value).to be_a(Integer)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
it "C5 preserves Float" do # C5
|
|
53
|
+
store.set("s", "k", 3.14)
|
|
54
|
+
value = store.get("s", "k")
|
|
55
|
+
expect(value).to eq(3.14)
|
|
56
|
+
expect(value).to be_a(Float)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
it "C6 preserves booleans" do # C6
|
|
60
|
+
store.set("s", "t", true)
|
|
61
|
+
store.set("s", "f", false)
|
|
62
|
+
expect(store.get("s", "t")).to be(true)
|
|
63
|
+
expect(store.get("s", "f")).to be(false)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
it "C7 preserves nil written without exception" do # C7
|
|
67
|
+
store.set("s", "k", nil)
|
|
68
|
+
expect(store.get("s", "k")).to be_nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
it "C8 converts Symbols (keys and values) to Strings" do # C8
|
|
72
|
+
store.set("s", "k", { chave: :valor })
|
|
73
|
+
expect(store.get("s", "k")).to eq({ "chave" => "valor" })
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
it "C9 returns nil for an absent key, never an exception" do # C9
|
|
77
|
+
expect(store.get("s", "nao-existe")).to be_nil
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
it "C10 overwrites silently (last-write-wins)" do # C10
|
|
81
|
+
store.set("s", "k", "primeiro")
|
|
82
|
+
store.set("s", "k", "segundo")
|
|
83
|
+
expect(store.get("s", "k")).to eq("segundo")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
it "C11 set returns the same object passed in (not the round-trip)" do # C11
|
|
87
|
+
obj = { "a" => 1 }
|
|
88
|
+
expect(store.set("s", "k", obj)).to equal(obj)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
describe "#delete" do
|
|
93
|
+
it "C12 removes existing and returns true" do # C12
|
|
94
|
+
store.set("s", "k", 1)
|
|
95
|
+
expect(store.delete("s", "k")).to be(true)
|
|
96
|
+
expect(store.get("s", "k")).to be_nil
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
it "C13 returns false for a nonexistent key" do # C13
|
|
100
|
+
expect(store.delete("s", "k")).to be(false)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
describe "#list" do
|
|
105
|
+
it "C14 returns scope keys sorted lexicographically" do # C14
|
|
106
|
+
store.set("s", "b", 1)
|
|
107
|
+
store.set("s", "a", 1)
|
|
108
|
+
store.set("s", "c", 1)
|
|
109
|
+
expect(store.list("s")).to eq(%w[a b c])
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
it "C15 filters by prefix with start_with? (not include?)" do # C15
|
|
113
|
+
store.set("s", "task:1", 1)
|
|
114
|
+
store.set("s", "task:2", 1)
|
|
115
|
+
store.set("s", "checkpoint:1", 1)
|
|
116
|
+
store.set("s", "my-task:1", 1) # trap: contains "task:" but does not start with it
|
|
117
|
+
expect(store.list("s", "task:")).to eq(%w[task:1 task:2])
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
it "C16 returns [] for an empty scope" do # C16
|
|
121
|
+
expect(store.list("s")).to eq([])
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
it "sorts lexicographically, not numerically" do # edge case 2
|
|
125
|
+
store.set("s", "task:10", 1)
|
|
126
|
+
store.set("s", "task:2", 1)
|
|
127
|
+
expect(store.list("s", "task:")).to eq(%w[task:10 task:2])
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
describe "scope isolation" do
|
|
132
|
+
it "C17 keeps scopes independent in get/list/delete" do # C17
|
|
133
|
+
store.set("s1", "k", 1)
|
|
134
|
+
store.set("s2", "k", 2)
|
|
135
|
+
|
|
136
|
+
expect(store.get("s1", "k")).to eq(1)
|
|
137
|
+
expect(store.get("s2", "k")).to eq(2)
|
|
138
|
+
expect(store.list("s1")).to eq(%w[k])
|
|
139
|
+
|
|
140
|
+
store.delete("s1", "k")
|
|
141
|
+
expect(store.get("s2", "k")).to eq(2)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
describe "#transaction" do
|
|
146
|
+
it "C18 returns the block's value" do # C18
|
|
147
|
+
expect(store.transaction { 42 }).to eq(42)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
it "C19 commits the block's writes" do # C19
|
|
151
|
+
store.transaction { store.set("s", "k", "commitado") }
|
|
152
|
+
expect(store.get("s", "k")).to eq("commitado")
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
it "C20 does a real rollback of set AND delete when the block raises" do # C20
|
|
156
|
+
store.set("s", "manter", "antigo")
|
|
157
|
+
store.set("s", "apagar", "existe")
|
|
158
|
+
|
|
159
|
+
expect do
|
|
160
|
+
store.transaction do
|
|
161
|
+
store.set("s", "manter", "novo")
|
|
162
|
+
store.delete("s", "apagar")
|
|
163
|
+
raise "boom"
|
|
164
|
+
end
|
|
165
|
+
end.to raise_error("boom")
|
|
166
|
+
|
|
167
|
+
# all the block's effects undone
|
|
168
|
+
expect(store.get("s", "manter")).to eq("antigo")
|
|
169
|
+
expect(store.get("s", "apagar")).to eq("existe")
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
it "C21 reuses the outer transaction when nested" do # C21
|
|
173
|
+
store.set("s", "k", "antigo")
|
|
174
|
+
|
|
175
|
+
expect do
|
|
176
|
+
store.transaction do
|
|
177
|
+
store.transaction { store.set("s", "k", "novo") }
|
|
178
|
+
raise "boom"
|
|
179
|
+
end
|
|
180
|
+
end.to raise_error("boom")
|
|
181
|
+
|
|
182
|
+
# outer rollback undoes the inner set (no nesting error)
|
|
183
|
+
expect(store.get("s", "k")).to eq("antigo")
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
describe "serialization errors" do
|
|
188
|
+
it "C22 raises StoreError and does not write a non-serializable value" do # C22
|
|
189
|
+
expect do
|
|
190
|
+
store.set("s", "k", Object.new)
|
|
191
|
+
end.to raise_error(Insika::StoreError)
|
|
192
|
+
expect(store.get("s", "k")).to be_nil
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Multi-worker safety (RFC-0018 A3). Every RFC-0016 A1 claim (outbox, delegation
|
|
198
|
+
# sweep, recovery) is a read-check-write inside `transaction` — on SQLite that is
|
|
199
|
+
# atomic because the backend opens BEGIN IMMEDIATE; a backend whose transaction
|
|
200
|
+
# only yields passes all 22 cases above and still double-claims under two
|
|
201
|
+
# workers, silently. These cases close that hole, across REAL concurrent
|
|
202
|
+
# connections (threads, each on its own handle from `store_factory`).
|
|
203
|
+
#
|
|
204
|
+
# The `sleep` inside each transaction is deliberate: it holds the read state
|
|
205
|
+
# open long enough that a backend without isolation ALWAYS lets a second
|
|
206
|
+
# connection through, while a correct backend serializes the writers. Without
|
|
207
|
+
# it a wrong backend could pass by scheduling luck — the failure E2 relies on.
|
|
208
|
+
RSpec.shared_examples "an Insika store safe for N workers" do
|
|
209
|
+
# Starts N threads, each holding its OWN connection to the same backend,
|
|
210
|
+
# releases them together, and returns each block's value. Connections are
|
|
211
|
+
# closed on the way out when the backend has a #close.
|
|
212
|
+
def with_concurrent_connections(n, &blk)
|
|
213
|
+
ready = Queue.new
|
|
214
|
+
go = Queue.new
|
|
215
|
+
threads = n.times.map do
|
|
216
|
+
Thread.new do
|
|
217
|
+
conn = store_factory.call
|
|
218
|
+
ready << true
|
|
219
|
+
go.pop # hold every connection at the line, then release them together
|
|
220
|
+
blk.call(conn)
|
|
221
|
+
ensure
|
|
222
|
+
conn.close if conn.respond_to?(:close)
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
n.times { ready.pop }
|
|
226
|
+
go.close # a closed queue pops nil immediately: the starting gun
|
|
227
|
+
threads.map(&:value)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# The outbox/delegation claim, verbatim in shape: read the status, and only
|
|
231
|
+
# the connection that still sees "pending" may flip it. A correct backend
|
|
232
|
+
# serializes the transactions, so exactly ONE of the 8 observes "pending".
|
|
233
|
+
it "claims a pending key exactly once across concurrent connections" do
|
|
234
|
+
store.set("jobs", "job:1", "pending")
|
|
235
|
+
|
|
236
|
+
claims = with_concurrent_connections(8) do |conn|
|
|
237
|
+
conn.transaction do
|
|
238
|
+
next unless conn.get("jobs", "job:1") == "pending"
|
|
239
|
+
|
|
240
|
+
sleep 0.01 # hold the read open: a wrong backend lets everyone through
|
|
241
|
+
conn.set("jobs", "job:1", "claimed")
|
|
242
|
+
true
|
|
243
|
+
end
|
|
244
|
+
end.count(true)
|
|
245
|
+
|
|
246
|
+
expect(claims).to eq(1)
|
|
247
|
+
expect(store.get("jobs", "job:1")).to eq("claimed")
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# The lost-update shape the claim generalizes from: read-modify-write of a
|
|
251
|
+
# counter. A backend whose transaction yields without isolation drops
|
|
252
|
+
# increments whenever two connections overlap.
|
|
253
|
+
it "does not lose updates across concurrent connections" do
|
|
254
|
+
store.set("meters", "hits", 0)
|
|
255
|
+
connections = 4
|
|
256
|
+
increments = 10
|
|
257
|
+
|
|
258
|
+
with_concurrent_connections(connections) do |conn|
|
|
259
|
+
increments.times do
|
|
260
|
+
conn.transaction do
|
|
261
|
+
current = conn.get("meters", "hits")
|
|
262
|
+
sleep 0.001 # widen the window a wrong backend loses updates through
|
|
263
|
+
conn.set("meters", "hits", current + 1)
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
expect(store.get("meters", "hits")).to eq(connections * increments)
|
|
269
|
+
end
|
|
270
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
# Cheap token estimate behind an interface.
|
|
5
|
+
# Default: text.length / 4 (off by ~±15%, absorbed by the budget margin).
|
|
6
|
+
# Swappable for a real tokenizer without touching the Builder: any object that
|
|
7
|
+
# responds to #estimate(text) -> Integer works as a replacement (injected at the
|
|
8
|
+
# composition root).
|
|
9
|
+
module TokenEstimator
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def estimate(text)
|
|
13
|
+
text.to_s.length.ceildiv(4)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "async"
|
|
4
|
+
require "async/semaphore"
|
|
5
|
+
|
|
6
|
+
module Insika
|
|
7
|
+
# Turn-scoped assembly of the agent's tool instances (pipeline stage 3 tail):
|
|
8
|
+
# capability resolution, instantiation (Entry#factory | ready instance),
|
|
9
|
+
# turn-context (D2) injection, the capability<->direct dedup join, and the
|
|
10
|
+
# ToolEnvelope wrap (stage 7 seam: per-call timeout + side-effect recording).
|
|
11
|
+
#
|
|
12
|
+
# Extracted from the Executor (§11 B5) to keep the hot-path file smaller. It is
|
|
13
|
+
# a pure collaborator — it holds only the injected registries/stores and no
|
|
14
|
+
# per-turn state; everything turn-specific arrives via `state`/`turn_context`.
|
|
15
|
+
# The Executor keeps thin delegators (resolve_capabilities/assemble_tool_instances/
|
|
16
|
+
# wrap_tools) so the existing private-method contract stays intact.
|
|
17
|
+
class ToolAssembly
|
|
18
|
+
def initialize(tool_registry:, capability_registry:, event_stream:,
|
|
19
|
+
checkpoint_store:, tool_trace_store:)
|
|
20
|
+
@tool_registry = tool_registry
|
|
21
|
+
@capability_registry = capability_registry
|
|
22
|
+
@event_stream = event_stream
|
|
23
|
+
@checkpoint_store = checkpoint_store
|
|
24
|
+
@tool_trace_store = tool_trace_store
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Resolution sub-step BETWEEN Context and Policy —
|
|
28
|
+
# it does NOT feed candidate_tools (those stay ONLY tool_registry.entries,
|
|
29
|
+
# a capability does not go through the ToolAllowlist). Resolves each
|
|
30
|
+
# capability of the profile to the concrete Entry already registered in the
|
|
31
|
+
# tool_registry and keeps the impl_name -> capability_name mapping for the
|
|
32
|
+
# post-Policy join. Errors
|
|
33
|
+
# (Unavailable/Ambiguous, or an unregistered impl) propagate as a
|
|
34
|
+
# CapabilityError -> single capture in `execute` (stage :capability). Without
|
|
35
|
+
# @capability_registry OR without profile.capabilities: {} (parity).
|
|
36
|
+
def resolve_capabilities(profile, context)
|
|
37
|
+
return {} if @capability_registry.nil?
|
|
38
|
+
|
|
39
|
+
Array(profile.capabilities).each_with_object({}) do |cap_name, names|
|
|
40
|
+
provider = @capability_registry.resolve(cap_name, profile: profile, context: context,
|
|
41
|
+
event_stream: @event_stream)
|
|
42
|
+
next if provider.kind == :workflow # exposure to the agent loop is a follow-up
|
|
43
|
+
|
|
44
|
+
entry = @tool_registry.entries.find { |e| e.name == provider.impl_name.to_s }
|
|
45
|
+
if entry.nil?
|
|
46
|
+
raise CapabilityError, "capability '#{cap_name}' resolveu para impl " \
|
|
47
|
+
"'#{provider.impl_name}', not registered in tool_registry"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
names[entry.name] ||= cap_name.to_s # the 1st capability to claim an impl wins
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Joins the direct instances (Policy/ToolAllowlist) with the
|
|
55
|
+
# capability-sourced ones (grant = profile.capabilities — they never went
|
|
56
|
+
# through Policy). Avoids double-exposure: if the SAME impl_name was also
|
|
57
|
+
# allowed directly, the DIRECT instance is discarded — the model sees only the
|
|
58
|
+
# capability alias.
|
|
59
|
+
def assemble_tool_instances(allowed, state)
|
|
60
|
+
names = state.respond_to?(:capability_names) ? (state.capability_names || {}) : {}
|
|
61
|
+
ctx = state.respond_to?(:turn_context) ? state.turn_context : nil
|
|
62
|
+
return instantiate_tools(allowed, ctx) if names.empty?
|
|
63
|
+
|
|
64
|
+
# Dedup by the ENTRY NAME (registry key = impl_name) BEFORE
|
|
65
|
+
# instantiating — the INSTANCE's `.name` (RubyLLM) is not the registration
|
|
66
|
+
# name.
|
|
67
|
+
direct = Array(allowed).reject { |e| e.respond_to?(:name) && names.key?(e.name.to_s) }
|
|
68
|
+
instantiate_tools(direct, ctx) + capability_tool_instances(names, ctx)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Envelopes each allowed tool (per-call timeout + side-effect recording).
|
|
72
|
+
# The system LoadSkill (configure_chat) is NOT enveloped — it is a system
|
|
73
|
+
# tool with no side-effect and of trivial latency.
|
|
74
|
+
def wrap_tools(tools, state, skip_side_effects = [])
|
|
75
|
+
timeout = state.profile.limits[:tool_timeout] || 60
|
|
76
|
+
install_tool_gate(state)
|
|
77
|
+
tools.map do |tool|
|
|
78
|
+
ToolEnvelope.new(tool, state: state, checkpoint_store: @checkpoint_store,
|
|
79
|
+
tool_registry: @tool_registry, timeout: timeout,
|
|
80
|
+
skip_side_effects: skip_side_effects,
|
|
81
|
+
trace_recorder: @tool_trace_store)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
# D4 (item 30): the model decides the fan-out, so without a cap a batch of 15
|
|
88
|
+
# data-tools is 15 simultaneous requests to the same upstream — which is how
|
|
89
|
+
# one turn earns a 429 for every other turn in the process. Note the contrast
|
|
90
|
+
# with the primitives that already shipped: `spawn_subagents` caps at 8
|
|
91
|
+
# (SubagentGraph.fan_out_cap) and `Tools::Concurrency.gather` takes `max:`.
|
|
92
|
+
#
|
|
93
|
+
# The cap is enforced in OUR decorator, not in the gem (which has none): ONE
|
|
94
|
+
# semaphore per turn, installed here — once, in the turn's own fiber, before
|
|
95
|
+
# any tool can run — and acquired by every ToolEnvelope. Off (nil) unless the
|
|
96
|
+
# turn actually got concurrency, so the serial path allocates nothing.
|
|
97
|
+
# A state that predates these fields (a unit stub) is left alone.
|
|
98
|
+
def install_tool_gate(state)
|
|
99
|
+
return unless state.respond_to?(:tool_gate) && state.respond_to?(:tool_concurrency)
|
|
100
|
+
|
|
101
|
+
cap = state.tool_concurrency
|
|
102
|
+
state.tool_gate = cap ? Async::Semaphore.new(cap) : nil
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Real Engine -> Entries (respond to factory); fakes -> ready instances.
|
|
106
|
+
# `turn_context` (D2) is deposited into the instances that expose it
|
|
107
|
+
# (data-tools); the rest ignore it (parity).
|
|
108
|
+
def instantiate_tools(allowed, turn_context = nil)
|
|
109
|
+
Array(allowed).map do |t|
|
|
110
|
+
tool = t.respond_to?(:factory) ? t.factory.call : t
|
|
111
|
+
inject_turn_context(tool, turn_context)
|
|
112
|
+
tool
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# D2/G3 seam: deposits the turn context into the freshly created instance
|
|
117
|
+
# (same idea as `remember`, which receives tenant/state) BEFORE the
|
|
118
|
+
# ToolEnvelope. Duck-typed: only what exposes `turn_context=` (DataDefinedTool)
|
|
119
|
+
# receives it. nil (a state with no turn_context, e.g. a test stub) -> no-op.
|
|
120
|
+
def inject_turn_context(tool, turn_context)
|
|
121
|
+
return if turn_context.nil?
|
|
122
|
+
|
|
123
|
+
tool.turn_context = turn_context if tool.respond_to?(:turn_context=)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# impl_name -> Capability::ResolvedTool(capability_name:), STILL without
|
|
127
|
+
# ToolEnvelope (the call site's wrap_tools wraps the whole set — same
|
|
128
|
+
# order impl -> ResolvedTool -> ToolEnvelope). entry already validated in
|
|
129
|
+
# resolve_capabilities.
|
|
130
|
+
def capability_tool_instances(names, turn_context = nil)
|
|
131
|
+
names.map do |impl_name, capability_name|
|
|
132
|
+
entry = @tool_registry.entries.find { |e| e.name == impl_name }
|
|
133
|
+
tool = entry.factory.call
|
|
134
|
+
inject_turn_context(tool, turn_context)
|
|
135
|
+
Capability::ResolvedTool.new(tool, capability_name: capability_name,
|
|
136
|
+
impl_name: impl_name)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
# "Level 1" (progressive disclosure) view of the tools: just
|
|
5
|
+
# name+description, the analog of SkillCatalog for tools. It does NOT read
|
|
6
|
+
# disk (the tools are already in the ToolRegistry, registered at boot) and does NOT
|
|
7
|
+
# inherit from RubyLLM::Tool — duck typing over `.description` (pure, testable without
|
|
8
|
+
# the gem).
|
|
9
|
+
#
|
|
10
|
+
# The canonical `description` does not live in the Entry (Registry::Entry has no such
|
|
11
|
+
# field): it comes from the tool INSTANCE (`factory.call.description`). That is why the
|
|
12
|
+
# catalog is LAZY: it only instantiates the tools on the first query (`all`), not at
|
|
13
|
+
# boot. A deployment without `tools_deferred` never touches the catalog and pays for
|
|
14
|
+
# no instantiation at all; a broken factory surfaces on first use (where the
|
|
15
|
+
# Executor would also catch it at stage 3), not at construction.
|
|
16
|
+
class ToolCatalog
|
|
17
|
+
Entry = Data.define(:name, :description)
|
|
18
|
+
|
|
19
|
+
def initialize(tool_registry:)
|
|
20
|
+
@tool_registry = tool_registry
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def all
|
|
24
|
+
@entries ||= build_entries
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Reloads the index (after authoring a data-tool in the overlay). Mirrors
|
|
28
|
+
# SkillCatalog#reload — level-1/tool_search starts seeing the new tool without a
|
|
29
|
+
# restart. An in-flight turn has already captured `all`.
|
|
30
|
+
def reload
|
|
31
|
+
@entries = build_entries
|
|
32
|
+
self
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Allowed deferred slice (typically allowed_tools ∩ tools_deferred).
|
|
36
|
+
# Names outside the catalog are silently ignored (safe failure: less
|
|
37
|
+
# exposure, never more).
|
|
38
|
+
def subset(names)
|
|
39
|
+
wanted = Array(names).map(&:to_s)
|
|
40
|
+
all.select { |e| wanted.include?(e.name) }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# PURE matcher: case-insensitive, substring/keyword, NO
|
|
44
|
+
# embeddings. name weighs 2, description weighs 1; ties broken by original index
|
|
45
|
+
# (Ruby's sort_by is not stable). `within:` restricts the universe via subset.
|
|
46
|
+
def search(query, within: nil)
|
|
47
|
+
terms = query.to_s.downcase.split(/\s+/).reject(&:empty?)
|
|
48
|
+
return [] if terms.empty?
|
|
49
|
+
|
|
50
|
+
universe = within ? subset(within) : all
|
|
51
|
+
scored = universe.each_with_index.filter_map do |entry, idx|
|
|
52
|
+
score = score_entry(entry, terms)
|
|
53
|
+
[entry, score, idx] if score.positive?
|
|
54
|
+
end
|
|
55
|
+
scored.sort_by { |_entry, score, idx| [-score, idx] }.map(&:first)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Level 1 injected into the prompt — mirror of SkillCatalog#format_for_prompt,
|
|
59
|
+
# swapping the tag and the final instruction (load_skill -> tool_search).
|
|
60
|
+
def format_for_prompt(entries = all)
|
|
61
|
+
return "" if entries.empty?
|
|
62
|
+
|
|
63
|
+
lines = entries.map { |e| %( <tool name="#{e.name}">#{e.description}</tool>) }.join("\n")
|
|
64
|
+
|
|
65
|
+
<<~PROMPT.strip
|
|
66
|
+
<available_tools>
|
|
67
|
+
#{lines}
|
|
68
|
+
</available_tools>
|
|
69
|
+
|
|
70
|
+
Before using a tool above, call `tool_search` with what you need
|
|
71
|
+
to do to enable it in this conversation.
|
|
72
|
+
PROMPT
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def build_entries
|
|
78
|
+
@tool_registry.entries.map do |entry|
|
|
79
|
+
Entry.new(name: entry.name, description: entry.factory.call.description.to_s)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def score_entry(entry, terms)
|
|
84
|
+
name = entry.name.downcase
|
|
85
|
+
desc = entry.description.downcase
|
|
86
|
+
terms.sum { |term| (name.include?(term) ? 2 : 0) + (desc.include?(term) ? 1 : 0) }
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|