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
data/lib/insika/channels/web.rb
CHANGED
|
@@ -6,7 +6,7 @@ require "securerandom"
|
|
|
6
6
|
|
|
7
7
|
module Insika
|
|
8
8
|
module Channels
|
|
9
|
-
# The web widget
|
|
9
|
+
# The web widget — the FIRST Shape A channel, and the adoption
|
|
10
10
|
# claim behind the whole RFC: an adopter pastes one tag into their site and has
|
|
11
11
|
# a working agent, with no backend of their own and no build step.
|
|
12
12
|
#
|
|
@@ -17,7 +17,7 @@ module Insika
|
|
|
17
17
|
# no outbox, no claim and no `deliver` here — the three routes and a static
|
|
18
18
|
# asset are the entire surface:
|
|
19
19
|
#
|
|
20
|
-
# POST /channels/web/sessions mint an opaque session id (the engine's
|
|
20
|
+
# POST /channels/web/sessions mint an opaque session id (the engine's)
|
|
21
21
|
# POST /channels/web/messages the turn, answered as SSE on this connection
|
|
22
22
|
# GET /channels/web/asset/widget.js
|
|
23
23
|
#
|
|
@@ -29,7 +29,7 @@ module Insika
|
|
|
29
29
|
# · an AGENT allowlist — a visitor addresses the agents the operator published
|
|
30
30
|
# to the widget, not every agent in the deployment;
|
|
31
31
|
# · an ORIGIN allowlist — exact match, no wildcards, and no "allow all" value;
|
|
32
|
-
# · a mandatory chat RATE LIMIT
|
|
32
|
+
# · a mandatory chat RATE LIMIT — the channel answers `:disabled`
|
|
33
33
|
# until one is configured, because a public endpoint with an LLM behind it
|
|
34
34
|
# and no ceiling is an unmetered bill waiting to happen.
|
|
35
35
|
#
|
|
@@ -49,7 +49,7 @@ module Insika
|
|
|
49
49
|
# An unversioned URL cannot be cached for a year — the next release would
|
|
50
50
|
# never reach a browser that already has it. Short max-age + an ETag instead:
|
|
51
51
|
# the common case is a 304 with no body, and an upgrade lands within minutes.
|
|
52
|
-
# (A deliberate narrowing of
|
|
52
|
+
# (A deliberate narrowing of's "long-cache versioned URL": the install
|
|
53
53
|
# snippet in the RFC has no version in it, so there is nothing to bust.)
|
|
54
54
|
ASSET_CACHE_CONTROL = "public, max-age=300"
|
|
55
55
|
|
|
@@ -67,7 +67,7 @@ module Insika
|
|
|
67
67
|
new(origins: origins, agents: agents, id: id, chat_rate_limit: chat_rate_limit)
|
|
68
68
|
end
|
|
69
69
|
|
|
70
|
-
# The probe
|
|
70
|
+
# The probe's gate needs, asked exactly the way `EdgeLimiter` asks it at
|
|
71
71
|
# turn time: the per-agent override first, the platform default second. Built
|
|
72
72
|
# here so both composition roots wire the gate identically, and returned as a
|
|
73
73
|
# lambda so the channel itself stays store-free.
|
|
@@ -149,7 +149,7 @@ module Insika
|
|
|
149
149
|
{ agent: agent, session_id: session_id, message: message }
|
|
150
150
|
end
|
|
151
151
|
|
|
152
|
-
#
|
|
152
|
+
#'s hard rule for a public channel: the ENGINE issues the id and the
|
|
153
153
|
# client never proposes one. A visitor-supplied session id on an anonymous
|
|
154
154
|
# endpoint is session hijacking by enumeration, so there is no create-on-write
|
|
155
155
|
# path — `POST /messages` with an unknown id is a 404, not a new conversation.
|
|
@@ -159,7 +159,7 @@ module Insika
|
|
|
159
159
|
# protocol: what to type, what to say while a tool runs, and how it ended.
|
|
160
160
|
#
|
|
161
161
|
# `:intermediate` and `:thinking` are deliberately absent. `:content` is the
|
|
162
|
-
# ANSWER
|
|
162
|
+
# ANSWER — the model's narration on the way there is internal, and a
|
|
163
163
|
# widget that rendered it would show the customer the engine thinking out loud.
|
|
164
164
|
def frame_for(event)
|
|
165
165
|
case event.type
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
module Channels
|
|
7
|
+
# A webhook as a Shape B "channel": the recipient of operator ALERTS
|
|
8
|
+
# (WS6). One instance per configured URL, registered in the ChannelRegistry
|
|
9
|
+
# so ChannelDelivery's outbox+claim+retry pipeline delivers the alert the
|
|
10
|
+
# same way it delivers a chat answer — at-most-once, bounded retry, boot
|
|
11
|
+
# sweep. Deliberately NOT Slack/CRM/anything: it POSTs the event as JSON and
|
|
12
|
+
# the consumer interprets it (the engine transports, it does not integrate).
|
|
13
|
+
#
|
|
14
|
+
# The target URL is operator configuration, so the POST crosses the SAME
|
|
15
|
+
# egress guard the Relay applies: https-only (fails closed), private/
|
|
16
|
+
# loopback/metadata targets and DNS-rebindable hosts blocked. Without it the
|
|
17
|
+
# `alerts.webhook` URL is an SSRF vector — a URL pointed at cloud metadata
|
|
18
|
+
# or an internal API exfiltrates alert events out of the boundary (WS6).
|
|
19
|
+
class Webhook
|
|
20
|
+
def initialize(url, http:, allow_http: false, allow_private: false)
|
|
21
|
+
@http = http
|
|
22
|
+
@allow_http = allow_http
|
|
23
|
+
@allow_private = allow_private
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# The ChannelDelivery contract: -> HTTP status (200..299 = delivered).
|
|
27
|
+
# Every failure becomes a DeliveryError so the bounded retry records it.
|
|
28
|
+
def deliver(payload, to:, delivery_id: nil)
|
|
29
|
+
if (reason = egress_violation(to))
|
|
30
|
+
raise Insika::DeliveryError, "webhook egress blocked for #{to}: #{reason}"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
result = @http.request(
|
|
34
|
+
method: :post, url: to,
|
|
35
|
+
headers: { "content-type" => "application/json" },
|
|
36
|
+
body: JSON.generate(payload)
|
|
37
|
+
)
|
|
38
|
+
result[:status].to_i
|
|
39
|
+
rescue Insika::DeliveryError
|
|
40
|
+
raise
|
|
41
|
+
rescue StandardError => e
|
|
42
|
+
raise Insika::DeliveryError, "webhook: #{e.message}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
# Resolved on EVERY delivery, not once at registration: a host that
|
|
48
|
+
# answered a public address yesterday can answer 169.254.169.254 today,
|
|
49
|
+
# and this POST carries the operator's alerts.
|
|
50
|
+
def egress_violation(url)
|
|
51
|
+
Insika::EgressGuard.violation(url, allow_http: @allow_http,
|
|
52
|
+
allow_private: @allow_private)
|
|
53
|
+
rescue URI::InvalidURIError
|
|
54
|
+
"invalid URL"
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
data/lib/insika/chat_builder.rb
CHANGED
|
@@ -13,7 +13,8 @@ module Insika
|
|
|
13
13
|
# Executor, injected as the `emit` callable.
|
|
14
14
|
class ChatBuilder
|
|
15
15
|
def initialize(tool_registry:, skill_catalog:, checkpoint_store:, event_stream:,
|
|
16
|
-
hooks:, tool_catalog: nil, memory_store: nil, subagent_runner: nil
|
|
16
|
+
hooks:, tool_catalog: nil, memory_store: nil, subagent_runner: nil,
|
|
17
|
+
tool_trace_store: nil)
|
|
17
18
|
@tool_registry = tool_registry
|
|
18
19
|
@skill_catalog = skill_catalog
|
|
19
20
|
@checkpoint_store = checkpoint_store
|
|
@@ -21,7 +22,9 @@ module Insika
|
|
|
21
22
|
@hooks = hooks
|
|
22
23
|
@tool_catalog = tool_catalog
|
|
23
24
|
@memory_store = memory_store
|
|
24
|
-
#
|
|
25
|
+
# only to trace load_skill, which is not enveloped — nil = no trace (parity).
|
|
26
|
+
@tool_trace_store = tool_trace_store
|
|
27
|
+
# the object exposing #run_subagent (the Executor). nil = the
|
|
25
28
|
# spawn_subagent system tool is never wired (parity for a builder used
|
|
26
29
|
# without delegation, e.g. some unit stubs).
|
|
27
30
|
@subagent_runner = subagent_runner
|
|
@@ -69,9 +72,16 @@ module Insika
|
|
|
69
72
|
|
|
70
73
|
# load_skill is a system default (outside the allowlist), otherwise
|
|
71
74
|
# progressive disclosure breaks. allowed_skills comes from the Resolution
|
|
72
|
-
# (policy)
|
|
73
|
-
|
|
74
|
-
|
|
75
|
+
# (policy), minus the EAGER ones: their bodies are already in the prompt, so a
|
|
76
|
+
# call could only pay for a duplicate. Nothing lazy left -> the tool is not
|
|
77
|
+
# wired at all. Keeping it for the discretionary skills is deliberate: that
|
|
78
|
+
# call is the only record of which skill the model actually reached for.
|
|
79
|
+
skill_names = lazy_skill_names(state)
|
|
80
|
+
unless skill_names.empty?
|
|
81
|
+
tools << Tools::LoadSkill.new(@skill_catalog, skill_names,
|
|
82
|
+
trace_recorder: @tool_trace_store, state: state,
|
|
83
|
+
agent: state.profile.id)
|
|
84
|
+
end
|
|
75
85
|
|
|
76
86
|
# remember is the memory-write system tool — wired only with
|
|
77
87
|
# @memory_store present AND profile.memory (a double gate). Never enveloped.
|
|
@@ -80,24 +90,32 @@ module Insika
|
|
|
80
90
|
event_stream: @event_stream, state: state)
|
|
81
91
|
end
|
|
82
92
|
|
|
83
|
-
#
|
|
93
|
+
# signal_stuck is the "I cannot proceed" system tool (WS5) — wired only
|
|
94
|
+
# when the agent opted in (`profile.stuck_signal`), never enveloped. It is a
|
|
95
|
+
# deterministic signal; the consumer decides what "stuck" means. Defensive
|
|
96
|
+
# read: a minimal profile double without the reader means off (nil = parity).
|
|
97
|
+
if state.profile.respond_to?(:stuck_signal) && state.profile.stuck_signal
|
|
98
|
+
tools << Tools::StuckSignal.new(state: state)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# spawn_subagent is the delegation system tool — wired only with a
|
|
84
102
|
# runner present AND profile.subagents non-empty (a double gate, like
|
|
85
103
|
# remember). Never enveloped: in the synchronous mode the child lives in the
|
|
86
104
|
# parent's envelope. The runtime gate on WHICH agent is spawnable is the
|
|
87
105
|
# parent's subagents allowlist, enforced in Executor#run_subagent.
|
|
88
106
|
if @subagent_runner && !Array(state.profile.subagents).empty?
|
|
89
107
|
tools << Tools::Subagent.new(runner: @subagent_runner, state: state)
|
|
90
|
-
#
|
|
108
|
+
# and its parallel sibling: fan-out N children at once.
|
|
91
109
|
tools << Tools::Subagents.new(runner: @subagent_runner, state: state)
|
|
92
110
|
end
|
|
93
111
|
|
|
94
112
|
unless tools.empty?
|
|
95
|
-
#
|
|
113
|
+
# the ONLY place the gem is told to run tool calls in parallel.
|
|
96
114
|
# `:fibers` is not a preference but the only admissible mode — `:threads`
|
|
97
115
|
# breaks ToolEnvelope's `Async::Task.current.with_timeout`, the SQLite
|
|
98
116
|
# store's fiber semaphore, and the turn's own durability (mailbox,
|
|
99
117
|
# approvals, cancellation are all expressed in fiber terms). The number
|
|
100
|
-
# the operator configured is OUR cap (
|
|
118
|
+
# the operator configured is OUR cap (ToolAssembly#install_tool_gate);
|
|
101
119
|
# the gem has none.
|
|
102
120
|
if tool_concurrency_for(state)
|
|
103
121
|
chat.with_tools(*tools, concurrency: :fibers)
|
|
@@ -109,9 +127,21 @@ module Insika
|
|
|
109
127
|
chat
|
|
110
128
|
end
|
|
111
129
|
|
|
130
|
+
# The skills the model still has to ask for: the Resolution's set minus the eager
|
|
131
|
+
# ones. Intersected by NAME against the catalog's own verdict (SkillCatalog#eager_for)
|
|
132
|
+
# so the tool and the level-1 list can never disagree about who is eager. A catalog
|
|
133
|
+
# without the reader (a unit stub) falls back to the whole set — parity.
|
|
134
|
+
def lazy_skill_names(state)
|
|
135
|
+
names = Array(state.allowed_skills).map { |s| s.respond_to?(:name) ? s.name : s.to_s }
|
|
136
|
+
return names unless @skill_catalog.respond_to?(:eager_for)
|
|
137
|
+
|
|
138
|
+
eager = @skill_catalog.eager_for(state.profile).map(&:name)
|
|
139
|
+
names - eager
|
|
140
|
+
end
|
|
141
|
+
|
|
112
142
|
# The turn's effective tool concurrency (nil = serial), plus the ONE thing the
|
|
113
143
|
# gate owes the operator: when the profile asked for parallel tool calls and
|
|
114
|
-
# this turn silently cannot have them (
|
|
144
|
+
# this turn silently cannot have them (an approval-required tool would
|
|
115
145
|
# deadlock two fibers on the single per-task mailbox), say so once. Otherwise
|
|
116
146
|
# the speedup just vanishes with no reason given. The rule itself lives in
|
|
117
147
|
# TurnState; a state predating those readers (a unit stub) means off.
|
|
@@ -137,7 +167,7 @@ module Insika
|
|
|
137
167
|
))
|
|
138
168
|
end
|
|
139
169
|
|
|
140
|
-
#
|
|
170
|
+
# R3: opt-in Anthropic prompt caching. When the agent enables
|
|
141
171
|
# prompt_caching AND the resolved provider is Anthropic, wrap the system in
|
|
142
172
|
# the provider's native Content helper with cache: true — ONE breakpoint at
|
|
143
173
|
# the END of the system block. By Anthropic's prefix order
|
|
@@ -176,7 +206,7 @@ module Insika
|
|
|
176
206
|
# History comes from the context/checkpoint. The {role:, content:} shape
|
|
177
207
|
# tolerates string keys (JSON from the stores). `flatten(1)` dissolves the
|
|
178
208
|
# Session provider's "eviction units" (an assistant+tool_results cycle grouped
|
|
179
|
-
# as one Array,
|
|
209
|
+
# as one Array, R1) back into a flat message stream.
|
|
180
210
|
#
|
|
181
211
|
# tool_calls / tool_call_id are rehydrated ONLY when present, so a message
|
|
182
212
|
# without them keeps the 2-arg shape the specs' FakeChat expects (no unknown
|
|
@@ -216,6 +246,14 @@ module Insika
|
|
|
216
246
|
tool_calls = 0
|
|
217
247
|
max_tool_calls = state.profile.limits[:max_tool_calls] || 50
|
|
218
248
|
|
|
249
|
+
# the loop detector. Needs #after_message + #add_message for the
|
|
250
|
+
# batch-boundary intervention; a chat without them (smoke shim, minimal
|
|
251
|
+
# double) stays bounded by max_tool_calls alone — never half-wired.
|
|
252
|
+
detector = if %i[after_message add_message].all? { |m| chat.respond_to?(m) }
|
|
253
|
+
repeat = state.profile.limits[:max_tool_repeat] || Insika::AgentProfile::DEFAULT_LIMITS[:max_tool_repeat]
|
|
254
|
+
Insika::LoopDetector.new(chat: chat, limit: repeat, emit: emit) if repeat >= 2
|
|
255
|
+
end
|
|
256
|
+
|
|
219
257
|
chat.before_tool_call do |tool_call|
|
|
220
258
|
# call<->decorator correlation (side-effects/skip) — 1st line.
|
|
221
259
|
state.current_tool_call = tool_call
|
|
@@ -224,9 +262,13 @@ module Insika
|
|
|
224
262
|
tool_calls += 1
|
|
225
263
|
if tool_calls > max_tool_calls
|
|
226
264
|
raise Insika::TimeoutError.new("tool call limit exceeded (#{max_tool_calls})",
|
|
227
|
-
|
|
265
|
+
stage: :tool_limit)
|
|
228
266
|
end
|
|
229
267
|
|
|
268
|
+
# AFTER the count, BEFORE the call runs — a post-warning
|
|
269
|
+
# repeat raises here, so the stubborn loop pays for no extra call.
|
|
270
|
+
detector&.tool_call(tool_call.name, tool_call.arguments)
|
|
271
|
+
|
|
230
272
|
# :tool pair: RubyLLM's callbacks are additive — the altered subject
|
|
231
273
|
# feeds later hooks and the events, but does not rewrite the call the
|
|
232
274
|
# model executes. A hook exception here aborts the turn.
|
|
@@ -246,9 +288,16 @@ module Insika
|
|
|
246
288
|
end
|
|
247
289
|
|
|
248
290
|
chat.after_tool_result do |result|
|
|
291
|
+
# the RAW result — the only place a Tool::Halt (halt_when) is
|
|
292
|
+
# still recognizable, and a halted batch must receive no intervention.
|
|
293
|
+
detector&.tool_result(result)
|
|
249
294
|
result = @hooks.run_after(:tool, result)
|
|
250
295
|
emit.call(:tool_result, { name: state.current_tool_name, result: result.to_s })
|
|
251
296
|
end
|
|
297
|
+
|
|
298
|
+
# the intervention appends at the batch boundary (the Nth tool
|
|
299
|
+
# result closing) — never between two tool results of one batch.
|
|
300
|
+
chat.after_message { |message| detector.message_ended(message) } if detector
|
|
252
301
|
end
|
|
253
302
|
end
|
|
254
303
|
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
# The circuit breaker's durable state (WS3): one cell per
|
|
7
|
+
# (tenant, provider/model ref) holding the recent failures and when the
|
|
8
|
+
# circuit opened. The read-modify-write rides `@store.transaction` — the same
|
|
9
|
+
# discipline as the dispatch claim (A1): two concurrent failures of the same
|
|
10
|
+
# cell serialize, so the "10 failures in 60s" count is never lost to a race.
|
|
11
|
+
#
|
|
12
|
+
# States, standard semantics:
|
|
13
|
+
# :closed fewer than `after` failures in the `within` window — attempts flow.
|
|
14
|
+
# :open the window count is met AND the cooldown hasn't elapsed — the
|
|
15
|
+
# edge FAIL-FASTS (no provider call) with retry_after = remaining
|
|
16
|
+
# cooldown. Reached at the `after`-th failure, which stamps
|
|
17
|
+
# opened_at.
|
|
18
|
+
# :half_open the cooldown elapsed — the next attempt is the TRIAL: a success
|
|
19
|
+
# closes (record_success clears the cell), a failure reopens
|
|
20
|
+
# (a new failure count / opened_at).
|
|
21
|
+
class CircuitState
|
|
22
|
+
SCOPE = "circuit_state"
|
|
23
|
+
|
|
24
|
+
# Timestamps are pruned to COUNT_LIMIT — a pathological loop cannot grow the
|
|
25
|
+
# cell unboundedly (the window is 60s-wide; 100 reads and writes bounded).
|
|
26
|
+
COUNT_LIMIT = 100
|
|
27
|
+
|
|
28
|
+
Record = Data.define(:failures, :opened_at)
|
|
29
|
+
|
|
30
|
+
def initialize(store:)
|
|
31
|
+
@store = store
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Records ONE failure for (tenant, ref). If this failure makes the window
|
|
35
|
+
# reach `after` and the circuit is not already open, it stamps opened_at
|
|
36
|
+
# (the instant the breaker trips). -> :closed (still closed) | :open (JUST
|
|
37
|
+
# tripped — closed->open, the ONLY transition that alerts) | :reopened (an
|
|
38
|
+
# already-tripped cell re-stamped: a half-open trial failed; the circuit is
|
|
39
|
+
# open again but the node already told the operator it is sick — no NEW
|
|
40
|
+
# alert (WS3)).
|
|
41
|
+
def record_failure(tenant:, ref:, after: 10, within: 60, now: Time.now)
|
|
42
|
+
key = key_for(tenant, ref)
|
|
43
|
+
@store.transaction do
|
|
44
|
+
record = load(key)
|
|
45
|
+
cutoff = (now.to_i - within)
|
|
46
|
+
retained = record.failures.select { |t| t > cutoff }.last(COUNT_LIMIT)
|
|
47
|
+
failures = (retained + [now.to_i]).last(COUNT_LIMIT)
|
|
48
|
+
opened_at = record.opened_at
|
|
49
|
+
tripped = :closed
|
|
50
|
+
if opened_at.nil?
|
|
51
|
+
if failures.size >= after
|
|
52
|
+
opened_at = now.to_i
|
|
53
|
+
tripped = :open
|
|
54
|
+
end
|
|
55
|
+
else
|
|
56
|
+
# the circuit was tripped before; a failure here can only be a HALF-OPEN
|
|
57
|
+
# trial that failed. Re-stamp opened_at so it RE-OPENS — a breaker that
|
|
58
|
+
# never restamps is one-shot: after the first cooldown every later turn
|
|
59
|
+
# is an unlocked half-open trial (WS3).
|
|
60
|
+
opened_at = now.to_i
|
|
61
|
+
tripped = :reopened
|
|
62
|
+
end
|
|
63
|
+
@store.set(SCOPE, key, { "failures" => failures, "opened_at" => opened_at })
|
|
64
|
+
tripped
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# A successful attempt CLOSES the circuit: the cell is cleared so the
|
|
69
|
+
# failure window starts fresh (half-open trial success included).
|
|
70
|
+
def record_success(tenant:, ref:)
|
|
71
|
+
@store.delete(SCOPE, key_for(tenant, ref))
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# -> :closed | :open | :half_open
|
|
75
|
+
def state(tenant:, ref:, after: 10, within: 60, cooldown: 300, now: Time.now)
|
|
76
|
+
record = load(key_for(tenant, ref))
|
|
77
|
+
opened = record.opened_at.to_i
|
|
78
|
+
# :closed needs BOTH doors shut: the window count is under `after` AND the
|
|
79
|
+
# circuit never tripped. A surviving opened_at tombstone keeps the circuit
|
|
80
|
+
# governed by the cooldown (open, then half-open) even after its original
|
|
81
|
+
# failures age out of the rolling window — only a SUCCESS clears it (WS3:
|
|
82
|
+
# a failed half-open trial must reopen, not silently close).
|
|
83
|
+
return :closed if record.failures.size < after && opened.zero?
|
|
84
|
+
|
|
85
|
+
return :open if opened.zero? || (now.to_i - opened) < cooldown
|
|
86
|
+
|
|
87
|
+
:half_open
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Seconds until the circuit can be retried (the remaining cooldown). nil
|
|
91
|
+
# while closed.
|
|
92
|
+
def retry_after(tenant:, ref:, cooldown: 300, now: Time.now)
|
|
93
|
+
record = load(key_for(tenant, ref))
|
|
94
|
+
return nil if record.failures.empty?
|
|
95
|
+
|
|
96
|
+
opened = record.opened_at.to_i
|
|
97
|
+
return nil if opened.zero?
|
|
98
|
+
|
|
99
|
+
remaining = cooldown - (now.to_i - opened)
|
|
100
|
+
remaining.positive? ? remaining : nil
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
def key_for(tenant, ref) = "#{tenant || 'platform'}:#{ref}"
|
|
106
|
+
|
|
107
|
+
def load(key)
|
|
108
|
+
record = @store.get(SCOPE, key)
|
|
109
|
+
return Record.new([], nil) if record.nil?
|
|
110
|
+
|
|
111
|
+
Record.new(Array(record["failures"]), record["opened_at"])
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
data/lib/insika/coercion.rb
CHANGED
|
@@ -19,6 +19,14 @@ module Insika
|
|
|
19
19
|
# Inverse of blank? — a usable, non-empty value.
|
|
20
20
|
def present?(value) = !blank?(value)
|
|
21
21
|
|
|
22
|
+
# The operator-facing truthy: what a form checkbox ("1"), a JSON round-trip
|
|
23
|
+
# ("true") or the DSL (true) produce for an opt-in. One list, one reading —
|
|
24
|
+
# AgentProfile#stream_public?, SkillCatalog#blanket? and the Studio all consult
|
|
25
|
+
# it, so a value that opts in on one surface cannot opt out on another.
|
|
26
|
+
TRUTHY = [true, "true", "1", "yes", "on"].freeze
|
|
27
|
+
|
|
28
|
+
def truthy?(value) = TRUTHY.include?(value)
|
|
29
|
+
|
|
22
30
|
# Bytes that come from outside the engine (sockets, pipes, subprocesses)
|
|
23
31
|
# arrive tagged BINARY, or as UTF-8 carrying invalid sequences. Both break
|
|
24
32
|
# `JSON.generate` the moment the text has an accent or an emoji — today a
|
|
@@ -10,9 +10,11 @@ module Insika
|
|
|
10
10
|
module AgentPayload
|
|
11
11
|
# Fields accepted by AgentProfile.build (order irrelevant).
|
|
12
12
|
FIELDS = %i[id model provider base_prompt prompt_files tools_allow tools_deny
|
|
13
|
-
tools_allow_groups skills context_providers workflows_allow policies
|
|
13
|
+
tools_allow_groups skills skills_eager context_providers workflows_allow policies
|
|
14
14
|
prompt_refs limits approvals_required capabilities subagents tools_deferred
|
|
15
|
-
memory
|
|
15
|
+
memory prompt_caching tool_output_compression budget reliability alerts
|
|
16
|
+
stuck_signal
|
|
17
|
+
params model_policy guardrails refinement capabilities_declared
|
|
16
18
|
edge_stream metadata].freeze
|
|
17
19
|
|
|
18
20
|
module_function
|
|
@@ -25,7 +27,7 @@ module Insika
|
|
|
25
27
|
out[:provider] = presence(out[:provider])&.to_sym if out.key?(:provider)
|
|
26
28
|
out[:policies] = Array(out[:policies]).map(&:to_sym) if out.key?(:policies)
|
|
27
29
|
out[:limits] = out[:limits].transform_keys(&:to_sym) if out[:limits].is_a?(Hash)
|
|
28
|
-
# generation params
|
|
30
|
+
# generation params consumed by symbol key (temperature/max_tokens/thinking).
|
|
29
31
|
out[:params] = out[:params].transform_keys(&:to_sym) if out[:params].is_a?(Hash)
|
|
30
32
|
out
|
|
31
33
|
end
|
|
@@ -17,13 +17,13 @@ module Insika
|
|
|
17
17
|
attrs = AgentPayload.attrs(command.payload)
|
|
18
18
|
id = AgentPayload.presence(attrs[:id])
|
|
19
19
|
raise Insika::ValidationError, "id is required" if id.nil?
|
|
20
|
-
# model is OPTIONAL as of v2
|
|
20
|
+
# model is OPTIONAL as of v2: omitted -> resolves the platform
|
|
21
21
|
# default_model (Settings) at turn start. An agent with neither its own
|
|
22
22
|
# model nor a platform default fails clearly at the first turn.
|
|
23
23
|
raise Insika::ValidationError, "agent '#{id}' already exists" if @profile_source.fetch(id)
|
|
24
24
|
|
|
25
25
|
profile = Insika::AgentProfile.build(**attrs)
|
|
26
|
-
#
|
|
26
|
+
# definition-time cycle + depth check. Only a profile that
|
|
27
27
|
# introduces edges (non-empty subagents) can create a violation — a
|
|
28
28
|
# childless agent is always a safe leaf. Raises SubagentError (a
|
|
29
29
|
# ValidationError) BEFORE persisting, so a bad graph never lands.
|
|
@@ -21,7 +21,7 @@ module Insika
|
|
|
21
21
|
vars = command.payload[:vars] || command.payload["vars"] || {}
|
|
22
22
|
raise Insika::ValidationError, "vars must be a Hash" unless vars.is_a?(Hash)
|
|
23
23
|
|
|
24
|
-
# Per-chat model pin
|
|
24
|
+
# Per-chat model pin: `model`/`provider` on the payload become a
|
|
25
25
|
# reserved, collision-safe slot in vars (never rendered in the prompt — the
|
|
26
26
|
# Request provider skips "__"-prefixed vars). The ModelResolver reads it as
|
|
27
27
|
# the highest-precedence layer (Chat > Agent > platform default).
|
|
@@ -6,7 +6,7 @@ module Insika
|
|
|
6
6
|
module Commands
|
|
7
7
|
# Control command: removes an LLM provider from the
|
|
8
8
|
# LLMProviderStore. Idempotent (`existed: false` if there was none). UNDOES the
|
|
9
|
-
# config in RubyLLM at runtime
|
|
9
|
+
# config in RubyLLM at runtime: when the provider existed, calls
|
|
10
10
|
# `configurator.unapply(api)` clearing the global key/base — without a restart. A provider
|
|
11
11
|
# that RubyLLM does not recognize degrades gracefully (unapply: false, nothing to
|
|
12
12
|
# undo). -> { existed: bool }.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
module Commands
|
|
7
|
+
# Control command: removes an authored skill from the SkillStore and RELOADS the
|
|
8
|
+
# catalog. -> { name, agent, deleted }.
|
|
9
|
+
#
|
|
10
|
+
# With `agent`, it deletes only that agent's SPECIALIZATION — the shared skill
|
|
11
|
+
# stays exactly where it was, and the agent falls back to it on the next turn.
|
|
12
|
+
# "Stop specializing this" is the inverse of the specialize action, and it must not
|
|
13
|
+
# be expressible as "delete the skill".
|
|
14
|
+
#
|
|
15
|
+
# Deleting a shared skill does NOT touch any allowlist: an agent left naming a
|
|
16
|
+
# skill that no longer exists sees nothing (the catalog resolves nothing), which is
|
|
17
|
+
# the same outcome as never having allowed it.
|
|
18
|
+
class DeleteSkill
|
|
19
|
+
def initialize(skill_store:, skill_catalog:, event_stream:)
|
|
20
|
+
@skill_store = skill_store
|
|
21
|
+
@skill_catalog = skill_catalog
|
|
22
|
+
@event_stream = event_stream
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def call(command)
|
|
26
|
+
p = AgentPayload.symbolize(command.payload)
|
|
27
|
+
name = AgentPayload.presence(p[:name])
|
|
28
|
+
agent = AgentPayload.presence(p[:agent])
|
|
29
|
+
raise Insika::ValidationError, "name is required" if name.nil?
|
|
30
|
+
|
|
31
|
+
deleted = @skill_store.delete(name, agent: agent)
|
|
32
|
+
raise Insika::NotFoundError, "skill '#{name}' not found#{" for agent '#{agent}'" if agent}" unless deleted
|
|
33
|
+
|
|
34
|
+
@skill_catalog.reload
|
|
35
|
+
@event_stream.emit(Insika::Event.new(
|
|
36
|
+
type: :skill_deleted, data: { name: name, agent: agent }.compact,
|
|
37
|
+
meta: { at: Time.now.utc.iso8601 }
|
|
38
|
+
))
|
|
39
|
+
{ name: name, agent: agent, deleted: true }
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -4,21 +4,21 @@ require "time"
|
|
|
4
4
|
|
|
5
5
|
module Insika
|
|
6
6
|
module Commands
|
|
7
|
-
# Control command
|
|
7
|
+
# Control command: takes a CANDIDATE for a completed
|
|
8
8
|
# refinement run, validates it against the agent's write allowlist, and scores it
|
|
9
9
|
# by actually running it — clone the agent, apply the edits to the clone, replay
|
|
10
|
-
# the golden set, compare to the accepted baseline
|
|
10
|
+
# the golden set, compare to the accepted baseline.
|
|
11
11
|
#
|
|
12
12
|
# Synchronous like `run_refinement`, and for the same reason: it is operator-paced
|
|
13
13
|
# work with a human waiting on the answer. It is NOT cheap — the replay is a real
|
|
14
14
|
# conversation per golden case — so it is fired deliberately, never on a timer
|
|
15
|
-
# (
|
|
15
|
+
# (the engine has no scheduler and this RFC does not add one).
|
|
16
16
|
#
|
|
17
17
|
# Payload:
|
|
18
18
|
# run_id (required) a run in :completed — the evidence the candidate answers
|
|
19
19
|
# candidate { proposer?, rationale?, edits: [ {file, op, anchor?, before, after,
|
|
20
20
|
# addresses?} ] } — or omit it and pass `propose: true` to have the
|
|
21
|
-
# configured model(s) write one from the run's findings
|
|
21
|
+
# configured model(s) write one from the run's findings.
|
|
22
22
|
# propose truthy — write the candidate with the agent's proposer PANEL.
|
|
23
23
|
# tolerance Float — overrides the configured judge-score tolerance for this gate
|
|
24
24
|
#
|
|
@@ -30,14 +30,14 @@ module Insika
|
|
|
30
30
|
# forgot the candidate should get an error, not a bill.
|
|
31
31
|
#
|
|
32
32
|
# Two refusals happen BEFORE anything is cloned, because both mean the operator
|
|
33
|
-
# has not actually enabled this: an agent still in `mode: report` (
|
|
33
|
+
# has not actually enabled this: an agent still in `mode: report` (writing
|
|
34
34
|
# is opt-in and an absent config is report-only), and a candidate whose every edit
|
|
35
35
|
# was dropped (stale, off-allowlist, over budget). Neither is worth a provider bill.
|
|
36
36
|
#
|
|
37
|
-
#
|
|
37
|
+
# makes the proposal a PANEL and the run's spend a BUDGET. The
|
|
38
38
|
# command's shape does not change: N models write N candidates, the gate scores
|
|
39
39
|
# each, and the best survivor is the one proposal a human is shown. A deployment
|
|
40
|
-
# with one proposer and no budget behaves exactly as
|
|
40
|
+
# with one proposer and no budget behaves exactly as did.
|
|
41
41
|
class GateRefinement
|
|
42
42
|
WRITE_MODES = %w[propose auto_apply].freeze
|
|
43
43
|
|
|
@@ -49,7 +49,7 @@ module Insika
|
|
|
49
49
|
|
|
50
50
|
# proposer_factory: ->(refinement_config) { [Refinement::Proposer] | Proposer | nil }.
|
|
51
51
|
# Optional — a deployment with none can still gate a candidate that arrives from
|
|
52
|
-
# the API, which is exactly what
|
|
52
|
+
# the API, which is exactly what shipped before the proposer existed.
|
|
53
53
|
# resolver: the :resolve_refinement handler, used ONLY for `mode: auto_apply`.
|
|
54
54
|
# Reusing it rather than writing files here is what keeps auto-apply honest: it
|
|
55
55
|
# goes through the same staleness re-check, the same versioned write and the
|
|
@@ -125,7 +125,7 @@ module Insika
|
|
|
125
125
|
end
|
|
126
126
|
|
|
127
127
|
# Editing an agent's instructions is opt-in, explicitly, per agent — the whole
|
|
128
|
-
# point of
|
|
128
|
+
# point of is that the reachable surface is small and declared. An absent
|
|
129
129
|
# or `report` config is not "not configured yet", it is a NO.
|
|
130
130
|
def require_write_mode!(config, agent_id)
|
|
131
131
|
mode = Coercion.presence(config["mode"]) || "report"
|
|
@@ -148,7 +148,7 @@ module Insika
|
|
|
148
148
|
raise ArgumentError, "run #{run.id} is #{run.status}, expected completed"
|
|
149
149
|
end
|
|
150
150
|
|
|
151
|
-
# The model(s) write the candidates
|
|
151
|
+
# The model(s) write the candidates. Each is shown the run's findings
|
|
152
152
|
# and the CURRENT content of the allowlisted files only — the same allowlist the
|
|
153
153
|
# builder then enforces, so a proposal cannot even name a file it may not touch.
|
|
154
154
|
#
|
|
@@ -194,7 +194,7 @@ module Insika
|
|
|
194
194
|
allow
|
|
195
195
|
end
|
|
196
196
|
|
|
197
|
-
# `mode: auto_apply`
|
|
197
|
+
# `mode: auto_apply` — the ONE path where a prompt changes with no
|
|
198
198
|
# human in the loop. Off by default and deliberately narrow: it needs the mode,
|
|
199
199
|
# a gate PASS with zero regressions, and a diff under `auto_apply_max_edits`.
|
|
200
200
|
# Everything else parks at :awaiting_approval, which is the product.
|
|
@@ -232,7 +232,7 @@ module Insika
|
|
|
232
232
|
end
|
|
233
233
|
|
|
234
234
|
# Counts and ids only, never file content: these events reach the operator
|
|
235
|
-
# stream and the same rule the findings follow applies here
|
|
235
|
+
# stream and the same rule the findings follow applies here.
|
|
236
236
|
def emit(type, run, **data)
|
|
237
237
|
@event_stream.emit(Insika::Event.new(
|
|
238
238
|
type: type,
|
|
@@ -4,7 +4,7 @@ require "time"
|
|
|
4
4
|
|
|
5
5
|
module Insika
|
|
6
6
|
module Commands
|
|
7
|
-
# Control command: LIVE MCP ingestion
|
|
7
|
+
# Control command: LIVE MCP ingestion. Receives the NAME of
|
|
8
8
|
# an MCP instance, delegates to McpToolIngestor (discovers via an injectable client
|
|
9
9
|
# -> builds the manifest -> reuses :import_tools: batch upsert + hot reload)
|
|
10
10
|
# and returns the per-tool report in the shape of :import_tools, plus
|
|
@@ -4,18 +4,18 @@ require "time"
|
|
|
4
4
|
|
|
5
5
|
module Insika
|
|
6
6
|
module Commands
|
|
7
|
-
# BATCH ingestion of data-tools from a manifest
|
|
7
|
+
# BATCH ingestion of data-tools from a manifest.
|
|
8
8
|
# Normalizes each tool (defaults + envelope adapter + endpoint→url + secret/
|
|
9
9
|
# env) via ToolManifest, UPSERTs it into the ToolStore, and RELOADS the overlay +
|
|
10
|
-
# catalog ONCE at the end — takes effect without a restart
|
|
10
|
+
# catalog ONCE at the end — takes effect without a restart. Idempotent
|
|
11
11
|
# (re-importing reconciles). Injects the deployment's `{{secret.*}}`/`{{env.*}}`
|
|
12
|
-
# resolvers
|
|
12
|
+
# resolvers: the secret NEVER comes in the manifest.
|
|
13
13
|
#
|
|
14
14
|
# ISOLATED PARTIAL FAILURE (R4): a malformed tool (invalid envelope, missing
|
|
15
15
|
# endpoint, unconfigured secret, collision with a code tool, invalid url)
|
|
16
16
|
# does NOT bring down the batch — it becomes an entry in `errors[]`. Only a STRUCTURAL error in the
|
|
17
17
|
# manifest (defaults/tools of the wrong type) raises (transport -> 422).
|
|
18
|
-
# Per-tool report in the shape of the
|
|
18
|
+
# Per-tool report in the shape of the pack importer.
|
|
19
19
|
# -> { version, created: [names], updated: [names], errors: [{tool,error}] }
|
|
20
20
|
class ImportTools
|
|
21
21
|
def initialize(tool_store:, registry:, tool_catalog:, event_stream:, secrets: ENV, env: ENV)
|