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,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
module Commands
|
|
7
|
+
# Control command: creates an agent (AgentProfile) at
|
|
8
|
+
# RUNTIME and persists it in the ProfileSource (ConfigStore). This is the "everyone creates
|
|
9
|
+
# their own BIA". Synchronous; does not create a Task. -> AgentProfile (round-tripped from the store).
|
|
10
|
+
class CreateAgent
|
|
11
|
+
def initialize(profile_source:, event_stream:)
|
|
12
|
+
@profile_source = profile_source
|
|
13
|
+
@event_stream = event_stream
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call(command)
|
|
17
|
+
attrs = AgentPayload.attrs(command.payload)
|
|
18
|
+
id = AgentPayload.presence(attrs[:id])
|
|
19
|
+
raise Insika::ValidationError, "id is required" if id.nil?
|
|
20
|
+
# model is OPTIONAL as of v2 (§10): omitted -> resolves the platform
|
|
21
|
+
# default_model (Settings) at turn start. An agent with neither its own
|
|
22
|
+
# model nor a platform default fails clearly at the first turn.
|
|
23
|
+
raise Insika::ValidationError, "agent '#{id}' already exists" if @profile_source.fetch(id)
|
|
24
|
+
|
|
25
|
+
profile = Insika::AgentProfile.build(**attrs)
|
|
26
|
+
# RFC-0010 §4.4: definition-time cycle + depth check. Only a profile that
|
|
27
|
+
# introduces edges (non-empty subagents) can create a violation — a
|
|
28
|
+
# childless agent is always a safe leaf. Raises SubagentError (a
|
|
29
|
+
# ValidationError) BEFORE persisting, so a bad graph never lands.
|
|
30
|
+
validate_subagent_graph!(profile)
|
|
31
|
+
@profile_source.put(profile)
|
|
32
|
+
emit(:agent_created, id)
|
|
33
|
+
@profile_source.fetch(id) # returns the persisted profile (symbols already normalized)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
# Validates the delegation graph with `profile` added to the current set.
|
|
39
|
+
def validate_subagent_graph!(profile)
|
|
40
|
+
return if Array(profile.subagents).empty?
|
|
41
|
+
|
|
42
|
+
others = @profile_source.all.reject { |p| p.id.to_s == profile.id.to_s }
|
|
43
|
+
Insika::SubagentGraph.validate!(others + [profile])
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def emit(type, id)
|
|
47
|
+
@event_stream.emit(Insika::Event.new(
|
|
48
|
+
type: type, data: { agent_id: id },
|
|
49
|
+
meta: { at: Time.now.utc.iso8601 }
|
|
50
|
+
))
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
module Commands
|
|
7
|
+
# Control command: creates a session and responds synchronously —
|
|
8
|
+
# does not create a Task. Payload `{ vars: {} }` -> returns Session. `id` optional:
|
|
9
|
+
# when present, the session is born with that id (correlation by external key —
|
|
10
|
+
# e.g.: chat.id in the /v1/responses adapter; contextId in A2A). Absent -> uuid.
|
|
11
|
+
class CreateSession
|
|
12
|
+
# event_stream: any object with #emit(event) (tests use a spy).
|
|
13
|
+
def initialize(session_store:, event_stream:)
|
|
14
|
+
@session_store = session_store
|
|
15
|
+
@event_stream = event_stream
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def call(command)
|
|
19
|
+
# Accepts a symbol key (internal dispatch) and a string key (transport JSON)
|
|
20
|
+
# — normalizes at the handler boundary.
|
|
21
|
+
vars = command.payload[:vars] || command.payload["vars"] || {}
|
|
22
|
+
raise Insika::ValidationError, "vars must be a Hash" unless vars.is_a?(Hash)
|
|
23
|
+
|
|
24
|
+
# Per-chat model pin (v2, §10): `model`/`provider` on the payload become a
|
|
25
|
+
# reserved, collision-safe slot in vars (never rendered in the prompt — the
|
|
26
|
+
# Request provider skips "__"-prefixed vars). The ModelResolver reads it as
|
|
27
|
+
# the highest-precedence layer (Chat > Agent > platform default).
|
|
28
|
+
vars = apply_model_pin(vars.dup, command.payload)
|
|
29
|
+
|
|
30
|
+
id = command.payload[:id] || command.payload["id"]
|
|
31
|
+
session = id ? @session_store.create(id: id, vars: vars) : @session_store.create(vars: vars)
|
|
32
|
+
# :session_created is from the closed catalog (origin: CreateSession handler).
|
|
33
|
+
# meta without task_id/seq (control has no Task); Event#to_h does meta.compact.
|
|
34
|
+
@event_stream.emit(Insika::Event.new(
|
|
35
|
+
type: :session_created,
|
|
36
|
+
data: { session_id: session.id },
|
|
37
|
+
meta: { session_id: session.id, at: Time.now.utc.iso8601 }
|
|
38
|
+
))
|
|
39
|
+
session
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
# Reads `model`/`provider`/`thinking` (string|symbol keys) and, when any is
|
|
45
|
+
# present, writes the reserved vars slot. All must be strings; `thinking` is
|
|
46
|
+
# the per-chat reasoning override (off/on/low/medium/high) and may be set
|
|
47
|
+
# WITHOUT a model pin. Absent -> vars unchanged.
|
|
48
|
+
def apply_model_pin(vars, payload)
|
|
49
|
+
model = payload[:model] || payload["model"]
|
|
50
|
+
provider = payload[:provider] || payload["provider"]
|
|
51
|
+
thinking = payload[:thinking] || payload["thinking"]
|
|
52
|
+
return vars if model.nil? && provider.nil? && thinking.nil?
|
|
53
|
+
|
|
54
|
+
raise Insika::ValidationError, "model must be a String" unless model.nil? || model.is_a?(String)
|
|
55
|
+
raise Insika::ValidationError, "provider must be a String" unless provider.nil? || provider.is_a?(String)
|
|
56
|
+
raise Insika::ValidationError, "thinking must be a String" unless thinking.nil? || thinking.is_a?(String)
|
|
57
|
+
|
|
58
|
+
pin = {}
|
|
59
|
+
pin["model"] = model if model
|
|
60
|
+
pin["provider"] = provider if provider
|
|
61
|
+
pin["thinking"] = thinking if thinking
|
|
62
|
+
vars[Insika::ModelResolver::SESSION_SLOT] = pin
|
|
63
|
+
vars
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
module Commands
|
|
7
|
+
# Control command: removes an agent. -> the removed AgentProfile
|
|
8
|
+
# (so the transport can confirm what disappeared). Turns ALREADY in progress
|
|
9
|
+
# that captured the profile keep going until they finish (the ProfileSource only affects
|
|
10
|
+
# new dispatches).
|
|
11
|
+
class DeleteAgent
|
|
12
|
+
def initialize(profile_source:, event_stream:)
|
|
13
|
+
@profile_source = profile_source
|
|
14
|
+
@event_stream = event_stream
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def call(command)
|
|
18
|
+
id = AgentPayload.presence(AgentPayload.symbolize(command.payload)[:id])
|
|
19
|
+
raise Insika::ValidationError, "id is required" if id.nil?
|
|
20
|
+
|
|
21
|
+
removed = @profile_source.fetch(id) ||
|
|
22
|
+
(raise Insika::NotFoundError, "agent '#{id}' not found")
|
|
23
|
+
|
|
24
|
+
@profile_source.delete(id)
|
|
25
|
+
@event_stream.emit(Insika::Event.new(
|
|
26
|
+
type: :agent_deleted, data: { agent_id: id },
|
|
27
|
+
meta: { at: Time.now.utc.iso8601 }
|
|
28
|
+
))
|
|
29
|
+
removed
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
module Commands
|
|
7
|
+
# Control command: removes a prompt file from an agent's
|
|
8
|
+
# workspace. Nonexistent -> NotFoundError. -> { agent_id, file }.
|
|
9
|
+
class DeleteAgentFile
|
|
10
|
+
def initialize(profile_source:, agent_file_store:, event_stream:)
|
|
11
|
+
@profile_source = profile_source
|
|
12
|
+
@agent_files = agent_file_store
|
|
13
|
+
@event_stream = event_stream
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call(command)
|
|
17
|
+
p = AgentPayload.symbolize(command.payload)
|
|
18
|
+
agent_id = AgentPayload.presence(p[:agent_id])
|
|
19
|
+
file = AgentPayload.presence(p[:file])
|
|
20
|
+
raise Insika::ValidationError, "agent_id is required" if agent_id.nil?
|
|
21
|
+
raise Insika::ValidationError, "file is required" if file.nil?
|
|
22
|
+
|
|
23
|
+
unless @agent_files.delete(agent_id, file)
|
|
24
|
+
raise Insika::NotFoundError, "file '#{file}' not found for agent '#{agent_id}'"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
unregister_prompt_file(agent_id, file)
|
|
28
|
+
@event_stream.emit(Insika::Event.new(
|
|
29
|
+
type: :agent_file_deleted, data: { agent_id: agent_id, file: file },
|
|
30
|
+
meta: { at: Time.now.utc.iso8601 }
|
|
31
|
+
))
|
|
32
|
+
{ agent_id: agent_id, file: file }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
# Removing the file also drops it from prompt_files (the inverse operation of
|
|
38
|
+
# WriteAgentFile) — the Command's job, not the dispatcher's.
|
|
39
|
+
def unregister_prompt_file(agent_id, file)
|
|
40
|
+
profile = @profile_source.fetch(agent_id)
|
|
41
|
+
return unless profile
|
|
42
|
+
|
|
43
|
+
files = Array(profile.prompt_files).map(&:to_s)
|
|
44
|
+
return unless files.include?(file)
|
|
45
|
+
|
|
46
|
+
@profile_source.put(Insika::AgentProfile.build(**profile.to_h.merge(prompt_files: files - [file])))
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
module Commands
|
|
7
|
+
# Removes a data tool from the ToolStore and reloads overlay + catalog.
|
|
8
|
+
# 404 if it did not exist. -> { name }.
|
|
9
|
+
class DeleteDataTool
|
|
10
|
+
def initialize(tool_store:, registry:, tool_catalog:, event_stream:)
|
|
11
|
+
@tool_store = tool_store
|
|
12
|
+
@registry = registry
|
|
13
|
+
@tool_catalog = tool_catalog
|
|
14
|
+
@event_stream = event_stream
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def call(command)
|
|
18
|
+
p = AgentPayload.symbolize(command.payload)
|
|
19
|
+
name = AgentPayload.presence(p[:name])
|
|
20
|
+
raise Insika::ValidationError, "name is required" if name.nil?
|
|
21
|
+
raise Insika::NotFoundError, "tool '#{name}' not found" unless @tool_store.delete(name)
|
|
22
|
+
|
|
23
|
+
@registry.reload
|
|
24
|
+
@tool_catalog.reload
|
|
25
|
+
@event_stream.emit(Insika::Event.new(
|
|
26
|
+
type: :data_tool_deleted, data: { name: name },
|
|
27
|
+
meta: { at: Time.now.utc.iso8601 }
|
|
28
|
+
))
|
|
29
|
+
{ name: name }
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
module Commands
|
|
7
|
+
# Control command: removes an LLM provider from the
|
|
8
|
+
# LLMProviderStore. Idempotent (`existed: false` if there was none). UNDOES the
|
|
9
|
+
# config in RubyLLM at runtime (§9.5): when the provider existed, calls
|
|
10
|
+
# `configurator.unapply(api)` clearing the global key/base — without a restart. A provider
|
|
11
|
+
# that RubyLLM does not recognize degrades gracefully (unapply: false, nothing to
|
|
12
|
+
# undo). -> { existed: bool }.
|
|
13
|
+
class DeleteLLMProvider
|
|
14
|
+
def initialize(provider_store:, configurator:, event_stream:)
|
|
15
|
+
@provider_store = provider_store
|
|
16
|
+
@configurator = configurator
|
|
17
|
+
@event_stream = event_stream
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def call(command)
|
|
21
|
+
p = AgentPayload.symbolize(command.payload)
|
|
22
|
+
api = AgentPayload.presence(p[:api])
|
|
23
|
+
raise Insika::ValidationError, "api is required" if api.nil?
|
|
24
|
+
|
|
25
|
+
existed = @provider_store.delete(api)
|
|
26
|
+
@configurator.unapply(api) if existed
|
|
27
|
+
@event_stream.emit(Insika::Event.new(
|
|
28
|
+
type: :llm_provider_deleted,
|
|
29
|
+
data: { api: api, existed: existed },
|
|
30
|
+
meta: { at: Time.now.utc.iso8601 }
|
|
31
|
+
))
|
|
32
|
+
{ existed: existed }
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
module Commands
|
|
7
|
+
# Control command: removes an MCP instance from the
|
|
8
|
+
# McpStore. Idempotent (`existed: false` if there was none). -> { existed: bool }.
|
|
9
|
+
class DeleteMcp
|
|
10
|
+
def initialize(mcp_store:, event_stream:)
|
|
11
|
+
@mcp_store = mcp_store
|
|
12
|
+
@event_stream = event_stream
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def call(command)
|
|
16
|
+
p = AgentPayload.symbolize(command.payload)
|
|
17
|
+
name = AgentPayload.presence(p[:name])
|
|
18
|
+
raise Insika::ValidationError, "name is required" if name.nil?
|
|
19
|
+
|
|
20
|
+
existed = @mcp_store.delete(name)
|
|
21
|
+
@event_stream.emit(Insika::Event.new(
|
|
22
|
+
type: :mcp_deleted,
|
|
23
|
+
data: { name: name, existed: existed },
|
|
24
|
+
meta: { at: Time.now.utc.iso8601 }
|
|
25
|
+
))
|
|
26
|
+
{ existed: existed }
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
module Commands
|
|
7
|
+
# Control command: removes a global system file
|
|
8
|
+
# from the SystemFileStore. Idempotent (`existed: false`). -> { existed: bool }.
|
|
9
|
+
class DeleteSystemFile
|
|
10
|
+
def initialize(system_file_store:, event_stream:)
|
|
11
|
+
@system_files = system_file_store
|
|
12
|
+
@event_stream = event_stream
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def call(command)
|
|
16
|
+
p = AgentPayload.symbolize(command.payload)
|
|
17
|
+
file = AgentPayload.presence(p[:file])
|
|
18
|
+
raise Insika::ValidationError, "file is required" if file.nil?
|
|
19
|
+
|
|
20
|
+
existed = @system_files.delete(file)
|
|
21
|
+
@event_stream.emit(Insika::Event.new(
|
|
22
|
+
type: :system_file_deleted, data: { file: file, existed: existed },
|
|
23
|
+
meta: { at: Time.now.utc.iso8601 }
|
|
24
|
+
))
|
|
25
|
+
{ existed: existed }
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
module Commands
|
|
7
|
+
# Control command (RFC-0013 phase C): takes a CANDIDATE for a completed
|
|
8
|
+
# refinement run, validates it against the agent's write allowlist, and scores it
|
|
9
|
+
# by actually running it — clone the agent, apply the edits to the clone, replay
|
|
10
|
+
# the golden set, compare to the accepted baseline (§3.5).
|
|
11
|
+
#
|
|
12
|
+
# Synchronous like `run_refinement`, and for the same reason: it is operator-paced
|
|
13
|
+
# work with a human waiting on the answer. It is NOT cheap — the replay is a real
|
|
14
|
+
# conversation per golden case — so it is fired deliberately, never on a timer
|
|
15
|
+
# (D8: the engine has no scheduler and this RFC does not add one).
|
|
16
|
+
#
|
|
17
|
+
# Payload:
|
|
18
|
+
# run_id (required) a run in :completed — the evidence the candidate answers
|
|
19
|
+
# candidate { proposer?, rationale?, edits: [ {file, op, anchor?, before, after,
|
|
20
|
+
# addresses?} ] } — or omit it and pass `propose: true` to have the
|
|
21
|
+
# configured model(s) write one from the run's findings (§3.4).
|
|
22
|
+
# propose truthy — write the candidate with the agent's proposer PANEL.
|
|
23
|
+
# tolerance Float — overrides the configured judge-score tolerance for this gate
|
|
24
|
+
#
|
|
25
|
+
# -> the Run, now :awaiting_approval (gate passed), :applied (`mode: auto_apply`
|
|
26
|
+
# and the candidate cleared the extra bar) or :rejected (the gate said no).
|
|
27
|
+
#
|
|
28
|
+
# **`propose: true` is required rather than inferred from a missing candidate.**
|
|
29
|
+
# Both a proposal and a gate cost real provider money, and a caller who simply
|
|
30
|
+
# forgot the candidate should get an error, not a bill.
|
|
31
|
+
#
|
|
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` (§3.8 — writing
|
|
34
|
+
# is opt-in and an absent config is report-only), and a candidate whose every edit
|
|
35
|
+
# was dropped (stale, off-allowlist, over budget). Neither is worth a provider bill.
|
|
36
|
+
#
|
|
37
|
+
# Phase D (§3.9) makes the proposal a PANEL and the run's spend a BUDGET. The
|
|
38
|
+
# command's shape does not change: N models write N candidates, the gate scores
|
|
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 phase C did.
|
|
41
|
+
class GateRefinement
|
|
42
|
+
WRITE_MODES = %w[propose auto_apply].freeze
|
|
43
|
+
|
|
44
|
+
# An unattended write is held to a SMALLER bar than an approved one: one edit,
|
|
45
|
+
# by default. `auto_apply` exists so a well-understood, well-golden'd agent can
|
|
46
|
+
# fix a typo'd instruction overnight — not so it can rewrite three files while
|
|
47
|
+
# nobody is looking. The operator raises it deliberately.
|
|
48
|
+
DEFAULT_AUTO_APPLY_MAX_EDITS = 1
|
|
49
|
+
|
|
50
|
+
# proposer_factory: ->(refinement_config) { [Refinement::Proposer] | Proposer | nil }.
|
|
51
|
+
# Optional — a deployment with none can still gate a candidate that arrives from
|
|
52
|
+
# the API, which is exactly what phase C shipped before the proposer existed.
|
|
53
|
+
# resolver: the :resolve_refinement handler, used ONLY for `mode: auto_apply`.
|
|
54
|
+
# Reusing it rather than writing files here is what keeps auto-apply honest: it
|
|
55
|
+
# goes through the same staleness re-check, the same versioned write and the
|
|
56
|
+
# same `:refinement_applied` event a human approval does.
|
|
57
|
+
def initialize(profiles:, refinement_store:, agent_file_store:, gate:, event_stream:,
|
|
58
|
+
proposer_factory: nil, resolver: nil)
|
|
59
|
+
@profiles = ProfileSource.coerce(profiles)
|
|
60
|
+
@runs = refinement_store
|
|
61
|
+
@agent_files = agent_file_store
|
|
62
|
+
@gate = gate
|
|
63
|
+
@event_stream = event_stream
|
|
64
|
+
@proposer_factory = proposer_factory
|
|
65
|
+
@resolver = resolver
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def call(command)
|
|
69
|
+
p = AgentPayload.symbolize(command.payload)
|
|
70
|
+
run_id = AgentPayload.presence(p[:run_id])
|
|
71
|
+
raise Insika::ValidationError, "run_id is required" if run_id.nil?
|
|
72
|
+
|
|
73
|
+
run = @runs.find(run_id) || (raise Insika::NotFoundError, "refinement run not found: #{run_id}")
|
|
74
|
+
profile = @profiles[run.agent_id] ||
|
|
75
|
+
(raise Insika::NotFoundError, "agent '#{run.agent_id}' not configured")
|
|
76
|
+
config = refinement_config(profile)
|
|
77
|
+
require_write_mode!(config, run.agent_id)
|
|
78
|
+
require_completed!(run)
|
|
79
|
+
|
|
80
|
+
result = run_panel(run, config, p)
|
|
81
|
+
report = (result.winner || Refinement::Panel.best_refusal(result.entries)).report
|
|
82
|
+
|
|
83
|
+
gated = @runs.gated(run.id, report: report, panel: result.entries,
|
|
84
|
+
cost: result.budget)
|
|
85
|
+
emit(:refinement_gated, gated, passed: report.passed, reason: report.reason,
|
|
86
|
+
cases: report.cases, passed_cases: report.passed_cases,
|
|
87
|
+
regressions: report.regressions.size,
|
|
88
|
+
candidates: result.entries.size,
|
|
89
|
+
tokens: result.budget.to_h["spent"])
|
|
90
|
+
auto_apply(gated, config, report) || gated
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
# Propose (a panel, or the caller's own candidate), build, gate, rank. The
|
|
96
|
+
# `gating` status and the `:refinement_proposed` event land BEFORE any scoring,
|
|
97
|
+
# because the scoring is minutes of real replay and a run that says nothing
|
|
98
|
+
# until it finishes looks hung.
|
|
99
|
+
def run_panel(run, config, payload)
|
|
100
|
+
panel = Refinement::Panel.new(
|
|
101
|
+
gate: @gate, proposers: proposers_for(run, config, payload),
|
|
102
|
+
budget: Refinement::Budget.new(tokens: budget_tokens(config))
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
panel.run(agent_id: run.agent_id, run_id: run.id, findings: run.findings,
|
|
106
|
+
files: writable_files(run.agent_id, config),
|
|
107
|
+
allowlist: allowlist!(run.agent_id, config),
|
|
108
|
+
contents: current_files(run.agent_id), limits: config,
|
|
109
|
+
raw: payload[:candidate], tolerance: payload[:tolerance]) do |entries|
|
|
110
|
+
@runs.gating(run.id, candidates: entries)
|
|
111
|
+
emit(:refinement_proposed, run, candidates: entries.size,
|
|
112
|
+
proposers: entries.flat_map(&:proposers).uniq,
|
|
113
|
+
edits: entries.sum { |e| e.candidate.edits.size })
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def budget_tokens(config)
|
|
118
|
+
budget = config["budget"]
|
|
119
|
+
budget.is_a?(Hash) ? budget["tokens"] : nil
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def refinement_config(profile)
|
|
123
|
+
raw = profile.respond_to?(:refinement) ? profile.refinement : nil
|
|
124
|
+
raw.is_a?(Hash) ? Coercion.deep_stringify(raw) : {}
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Editing an agent's instructions is opt-in, explicitly, per agent — the whole
|
|
128
|
+
# point of §3.1 is that the reachable surface is small and declared. An absent
|
|
129
|
+
# or `report` config is not "not configured yet", it is a NO.
|
|
130
|
+
def require_write_mode!(config, agent_id)
|
|
131
|
+
mode = Coercion.presence(config["mode"]) || "report"
|
|
132
|
+
return if WRITE_MODES.include?(mode)
|
|
133
|
+
|
|
134
|
+
raise Insika::ValidationError,
|
|
135
|
+
"agent '#{agent_id}' is in refinement mode '#{mode}' — set `refinement.mode` to " \
|
|
136
|
+
"propose (and list the writable files) before gating a candidate"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# `RefinementStore#gating` refuses a run that is not :completed — but it is
|
|
140
|
+
# called AFTER the proposal, so the refusal used to arrive with the provider
|
|
141
|
+
# bill already paid. Found live: a run_id pointing at an :awaiting_approval run
|
|
142
|
+
# burned a minute and two model calls to learn the run could not be gated.
|
|
143
|
+
# Same rule as mode and allowlist: everything the engine can refuse for free
|
|
144
|
+
# is refused before a model is asked anything.
|
|
145
|
+
def require_completed!(run)
|
|
146
|
+
return if run.status == :completed
|
|
147
|
+
|
|
148
|
+
raise ArgumentError, "run #{run.id} is #{run.status}, expected completed"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# The model(s) write the candidates (§3.4/§3.9). Each is shown the run's findings
|
|
152
|
+
# and the CURRENT content of the allowlisted files only — the same allowlist the
|
|
153
|
+
# builder then enforces, so a proposal cannot even name a file it may not touch.
|
|
154
|
+
#
|
|
155
|
+
# A caller who supplied their own candidate needs no proposer at all; that path
|
|
156
|
+
# is what an API client and the phase-C Studio button use.
|
|
157
|
+
def proposers_for(run, config, payload)
|
|
158
|
+
return [] if payload[:candidate]
|
|
159
|
+
|
|
160
|
+
unless Insika::EnvSchema.truthy?(payload[:propose])
|
|
161
|
+
raise Insika::ValidationError,
|
|
162
|
+
"candidate is required (or pass propose: true to have the configured model write one)"
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
panel = Array(@proposer_factory&.call(config))
|
|
166
|
+
if panel.empty?
|
|
167
|
+
raise Insika::ValidationError,
|
|
168
|
+
"no proposer is configured for '#{run.agent_id}' — set `refinement.proposers` " \
|
|
169
|
+
"(or `refinement.proposer`, or a platform utility_model) to a model that can " \
|
|
170
|
+
"write the candidate"
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
panel
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# The allowlist ∩ what the agent actually has. A configured file the agent never
|
|
177
|
+
# got is not shown and not editable — the builder would drop the edit anyway
|
|
178
|
+
# ("does not exist for this agent"), and paying a model to write one is waste.
|
|
179
|
+
def writable_files(agent_id, config)
|
|
180
|
+
allow = allowlist!(agent_id, config)
|
|
181
|
+
current_files(agent_id).select { |name, _| allow.include?(name) }
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Checked before the proposer runs as well as before the gate: an empty
|
|
185
|
+
# allowlist is `mode: propose` with nothing switched on, and it is the one
|
|
186
|
+
# refusal that must land before a provider call, not after it.
|
|
187
|
+
def allowlist!(agent_id, config)
|
|
188
|
+
allow = Array(config["files"]).map(&:to_s)
|
|
189
|
+
if allow.empty?
|
|
190
|
+
raise Insika::ValidationError,
|
|
191
|
+
"agent '#{agent_id}' has an empty refinement.files allowlist — nothing is writable"
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
allow
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# `mode: auto_apply` (§3.6, D2) — the ONE path where a prompt changes with no
|
|
198
|
+
# human in the loop. Off by default and deliberately narrow: it needs the mode,
|
|
199
|
+
# a gate PASS with zero regressions, and a diff under `auto_apply_max_edits`.
|
|
200
|
+
# Everything else parks at :awaiting_approval, which is the product.
|
|
201
|
+
#
|
|
202
|
+
# A candidate that passed but is too large is NOT rejected — it waits for a
|
|
203
|
+
# person. "Too big to apply unattended" and "wrong" are different verdicts, and
|
|
204
|
+
# collapsing them would throw away a proposal the gate already paid to score.
|
|
205
|
+
#
|
|
206
|
+
# Without a wired resolver this returns nil and the run stays awaiting approval:
|
|
207
|
+
# a deployment that cannot apply must not record that it did.
|
|
208
|
+
# -> the applied Run, or nil when nothing was applied.
|
|
209
|
+
def auto_apply(run, config, report)
|
|
210
|
+
return nil unless Coercion.presence(config["mode"]) == "auto_apply"
|
|
211
|
+
return nil unless run.awaiting_approval? && report.passed && report.regressions.empty?
|
|
212
|
+
return nil if @resolver.nil?
|
|
213
|
+
|
|
214
|
+
max = (config["auto_apply_max_edits"] || DEFAULT_AUTO_APPLY_MAX_EDITS).to_i
|
|
215
|
+
edits = run.edits.size
|
|
216
|
+
if edits > max
|
|
217
|
+
emit(:refinement_auto_apply_skipped, run, edits: edits, max_edits: max)
|
|
218
|
+
return nil
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
@resolver.call(Insika::Command.build(:resolve_refinement,
|
|
222
|
+
{ run_id: run.id, decision: "approved",
|
|
223
|
+
operator: "auto_apply",
|
|
224
|
+
note: "auto_apply: gate passed #{report.passed_cases}/" \
|
|
225
|
+
"#{report.cases} with no regression" }))
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def current_files(agent_id)
|
|
229
|
+
@agent_files.list(agent_id).each_with_object({}) do |name, acc|
|
|
230
|
+
acc[name] = @agent_files.read(agent_id, name).to_s
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Counts and ids only, never file content: these events reach the operator
|
|
235
|
+
# stream and the same rule the findings follow applies here (§5).
|
|
236
|
+
def emit(type, run, **data)
|
|
237
|
+
@event_stream.emit(Insika::Event.new(
|
|
238
|
+
type: type,
|
|
239
|
+
data: { agent: run.agent_id, run_id: run.id, status: run.status.to_s, **data },
|
|
240
|
+
meta: { at: Time.now.utc.iso8601 }
|
|
241
|
+
))
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
module Commands
|
|
7
|
+
# Control command: LIVE MCP ingestion (Phase 7, Step E). Receives the NAME of
|
|
8
|
+
# an MCP instance, delegates to McpToolIngestor (discovers via an injectable client
|
|
9
|
+
# -> builds the manifest -> reuses :import_tools: batch upsert + hot reload)
|
|
10
|
+
# and returns the per-tool report in the shape of :import_tools, plus
|
|
11
|
+
# `instance:`. Idempotent (re-ingesting reconciles). A per-tool failure is isolated
|
|
12
|
+
# in `errors[]` (R4); an absent/disabled/url-less instance raises.
|
|
13
|
+
# -> { instance, version, created: [names], updated: [names], errors: [{tool,error}] }
|
|
14
|
+
class ImportMcpTools
|
|
15
|
+
def initialize(ingestor:, event_stream:)
|
|
16
|
+
@ingestor = ingestor
|
|
17
|
+
@event_stream = event_stream
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def call(command)
|
|
21
|
+
p = symbolize(command.payload)
|
|
22
|
+
name = Insika::Coercion.presence(p[:name]) ||
|
|
23
|
+
(raise Insika::ValidationError, "import_mcp_tools: 'name' (MCP instance) is required")
|
|
24
|
+
|
|
25
|
+
report = @ingestor.ingest(name)
|
|
26
|
+
emit(report)
|
|
27
|
+
report
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
# Emits only COUNTS + the instance name (never headers/env/secrets): 0 leakage.
|
|
33
|
+
def emit(report)
|
|
34
|
+
@event_stream.emit(Insika::Event.new(
|
|
35
|
+
type: :mcp_tools_imported,
|
|
36
|
+
data: { instance: report[:instance],
|
|
37
|
+
created: report[:created].size, updated: report[:updated].size,
|
|
38
|
+
errors: report[:errors].size },
|
|
39
|
+
meta: { at: Time.now.utc.iso8601 }
|
|
40
|
+
))
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def symbolize(payload)
|
|
44
|
+
(payload || {}).each_with_object({}) { |(k, v), acc| acc[k.to_sym] = v }
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|