insika 0.0.1 → 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 +361 -0
- data/LICENSE +21 -0
- data/README.md +136 -2
- data/bin/insika +366 -0
- data/docs/AGENTS.md +618 -0
- data/docs/ARCHITECTURE.md +333 -0
- data/docs/BENCHMARK.md +114 -0
- data/docs/CHANNELS.md +453 -0
- data/docs/CONTEXT.md +117 -0
- data/docs/DEPLOY.md +354 -0
- data/docs/EMBEDDING.md +198 -0
- data/docs/EVALS.md +273 -0
- data/docs/LOADTEST.md +232 -0
- data/docs/OBSERVABILITY.md +374 -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 +375 -0
- data/docs/SKILLS.md +284 -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 +255 -0
- data/lib/insika/alert_dispatcher.rb +139 -0
- data/lib/insika/allowlist.rb +28 -0
- data/lib/insika/baseline_store.rb +74 -0
- data/lib/insika/budget_ledger.rb +135 -0
- data/lib/insika/capability/resolved_tool.rb +34 -0
- data/lib/insika/capability_registry.rb +112 -0
- data/lib/insika/channel_delivery.rb +153 -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/channels/webhook.rb +58 -0
- data/lib/insika/chat_builder.rb +303 -0
- data/lib/insika/checkpoint.rb +13 -0
- data/lib/insika/checkpoint_store.rb +153 -0
- data/lib/insika/circuit_state.rb +114 -0
- data/lib/insika/coercion.rb +58 -0
- data/lib/insika/command.rb +32 -0
- data/lib/insika/command_bus.rb +39 -0
- data/lib/insika/commands/agent_payload.rb +43 -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_skill.rb +43 -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/issue_tenant_token.rb +41 -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/revoke_token.rb +39 -0
- data/lib/insika/commands/rotate_tenant_token.rb +43 -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 +112 -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 +60 -0
- data/lib/insika/commands/write_system_file.rb +31 -0
- data/lib/insika/config_store.rb +89 -0
- data/lib/insika/context/builder.rb +166 -0
- data/lib/insika/context/catalog_provider.rb +23 -0
- data/lib/insika/context/fragment.rb +43 -0
- data/lib/insika/context/priority.rb +30 -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 +123 -0
- data/lib/insika/context/providers/skill.rb +24 -0
- data/lib/insika/context/providers/skill_trigger.rb +128 -0
- data/lib/insika/context/providers/tool_search.rb +20 -0
- data/lib/insika/context_trace_store.rb +92 -0
- data/lib/insika/delegation_store.rb +153 -0
- data/lib/insika/doctor.rb +539 -0
- data/lib/insika/dsl/definition.rb +55 -0
- data/lib/insika/dsl/runtime.rb +382 -0
- data/lib/insika/dsl/server_boot.rb +98 -0
- data/lib/insika/dsl/system.rb +93 -0
- data/lib/insika/dsl/workflow_adapter.rb +59 -0
- data/lib/insika/dsl.rb +364 -0
- data/lib/insika/edge_limiter.rb +268 -0
- data/lib/insika/egress_guard.rb +75 -0
- data/lib/insika/env_schema.rb +249 -0
- data/lib/insika/errors.rb +201 -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 +132 -0
- data/lib/insika/executor.rb +1995 -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/loop_detector.rb +143 -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 +115 -0
- data/lib/insika/onboarding.rb +208 -0
- data/lib/insika/outbox_store.rb +166 -0
- data/lib/insika/overlay_tool_registry.rb +102 -0
- data/lib/insika/pack.rb +102 -0
- data/lib/insika/pack_importer.rb +123 -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 +143 -0
- data/lib/insika/prompt_catalog.rb +61 -0
- data/lib/insika/provider_error_classifier.rb +160 -0
- data/lib/insika/queue_policy.rb +167 -0
- data/lib/insika/recovery.rb +168 -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/reliability.rb +185 -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 +102 -0
- data/lib/insika/safety/moderator.rb +94 -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/app.rb +1022 -0
- data/lib/insika/server/boot.rb +119 -0
- data/lib/insika/server/rack_app.rb +118 -0
- data/lib/insika/server/responses.rb +165 -0
- data/lib/insika/server/sse_body.rb +96 -0
- data/lib/insika/server/tenant_auth.rb +61 -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 +220 -0
- data/lib/insika/skill_store.rb +127 -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 +1693 -0
- data/lib/insika/studio/assets/dist/application.css +1 -0
- data/lib/insika/studio/assets/dist/application.js +70 -0
- data/lib/insika/studio/forms.rb +335 -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 +137 -0
- data/lib/insika/studio/views/settings.erb +168 -0
- data/lib/insika/studio/views/skills.erb +141 -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/tick.rb +122 -0
- data/lib/insika/token_estimator.rb +16 -0
- data/lib/insika/token_store.rb +168 -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_output_compressor.rb +100 -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 +219 -0
- data/lib/insika/tools/load_skill.rb +99 -0
- data/lib/insika/tools/remember.rb +53 -0
- data/lib/insika/tools/stuck_signal.rb +44 -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 +162 -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 +249 -0
- data/lib/insika/workflow.rb +185 -0
- data/lib/insika/workflow_registry.rb +33 -0
- data/lib/insika.rb +220 -4
- metadata +412 -8
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
module Commands
|
|
7
|
+
# Control command: restores an old version of a
|
|
8
|
+
# prompt file as the current content (a new write — history is
|
|
9
|
+
# linear, no rewinding). `version` = index into `versions` (0 = most recent
|
|
10
|
+
# old one). -> { agent_id, file, updated_at }.
|
|
11
|
+
class RestoreAgentFile
|
|
12
|
+
def initialize(profile_source:, agent_file_store:, event_stream:)
|
|
13
|
+
@profile_source = profile_source
|
|
14
|
+
@agent_files = agent_file_store
|
|
15
|
+
@event_stream = event_stream
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def call(command)
|
|
19
|
+
p = AgentPayload.symbolize(command.payload)
|
|
20
|
+
agent_id = AgentPayload.presence(p[:agent_id])
|
|
21
|
+
file = AgentPayload.presence(p[:file])
|
|
22
|
+
raise Insika::ValidationError, "agent_id is required" if agent_id.nil?
|
|
23
|
+
raise Insika::ValidationError, "file is required" if file.nil?
|
|
24
|
+
raise Insika::ValidationError, "version is required" if p[:version].nil?
|
|
25
|
+
|
|
26
|
+
entry = @agent_files.restore(agent_id, file, p[:version])
|
|
27
|
+
@event_stream.emit(Insika::Event.new(
|
|
28
|
+
type: :agent_file_restored,
|
|
29
|
+
data: { agent_id: agent_id, file: file, version: Integer(p[:version]) },
|
|
30
|
+
meta: { at: Time.now.utc.iso8601 }
|
|
31
|
+
))
|
|
32
|
+
{ agent_id: agent_id, file: file, updated_at: entry["updated_at"] }
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
module Commands
|
|
7
|
+
# Restores an old version of a data tool (a new write) and reloads
|
|
8
|
+
# overlay + catalog. The ToolStore raises NotFound/Validation. -> { name,
|
|
9
|
+
# definition (masked) }.
|
|
10
|
+
class RestoreDataTool
|
|
11
|
+
def initialize(tool_store:, registry:, tool_catalog:, event_stream:)
|
|
12
|
+
@tool_store = tool_store
|
|
13
|
+
@registry = registry
|
|
14
|
+
@tool_catalog = tool_catalog
|
|
15
|
+
@event_stream = event_stream
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def call(command)
|
|
19
|
+
p = AgentPayload.symbolize(command.payload)
|
|
20
|
+
name = AgentPayload.presence(p[:name])
|
|
21
|
+
raise Insika::ValidationError, "name is required" if name.nil?
|
|
22
|
+
|
|
23
|
+
masked = @tool_store.restore(name, p[:index])
|
|
24
|
+
@registry.reload
|
|
25
|
+
@tool_catalog.reload
|
|
26
|
+
@event_stream.emit(Insika::Event.new(
|
|
27
|
+
type: :data_tool_restored, data: { name: name },
|
|
28
|
+
meta: { at: Time.now.utc.iso8601 }
|
|
29
|
+
))
|
|
30
|
+
{ name: name, definition: masked }
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
module Commands
|
|
7
|
+
# Control command: restores an old version of a
|
|
8
|
+
# global system file as the current content (a new write — linear
|
|
9
|
+
# history). -> { file, updated_at }.
|
|
10
|
+
class RestoreSystemFile
|
|
11
|
+
def initialize(system_file_store:, event_stream:)
|
|
12
|
+
@system_files = system_file_store
|
|
13
|
+
@event_stream = event_stream
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call(command)
|
|
17
|
+
p = AgentPayload.symbolize(command.payload)
|
|
18
|
+
file = AgentPayload.presence(p[:file])
|
|
19
|
+
raise Insika::ValidationError, "file is required" if file.nil?
|
|
20
|
+
raise Insika::ValidationError, "version is required" if p[:version].nil?
|
|
21
|
+
|
|
22
|
+
entry = @system_files.restore(file, p[:version])
|
|
23
|
+
@event_stream.emit(Insika::Event.new(
|
|
24
|
+
type: :system_file_restored, data: { file: file, version: p[:version].to_s },
|
|
25
|
+
meta: { at: Time.now.utc.iso8601 }
|
|
26
|
+
))
|
|
27
|
+
{ file: file, updated_at: entry["updated_at"] }
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
module Commands
|
|
5
|
+
# Turn command: resumes a task from the latest checkpoint. Crash-recovery
|
|
6
|
+
# and manual resume both use THIS same path (Recovery only discovers and dispatches).
|
|
7
|
+
# Re-executes the entire checkpointed turn; non-idempotent tools that already
|
|
8
|
+
# completed are skipped via the side-effect registry.
|
|
9
|
+
class ResumeTask
|
|
10
|
+
def initialize(profiles:, task_store:, checkpoint_store:, executor:)
|
|
11
|
+
@profiles = ProfileSource.coerce(profiles)
|
|
12
|
+
@task_store = task_store
|
|
13
|
+
@checkpoint_store = checkpoint_store
|
|
14
|
+
@executor = executor
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def call(command)
|
|
18
|
+
task_id = (command.payload[:task_id] || command.payload["task_id"]).to_s
|
|
19
|
+
raise Insika::ValidationError, "task_id is required" if task_id.empty?
|
|
20
|
+
|
|
21
|
+
task = @task_store.find(task_id) ||
|
|
22
|
+
(raise Insika::NotFoundError, "task '#{task_id}' not found")
|
|
23
|
+
|
|
24
|
+
# IN-PROCESS RESUME: a :paused task whose fiber is STILL alive (blocked
|
|
25
|
+
# on await) — do NOT re-dispatch (spawn would duplicate the fiber). Just post :resume;
|
|
26
|
+
# the fiber transitions paused->running and continues. (A live :waiting is resolved
|
|
27
|
+
# by ApproveAction, not here.)
|
|
28
|
+
if task.status == :paused && @executor.running?(task_id)
|
|
29
|
+
@executor.resume_live(task_id)
|
|
30
|
+
return { task_id: task_id }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# :queued: a turn that was in the SessionActor queue and never
|
|
34
|
+
# started at crash time (no checkpoint) — recovering = RUN from scratch, from the
|
|
35
|
+
# original Command. Profile comes from the agent in the Command itself.
|
|
36
|
+
if task.status == :queued
|
|
37
|
+
@executor.spawn_in_session(task, profile: profile_for(task), resume_from: nil)
|
|
38
|
+
return { task_id: task_id }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# RE-DISPATCH (crash-resume): no live fiber, re-executes from the checkpoint.
|
|
42
|
+
# resume requires a checkpoint; without one the task is unrecoverable (Recovery
|
|
43
|
+
# would already have marked it :failed during the sweep).
|
|
44
|
+
checkpoint = @checkpoint_store.latest(task_id) ||
|
|
45
|
+
(raise Insika::ValidationError,
|
|
46
|
+
"task '#{task_id}' has no checkpoint — unrecoverable")
|
|
47
|
+
|
|
48
|
+
check_eligibility!(task)
|
|
49
|
+
|
|
50
|
+
# profile comes from the checkpoint (agent_id): the agent may have been removed from
|
|
51
|
+
# the config between the crash and boot -> fail loudly and clearly.
|
|
52
|
+
profile = @profiles[checkpoint.agent_id] ||
|
|
53
|
+
(raise Insika::NotFoundError, "agent '#{checkpoint.agent_id}' not configured")
|
|
54
|
+
|
|
55
|
+
@executor.spawn_in_session(task, profile: profile, resume_from: checkpoint)
|
|
56
|
+
{ task_id: task_id }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
# Profile for re-running a :queued task: from the agent in the persisted Command
|
|
62
|
+
# (there is no checkpoint). NotFoundError if the agent left the config.
|
|
63
|
+
def profile_for(task)
|
|
64
|
+
payload = task.command["payload"] || task.command[:payload] || {}
|
|
65
|
+
agent = (payload["agent"] || payload[:agent]).to_s
|
|
66
|
+
@profiles[agent] || (raise Insika::NotFoundError, "agent '#{agent}' not configured")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Eligibility matrix: paused/waiting always; running only when
|
|
70
|
+
# orphaned (no live fiber IN THIS process — single-node); :queued is handled
|
|
71
|
+
# earlier (re-run); terminal states are not resumable.
|
|
72
|
+
def check_eligibility!(task)
|
|
73
|
+
case task.status
|
|
74
|
+
when :paused, :waiting
|
|
75
|
+
nil
|
|
76
|
+
when :running
|
|
77
|
+
raise Insika::ValidationError, "task '#{task.id}' is running" if @executor.running?(task.id)
|
|
78
|
+
else # terminal states (:completed, :failed, :cancelled)
|
|
79
|
+
raise Insika::ValidationError,
|
|
80
|
+
"task '#{task.id}' with status '#{task.status}' is not resumable"
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
module Commands
|
|
5
|
+
# WS1: revokes ONE token by id. The edge resolves tokens by hash, so the
|
|
6
|
+
# plaintext is not needed to kill a credential. Idempotent: revoking an
|
|
7
|
+
# already-revoked or unknown id -> { revoked: false }, never an error.
|
|
8
|
+
# -> { id:, revoked: bool }.
|
|
9
|
+
class RevokeToken
|
|
10
|
+
def initialize(token_store:, event_stream:)
|
|
11
|
+
@token_store = token_store
|
|
12
|
+
@event_stream = event_stream
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def call(command)
|
|
16
|
+
raise Insika::ValidationError, "token commands are operator-only" if command.meta[:tenant]
|
|
17
|
+
|
|
18
|
+
id = Insika::Coercion.presence(
|
|
19
|
+
command.payload[:token_id] || command.payload["token_id"]
|
|
20
|
+
)
|
|
21
|
+
raise Insika::ValidationError, "token_id is required" if id.nil?
|
|
22
|
+
|
|
23
|
+
revoked = @token_store.revoke(id)
|
|
24
|
+
emit(id, revoked)
|
|
25
|
+
{ id: id, revoked: revoked }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def emit(token_id, revoked)
|
|
31
|
+
@event_stream.emit(Insika::Event.new(
|
|
32
|
+
type: :tenant_token_revoked,
|
|
33
|
+
data: { token_id: token_id, revoked: revoked },
|
|
34
|
+
meta: { at: Time.now.utc.iso8601 }
|
|
35
|
+
))
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
module Commands
|
|
5
|
+
# WS1: rotates a tenant's credential — revokes every active token of the
|
|
6
|
+
# tenant and issues a fresh one, in ONE transaction (a crashed half-rotation
|
|
7
|
+
# must never leave the tenant with nothing valid). Only the tenant's own
|
|
8
|
+
# tokens are touched; the operator token and other tenants are untouched.
|
|
9
|
+
# -> { revoked: n, token: Issue.to_h }.
|
|
10
|
+
class RotateTenantToken
|
|
11
|
+
def initialize(token_store:, event_stream:)
|
|
12
|
+
@token_store = token_store
|
|
13
|
+
@event_stream = event_stream
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call(command)
|
|
17
|
+
raise Insika::ValidationError, "token commands are operator-only" if command.meta[:tenant]
|
|
18
|
+
|
|
19
|
+
tenant_id = Insika::Coercion.presence(
|
|
20
|
+
command.payload[:tenant_id] || command.payload["tenant_id"]
|
|
21
|
+
)
|
|
22
|
+
raise Insika::ValidationError, "tenant_id is required" if tenant_id.nil?
|
|
23
|
+
|
|
24
|
+
label = command.payload[:label] || command.payload["label"] || "default"
|
|
25
|
+
result = @token_store.rotate(tenant_id: tenant_id, label: label)
|
|
26
|
+
issue = result[:issue]
|
|
27
|
+
emit(issue.id, tenant_id, result[:revoked])
|
|
28
|
+
{ revoked: result[:revoked],
|
|
29
|
+
token: { token: issue.token, id: issue.id, tenant_id: tenant_id, label: label.to_s } }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def emit(token_id, tenant_id, revoked)
|
|
35
|
+
@event_stream.emit(Insika::Event.new(
|
|
36
|
+
type: :tenant_token_rotated,
|
|
37
|
+
data: { token_id: token_id, tenant_id: tenant_id, revoked: revoked },
|
|
38
|
+
meta: { at: Time.now.utc.iso8601 }
|
|
39
|
+
))
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
module Commands
|
|
7
|
+
# Control command: reads a window of the agent's real traffic
|
|
8
|
+
# and records a ranked failure REPORT. Synchronous — it only scans durable stores
|
|
9
|
+
# (no provider call, no fiber), so it answers with the Run and does not create a
|
|
10
|
+
# Task. It is the ONLY way a refinement run starts: the CLI, the Studio button
|
|
11
|
+
# and any external cron all dispatch this one command (there is no
|
|
12
|
+
# scheduler in the engine).
|
|
13
|
+
#
|
|
14
|
+
# Payload:
|
|
15
|
+
# agent (required) agent id
|
|
16
|
+
# since ISO8601 — only turns from this instant on
|
|
17
|
+
# last_sessions Integer — the N most recent conversations instead
|
|
18
|
+
# full truthy — ignore the previous run and use the full window
|
|
19
|
+
# max_findings Integer cap on the report
|
|
20
|
+
# exclude_sessions [prefix] — session ids to drop (load tests, debug traffic)
|
|
21
|
+
#
|
|
22
|
+
# Window resolution (first that applies): explicit payload -> INCREMENTAL (since
|
|
23
|
+
# the previous run for this agent, unless `full`) -> the agent's configured
|
|
24
|
+
# `refinement.window` -> the collector's default.
|
|
25
|
+
#
|
|
26
|
+
# writes NOTHING to the agent, so it needs no opt-in: an absent
|
|
27
|
+
# `refinement` config reads as report-only. Only `propose`/`auto_apply`
|
|
28
|
+
# require the operator to enable them explicitly.
|
|
29
|
+
class RunRefinement
|
|
30
|
+
READ_ONLY_MODES = %w[report propose auto_apply].freeze
|
|
31
|
+
|
|
32
|
+
def initialize(profiles:, refinement_store:, collector:, event_stream:)
|
|
33
|
+
@profiles = ProfileSource.coerce(profiles)
|
|
34
|
+
@refinement_store = refinement_store
|
|
35
|
+
@collector = collector
|
|
36
|
+
@event_stream = event_stream
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def call(command)
|
|
40
|
+
p = AgentPayload.symbolize(command.payload)
|
|
41
|
+
agent = AgentPayload.presence(p[:agent])
|
|
42
|
+
raise Insika::ValidationError, "agent is required" if agent.nil?
|
|
43
|
+
|
|
44
|
+
profile = @profiles[agent] ||
|
|
45
|
+
(raise Insika::NotFoundError, "agent '#{agent}' not configured")
|
|
46
|
+
config = refinement_config(profile)
|
|
47
|
+
validate_mode!(config)
|
|
48
|
+
|
|
49
|
+
window = resolve_window(agent, p, config)
|
|
50
|
+
run = @refinement_store.create(agent_id: agent, window: window)
|
|
51
|
+
emit(:refinement_started, agent: agent, run_id: run.id, window: window)
|
|
52
|
+
|
|
53
|
+
begin
|
|
54
|
+
report = @collector.collect(
|
|
55
|
+
agent_id: agent, max_findings: max_findings(p, config),
|
|
56
|
+
exclude_sessions: exclude_sessions(p, config), **collect_args(window)
|
|
57
|
+
)
|
|
58
|
+
done = @refinement_store.complete(run.id, findings: report.findings,
|
|
59
|
+
excluded: report.excluded)
|
|
60
|
+
emit(:refinement_report, agent: agent, run_id: run.id, status: done.status,
|
|
61
|
+
findings: done.findings_count,
|
|
62
|
+
sessions: report.sessions_seen, turns: report.turns_seen,
|
|
63
|
+
excluded: report.excluded)
|
|
64
|
+
done
|
|
65
|
+
rescue StandardError => e
|
|
66
|
+
@refinement_store.fail(run.id, error: e.message)
|
|
67
|
+
raise
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def refinement_config(profile)
|
|
74
|
+
raw = profile.respond_to?(:refinement) ? profile.refinement : nil
|
|
75
|
+
raw.is_a?(Hash) ? raw : {}
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# An unknown mode is a config typo, and a typo that silently degrades to
|
|
79
|
+
# "report" would be the kind of quiet wrong the strict-config rule exists to
|
|
80
|
+
# prevent.
|
|
81
|
+
def validate_mode!(config)
|
|
82
|
+
mode = AgentPayload.presence(config["mode"]) || "report"
|
|
83
|
+
return if READ_ONLY_MODES.include?(mode)
|
|
84
|
+
|
|
85
|
+
raise Insika::ValidationError,
|
|
86
|
+
"unknown refinement mode: #{mode} (expected #{READ_ONLY_MODES.join(', ')})"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def resolve_window(agent, payload, config)
|
|
90
|
+
since = AgentPayload.presence(payload[:since])
|
|
91
|
+
return { "since" => since } if since
|
|
92
|
+
return { "last_sessions" => Integer(payload[:last_sessions]) } if payload[:last_sessions]
|
|
93
|
+
|
|
94
|
+
# `full` arrives as true (internal dispatch) or "1" (Studio form) — one
|
|
95
|
+
# reading of a boolean, the same one item #131 settled on.
|
|
96
|
+
unless Insika::EnvSchema.truthy?(payload[:full])
|
|
97
|
+
previous = @refinement_store.latest_for(agent)
|
|
98
|
+
return { "since" => previous.started_at } if previous&.started_at
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
configured = config.dig("window", "last_sessions")
|
|
102
|
+
configured ? { "last_sessions" => Integer(configured) } : {}
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Window (as recorded) -> collector kwargs. {} = the collector's own default.
|
|
106
|
+
def collect_args(window)
|
|
107
|
+
return { since: window["since"] } if window["since"]
|
|
108
|
+
return { last_sessions: window["last_sessions"] } if window["last_sessions"]
|
|
109
|
+
|
|
110
|
+
{}
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def max_findings(payload, config)
|
|
114
|
+
raw = payload[:max_findings] || config["max_findings"]
|
|
115
|
+
raw ? Integer(raw) : Refinement::EvidenceCollector::DEFAULT_MAX_FINDINGS
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Session-id prefixes to drop (load tests, debug conversations). Configured per
|
|
119
|
+
# agent; a payload list overrides it for one run. Never defaulted — the report
|
|
120
|
+
# does not get to decide what counts as real traffic.
|
|
121
|
+
def exclude_sessions(payload, config)
|
|
122
|
+
Array(payload[:exclude_sessions] || config["exclude_sessions"]).map(&:to_s)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def emit(type, **data)
|
|
126
|
+
@event_stream.emit(Insika::Event.new(
|
|
127
|
+
type: type, data: data,
|
|
128
|
+
meta: { at: Time.now.utc.iso8601 }
|
|
129
|
+
))
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
module Commands
|
|
5
|
+
# Canonical turn command: validates everything synchronously, creates the Task, fires
|
|
6
|
+
# the fiber and responds `{task_id:}` immediately — the result flows through the Event
|
|
7
|
+
# Stream. Validations that fail do NOT create a Task
|
|
8
|
+
# (ValidationError/NotFoundError -> direct HTTP response).
|
|
9
|
+
class SendMessage
|
|
10
|
+
# surfaces whose response can carry the "you do not own the reply"
|
|
11
|
+
# verdict (`merged` for `collect`, `steered` for `steer`), and therefore the only
|
|
12
|
+
# ones where a message may join another turn. `/v1/responses` is NOT here: its body
|
|
13
|
+
# is OpenAI-shaped SSE with nowhere to put the field, and it is frozen because a
|
|
14
|
+
# live consumer speaks it. Joining a caller that cannot hear the verdict makes it
|
|
15
|
+
# deliver the same answer once per message, which is worse than not joining at all.
|
|
16
|
+
# Channels declare themselves by `channel:<id>` once lands.
|
|
17
|
+
COALESCABLE_TRANSPORTS = %i[http:json].freeze
|
|
18
|
+
|
|
19
|
+
# `inbound_log` is the retry window for channel event ids.
|
|
20
|
+
# nil = no dedup (every surface that does not send an `event_id`, which is all
|
|
21
|
+
# of them today), and a caller that cannot supply a stable id gets
|
|
22
|
+
# at-least-once turns rather than a content hash pretending to be dedup.
|
|
23
|
+
def initialize(profiles:, session_store:, task_store:, executor:, inbound_log: nil)
|
|
24
|
+
@profiles = ProfileSource.coerce(profiles)
|
|
25
|
+
@session_store = session_store
|
|
26
|
+
@task_store = task_store
|
|
27
|
+
@executor = executor
|
|
28
|
+
@inbound_log = inbound_log
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def call(command)
|
|
32
|
+
p = normalize(command.payload) # accepts string and symbol keys
|
|
33
|
+
|
|
34
|
+
agent = p[:agent].to_s
|
|
35
|
+
raise Insika::ValidationError, "agent is required" if agent.empty?
|
|
36
|
+
|
|
37
|
+
profile = @profiles[agent] ||
|
|
38
|
+
(raise Insika::NotFoundError, "agent '#{agent}' not configured")
|
|
39
|
+
|
|
40
|
+
message = p[:message]
|
|
41
|
+
raise Insika::ValidationError, "message is required and non-empty" if message.to_s.strip.empty?
|
|
42
|
+
|
|
43
|
+
# session_id XOR history (both -> error; neither -> one-shot).
|
|
44
|
+
if p[:session_id] && p[:history]
|
|
45
|
+
raise Insika::ValidationError, "session_id and history are mutually exclusive"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
validate_history!(p[:history]) if p[:history]
|
|
49
|
+
# WHO wrote this message (MessageOrigin). Absent = a customer typed it, which
|
|
50
|
+
# is what a turn has always meant. Refused here rather than downstream: a
|
|
51
|
+
# typo'd origin would read as absent, and a marker that silently means
|
|
52
|
+
# "unmarked" is worse than none — it looks like the filtering is on.
|
|
53
|
+
Insika::MessageOrigin.parse!(p[:origin])
|
|
54
|
+
if p[:session_id]
|
|
55
|
+
@session_store.find(p[:session_id]) ||
|
|
56
|
+
(raise Insika::NotFoundError, "session '#{p[:session_id]}' not found")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# the platform retried a webhook it already delivered. Answer
|
|
60
|
+
# with the turn it ALREADY produced and run nothing: without this, one flaky
|
|
61
|
+
# ack costs a second LLM turn and sends the customer the same answer twice.
|
|
62
|
+
# Checked before the queue doors on purpose — a duplicate is not a fragment to
|
|
63
|
+
# merge and not a correction to steer with, it is the same message again.
|
|
64
|
+
key = dedup_key(command, p)
|
|
65
|
+
if key && (prior = @inbound_log.find(key))
|
|
66
|
+
return { task_id: prior, duplicate: true }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
result = start_turn(command, p, profile)
|
|
70
|
+
@inbound_log.record(key, result[:task_id]) if key
|
|
71
|
+
result
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
# The turn (or the verdict that this message joined someone else's).
|
|
77
|
+
def start_turn(command, p, profile)
|
|
78
|
+
message = p[:message]
|
|
79
|
+
|
|
80
|
+
# a fragment for a session whose turn is still at the door
|
|
81
|
+
# joins it instead of becoming a turn of its own. Asked BEFORE `create` so a
|
|
82
|
+
# merge leaves no orphan :queued task behind (Recovery replays :queued at
|
|
83
|
+
# boot). Only offered on a surface that can report the verdict back —
|
|
84
|
+
# coalescing a caller that cannot hear `merged` makes it deliver the
|
|
85
|
+
# same answer twice.
|
|
86
|
+
if coalescable?(command)
|
|
87
|
+
if (joined = @executor.collect_into_pending(p[:session_id], message, profile: profile))
|
|
88
|
+
return { task_id: joined, merged: true }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# the turn is already RUNNING: the message is appended to it at
|
|
92
|
+
# the next tool-batch boundary. Same verdict as a merge, different word: the
|
|
93
|
+
# answer comes out of `task_id`, which is not this call's to deliver. Asked
|
|
94
|
+
# after `collect` because the two cannot both apply — a turn is either still at
|
|
95
|
+
# the door or running.
|
|
96
|
+
if (steered = @executor.steer_into_running(p[:session_id], message, profile: profile))
|
|
97
|
+
return { task_id: steered, steered: true }
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# command.to_h persists the entire Command in the Task;
|
|
102
|
+
# ResumeTask re-reads payload.message from there.
|
|
103
|
+
task = @task_store.create(command: command.to_h, session_id: p[:session_id])
|
|
104
|
+
# `interrupt` mode: the turn in flight is now answering the wrong
|
|
105
|
+
# question, so it is abandoned at its next boundary. This message keeps its OWN
|
|
106
|
+
# task and its own reply (that is why it needs no verdict and no surface gate), and
|
|
107
|
+
# the cancel is posted after `create` so the event can name what replaced what.
|
|
108
|
+
# No-op in every other mode.
|
|
109
|
+
@executor.interrupt_running(p[:session_id], profile: profile, replaced_by: task.id)
|
|
110
|
+
@executor.spawn_in_session(task, profile: profile)
|
|
111
|
+
{ task_id: task.id }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def coalescable?(command)
|
|
115
|
+
transport = command.meta[:transport]
|
|
116
|
+
COALESCABLE_TRANSPORTS.include?(transport) || transport.to_s.start_with?("channel:")
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Scoped by TRANSPORT, never bare: two channels are two consumers with two id
|
|
120
|
+
# spaces, and a Slack event id that happened to equal a `wamid` would otherwise
|
|
121
|
+
# silence a real message. nil (no log wired, or no id sent) = no dedup.
|
|
122
|
+
def dedup_key(command, payload)
|
|
123
|
+
return nil unless @inbound_log
|
|
124
|
+
|
|
125
|
+
event_id = Coercion.presence(payload[:event_id])
|
|
126
|
+
event_id && "#{command.meta[:transport]}:#{event_id}"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def normalize(payload)
|
|
130
|
+
{
|
|
131
|
+
agent: payload[:agent] || payload["agent"],
|
|
132
|
+
message: payload[:message] || payload["message"],
|
|
133
|
+
session_id: payload[:session_id] || payload["session_id"],
|
|
134
|
+
history: payload[:history] || payload["history"],
|
|
135
|
+
origin: payload[:origin] || payload["origin"],
|
|
136
|
+
event_id: payload[:event_id] || payload["event_id"]
|
|
137
|
+
}
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def validate_history!(history)
|
|
141
|
+
ok = history.is_a?(Array) && history.all? do |m|
|
|
142
|
+
m.is_a?(Hash) &&
|
|
143
|
+
!m.values_at(:role, "role").compact.empty? &&
|
|
144
|
+
!m.values_at(:content, "content").compact.empty?
|
|
145
|
+
end
|
|
146
|
+
raise Insika::ValidationError, "history must be [{role:, content:}]" unless ok
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
module Commands
|
|
7
|
+
# Control command: adjusts an agent's tools allow/denylist.
|
|
8
|
+
# `allow` nil = all (AgentProfile rule); `deny` always
|
|
9
|
+
# wins. `allow_groups`: per-group allowlist, only
|
|
10
|
+
# overwritten if the key comes in the payload (otherwise preserved). Takes effect on the next
|
|
11
|
+
# dispatch (hot). -> AgentProfile.
|
|
12
|
+
class SetAgentTools
|
|
13
|
+
def initialize(profile_source:, event_stream:)
|
|
14
|
+
@profile_source = profile_source
|
|
15
|
+
@event_stream = event_stream
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def call(command)
|
|
19
|
+
p = AgentPayload.symbolize(command.payload)
|
|
20
|
+
id = AgentPayload.presence(p[:id])
|
|
21
|
+
raise Insika::ValidationError, "id is required" if id.nil?
|
|
22
|
+
raise Insika::ValidationError, "allow must be a list or nil" unless p[:allow].nil? || p[:allow].is_a?(Array)
|
|
23
|
+
raise Insika::ValidationError, "allow_groups must be a list or nil" unless p[:allow_groups].nil? || p[:allow_groups].is_a?(Array)
|
|
24
|
+
|
|
25
|
+
existing = @profile_source.fetch(id) ||
|
|
26
|
+
(raise Insika::NotFoundError, "agent '#{id}' not found")
|
|
27
|
+
|
|
28
|
+
merged = existing.to_h.merge(tools_allow: p[:allow], tools_deny: Array(p[:deny]))
|
|
29
|
+
merged[:tools_allow_groups] = p[:allow_groups] if p.key?(:allow_groups)
|
|
30
|
+
@profile_source.put(Insika::AgentProfile.build(**merged))
|
|
31
|
+
@event_stream.emit(Insika::Event.new(
|
|
32
|
+
type: :agent_tools_set, data: { agent_id: id },
|
|
33
|
+
meta: { at: Time.now.utc.iso8601 }
|
|
34
|
+
))
|
|
35
|
+
@profile_source.fetch(id)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|