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,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
module Commands
|
|
7
|
+
# BATCH ingestion of data-tools from a manifest (Phase 7, Step B).
|
|
8
|
+
# Normalizes each tool (defaults + envelope adapter + endpoint→url + secret/
|
|
9
|
+
# env) via ToolManifest, UPSERTs it into the ToolStore, and RELOADS the overlay +
|
|
10
|
+
# catalog ONCE at the end — takes effect without a restart (NF3). Idempotent
|
|
11
|
+
# (re-importing reconciles). Injects the deployment's `{{secret.*}}`/`{{env.*}}`
|
|
12
|
+
# resolvers (D6/open-q2): the secret NEVER comes in the manifest.
|
|
13
|
+
#
|
|
14
|
+
# ISOLATED PARTIAL FAILURE (R4): a malformed tool (invalid envelope, missing
|
|
15
|
+
# endpoint, unconfigured secret, collision with a code tool, invalid url)
|
|
16
|
+
# does NOT bring down the batch — it becomes an entry in `errors[]`. Only a STRUCTURAL error in the
|
|
17
|
+
# manifest (defaults/tools of the wrong type) raises (transport -> 422).
|
|
18
|
+
# Per-tool report in the shape of the Phase 6 pack importer.
|
|
19
|
+
# -> { version, created: [names], updated: [names], errors: [{tool,error}] }
|
|
20
|
+
class ImportTools
|
|
21
|
+
def initialize(tool_store:, registry:, tool_catalog:, event_stream:, secrets: ENV, env: ENV)
|
|
22
|
+
@tool_store = tool_store
|
|
23
|
+
@registry = registry
|
|
24
|
+
@tool_catalog = tool_catalog
|
|
25
|
+
@event_stream = event_stream
|
|
26
|
+
@secrets = secrets
|
|
27
|
+
@env = env
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def call(command)
|
|
31
|
+
manifest = Insika::ToolManifest.from_h(command.payload) # structural -> 422
|
|
32
|
+
report = { version: manifest.version, created: [], updated: [], errors: [] }
|
|
33
|
+
|
|
34
|
+
manifest.tools.each_with_index do |raw, i|
|
|
35
|
+
import_one(manifest, raw, i, report)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
reload_hot unless report[:created].empty? && report[:updated].empty?
|
|
39
|
+
emit(report)
|
|
40
|
+
report
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def import_one(manifest, raw, index, report)
|
|
46
|
+
defn = manifest.definition_for(raw, secrets: @secrets, env: @env)
|
|
47
|
+
name = defn["name"].to_s
|
|
48
|
+
raise Insika::ValidationError, "'#{name}' is already a code tool" if @registry.code_tool?(name)
|
|
49
|
+
|
|
50
|
+
existed = !@tool_store.get(name).nil?
|
|
51
|
+
@tool_store.write(defn)
|
|
52
|
+
(existed ? report[:updated] : report[:created]) << name
|
|
53
|
+
rescue Insika::Error => e
|
|
54
|
+
report[:errors] << { tool: tool_label(raw, index), error: e.message }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def reload_hot
|
|
58
|
+
@registry.reload
|
|
59
|
+
@tool_catalog.reload
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Tool name for the error report; falls back to the index when the envelope
|
|
63
|
+
# didn't even carry a name (keeps the error entry from being anonymous).
|
|
64
|
+
def tool_label(raw, index)
|
|
65
|
+
h = raw.is_a?(Hash) ? raw : {}
|
|
66
|
+
name = h["name"] || h[:name] || h.dig("function", "name") || h.dig(:function, :name)
|
|
67
|
+
Insika::Coercion.presence(name) || "#<tool ##{index}>"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Emits only COUNTS + names (never headers/secrets): 0 leakage.
|
|
71
|
+
def emit(report)
|
|
72
|
+
@event_stream.emit(Insika::Event.new(
|
|
73
|
+
type: :tools_imported,
|
|
74
|
+
data: { created: report[:created].size, updated: report[:updated].size,
|
|
75
|
+
errors: report[:errors].size },
|
|
76
|
+
meta: { at: Time.now.utc.iso8601 }
|
|
77
|
+
))
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
module Commands
|
|
7
|
+
# Control command: appends a free-form note
|
|
8
|
+
# (append-only) to the agent's memory (MemoryStore, `notes` layer). Scoped
|
|
9
|
+
# by `tenant`. Synchronous; does not create a Task. -> Note.
|
|
10
|
+
class MemoryAddNote
|
|
11
|
+
def initialize(memory_store:, event_stream:)
|
|
12
|
+
@memory_store = memory_store
|
|
13
|
+
@event_stream = event_stream
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call(command)
|
|
17
|
+
p = AgentPayload.symbolize(command.payload)
|
|
18
|
+
text = AgentPayload.presence(p[:text])
|
|
19
|
+
raise Insika::ValidationError, "text is required" if text.nil?
|
|
20
|
+
|
|
21
|
+
tenant = AgentPayload.presence(p[:tenant]) || command.meta[:tenant]
|
|
22
|
+
note = @memory_store.add_note(tenant: tenant, text: text)
|
|
23
|
+
@event_stream.emit(Insika::Event.new(
|
|
24
|
+
type: :memory_note_added,
|
|
25
|
+
data: { tenant: tenant, note_id: note.id },
|
|
26
|
+
meta: { at: Time.now.utc.iso8601 }
|
|
27
|
+
))
|
|
28
|
+
note
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
module Commands
|
|
7
|
+
# Control command: forgets (removes) a fact from the
|
|
8
|
+
# agent's memory. Idempotent: forgetting something that doesn't exist is not an error
|
|
9
|
+
# (`existed: false`). Scoped by `tenant`. -> { existed: bool }.
|
|
10
|
+
class MemoryForgetFact
|
|
11
|
+
def initialize(memory_store:, event_stream:)
|
|
12
|
+
@memory_store = memory_store
|
|
13
|
+
@event_stream = event_stream
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call(command)
|
|
17
|
+
p = AgentPayload.symbolize(command.payload)
|
|
18
|
+
key = AgentPayload.presence(p[:key])
|
|
19
|
+
raise Insika::ValidationError, "key is required" if key.nil?
|
|
20
|
+
|
|
21
|
+
tenant = AgentPayload.presence(p[:tenant]) || command.meta[:tenant]
|
|
22
|
+
existed = @memory_store.forget_fact(tenant: tenant, key: key)
|
|
23
|
+
@event_stream.emit(Insika::Event.new(
|
|
24
|
+
type: :memory_fact_forgotten,
|
|
25
|
+
data: { tenant: tenant, key: key, existed: existed },
|
|
26
|
+
meta: { at: Time.now.utc.iso8601 }
|
|
27
|
+
))
|
|
28
|
+
{ existed: existed }
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
module Commands
|
|
7
|
+
# Control command: writes a stable fact to the agent's
|
|
8
|
+
# memory (MemoryStore, `profile` layer). Until now memory was only written
|
|
9
|
+
# from WITHIN the turn (the `remember` tool); this Command is the HTTP surface the
|
|
10
|
+
# Studio uses to edit facts directly. Scoped by `tenant` (nil = _default).
|
|
11
|
+
# Synchronous; does not create a Task. -> Fact.
|
|
12
|
+
class MemoryPutFact
|
|
13
|
+
def initialize(memory_store:, event_stream:)
|
|
14
|
+
@memory_store = memory_store
|
|
15
|
+
@event_stream = event_stream
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def call(command)
|
|
19
|
+
p = AgentPayload.symbolize(command.payload)
|
|
20
|
+
key = AgentPayload.presence(p[:key])
|
|
21
|
+
raise Insika::ValidationError, "key is required" if key.nil?
|
|
22
|
+
raise Insika::ValidationError, "value is required" if p[:value].nil?
|
|
23
|
+
|
|
24
|
+
tenant = AgentPayload.presence(p[:tenant]) || command.meta[:tenant]
|
|
25
|
+
fact = @memory_store.put_fact(tenant: tenant, key: key, value: p[:value])
|
|
26
|
+
@event_stream.emit(Insika::Event.new(
|
|
27
|
+
type: :memory_fact_put,
|
|
28
|
+
data: { tenant: tenant, key: key },
|
|
29
|
+
meta: { at: Time.now.utc.iso8601 }
|
|
30
|
+
))
|
|
31
|
+
fact
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
module Commands
|
|
5
|
+
# Control command: posts `:pause` to the Task's in-process
|
|
6
|
+
# mailbox and responds synchronously. Payload `{ task_id: String }` -> returns Task.
|
|
7
|
+
#
|
|
8
|
+
# Cooperative: the one that transitions `running -> paused` and emits
|
|
9
|
+
# `:task_paused` is the task's fiber when it drains at the next boundary — NEVER this
|
|
10
|
+
# handler. Pausing a task with no live fiber (terminal/orphaned) is an idempotent no-op.
|
|
11
|
+
class PauseTask
|
|
12
|
+
def initialize(task_store:, executor:)
|
|
13
|
+
@task_store = task_store
|
|
14
|
+
@executor = executor
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def call(command)
|
|
18
|
+
task_id = command.payload[:task_id] || command.payload["task_id"]
|
|
19
|
+
raise Insika::ValidationError, "task_id is required" if task_id.to_s.empty?
|
|
20
|
+
|
|
21
|
+
task = @task_store.find(task_id)
|
|
22
|
+
raise Insika::NotFoundError, "task '#{task_id}' not found" unless task
|
|
23
|
+
|
|
24
|
+
@executor.pause(task_id) # no-op if there is no live fiber in this process
|
|
25
|
+
@task_store.find(task_id) # current state after the post
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
module Commands
|
|
7
|
+
# Control command (RFC-0013 §3.6): the human's answer to a gated proposal.
|
|
8
|
+
#
|
|
9
|
+
# approve -> each edit is written through `AgentFileStore#write`, which pushes
|
|
10
|
+
# the previous content into `history`. Rollback is therefore already
|
|
11
|
+
# built: `restore_agent_file` with index 0, the same button the file
|
|
12
|
+
# editor has had since the Studio existed.
|
|
13
|
+
# reject -> recorded on the run with the operator's note. The finding must
|
|
14
|
+
# re-surface with new evidence before anything is proposed again;
|
|
15
|
+
# nothing retries on its own.
|
|
16
|
+
#
|
|
17
|
+
# Payload `{ run_id:, decision: "approved"|"rejected", operator?, note? }`.
|
|
18
|
+
# -> the resolved Run.
|
|
19
|
+
#
|
|
20
|
+
# **The candidate is re-validated against the CURRENT files before anything is
|
|
21
|
+
# written.** The gate scored a snapshot; an operator approves minutes or days
|
|
22
|
+
# later, and in between a human may have edited the same file in the Studio.
|
|
23
|
+
# Applying an edit whose `before` no longer matches would silently overwrite that
|
|
24
|
+
# person's work — the exact failure the anchored format exists to prevent, thrown
|
|
25
|
+
# away at the last step. A stale approval is refused and the operator re-gates.
|
|
26
|
+
class ResolveRefinement
|
|
27
|
+
def initialize(profiles:, refinement_store:, agent_file_store:, event_stream:)
|
|
28
|
+
@profiles = ProfileSource.coerce(profiles)
|
|
29
|
+
@runs = refinement_store
|
|
30
|
+
@agent_files = agent_file_store
|
|
31
|
+
@event_stream = event_stream
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def call(command)
|
|
35
|
+
p = AgentPayload.symbolize(command.payload)
|
|
36
|
+
run_id = AgentPayload.presence(p[:run_id])
|
|
37
|
+
decision = AgentPayload.presence(p[:decision]).to_s
|
|
38
|
+
raise Insika::ValidationError, "run_id is required" if run_id.nil?
|
|
39
|
+
|
|
40
|
+
run = @runs.find(run_id) || (raise Insika::NotFoundError, "refinement run not found: #{run_id}")
|
|
41
|
+
operator = p[:operator] || command.meta[:operator]
|
|
42
|
+
|
|
43
|
+
case decision
|
|
44
|
+
when "rejected"
|
|
45
|
+
resolved = @runs.resolve(run.id, decision: :rejected, operator: operator, note: p[:note])
|
|
46
|
+
emit(:refinement_rejected, resolved, files: [])
|
|
47
|
+
resolved
|
|
48
|
+
when "approved"
|
|
49
|
+
apply(run, operator: operator, note: p[:note])
|
|
50
|
+
else
|
|
51
|
+
raise Insika::ValidationError, "invalid decision: #{decision} (approved|rejected)"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def apply(run, operator:, note:)
|
|
58
|
+
unless run.awaiting_approval?
|
|
59
|
+
raise ArgumentError, "run #{run.id} is #{run.status}, expected awaiting_approval"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
config = refinement_config(run.agent_id)
|
|
63
|
+
contents = current_files(run.agent_id)
|
|
64
|
+
candidate = rebuild(run, config, contents)
|
|
65
|
+
|
|
66
|
+
written = candidate.apply(contents)
|
|
67
|
+
written.each { |file, body| @agent_files.write(run.agent_id, file, body) }
|
|
68
|
+
|
|
69
|
+
# Recorded AFTER the writes land: a crash in between leaves the run awaiting
|
|
70
|
+
# approval and the operator approves again. Re-approving re-validates against
|
|
71
|
+
# the (now edited) files and refuses as stale, which is a confusing message but
|
|
72
|
+
# a safe outcome — recording `applied` before writing would be a lie the
|
|
73
|
+
# history could not correct.
|
|
74
|
+
resolved = @runs.resolve(run.id, decision: :applied, operator: operator, note: note)
|
|
75
|
+
emit(:refinement_applied, resolved, files: written.keys, edits: candidate.edits.size)
|
|
76
|
+
resolved
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Re-runs the SAME validator the gate used, against the files as they are now.
|
|
80
|
+
# Anything that drifted comes back as a drop, and any drop at all refuses the
|
|
81
|
+
# apply: a partial application would land some edits and silently skip others,
|
|
82
|
+
# leaving a prompt in a state no one reviewed and the gate never scored.
|
|
83
|
+
def rebuild(run, config, contents)
|
|
84
|
+
allowlist = Array(config["files"]).map(&:to_s)
|
|
85
|
+
raw = { "proposer" => (run.candidate || {})["proposer"],
|
|
86
|
+
"rationale" => (run.candidate || {})["rationale"],
|
|
87
|
+
"edits" => run.edits }
|
|
88
|
+
candidate = Refinement::CandidateBuilder.build(raw, allowlist: allowlist,
|
|
89
|
+
contents: contents, limits: config)
|
|
90
|
+
|
|
91
|
+
if candidate.dropped.any? || candidate.edits.size != run.edits.size
|
|
92
|
+
reasons = candidate.dropped.map { |d| "#{d.file}: #{d.reason}" }
|
|
93
|
+
raise Insika::ValidationError,
|
|
94
|
+
"the files changed since this proposal was gated, so it no longer applies " \
|
|
95
|
+
"(#{reasons.join('; ')}). Re-run the gate against the current files."
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
candidate
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def refinement_config(agent_id)
|
|
102
|
+
profile = @profiles[agent_id] ||
|
|
103
|
+
(raise Insika::NotFoundError, "agent '#{agent_id}' not configured")
|
|
104
|
+
raw = profile.respond_to?(:refinement) ? profile.refinement : nil
|
|
105
|
+
raw.is_a?(Hash) ? Coercion.deep_stringify(raw) : {}
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def current_files(agent_id)
|
|
109
|
+
@agent_files.list(agent_id).each_with_object({}) do |name, acc|
|
|
110
|
+
acc[name] = @agent_files.read(agent_id, name).to_s
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# File NAMES are fine on the stream (the operator needs to know what changed);
|
|
115
|
+
# their content is not, and never appears here.
|
|
116
|
+
def emit(type, run, **data)
|
|
117
|
+
@event_stream.emit(Insika::Event.new(
|
|
118
|
+
type: type,
|
|
119
|
+
data: { agent: run.agent_id, run_id: run.id, status: run.status.to_s,
|
|
120
|
+
by: run.decision && run.decision["by"], **data },
|
|
121
|
+
meta: { at: Time.now.utc.iso8601 }
|
|
122
|
+
))
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -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,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
module Commands
|
|
7
|
+
# Control command (RFC-0013 phase A): 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 (§3.10 — 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
|
+
# Phase A writes NOTHING to the agent, so it needs no opt-in: an absent
|
|
27
|
+
# `refinement` config reads as report-only. Only `propose`/`auto_apply` (phase C)
|
|
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 (item 23).
|
|
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
|