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,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
# Per-agent workspace. Holds the
|
|
7
|
+
# CONTENT of each agent's prompt files (IDENTITY.md/SOUL.md/TOOLS.md
|
|
8
|
+
# and the like) in the durable Store — not on disk. It is what makes "each one
|
|
9
|
+
# builds its own BIA with its own identity": the Prompt provider reads from
|
|
10
|
+
# here, per agent, instead of the fixed `files:` in the wiring (which are now
|
|
11
|
+
# only the deployment default).
|
|
12
|
+
#
|
|
13
|
+
# One record per agent in the ConfigStore (scope "agent_files"):
|
|
14
|
+
# { "files" => { "<name>" => { "content" => str,
|
|
15
|
+
# "updated_at" => iso8601,
|
|
16
|
+
# "history" => [ { "content" => str, "at" => iso8601 }, ... ] } } }
|
|
17
|
+
#
|
|
18
|
+
# A write versions: the previous content goes into `history` (most recent
|
|
19
|
+
# first), capped at HISTORY_MAX — restoring becomes a new write
|
|
20
|
+
# (preserves the linear history, no destructive "time travel").
|
|
21
|
+
class AgentFileStore
|
|
22
|
+
SCOPE = "agent_files"
|
|
23
|
+
HISTORY_MAX = 20
|
|
24
|
+
|
|
25
|
+
def initialize(config_store:)
|
|
26
|
+
@cs = config_store
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# -> String | nil (current content; nil = nonexistent file/agent).
|
|
30
|
+
def read(agent_id, filename)
|
|
31
|
+
entry(agent_id, filename.to_s)&.fetch("content", nil)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# -> [String] the agent's file names, lexicographic order.
|
|
35
|
+
def list(agent_id)
|
|
36
|
+
files(agent_id).keys.sort
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# -> [String] every agent that has a workspace here (what `doctor` sweeps).
|
|
40
|
+
def agents = @cs.keys(SCOPE).sort
|
|
41
|
+
|
|
42
|
+
# A prompt file is TEXT. `to_s` on a structured value produces Ruby's `#inspect`
|
|
43
|
+
# and stores it as if it were the prompt: the pilot lost all 11 files of its
|
|
44
|
+
# production agent that way (someone wrote an ENTRY back as content, so the
|
|
45
|
+
# markdown reached the model as `{"content" => "…\n…"}` on a single line, escapes
|
|
46
|
+
# and all, for three weeks). Nothing legitimate passes a Hash or an Array here, so
|
|
47
|
+
# this is the one place that has to refuse instead of coerce.
|
|
48
|
+
def self.text!(content, agent_id, filename)
|
|
49
|
+
return content if content.is_a?(String)
|
|
50
|
+
return content.to_s unless content.is_a?(Hash) || content.is_a?(Array)
|
|
51
|
+
|
|
52
|
+
raise Insika::ValidationError,
|
|
53
|
+
"content for '#{filename}' (agent '#{agent_id}') must be text, got #{content.class} — " \
|
|
54
|
+
"pass the file's markdown, not the store entry or a wrapper object"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Writes (upsert). create_only: refuses to overwrite. Versions the previous
|
|
58
|
+
# content into history. -> Hash (the stored entry).
|
|
59
|
+
def write(agent_id, filename, content, create_only: false)
|
|
60
|
+
name = filename.to_s
|
|
61
|
+
content = self.class.text!(content, agent_id, name)
|
|
62
|
+
record = @cs.get(SCOPE, agent_id.to_s) || { "files" => {} }
|
|
63
|
+
record["files"] ||= {}
|
|
64
|
+
current = record["files"][name]
|
|
65
|
+
if create_only && current
|
|
66
|
+
raise Insika::ValidationError, "file '#{name}' already exists for agent '#{agent_id}'"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
record["files"][name] = build_entry(content.to_s, current)
|
|
70
|
+
@cs.put(SCOPE, agent_id.to_s, record)
|
|
71
|
+
record["files"][name]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# -> bool (did it exist?). Removes the file; if the agent ends up with no files,
|
|
75
|
+
# keeps the empty record (cheap; deleting the agent handles the cleanup).
|
|
76
|
+
def delete(agent_id, filename)
|
|
77
|
+
name = filename.to_s
|
|
78
|
+
record = @cs.get(SCOPE, agent_id.to_s)
|
|
79
|
+
return false unless record && record.dig("files", name)
|
|
80
|
+
|
|
81
|
+
record["files"].delete(name)
|
|
82
|
+
@cs.put(SCOPE, agent_id.to_s, record)
|
|
83
|
+
true
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# -> [ { "content" =>, "at" => } ] older versions, most recent first.
|
|
87
|
+
def versions(agent_id, filename)
|
|
88
|
+
entry(agent_id, filename.to_s)&.fetch("history", []) || []
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Restores version `index` from history as the current content (a new write:
|
|
92
|
+
# the current one goes to the top of history). -> Hash (entry) | raises if index
|
|
93
|
+
# invalid / file nonexistent.
|
|
94
|
+
def restore(agent_id, filename, index)
|
|
95
|
+
name = filename.to_s
|
|
96
|
+
hist = versions(agent_id, name)
|
|
97
|
+
i = Integer(index)
|
|
98
|
+
unless entry(agent_id, name)
|
|
99
|
+
raise Insika::NotFoundError, "file '#{name}' not found for agent '#{agent_id}'"
|
|
100
|
+
end
|
|
101
|
+
raise Insika::ValidationError, "version #{index} does not exist" if i.negative? || i >= hist.length
|
|
102
|
+
|
|
103
|
+
write(agent_id, name, hist[i]["content"])
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
def files(agent_id)
|
|
109
|
+
(@cs.get(SCOPE, agent_id.to_s) || {})["files"] || {}
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def entry(agent_id, filename)
|
|
113
|
+
files(agent_id)[filename]
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def build_entry(content, current)
|
|
117
|
+
history = current ? current.fetch("history", []) : []
|
|
118
|
+
if current
|
|
119
|
+
history = [{ "content" => current["content"], "at" => current["updated_at"] }] + history
|
|
120
|
+
history = history.first(HISTORY_MAX)
|
|
121
|
+
end
|
|
122
|
+
{ "content" => content, "updated_at" => Time.now.utc.iso8601, "history" => history }
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "coercion"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
# Single point of per-agent policy.
|
|
7
|
+
# ONE allowlist semantics for tools, skills, providers and workflows
|
|
8
|
+
# (nil = all [+ opt-in for optional tools]; [] = none for skills/allow;
|
|
9
|
+
# [names] = the final set; deny always wins) — a single rule, tested once.
|
|
10
|
+
#
|
|
11
|
+
# Deliberate EXCEPTION: `capabilities` does NOT follow the
|
|
12
|
+
# `nil = all` rule. nil/absent = NO capability (explicit opt-in — exposing
|
|
13
|
+
# every registered capability by mistake would couple the agent to plugins it
|
|
14
|
+
# didn't ask for). Do NOT "fix" it to be consistent with tools_allow.
|
|
15
|
+
AgentProfile = Data.define(
|
|
16
|
+
:id, :model, :provider,
|
|
17
|
+
:base_prompt, :prompt_files,
|
|
18
|
+
:tools_allow, :tools_deny,
|
|
19
|
+
:tools_allow_groups, # per-GROUP allowlist:
|
|
20
|
+
# union with tools_allow; deny wins; both
|
|
21
|
+
# nil = all (parity). Expands to the group's
|
|
22
|
+
# tools in the ToolAllowlist policy.
|
|
23
|
+
:skills,
|
|
24
|
+
:skills_eager, # progressive disclosure OFF, wholly or in part:
|
|
25
|
+
# nil/false = level 1 + load_skill (parity); true = every
|
|
26
|
+
# allowed skill; [names] = exactly these. An eager skill's
|
|
27
|
+
# BODY enters the prompt each turn, it leaves the
|
|
28
|
+
# <available_skills> catalog and load_skill refuses it.
|
|
29
|
+
# Removes the activation DECISION (no miss rate) at the
|
|
30
|
+
# cost of the bodies' tokens — measure them against
|
|
31
|
+
# context_budget before turning it on. Same opt-in as
|
|
32
|
+
# `memory`. It lives HERE and not in the SKILL.md
|
|
33
|
+
# frontmatter because skills are shared between agents:
|
|
34
|
+
# a per-skill flag forced one decision onto every
|
|
35
|
+
# allowlist holding the skill. NOT `Allowlist`
|
|
36
|
+
# semantics — nil means NONE here (SkillCatalog#eager_for).
|
|
37
|
+
:context_providers, # provider allowlist
|
|
38
|
+
:workflows_allow, # applied by WorkflowAllowlist
|
|
39
|
+
:policies, # names in the Policy Registry
|
|
40
|
+
:prompt_refs, # names in the Prompt Catalog
|
|
41
|
+
:limits, # timeouts/budgets
|
|
42
|
+
:approvals_required, # tools that require approval (ApprovalRequired)
|
|
43
|
+
:capabilities, # intents the agent can trigger.
|
|
44
|
+
# nil = NONE (opt-in, see above).
|
|
45
|
+
:subagents, # allowlist of child agent ids this agent MAY spawn
|
|
46
|
+
# CAPACITY field — NEVER inherits;
|
|
47
|
+
# opt-in like `capabilities`: nil/absent = NONE (do NOT
|
|
48
|
+
# "fix" to nil = all). Present => the `spawn_subagent`
|
|
49
|
+
# system tool is wired (ChatBuilder), gated by this set.
|
|
50
|
+
:tools_deferred, # searchable-not-wired tools (Tool Search).
|
|
51
|
+
# nil = no deferred (all eager — parity);
|
|
52
|
+
# [names] ⊆ allowed_tools, exposed via tool_search.
|
|
53
|
+
:memory, # cross-session memory.
|
|
54
|
+
# nil/false = OFF (parity: provider []; the `remember`
|
|
55
|
+
# tool not wired); true = ON. Same opt-in as capabilities.
|
|
56
|
+
:prompt_caching, # Anthropic prompt caching (R3): nil/false = OFF
|
|
57
|
+
# (parity); true = ON. Same opt-in as `memory`. When ON
|
|
58
|
+
# AND the resolved provider is Anthropic, ChatBuilder sets
|
|
59
|
+
# ONE cache breakpoint at the end of the system block
|
|
60
|
+
# (caches tools+system by the tools->system->messages
|
|
61
|
+
# prefix order; immune to history eviction). PRE-AUDIT:
|
|
62
|
+
# the system prompt MUST be byte-stable between turns —
|
|
63
|
+
# a context provider injecting volatile content into
|
|
64
|
+
# :system turns every turn into a paid cache WRITE with
|
|
65
|
+
# no read hit. Enable only for stable-system agents.
|
|
66
|
+
:tool_output_compression, # MECHANICAL tool-result dedupe in the replayed
|
|
67
|
+
# history (A3/C3): nil/false = OFF (parity); true = ON.
|
|
68
|
+
# Same opt-in as `memory`. When ON, the history the
|
|
69
|
+
# Session provider seeds replaces byte-identical repeated
|
|
70
|
+
# tool results with a compact back-reference (first
|
|
71
|
+
# occurrence stays full) — no LLM involved. CHANGES WHAT
|
|
72
|
+
# THE MODEL SEES: an older full result is only the first
|
|
73
|
+
# occurrence; a model that wants an older detail re-calls
|
|
74
|
+
# the tool. Cheap half of compaction for bloated histories.
|
|
75
|
+
:params, # LLM generation params: a Hash with
|
|
76
|
+
# temperature/max_tokens/thinking, applied to the chat at
|
|
77
|
+
# stage 5. {} = provider defaults (parity).
|
|
78
|
+
:budget, # spend caps per (tenant, agent) over
|
|
79
|
+
# CALENDAR windows (WS2): { "daily" => int,
|
|
80
|
+
# "monthly" => int, "soft" => bool, "alert_at" => 0.8 }.
|
|
81
|
+
# HARD is the default: absent/"soft": false, a turn
|
|
82
|
+
# arriving at/over the cap fails with Insika::BudgetExceeded
|
|
83
|
+
# and the envelope quotes `budget_exceeded` + retry_after.
|
|
84
|
+
# "soft": true crosses the cap and still runs
|
|
85
|
+
# (one budget_warning event per window + a note in the
|
|
86
|
+
# context).
|
|
87
|
+
# Tokens count the billed spend (input+output+cached+
|
|
88
|
+
# cache_creation). nil/absent = no budget (parity).
|
|
89
|
+
:reliability, # the provider-interaction reliability policy (WS3):
|
|
90
|
+
# { "retries" => 3, "backoff" => "exponential",
|
|
91
|
+
# "fallback" => ["gpt-4o-mini", ...],
|
|
92
|
+
# "circuit_breaker" => { "after" => 10, "within" => 60,
|
|
93
|
+
# "cooldown" => 300 }, "timeout" => 30 }. Data, never
|
|
94
|
+
# DSL: retries + exponential backoff on :retryable /
|
|
95
|
+
# :rate_limited_* failures (never :fatal), mid-turn
|
|
96
|
+
# rotation across the fallback chain (profile's first,
|
|
97
|
+
# then the platform's resolved fallbacks), and a circuit
|
|
98
|
+
# breaker per (tenant, provider/model) that fail-fasts
|
|
99
|
+
# with circuit_open + retry_after once the window count
|
|
100
|
+
# trips. nil/absent = the plain single attempt (parity).
|
|
101
|
+
:alerts, # operator alert delivery (WS6): { "webhook" => url }.
|
|
102
|
+
# When present, the agent's budget_warning /
|
|
103
|
+
# breaker_open / delivery_failed events are POSTed to
|
|
104
|
+
# the URL as JSON (outbox + claim, at-most-once).
|
|
105
|
+
# nil/absent = no webhook (parity).
|
|
106
|
+
:stuck_signal, # the agent may signal it cannot proceed (WS5):
|
|
107
|
+
# nil/false = OFF (parity — the signal_stuck system
|
|
108
|
+
# tool is not wired); true = ON (the model may call
|
|
109
|
+
# signal_stuck, which ends the turn with
|
|
110
|
+
# `outcome: :stuck` + a final message + a :turn_stuck
|
|
111
|
+
# event the consumer acts on). Same opt-in as
|
|
112
|
+
# `memory`. What "stuck" MEANS is the consumer's call
|
|
113
|
+
# (escalation via CRM/operator), never the engine's.
|
|
114
|
+
:model_policy, # governance of WHICH models the agent may use:
|
|
115
|
+
# { "allow" => [refs] }. nil = NO fence (all models —
|
|
116
|
+
# parity). Enforced on the RESOLVED model (ModelResolver).
|
|
117
|
+
:guardrails, # content-safety config: { input:, output:,
|
|
118
|
+
# moderator:, strictness: }. OPT-IN like capabilities —
|
|
119
|
+
# nil/absent = the conservative default (Safety::Config:
|
|
120
|
+
# deterministic on, moderator off). Parsed, never a policy.
|
|
121
|
+
:sandbox, # confined-execution config:
|
|
122
|
+
# { provider: "local"|"docker", root:, timeout:, ...+provider
|
|
123
|
+
# keys }. Declarative provider selection (config-over-code) —
|
|
124
|
+
# consumed by Insika::Sandbox.build. {} = absent (a
|
|
125
|
+
# deployment builds a `local` sandbox by default). It is
|
|
126
|
+
# CONFIG, never a policy — it does not decide security by
|
|
127
|
+
# itself; the FS boundary + approvals do.
|
|
128
|
+
:refinement, # self-improvement config:
|
|
129
|
+
# { mode: "report"|"propose"|"auto_apply", window: {…},
|
|
130
|
+
# files: [allowlist], proposers: [refs], budget: {tokens:},
|
|
131
|
+
# auto_apply_max_edits:, max_findings:, … }. nil/absent =
|
|
132
|
+
# REPORT-ONLY (writes nothing to the agent, so
|
|
133
|
+
# reading your own traces needs no opt-in); `propose`
|
|
134
|
+
# and above must be enabled explicitly. It is CONFIG,
|
|
135
|
+
# never a policy — the write allowlist it carries is
|
|
136
|
+
# enforced by the applier, not by this field.
|
|
137
|
+
:capabilities_declared, # FACTS ABOUT THIS DEPLOYMENT that are not tools
|
|
138
|
+
# %w[promotions human_handoff
|
|
139
|
+
# b2b_pricing]. An eval case declares what it
|
|
140
|
+
# `requires` and is SKIPPED — never failed — where
|
|
141
|
+
# the deployment lacks it, which is what makes one
|
|
142
|
+
# corpus usable across stores. A flat list the
|
|
143
|
+
# OPERATOR writes: inferring "this store has
|
|
144
|
+
# promotions" from data is how a suite starts lying.
|
|
145
|
+
# nil/[] = declares nothing.
|
|
146
|
+
# NOT `capabilities` above — that one is the
|
|
147
|
+
# capability-resolution intents the agent may
|
|
148
|
+
# trigger, a runtime allowlist. This one decides
|
|
149
|
+
# nothing at runtime and is read only by the evals.
|
|
150
|
+
:edge_stream, # WHICH INTERNAL CHANNELS MAY CROSS TO THE CUSTOMER:
|
|
151
|
+
# { "thinking" => bool, "intermediate" => bool }.
|
|
152
|
+
# {} / absent = NEITHER, which is the safe default and
|
|
153
|
+
# the reason the engine holds them back at all: the
|
|
154
|
+
# answer is `:content`, and a turn's other text (the
|
|
155
|
+
# provider's reasoning, the model narrating its tool
|
|
156
|
+
# loop) is for the Studio and the trace. A product that
|
|
157
|
+
# WANTS to show reasoning — a chat UI with a "thinking"
|
|
158
|
+
# panel — opts in per agent, and each channel then gets
|
|
159
|
+
# its OWN frame type at `/v1/responses`, never the
|
|
160
|
+
# answer's. Turning it on for a channel where the
|
|
161
|
+
# consumer concatenates every delta into one message
|
|
162
|
+
# (WhatsApp) puts the deliberation in front of a
|
|
163
|
+
# customer; that is the operator's call to make, not a
|
|
164
|
+
# default to inherit.
|
|
165
|
+
:metadata # free-form agent metadata, stable per agent
|
|
166
|
+
# (from the pack `agent.config.json`). Home of the `store_id`
|
|
167
|
+
# that becomes turn context (ctx.store_id).
|
|
168
|
+
# It is NOT a policy — never decides security. {} = absent.
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
# Reopened class (not a Data.define block): a constant assigned inside
|
|
172
|
+
# the block would leak into the lexical scope (Insika::DEFAULT_LIMITS).
|
|
173
|
+
class AgentProfile
|
|
174
|
+
DEFAULT_LIMITS = {
|
|
175
|
+
turn_timeout: 300, tool_timeout: 60, provider_timeout: 5,
|
|
176
|
+
context_budget: 8_000, max_tool_calls: 50,
|
|
177
|
+
# consecutive identical (tool, args) calls that trigger the ONE
|
|
178
|
+
# loop warning; a repeat after it aborts like max_tool_calls. < 2 = off.
|
|
179
|
+
max_tool_repeat: 3,
|
|
180
|
+
approval_timeout: 3_600, # cap on the wait for human approval (~1h)
|
|
181
|
+
# parallel tool calls. ONE number is both the switch and the cap
|
|
182
|
+
# (nil/0/1 = serial, the default; N > 1 = at most N tool calls in flight).
|
|
183
|
+
# It sits next to tool_timeout/max_tool_calls because it is the third bound
|
|
184
|
+
# on tool execution. Read through TurnState#tool_concurrency, which also
|
|
185
|
+
# applies the approval gate.
|
|
186
|
+
tool_concurrency: 1
|
|
187
|
+
}.freeze
|
|
188
|
+
|
|
189
|
+
# `model` is OPTIONAL as of v2: an agent without one resolves the
|
|
190
|
+
# platform `default_model` (Settings) at turn start via the ModelResolver.
|
|
191
|
+
def self.build(id:, model: nil, provider: nil, base_prompt: "", prompt_files: [],
|
|
192
|
+
tools_allow: nil, tools_deny: [], tools_allow_groups: nil, skills: nil,
|
|
193
|
+
skills_eager: nil, context_providers: nil, workflows_allow: nil,
|
|
194
|
+
policies: [], prompt_refs: [], limits: {}, approvals_required: nil,
|
|
195
|
+
capabilities: nil, subagents: nil, tools_deferred: nil, memory: nil,
|
|
196
|
+
prompt_caching: nil, tool_output_compression: nil,
|
|
197
|
+
params: {}, model_policy: nil, guardrails: nil, sandbox: nil,
|
|
198
|
+
refinement: nil, capabilities_declared: nil, edge_stream: nil, metadata: {},
|
|
199
|
+
budget: nil, reliability: nil, alerts: nil, stuck_signal: nil)
|
|
200
|
+
new(
|
|
201
|
+
id: id, model: model, provider: provider, base_prompt: base_prompt,
|
|
202
|
+
prompt_files: Array(prompt_files), tools_allow: tools_allow,
|
|
203
|
+
tools_deny: Array(tools_deny), tools_allow_groups: tools_allow_groups, skills: skills,
|
|
204
|
+
skills_eager: skills_eager,
|
|
205
|
+
context_providers: context_providers, workflows_allow: workflows_allow,
|
|
206
|
+
policies: Array(policies), prompt_refs: Array(prompt_refs),
|
|
207
|
+
limits: DEFAULT_LIMITS.merge(limits), approvals_required: approvals_required,
|
|
208
|
+
capabilities: capabilities,
|
|
209
|
+
# opt-in like capabilities: nil => NONE. Array-normalize a present value so
|
|
210
|
+
# readers get a clean [] and the ChatBuilder gate (present? => wire) is stable.
|
|
211
|
+
subagents: subagents.nil? ? nil : Array(subagents).map(&:to_s),
|
|
212
|
+
tools_deferred: tools_deferred, memory: memory,
|
|
213
|
+
prompt_caching: prompt_caching, tool_output_compression: tool_output_compression,
|
|
214
|
+
# The free-form hashes arrive with symbol keys (internal build) OR string
|
|
215
|
+
# keys (StoredProfileSource JSON round-trip). Normalize to string keys ONCE
|
|
216
|
+
# here — the single front door every profile passes through — so no reader
|
|
217
|
+
# downstream has to defend against both (store_id, model_policy, Studio forms).
|
|
218
|
+
params: Coercion.deep_stringify(params || {}),
|
|
219
|
+
model_policy: Coercion.deep_stringify(model_policy),
|
|
220
|
+
guardrails: Coercion.deep_stringify(guardrails),
|
|
221
|
+
sandbox: Coercion.deep_stringify(sandbox),
|
|
222
|
+
refinement: Coercion.deep_stringify(refinement),
|
|
223
|
+
# Flat [String] — the evals compare it against a case's `requires`, and a
|
|
224
|
+
# symbol/string mix there would be a silent miss.
|
|
225
|
+
capabilities_declared: Array(capabilities_declared).map(&:to_s),
|
|
226
|
+
edge_stream: Coercion.deep_stringify(edge_stream || {}),
|
|
227
|
+
metadata: Coercion.deep_stringify(metadata || {}),
|
|
228
|
+
budget: Coercion.deep_stringify(budget),
|
|
229
|
+
reliability: Coercion.deep_stringify(reliability),
|
|
230
|
+
alerts: Coercion.deep_stringify(alerts),
|
|
231
|
+
stuck_signal: stuck_signal
|
|
232
|
+
)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# opt-in for an optional tool = being in the agent's allow list.
|
|
236
|
+
def tool_opted_in?(name)
|
|
237
|
+
Array(tools_allow).include?(name)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# store_id of the turn context (ctx.store_id): lives in `metadata` (stable
|
|
241
|
+
# per store, comes from the pack). `build` string-keys metadata, so a plain
|
|
242
|
+
# string lookup is enough. nil = absent (the data-tool emits an empty header).
|
|
243
|
+
# It is NOT consumer-specific: `store_id` is a field of the turn-context contract
|
|
244
|
+
# generic per project.
|
|
245
|
+
def store_id = (metadata || {})["store_id"]
|
|
246
|
+
|
|
247
|
+
# May this channel (:thinking / :intermediate) cross to the customer? Tolerant
|
|
248
|
+
# of the string values a form or a pack round-trip produces ("1"/"true"), and
|
|
249
|
+
# of anything else being absent: the safe reading is the default one.
|
|
250
|
+
def stream_public?(channel)
|
|
251
|
+
v = (edge_stream || {})[channel.to_s]
|
|
252
|
+
Coercion.truthy?(v)
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Insika
|
|
7
|
+
# Operator alerts to a webhook (WS6): the events `:budget_warning`,
|
|
8
|
+
# `:breaker_open` and `:delivery_failed` are answered per AGENT — a profile
|
|
9
|
+
# declaring `alerts: { "webhook" => url }` gets its alerts POSTed there as
|
|
10
|
+
# JSON. The delivery reuses the outbox + claim mechanism whole
|
|
11
|
+
# (`ChannelDelivery`): the handler only WRITES the outbox row; the existing
|
|
12
|
+
# tick sweep and boot recovery claim and POST it, at-most-once with bounded
|
|
13
|
+
# retry, via a registered `Channels::Webhook`. The engine transports the event
|
|
14
|
+
# and does not interpret it — a Slack/CRM adapter is the consumer's.
|
|
15
|
+
#
|
|
16
|
+
# Started as a child of the turn supervisor (like the tick) in serving mode;
|
|
17
|
+
# tests drive `handle` directly.
|
|
18
|
+
class AlertDispatcher
|
|
19
|
+
ALERT_TYPES = %i[budget_warning breaker_open delivery_failed].freeze
|
|
20
|
+
|
|
21
|
+
def initialize(event_stream:, outbox:, channels:, profiles:, task_store: nil, http:)
|
|
22
|
+
@event_stream = event_stream
|
|
23
|
+
@outbox = outbox
|
|
24
|
+
@channels = channels
|
|
25
|
+
@profiles = profiles
|
|
26
|
+
@task_store = task_store
|
|
27
|
+
@http = http
|
|
28
|
+
@webhook_ids = {} # url -> registered channel id (one webhook per URL)
|
|
29
|
+
# WS6 (boot recovery): webhook channels are derived from PROFILE config,
|
|
30
|
+
# not from events. Registering lazily (on the first alert) means a pending
|
|
31
|
+
# outbox row a crashed process left is swept at boot against an EMPTY
|
|
32
|
+
# registry and marked failed terminal. Pre-registering every configured
|
|
33
|
+
# URL at wiring time lets the boot sweep find the channel and deliver.
|
|
34
|
+
register_all_webhooks
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Serving: a long-lived consumer that answers every alert event. Drains on
|
|
38
|
+
# the supervisor fiber (blocks on the queue — no spin), exactly like the tick.
|
|
39
|
+
# It subscribes TYPED (only the alert events enter its queue — it answers
|
|
40
|
+
# payloads a full-traffic stream would otherwise overflow away) and, on an
|
|
41
|
+
# overflow close, RE-SUBSCRIBES: a consumer that never re-binds is how alerts
|
|
42
|
+
# stop in silence (WS6).
|
|
43
|
+
def start(parent:)
|
|
44
|
+
parent.async do |t|
|
|
45
|
+
t.annotate("insika-alerts")
|
|
46
|
+
loop do
|
|
47
|
+
subscription = @event_stream.subscribe(types: ALERT_TYPES)
|
|
48
|
+
subscription.each { |event| handle(event) }
|
|
49
|
+
# the subscription closed (its overflow path) — alerts must not die here
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
true
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# The event -> outbox row. Cheap (one transactional write); the DELIVERY is
|
|
56
|
+
# the tick's job. Never raises: an alerting failure must not break the turn.
|
|
57
|
+
def handle(event)
|
|
58
|
+
type = event.type.to_s.to_sym
|
|
59
|
+
return unless ALERT_TYPES.include?(type)
|
|
60
|
+
|
|
61
|
+
agent = agent_for(event)
|
|
62
|
+
return if agent.nil?
|
|
63
|
+
|
|
64
|
+
profile = @profiles.respond_to?(:fetch) ? @profiles.fetch(agent.to_s) : nil
|
|
65
|
+
return if profile.nil?
|
|
66
|
+
|
|
67
|
+
url = profile&.respond_to?(:alerts) ? profile.alerts&.dig("webhook") : nil
|
|
68
|
+
return if Coercion.blank?(url)
|
|
69
|
+
|
|
70
|
+
record_alert(agent: agent.to_s, url: url.to_s, event: event)
|
|
71
|
+
rescue StandardError
|
|
72
|
+
nil
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
# The agent the alert belongs to: the event carries it for the alerts the
|
|
78
|
+
# engine emits with context (budget_warning / breaker_open); a
|
|
79
|
+
# delivery_failed resolves its task's command. Guards the loop: a webhook's
|
|
80
|
+
# OWN delivery failing is not re-alerted.
|
|
81
|
+
def agent_for(event)
|
|
82
|
+
case event.type.to_sym
|
|
83
|
+
when :delivery_failed
|
|
84
|
+
channel = event.data[:channel]
|
|
85
|
+
return nil if channel.to_s.start_with?("webhook:") # loop guard
|
|
86
|
+
agent_for_task(event.meta[:task_id])
|
|
87
|
+
else
|
|
88
|
+
event.data[:agent] || agent_for_task(event.meta[:task_id])
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def agent_for_task(task_id)
|
|
93
|
+
return nil if task_id.nil? || @task_store.nil?
|
|
94
|
+
|
|
95
|
+
task = @task_store.find(task_id.to_s)
|
|
96
|
+
command = task&.respond_to?(:command) ? task.command : nil
|
|
97
|
+
return nil unless command.is_a?(Hash)
|
|
98
|
+
|
|
99
|
+
payload = command["payload"] || command[:payload] || {}
|
|
100
|
+
payload["agent"] || payload[:agent]
|
|
101
|
+
rescue StandardError
|
|
102
|
+
nil
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# The event's durable record, as the CHANNEL would see it. `to` is the
|
|
106
|
+
# webhook URL; `payload` is the event itself (type/data/meta).
|
|
107
|
+
def record_alert(agent:, url:, event:)
|
|
108
|
+
channel = webhook_id(url)
|
|
109
|
+
@outbox.create(
|
|
110
|
+
channel: channel, to: url,
|
|
111
|
+
task_id: event.meta[:task_id], session_id: event.meta[:session_id],
|
|
112
|
+
payload: { "type" => event.type.to_s, "data" => event.data,
|
|
113
|
+
"meta" => event.meta, "agent" => agent }
|
|
114
|
+
)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# One channel per URL, registered so ChannelDelivery.sweep can claim it.
|
|
118
|
+
def webhook_id(url)
|
|
119
|
+
@webhook_ids[url] ||= begin
|
|
120
|
+
id = "webhook:#{Digest::SHA1.hexdigest(url)[0, 8]}"
|
|
121
|
+
@channels.register(id, Channels::Webhook.new(url, http: @http))
|
|
122
|
+
id
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Boot face of `webhook_id`: register every configured URL up front (at
|
|
127
|
+
# wiring time, before the boot recovery's channel sweep runs). The url is
|
|
128
|
+
# PROFILE data, so it is known before any alert ever fires.
|
|
129
|
+
def register_all_webhooks
|
|
130
|
+
profiles = @profiles.respond_to?(:all) ? @profiles.all : []
|
|
131
|
+
profiles.each do |profile|
|
|
132
|
+
next unless profile&.respond_to?(:alerts)
|
|
133
|
+
|
|
134
|
+
url = profile.alerts&.dig("webhook")
|
|
135
|
+
webhook_id(url.to_s) unless Coercion.blank?(url)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
# Profile allowlist semantics, defined once: nil = all; [] = none;
|
|
5
|
+
# [names] = subset. It used to be copied in the Builder, the SkillCatalog and the
|
|
6
|
+
# policies — the rule must live in a single place.
|
|
7
|
+
module Allowlist
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
# Filters a collection by the allowlist, comparing `key.call(candidate)` (or the
|
|
11
|
+
# candidate itself) against the allowed names.
|
|
12
|
+
def filter(candidates, allow, &key)
|
|
13
|
+
return candidates if allow.nil?
|
|
14
|
+
return [] if allow.empty?
|
|
15
|
+
|
|
16
|
+
names = Array(allow).map(&:to_s)
|
|
17
|
+
candidates.select { |c| names.include?((key ? key.call(c) : c).to_s) }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Per-item boolean variant (to compose with other filters in a select).
|
|
21
|
+
def allows?(allow, value)
|
|
22
|
+
return true if allow.nil?
|
|
23
|
+
return false if allow.empty?
|
|
24
|
+
|
|
25
|
+
Array(allow).map(&:to_s).include?(value.to_s)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
# The ACCEPTED state of an agent's golden set (promoted to a store
|
|
7
|
+
# by). One record per agent in the ConfigStore (scope "baselines"):
|
|
8
|
+
#
|
|
9
|
+
# { "at" => iso8601, "cases" => { "<case id>" => { "pass" => bool, "score" => n } } }
|
|
10
|
+
#
|
|
11
|
+
# Exactly the shape `Evals::Baseline.snapshot` already produces, so the file and
|
|
12
|
+
# the record are the same document in two places — no converter to keep honest,
|
|
13
|
+
# the same discipline `GoldenStore` applies to the corpus.
|
|
14
|
+
#
|
|
15
|
+
# **Why it had to leave the file.** `evals/baseline.json` works fine for the CLI,
|
|
16
|
+
# which runs from a checkout. The refinement gate does not: it runs inside a
|
|
17
|
+
# Railway deployment, scores a throwaway clone against the accepted state, and
|
|
18
|
+
# there is no checkout there to read. A gate that fell back to "no baseline found,
|
|
19
|
+
# nothing regressed" would be the most dangerous default in the system — so the
|
|
20
|
+
# baseline became a per-agent record, and the gate refuses when it is missing.
|
|
21
|
+
#
|
|
22
|
+
# The file stays the export format and the seed for a fresh deploy
|
|
23
|
+
# (`insika evals:baseline import|export`), for the same reason the goldens' YAML
|
|
24
|
+
# does. It is also still what `evals/run.rb --gate` reads for the pre-merge check,
|
|
25
|
+
# which is a checkout-side job and should stay one.
|
|
26
|
+
#
|
|
27
|
+
# Per AGENT and not one blob, unlike the file: a deployment serves many agents, a
|
|
28
|
+
# refinement run is about exactly one, and re-baselining the store after fixing
|
|
29
|
+
# agent A must not silently accept agent B's current state.
|
|
30
|
+
class BaselineStore
|
|
31
|
+
SCOPE = "baselines"
|
|
32
|
+
|
|
33
|
+
def initialize(config_store:)
|
|
34
|
+
@cs = config_store
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# -> { "at" =>, "cases" => {…} } | nil. nil means NOT RECORDED, which every
|
|
38
|
+
# caller must treat as "cannot gate", never as "nothing regressed".
|
|
39
|
+
def get(agent_id)
|
|
40
|
+
@cs.get(SCOPE, agent_id.to_s)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# -> the stored record. `snapshot` is `Evals::Baseline.snapshot` output (or the
|
|
44
|
+
# equivalent hash); `at` is stamped here when the snapshot carries none.
|
|
45
|
+
def put(agent_id, snapshot, at: nil)
|
|
46
|
+
raw = Coercion.deep_stringify(snapshot || {})
|
|
47
|
+
cases = raw["cases"]
|
|
48
|
+
raise Insika::ValidationError, "baseline needs a 'cases' mapping" unless cases.is_a?(Hash)
|
|
49
|
+
|
|
50
|
+
record = { "at" => Coercion.presence(raw["at"]) || at || timestamp, "cases" => cases }
|
|
51
|
+
@cs.put(SCOPE, agent_id.to_s, record)
|
|
52
|
+
record
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# -> bool (did it exist?). Removing a baseline disables the gate for that agent,
|
|
56
|
+
# which is the honest consequence and not a side effect worth hiding.
|
|
57
|
+
def delete(agent_id) = !!@cs.delete(SCOPE, agent_id.to_s)
|
|
58
|
+
|
|
59
|
+
# -> [String] agents with a recorded baseline.
|
|
60
|
+
def agents = @cs.keys(SCOPE)
|
|
61
|
+
|
|
62
|
+
# How many cases the accepted state covers. `0` and `nil` are different answers:
|
|
63
|
+
# nil = never recorded, 0 = recorded and empty (every case was skipped), and only
|
|
64
|
+
# the first is a configuration mistake.
|
|
65
|
+
def size(agent_id)
|
|
66
|
+
record = get(agent_id)
|
|
67
|
+
record && (record["cases"] || {}).size
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def timestamp = Time.now.utc.iso8601
|
|
73
|
+
end
|
|
74
|
+
end
|