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,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
|
+
# RFC-0015 §5.5 — 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 RFC-0011 §6 lands.
|
|
17
|
+
COALESCABLE_TRANSPORTS = %i[http:json].freeze
|
|
18
|
+
|
|
19
|
+
# RFC-0011 §6.4 — `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 (D2)"
|
|
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
|
+
# RFC-0011 §6.4 — 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
|
+
# RFC-0015 §5.3 — 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
|
+
# §5.5: 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
|
+
# RFC-0015 §5.1 — 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
|
+
# RFC-0015 §6.4 — `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` (Phase 7/D4/F5, Step C): 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
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
module Commands
|
|
7
|
+
# Control command: enables/disables a skill on N
|
|
8
|
+
# agents at once, adjusting each one's `profile.skills` allowlist.
|
|
9
|
+
# Takes effect on the next dispatch (hot via ProfileSource). -> { name, enabled_for }.
|
|
10
|
+
#
|
|
11
|
+
# Allowlist semantics (AgentProfile): nil = ALL, [] = none,
|
|
12
|
+
# [names] = subset. Important and deliberate consequence:
|
|
13
|
+
# - enabling on an agent with skills=nil: no-op (already has all).
|
|
14
|
+
# - DISABLING on an agent with skills=nil: NOT done here — removing one from
|
|
15
|
+
# "all" would require enumerating the catalog and materializing an explicit
|
|
16
|
+
# allowlist (destructive/surprising). These agents are left intact and
|
|
17
|
+
# go into `skipped_all`. To restrict, use an explicit :set_agent_tools/allowlist
|
|
18
|
+
# first.
|
|
19
|
+
class SetSkillAgents
|
|
20
|
+
def initialize(profile_source:, event_stream:)
|
|
21
|
+
@profile_source = profile_source
|
|
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
|
+
raise Insika::ValidationError, "name is required" if name.nil?
|
|
29
|
+
raise Insika::ValidationError, "agent_ids must be a list" unless p[:agent_ids].nil? || p[:agent_ids].is_a?(Array)
|
|
30
|
+
|
|
31
|
+
wanted = Array(p[:agent_ids]).map(&:to_s)
|
|
32
|
+
enabled_for = []
|
|
33
|
+
skipped_all = []
|
|
34
|
+
|
|
35
|
+
@profile_source.all.each do |profile|
|
|
36
|
+
want = wanted.include?(profile.id)
|
|
37
|
+
new_skills = next_skills(profile.skills, name, want)
|
|
38
|
+
|
|
39
|
+
if new_skills == :skip
|
|
40
|
+
skipped_all << profile.id
|
|
41
|
+
next
|
|
42
|
+
end
|
|
43
|
+
next if new_skills == profile.skills # no change
|
|
44
|
+
|
|
45
|
+
@profile_source.put(Insika::AgentProfile.build(**profile.to_h.merge(skills: new_skills)))
|
|
46
|
+
enabled_for << profile.id if want
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
@event_stream.emit(Insika::Event.new(
|
|
50
|
+
type: :skill_agents_set,
|
|
51
|
+
data: { name: name, agent_ids: wanted, skipped_all: skipped_all },
|
|
52
|
+
meta: { at: Time.now.utc.iso8601 }
|
|
53
|
+
))
|
|
54
|
+
{ name: name, enabled_for: enabled_for, skipped_all: skipped_all }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
# -> new allowlist | :skip (agent with nil that requested disable).
|
|
60
|
+
def next_skills(current, name, want)
|
|
61
|
+
if want
|
|
62
|
+
current.nil? ? nil : (Array(current).map(&:to_s) | [name])
|
|
63
|
+
elsif current.nil?
|
|
64
|
+
:skip
|
|
65
|
+
else
|
|
66
|
+
Array(current).map(&:to_s) - [name]
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
module Commands
|
|
5
|
+
# Turn command: fires a workflow. Reuses the canonical
|
|
6
|
+
# pipeline — only stage 6 varies (Executor). Validates everything
|
|
7
|
+
# synchronously and responds `{task_id:}` immediately. The workflow allowlist is NOT
|
|
8
|
+
# checked here: it is stage-3 enforcement via WorkflowAllowlist
|
|
9
|
+
# -> PolicyDenied -> task :failed.
|
|
10
|
+
class TriggerWorkflow
|
|
11
|
+
ALLOWED_KEYS = %i[workflow agent input session_id].freeze
|
|
12
|
+
|
|
13
|
+
def initialize(profiles:, session_store:, task_store:, executor:, workflow_registry:)
|
|
14
|
+
@profiles = ProfileSource.coerce(profiles)
|
|
15
|
+
@session_store = session_store
|
|
16
|
+
@task_store = task_store
|
|
17
|
+
@executor = executor
|
|
18
|
+
@workflow_registry = workflow_registry
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def call(command)
|
|
22
|
+
p = normalize(command.payload)
|
|
23
|
+
reject_unknown_keys!(command.payload) # strict validation
|
|
24
|
+
|
|
25
|
+
workflow = p[:workflow].to_s
|
|
26
|
+
raise Insika::ValidationError, "workflow is required" if workflow.empty?
|
|
27
|
+
|
|
28
|
+
agent = p[:agent].to_s
|
|
29
|
+
raise Insika::ValidationError, "agent is required" if agent.empty?
|
|
30
|
+
|
|
31
|
+
profile = @profiles[agent] ||
|
|
32
|
+
(raise Insika::NotFoundError, "agent '#{agent}' not configured")
|
|
33
|
+
|
|
34
|
+
input = p[:input] || {}
|
|
35
|
+
raise Insika::ValidationError, "input must be a Hash" unless input.is_a?(Hash)
|
|
36
|
+
|
|
37
|
+
if p[:session_id]
|
|
38
|
+
@session_store.find(p[:session_id]) ||
|
|
39
|
+
(raise Insika::NotFoundError, "session '#{p[:session_id]}' not found")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# existence is validatable without executing — names, NEVER resolve (don't instantiate
|
|
43
|
+
# outside the fiber).
|
|
44
|
+
unless @workflow_registry.names.include?(workflow)
|
|
45
|
+
raise Insika::NotFoundError, "workflow '#{workflow}' not registered"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# I/O by schema (item 22 / §4.4): the INPUT is validated SYNCHRONOUSLY —
|
|
49
|
+
# a non-conforming input is a WorkflowSchemaError (< ValidationError -> 422)
|
|
50
|
+
# with NO run created. `definition` reads the schema metadata without
|
|
51
|
+
# resolving the factory (stays out of the fiber). No-op without an input_schema.
|
|
52
|
+
@workflow_registry.definition(workflow).validate_input!(input)
|
|
53
|
+
|
|
54
|
+
task = @task_store.create(command: command.to_h, session_id: p[:session_id])
|
|
55
|
+
@executor.spawn_in_session(task, profile: profile)
|
|
56
|
+
# runId = the run's durable record, which IS the Task (checkpointed +
|
|
57
|
+
# recoverable). Exposed under both names so the workflow consumer has a
|
|
58
|
+
# stable `run_id` while every task surface (GET /v1/tasks/:id, the event
|
|
59
|
+
# stream) keeps working unchanged.
|
|
60
|
+
{ task_id: task.id, run_id: task.id }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def normalize(payload)
|
|
66
|
+
{
|
|
67
|
+
workflow: payload[:workflow] || payload["workflow"],
|
|
68
|
+
agent: payload[:agent] || payload["agent"],
|
|
69
|
+
input: payload[:input] || payload["input"],
|
|
70
|
+
session_id: payload[:session_id] || payload["session_id"]
|
|
71
|
+
}
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def reject_unknown_keys!(payload)
|
|
75
|
+
extra = payload.keys.map(&:to_sym) - ALLOWED_KEYS
|
|
76
|
+
raise Insika::ValidationError, "unknown keys in payload: #{extra.join(', ')}" unless extra.empty?
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
module Commands
|
|
7
|
+
# Control command: edits an existing agent. Merges the
|
|
8
|
+
# patch over the current profile (only the sent fields change) + rebuild + put.
|
|
9
|
+
# Takes effect on the NEXT dispatch (the ProfileSource reads fresh). -> AgentProfile.
|
|
10
|
+
class UpdateAgent
|
|
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
|
+
patch = AgentPayload.attrs(command.payload)
|
|
18
|
+
id = AgentPayload.presence(patch[:id])
|
|
19
|
+
raise Insika::ValidationError, "id is required" if id.nil?
|
|
20
|
+
|
|
21
|
+
existing = @profile_source.fetch(id) ||
|
|
22
|
+
(raise Insika::NotFoundError, "agent '#{id}' not found")
|
|
23
|
+
|
|
24
|
+
# to_h of the current profile brings the correct symbols; patch overwrites only
|
|
25
|
+
# what was sent. id never changes (rename = create+delete, out of scope).
|
|
26
|
+
merged = existing.to_h.merge(patch).merge(id: id)
|
|
27
|
+
profile = Insika::AgentProfile.build(**merged)
|
|
28
|
+
# RFC-0010 §4.4: definition-time cycle + depth check (an update may ADD
|
|
29
|
+
# subagents to an existing agent). Raises SubagentError before persisting.
|
|
30
|
+
validate_subagent_graph!(profile)
|
|
31
|
+
@profile_source.put(profile)
|
|
32
|
+
@event_stream.emit(Insika::Event.new(
|
|
33
|
+
type: :agent_updated, data: { agent_id: id },
|
|
34
|
+
meta: { at: Time.now.utc.iso8601 }
|
|
35
|
+
))
|
|
36
|
+
@profile_source.fetch(id)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def validate_subagent_graph!(profile)
|
|
42
|
+
return if Array(profile.subagents).empty?
|
|
43
|
+
|
|
44
|
+
others = @profile_source.all.reject { |p| p.id.to_s == profile.id.to_s }
|
|
45
|
+
Insika::SubagentGraph.validate!(others + [profile])
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
module Commands
|
|
7
|
+
# Control command: merges a patch into the
|
|
8
|
+
# general settings (timeouts/streaming/compaction) and persists it in the SettingsStore.
|
|
9
|
+
# Only what came in the patch changes; the rest (and the defaults) is preserved. Takes effect on the
|
|
10
|
+
# next turn that reads settings. -> Hash (resulting settings).
|
|
11
|
+
class UpdateSettings
|
|
12
|
+
def initialize(settings_store:, event_stream:)
|
|
13
|
+
@settings_store = settings_store
|
|
14
|
+
@event_stream = event_stream
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def call(command)
|
|
18
|
+
p = AgentPayload.symbolize(command.payload)
|
|
19
|
+
patch = p[:patch]
|
|
20
|
+
raise Insika::ValidationError, "patch must be an object" unless patch.nil? || patch.is_a?(Hash)
|
|
21
|
+
raise Insika::ValidationError, "empty patch" if patch.nil? || patch.empty?
|
|
22
|
+
|
|
23
|
+
settings = @settings_store.update(patch)
|
|
24
|
+
@event_stream.emit(Insika::Event.new(
|
|
25
|
+
type: :settings_updated,
|
|
26
|
+
data: { keys: patch.keys.map(&:to_s) },
|
|
27
|
+
meta: { at: Time.now.utc.iso8601 }
|
|
28
|
+
))
|
|
29
|
+
settings
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
module Commands
|
|
7
|
+
# Control command: creates/edits an LLM provider
|
|
8
|
+
# (api/base_url/auth_header/api_key/models) in the LLMProviderStore and RECONFIGURES
|
|
9
|
+
# RubyLLM at runtime (LLMConfigurator) — swaps key/base without a restart.
|
|
10
|
+
# The `api_key` is sentinel-aware (__OCULTO__ preserves; "" clears; a new string
|
|
11
|
+
# replaces). Returns the MASKED record (the key never comes back in plaintext).
|
|
12
|
+
class UpsertLLMProvider
|
|
13
|
+
def initialize(provider_store:, configurator:, event_stream:)
|
|
14
|
+
@provider_store = provider_store
|
|
15
|
+
@configurator = configurator
|
|
16
|
+
@event_stream = event_stream
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def call(command)
|
|
20
|
+
p = AgentPayload.symbolize(command.payload)
|
|
21
|
+
masked = @provider_store.upsert(p) # validates `api`; returns masked
|
|
22
|
+
|
|
23
|
+
# Reconfigures only THIS provider (with the real key from the store, already reconciled).
|
|
24
|
+
result = @configurator.apply([@provider_store.get_raw(masked["api"])])
|
|
25
|
+
@event_stream.emit(Insika::Event.new(
|
|
26
|
+
type: :llm_provider_upserted,
|
|
27
|
+
data: { api: masked["api"], applied: result[:applied], skipped: result[:skipped] },
|
|
28
|
+
meta: { at: Time.now.utc.iso8601 }
|
|
29
|
+
))
|
|
30
|
+
masked
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
module Commands
|
|
7
|
+
# Control command: creates/edits an MCP instance
|
|
8
|
+
# (transport/command/url/enabled + `env` credentials) in the McpStore. The
|
|
9
|
+
# credentials are sentinel-aware per key (__OCULTO__ preserves; "" clears;
|
|
10
|
+
# a new string replaces). Returns the MASKED record (env never comes back in
|
|
11
|
+
# plaintext). Durable config CRUD — running an MCP client against the
|
|
12
|
+
# instance is later runtime.
|
|
13
|
+
class UpsertMcp
|
|
14
|
+
def initialize(mcp_store:, event_stream:)
|
|
15
|
+
@mcp_store = mcp_store
|
|
16
|
+
@event_stream = event_stream
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def call(command)
|
|
20
|
+
p = AgentPayload.symbolize(command.payload)
|
|
21
|
+
masked = @mcp_store.upsert(p) # validates `name`; returns masked
|
|
22
|
+
|
|
23
|
+
@event_stream.emit(Insika::Event.new(
|
|
24
|
+
type: :mcp_upserted,
|
|
25
|
+
data: { name: masked["name"], enabled: masked["enabled"] },
|
|
26
|
+
meta: { at: Time.now.utc.iso8601 }
|
|
27
|
+
))
|
|
28
|
+
masked
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
module Commands
|
|
7
|
+
# Control command: writes an agent's prompt file to the
|
|
8
|
+
# workspace (AgentFileStore). This is what gives each BIA its own identity — the
|
|
9
|
+
# Prompt provider reads these files via `profile.prompt_files`. Takes effect
|
|
10
|
+
# on the next dispatch (hot). -> { agent_id, file, updated_at }.
|
|
11
|
+
class WriteAgentFile
|
|
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
|
+
profile = @profile_source.fetch(agent_id) ||
|
|
25
|
+
(raise Insika::NotFoundError, "agent '#{agent_id}' not found")
|
|
26
|
+
|
|
27
|
+
# `.to_s` used to be unconditional here, which is how a JSON body carrying an
|
|
28
|
+
# OBJECT for `content` became a prompt file holding Ruby's #inspect of it. The
|
|
29
|
+
# store refuses that now; keep the coercion for scalars (a form always sends a
|
|
30
|
+
# String, and nil means "clear the file").
|
|
31
|
+
content = Insika::AgentFileStore.text!(p[:content], agent_id, file).to_s
|
|
32
|
+
entry = @agent_files.write(agent_id, file, content, create_only: !!p[:create_only])
|
|
33
|
+
# Writing a prompt registers it in prompt_files — otherwise the Prompt provider
|
|
34
|
+
# would never load it. It's the Command's job, not the dispatcher's.
|
|
35
|
+
register_prompt_file(profile, file)
|
|
36
|
+
emit(:agent_file_written, agent_id, file)
|
|
37
|
+
{ agent_id: agent_id, file: file, updated_at: entry["updated_at"] }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def register_prompt_file(profile, file)
|
|
43
|
+
files = Array(profile.prompt_files).map(&:to_s)
|
|
44
|
+
return if files.include?(file)
|
|
45
|
+
|
|
46
|
+
@profile_source.put(Insika::AgentProfile.build(**profile.to_h.merge(prompt_files: files + [file])))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def emit(type, agent_id, file)
|
|
50
|
+
@event_stream.emit(Insika::Event.new(
|
|
51
|
+
type: type, data: { agent_id: agent_id, file: file },
|
|
52
|
+
meta: { at: Time.now.utc.iso8601 }
|
|
53
|
+
))
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
module Commands
|
|
7
|
+
# Writes (upserts) a DATA-defined tool into the ToolStore and RELOADS the overlay +
|
|
8
|
+
# catalog — takes effect without a restart (F5). Rejects a name that collides with a
|
|
9
|
+
# code tool (R3). Validating the definition (format, method/url, types,
|
|
10
|
+
# placeholders) is the ToolStore/ToolDefinition's job. Secrets (headers) reconcile
|
|
11
|
+
# in the store; the event carries only the name (0 leakage). -> { name, definition }.
|
|
12
|
+
class WriteDataTool
|
|
13
|
+
def initialize(tool_store:, registry:, tool_catalog:, event_stream:)
|
|
14
|
+
@tool_store = tool_store
|
|
15
|
+
@registry = registry
|
|
16
|
+
@tool_catalog = tool_catalog
|
|
17
|
+
@event_stream = event_stream
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def call(command)
|
|
21
|
+
p = AgentPayload.symbolize(command.payload)
|
|
22
|
+
name = AgentPayload.presence(p[:name])
|
|
23
|
+
raise Insika::ValidationError, "name is required" if name.nil?
|
|
24
|
+
raise Insika::ValidationError, "'#{name}' is already a code tool" if @registry.code_tool?(name)
|
|
25
|
+
|
|
26
|
+
masked = @tool_store.write(p, create_only: !!p[:create_only])
|
|
27
|
+
@registry.reload
|
|
28
|
+
@tool_catalog.reload
|
|
29
|
+
emit(name)
|
|
30
|
+
{ name: name, definition: masked }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def emit(name)
|
|
36
|
+
@event_stream.emit(Insika::Event.new(
|
|
37
|
+
type: :data_tool_written, data: { name: name },
|
|
38
|
+
meta: { at: Time.now.utc.iso8601 }
|
|
39
|
+
))
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
module Commands
|
|
7
|
+
# Control command: writes an eval case (RFC-0008 §3.1) into the GoldenStore. This
|
|
8
|
+
# is what lets a domain owner add a case — above all its RUBRIC, the part of an eval
|
|
9
|
+
# that is plain language — without a checkout and a text editor.
|
|
10
|
+
#
|
|
11
|
+
# The payload carries the case as DATA (`case:`), the same mapping the YAML file
|
|
12
|
+
# holds; the store validates it with the one loader, so a case authored here and a
|
|
13
|
+
# case read from `evals/golden/` cannot diverge. -> { id, agent }.
|
|
14
|
+
class WriteGolden
|
|
15
|
+
def initialize(golden_store:, event_stream:)
|
|
16
|
+
@goldens = golden_store
|
|
17
|
+
@event_stream = event_stream
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def call(command)
|
|
21
|
+
p = AgentPayload.symbolize(command.payload)
|
|
22
|
+
raise Insika::ValidationError, "case is required" unless p[:case].is_a?(Hash)
|
|
23
|
+
|
|
24
|
+
golden = @goldens.write(p[:case])
|
|
25
|
+
emit(:golden_written, id: golden.id, agent: golden.agent)
|
|
26
|
+
{ id: golden.id, agent: golden.agent }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def emit(type, **data)
|
|
32
|
+
@event_stream.emit(Insika::Event.new(type: type, data: data,
|
|
33
|
+
meta: { at: Time.now.utc.iso8601 }))
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Control command: removes an eval case. The corpus on disk is the way back
|
|
38
|
+
# (`insika evals:import`), which is why this needs no version history.
|
|
39
|
+
# -> { id, existed }.
|
|
40
|
+
class DeleteGolden
|
|
41
|
+
def initialize(golden_store:, event_stream:)
|
|
42
|
+
@goldens = golden_store
|
|
43
|
+
@event_stream = event_stream
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def call(command)
|
|
47
|
+
p = AgentPayload.symbolize(command.payload)
|
|
48
|
+
id = AgentPayload.presence(p[:id])
|
|
49
|
+
raise Insika::ValidationError, "id is required" if id.nil?
|
|
50
|
+
|
|
51
|
+
existed = @goldens.delete(id)
|
|
52
|
+
@event_stream.emit(Insika::Event.new(type: :golden_deleted, data: { id: id, existed: existed },
|
|
53
|
+
meta: { at: Time.now.utc.iso8601 }))
|
|
54
|
+
{ id: id, existed: existed }
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|