insika 0.1.0 → 0.3.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 +199 -5
- data/README.md +8 -2
- data/bin/insika +231 -13
- data/docs/AGENTS.md +505 -6
- data/docs/API.md +56 -0
- data/docs/CHANNELS.md +100 -10
- data/docs/CONTEXT.md +147 -19
- data/docs/DEPLOY.md +34 -11
- data/docs/EMBEDDING.md +11 -7
- data/docs/EVALS.md +20 -1
- data/docs/FACTS.md +135 -0
- data/docs/HARVEST.md +117 -0
- data/docs/LOADTEST.md +17 -10
- data/docs/OBSERVABILITY.md +65 -2
- data/docs/REFINEMENT.md +9 -9
- data/docs/RELEASING.md +34 -7
- data/docs/RUNNING-LOCAL.md +4 -4
- data/docs/SECURITY.md +85 -11
- data/docs/SKILLS.md +189 -3
- data/docs/SOAK.md +127 -0
- data/docs/TOOLS.md +70 -2
- data/docs/WHY.md +1 -1
- data/docs/WORKFLOWS.md +2 -2
- data/docs/domain.md +115 -0
- data/docs/index.md +2 -2
- data/docs/onboarding/start.md +1 -1
- data/lib/insika/agent_profile.rb +228 -26
- data/lib/insika/alert_dispatcher.rb +139 -0
- data/lib/insika/balloon_splitter.rb +102 -0
- data/lib/insika/baseline_store.rb +2 -2
- data/lib/insika/budget_ledger.rb +166 -0
- data/lib/insika/cache_series_store.rb +49 -0
- data/lib/insika/channel_delivery.rb +132 -24
- data/lib/insika/channel_registry.rb +1 -1
- data/lib/insika/channels/relay.rb +80 -6
- data/lib/insika/channels/web/widget.js +2 -2
- data/lib/insika/channels/web.rb +9 -9
- data/lib/insika/channels/webhook.rb +58 -0
- data/lib/insika/chat_builder.rb +145 -13
- data/lib/insika/checkpoint_store.rb +16 -0
- data/lib/insika/circuit_state.rb +114 -0
- data/lib/insika/coercion.rb +8 -0
- data/lib/insika/commands/agent_payload.rb +6 -4
- data/lib/insika/commands/cancel_followup.rb +49 -0
- data/lib/insika/commands/create_agent.rb +2 -2
- data/lib/insika/commands/create_session.rb +1 -1
- data/lib/insika/commands/delete_llm_provider.rb +1 -1
- data/lib/insika/commands/delete_skill.rb +43 -0
- data/lib/insika/commands/delete_tenant_data.rb +95 -0
- data/lib/insika/commands/export_customer_memory.rb +48 -0
- data/lib/insika/commands/forget_customer.rb +117 -0
- data/lib/insika/commands/freeze_funnel_baseline.rb +113 -0
- data/lib/insika/commands/gate_harvest.rb +138 -0
- data/lib/insika/commands/gate_refinement.rb +12 -12
- data/lib/insika/commands/import_mcp_tools.rb +1 -1
- data/lib/insika/commands/import_tools.rb +4 -4
- data/lib/insika/commands/issue_tenant_token.rb +41 -0
- data/lib/insika/commands/judge_shadow_pairs.rb +124 -0
- data/lib/insika/commands/memory_forget_fact.rb +20 -4
- data/lib/insika/commands/memory_put_fact.rb +23 -4
- data/lib/insika/commands/promote_harvest.rb +130 -0
- data/lib/insika/commands/record_outcome.rb +46 -0
- data/lib/insika/commands/record_shadow_reply.rb +68 -0
- data/lib/insika/commands/reject_harvest.rb +38 -0
- data/lib/insika/commands/resolve_proposal.rb +108 -0
- data/lib/insika/commands/resolve_refinement.rb +1 -1
- data/lib/insika/commands/revoke_contact.rb +49 -0
- data/lib/insika/commands/revoke_token.rb +39 -0
- data/lib/insika/commands/rollback_harvest.rb +86 -0
- data/lib/insika/commands/rotate_tenant_token.rb +43 -0
- data/lib/insika/commands/run_distillation.rb +186 -0
- data/lib/insika/commands/run_harvest.rb +393 -0
- data/lib/insika/commands/run_refinement.rb +5 -5
- data/lib/insika/commands/send_message.rb +112 -15
- data/lib/insika/commands/session_purge.rb +67 -0
- data/lib/insika/commands/set_agent_tools.rb +1 -1
- data/lib/insika/commands/set_skill_agents.rb +60 -19
- data/lib/insika/commands/trigger_workflow.rb +1 -1
- data/lib/insika/commands/update_agent.rb +1 -1
- data/lib/insika/commands/write_data_tool.rb +1 -1
- data/lib/insika/commands/write_golden.rb +1 -1
- data/lib/insika/commands/write_skill.rb +19 -9
- data/lib/insika/config_store.rb +8 -4
- data/lib/insika/contact_store.rb +183 -0
- data/lib/insika/context/builder.rb +23 -5
- data/lib/insika/context/fragment.rb +31 -3
- data/lib/insika/context/priority.rb +6 -2
- data/lib/insika/context/provider.rb +17 -3
- data/lib/insika/context/providers/briefing.rb +96 -0
- data/lib/insika/context/providers/memory.rb +16 -7
- data/lib/insika/context/providers/prompt.rb +30 -2
- data/lib/insika/context/providers/request.rb +1 -1
- data/lib/insika/context/providers/session.rb +17 -2
- data/lib/insika/context/providers/skill.rb +7 -1
- data/lib/insika/context/providers/skill_trigger.rb +128 -0
- data/lib/insika/context/providers/tool_search.rb +2 -0
- data/lib/insika/context_trace_store.rb +128 -0
- data/lib/insika/delegation_store.rb +2 -2
- data/lib/insika/distill.rb +224 -0
- data/lib/insika/distill_engine.rb +169 -0
- data/lib/insika/doctor.rb +962 -7
- data/lib/insika/dsl/runtime.rb +20 -11
- data/lib/insika/dsl/server_boot.rb +74 -4
- data/lib/insika/dsl/system.rb +1 -1
- data/lib/insika/dsl.rb +152 -15
- data/lib/insika/edge_limiter.rb +167 -8
- data/lib/insika/egress_guard.rb +3 -3
- data/lib/insika/env_schema.rb +22 -12
- data/lib/insika/errors.rb +72 -5
- data/lib/insika/evals/assertions.rb +15 -14
- data/lib/insika/evals/baseline.rb +3 -3
- data/lib/insika/evals/golden.rb +8 -8
- data/lib/insika/evals/judge.rb +7 -7
- data/lib/insika/evals/pairwise.rb +21 -9
- data/lib/insika/evals/report.rb +2 -2
- data/lib/insika/evals/runner.rb +6 -6
- data/lib/insika/evals/transport.rb +2 -2
- data/lib/insika/event_stream.rb +23 -5
- data/lib/insika/evidence.rb +183 -0
- data/lib/insika/executor.rb +1092 -160
- data/lib/insika/followup_engine.rb +207 -0
- data/lib/insika/followup_policy.rb +221 -0
- data/lib/insika/followup_store.rb +306 -0
- data/lib/insika/frontmatter.rb +1 -1
- data/lib/insika/funnel_declaration.rb +106 -0
- data/lib/insika/funnel_fold.rb +179 -0
- data/lib/insika/funnel_store.rb +163 -0
- data/lib/insika/golden_store.rb +3 -3
- data/lib/insika/grounding/matcher.rb +69 -0
- data/lib/insika/grounding.rb +44 -0
- data/lib/insika/harvest/conversion_gate.rb +159 -0
- data/lib/insika/harvest/criterion.rb +98 -0
- data/lib/insika/harvest/gate.rb +194 -0
- data/lib/insika/harvest/negative_list.rb +199 -0
- data/lib/insika/harvest.rb +241 -0
- data/lib/insika/harvest_engine.rb +193 -0
- data/lib/insika/harvest_store.rb +548 -0
- data/lib/insika/http_client.rb +3 -3
- data/lib/insika/inbound_log.rb +1 -1
- data/lib/insika/llm_configurator.rb +3 -3
- data/lib/insika/loop_detector.rb +143 -0
- data/lib/insika/mcp_http_client.rb +4 -4
- data/lib/insika/mcp_tool_ingestor.rb +6 -6
- data/lib/insika/media.rb +298 -0
- data/lib/insika/memory_audit_store.rb +85 -0
- data/lib/insika/memory_store.rb +264 -23
- data/lib/insika/message_origin.rb +8 -3
- data/lib/insika/model_resolver.rb +1 -1
- data/lib/insika/model_selection.rb +5 -4
- data/lib/insika/model_visible.rb +87 -0
- data/lib/insika/model_visible_trace_store.rb +66 -0
- data/lib/insika/onboarding.rb +8 -3
- data/lib/insika/outbox_store.rb +44 -6
- data/lib/insika/outcome_store.rb +147 -0
- data/lib/insika/overlay_tool_registry.rb +3 -4
- data/lib/insika/pack.rb +3 -3
- data/lib/insika/pack_importer.rb +17 -15
- data/lib/insika/packaging.rb +163 -0
- data/lib/insika/parity/criterion.rb +79 -0
- data/lib/insika/parity/verdict.rb +318 -0
- data/lib/insika/pending_action_store.rb +1 -1
- data/lib/insika/plugin/loader.rb +2 -2
- data/lib/insika/policy/policy.rb +1 -1
- data/lib/insika/prefix_fingerprint.rb +58 -0
- data/lib/insika/profile_source.rb +34 -7
- data/lib/insika/proposal_store.rb +271 -0
- data/lib/insika/provider_error_classifier.rb +160 -0
- data/lib/insika/queue_policy.rb +6 -3
- data/lib/insika/recovery.rb +47 -6
- data/lib/insika/refinement/candidate.rb +4 -4
- data/lib/insika/refinement/evidence_collector.rb +6 -6
- data/lib/insika/refinement/gate.rb +7 -7
- data/lib/insika/refinement/panel.rb +7 -7
- data/lib/insika/refinement/proposer.rb +10 -10
- data/lib/insika/refinement_store.rb +12 -12
- data/lib/insika/reliability.rb +211 -0
- data/lib/insika/retention.rb +281 -0
- data/lib/insika/routing.rb +101 -0
- data/lib/insika/safety/config.rb +46 -6
- data/lib/insika/safety/corpus.rb +255 -0
- data/lib/insika/safety/detectors.rb +34 -115
- data/lib/insika/safety/factory.rb +18 -5
- data/lib/insika/safety/grounding_enforcer.rb +59 -0
- data/lib/insika/safety/grounding_validator.rb +49 -0
- data/lib/insika/safety/input_guardrail.rb +20 -5
- data/lib/insika/safety/moderator.rb +19 -11
- data/lib/insika/safety/output_filter.rb +10 -6
- data/lib/insika/safety/output_validator.rb +13 -7
- data/lib/insika/safety/safe_responses.rb +1 -1
- data/lib/insika/sandbox/boundary.rb +2 -2
- data/lib/insika/sandbox.rb +1 -1
- data/lib/insika/schema_guard.rb +35 -0
- data/lib/insika/server/app.rb +366 -54
- data/lib/insika/server/boot.rb +4 -4
- data/lib/insika/server/rack_app.rb +31 -7
- data/lib/insika/server/responses.rb +58 -9
- data/lib/insika/server/tenant_auth.rb +61 -0
- data/lib/insika/session_actor.rb +11 -7
- data/lib/insika/session_store.rb +66 -3
- data/lib/insika/settings_store.rb +15 -5
- data/lib/insika/shadow_pair_store.rb +258 -0
- data/lib/insika/shutdown.rb +4 -4
- data/lib/insika/skill_catalog.rb +131 -20
- data/lib/insika/skill_store.rb +70 -22
- data/lib/insika/soak/envelope.rb +140 -0
- data/lib/insika/soak/report.rb +392 -0
- data/lib/insika/soak/runner.rb +554 -0
- data/lib/insika/steer_injector.rb +1 -1
- data/lib/insika/store.rb +11 -2
- data/lib/insika/stores/memory.rb +6 -0
- data/lib/insika/stores/sqlite.rb +8 -0
- data/lib/insika/studio/app.rb +1058 -75
- data/lib/insika/studio/assets/dist/application.css +1 -1
- data/lib/insika/studio/assets/dist/application.js +27 -26
- data/lib/insika/studio/assets/dist/favicon.svg +6 -0
- data/lib/insika/studio/forms.rb +274 -22
- data/lib/insika/studio/nav_icons.rb +7 -2
- data/lib/insika/studio/views/_message.erb +2 -2
- data/lib/insika/studio/views/agent_detail.erb +629 -86
- data/lib/insika/studio/views/agents.erb +11 -7
- data/lib/insika/studio/views/approvals.erb +4 -1
- data/lib/insika/studio/views/chats.erb +4 -1
- data/lib/insika/studio/views/customer.erb +94 -0
- data/lib/insika/studio/views/customers.erb +32 -0
- data/lib/insika/studio/views/evals.erb +4 -1
- data/lib/insika/studio/views/facts.erb +133 -0
- data/lib/insika/studio/views/followups.erb +125 -0
- data/lib/insika/studio/views/funnel.erb +106 -0
- data/lib/insika/studio/views/harvest.erb +234 -0
- data/lib/insika/studio/views/home.erb +2 -1
- data/lib/insika/studio/views/layout.erb +1 -0
- data/lib/insika/studio/views/parity.erb +147 -0
- data/lib/insika/studio/views/playground.erb +7 -1
- data/lib/insika/studio/views/refinement.erb +4 -4
- data/lib/insika/studio/views/session.erb +133 -3
- data/lib/insika/studio/views/settings.erb +9 -12
- data/lib/insika/studio/views/skills.erb +66 -12
- data/lib/insika/studio/views/system_files.erb +1 -1
- data/lib/insika/studio/views/task.erb +13 -0
- data/lib/insika/studio/views/tasks.erb +4 -1
- data/lib/insika/studio/views/tools.erb +0 -1
- data/lib/insika/subagent_graph.rb +3 -3
- data/lib/insika/task_actor.rb +3 -3
- data/lib/insika/task_store.rb +22 -2
- data/lib/insika/telemetry/pricing.rb +3 -3
- data/lib/insika/telemetry/recorder.rb +1 -1
- data/lib/insika/telemetry.rb +2 -2
- data/lib/insika/testing/store_contract.rb +54 -33
- data/lib/insika/tick.rb +146 -0
- data/lib/insika/token_store.rb +168 -0
- data/lib/insika/tool_assembly.rb +5 -5
- data/lib/insika/tool_definition.rb +25 -15
- data/lib/insika/tool_envelope.rb +70 -1
- data/lib/insika/tool_manifest.rb +11 -7
- data/lib/insika/tool_output_compressor.rb +100 -0
- data/lib/insika/tool_store.rb +1 -1
- data/lib/insika/tool_trace_store.rb +1 -1
- data/lib/insika/tools/concurrency.rb +2 -2
- data/lib/insika/tools/data_defined_tool.rb +14 -5
- data/lib/insika/tools/generate_image.rb +44 -0
- data/lib/insika/tools/load_skill.rb +61 -3
- data/lib/insika/tools/schedule_followup.rb +164 -0
- data/lib/insika/tools/stuck_signal.rb +44 -0
- data/lib/insika/tools/subagent.rb +4 -4
- data/lib/insika/tools/subagents.rb +1 -1
- data/lib/insika/tools/tts.rb +47 -0
- data/lib/insika/tools/update_briefing.rb +126 -0
- data/lib/insika/turn_output.rb +2 -2
- data/lib/insika/turn_state.rb +54 -13
- data/lib/insika/turn_timing.rb +24 -4
- data/lib/insika/usage_ledger.rb +1 -1
- data/lib/insika/version.rb +1 -1
- data/lib/insika/vitals.rb +84 -0
- data/lib/insika/wiring/graph.rb +372 -34
- data/lib/insika/workflow.rb +1 -1
- data/lib/insika/workflow_registry.rb +1 -1
- data/lib/insika.rb +122 -16
- metadata +95 -2
- data/lib/insika/server/admin_auth.rb +0 -29
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
module Commands
|
|
7
|
+
# — the double gate on ONE candidate, in order:
|
|
8
|
+
# eval first (expensive — a full golden replay), conversion second (cheap
|
|
9
|
+
# — a fold read). Synchronous control command (the Studio dispatches it;
|
|
10
|
+
# the gate is minutes of real replay, exactly like `gate_refinement`).
|
|
11
|
+
# It records both reports on the candidate and moves it: both passed ->
|
|
12
|
+
# awaiting_approval; either failed -> rejected with the reason (a failed
|
|
13
|
+
# candidate is terminal — the same finding must re-surface with new
|
|
14
|
+
# evidence, no silent retry).
|
|
15
|
+
class GateHarvest
|
|
16
|
+
def initialize(harvest_store:, gate:, conversion_gate:, criterion: nil, event_stream:)
|
|
17
|
+
@harvest_store = harvest_store
|
|
18
|
+
@gate = gate
|
|
19
|
+
@conversion_gate = conversion_gate
|
|
20
|
+
@criterion = criterion
|
|
21
|
+
@event_stream = event_stream
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# payload: { candidate_id: } (tenant from command.meta — WS1)
|
|
25
|
+
# -> Candidate (awaiting_approval | rejected | gated-parked; both
|
|
26
|
+
# reports attached)
|
|
27
|
+
def call(command)
|
|
28
|
+
p = AgentPayload.symbolize(command.payload)
|
|
29
|
+
candidate_id = AgentPayload.presence(p[:candidate_id])
|
|
30
|
+
raise Insika::ValidationError, "candidate_id is required" if candidate_id.nil?
|
|
31
|
+
|
|
32
|
+
candidate = @harvest_store.find_candidate(candidate_id) ||
|
|
33
|
+
(raise Insika::NotFoundError, "harvest candidate not found: #{candidate_id}")
|
|
34
|
+
|
|
35
|
+
# The base graph wires NO gates (the deployment owns the eval surface):
|
|
36
|
+
# the candidate parks at gated with the named reason — refuse-with-a-
|
|
37
|
+
# named-reason, never pass, never crash.
|
|
38
|
+
if @gate.nil? || @conversion_gate.nil?
|
|
39
|
+
parked = @harvest_store.attach_gate(
|
|
40
|
+
candidate.id,
|
|
41
|
+
eval_gate: { "passed" => false, "reason" => "no_eval_gate_wired" },
|
|
42
|
+
conversion_gate: { "passed" => false, "reason" => "no_conversion_gate_wired" },
|
|
43
|
+
criterion_sha: nil
|
|
44
|
+
)
|
|
45
|
+
return parked
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# The mining budget is read HERE too (the review fix): once the mining
|
|
49
|
+
# pass spent the pack's cap, the expensive golden replay is refused
|
|
50
|
+
# with the named reason — the Refinement::Budget discipline ("not
|
|
51
|
+
# gated — the budget was spent"), applied before the provider bill.
|
|
52
|
+
run = @harvest_store.find_run(candidate.run_id)
|
|
53
|
+
cap = run && run.budget && run.budget["tokens"].to_i
|
|
54
|
+
spent = run && run.cost && run.cost["spent"].to_i
|
|
55
|
+
if cap&.positive? && spent.to_i >= cap
|
|
56
|
+
parked = @harvest_store.attach_gate(
|
|
57
|
+
candidate.id,
|
|
58
|
+
eval_gate: { "passed" => false, "reason" => "budget_exceeded",
|
|
59
|
+
"spent" => spent, "tokens" => cap },
|
|
60
|
+
conversion_gate: { "passed" => false, "reason" => "budget_exceeded" },
|
|
61
|
+
criterion_sha: nil
|
|
62
|
+
)
|
|
63
|
+
return parked
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
skill = { "name" => candidate.name, "description" => candidate.description,
|
|
67
|
+
"body" => candidate.body, "triggers" => candidate.triggers }
|
|
68
|
+
eval_report = @gate.score(agent_id: candidate.agent, skill: skill,
|
|
69
|
+
run_id: candidate.run_id)
|
|
70
|
+
conversion_report = @conversion_gate.call(tenant: command.meta[:tenant],
|
|
71
|
+
agent: candidate.agent)
|
|
72
|
+
criterion_sha = @criterion ? @criterion.sha : nil
|
|
73
|
+
|
|
74
|
+
# A conversion REFUSAL (missing data — :no_frozen_baseline etc.) is NOT
|
|
75
|
+
# a candidate failure: it parks the candidate at gated with both
|
|
76
|
+
# reports and the named reason — the page shows the ruler's hole.
|
|
77
|
+
# Only a conversion FAILURE (current worse than baseline) rejects.
|
|
78
|
+
if refused?(conversion_report)
|
|
79
|
+
gated = @harvest_store.attach_gate(candidate.id,
|
|
80
|
+
eval_gate: eval_report.to_h,
|
|
81
|
+
conversion_gate: conversion_report.to_h,
|
|
82
|
+
criterion_sha: criterion_sha)
|
|
83
|
+
emit(:harvest_gated, candidate, gated, eval_report, conversion_report)
|
|
84
|
+
return gated
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
passed = eval_report.passed && conversion_report.passed
|
|
88
|
+
@harvest_store.attach_gate(candidate.id,
|
|
89
|
+
eval_gate: eval_report.to_h,
|
|
90
|
+
conversion_gate: conversion_report.to_h,
|
|
91
|
+
criterion_sha: criterion_sha)
|
|
92
|
+
result = if passed
|
|
93
|
+
@harvest_store.mark_awaiting(candidate.id)
|
|
94
|
+
else
|
|
95
|
+
reason = eval_report.passed ? conversion_report.reason : eval_report.reason
|
|
96
|
+
@harvest_store.mark_rejected(candidate.id, operator: "gate", note: reason)
|
|
97
|
+
end
|
|
98
|
+
emit(:harvest_gated, candidate, result, eval_report, conversion_report)
|
|
99
|
+
result
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
# The D6 refusals: the ruler is MISSING, not moving — the candidate
|
|
105
|
+
# parks. Anything else with passed: false is a real failure.
|
|
106
|
+
def refused?(report)
|
|
107
|
+
!report.passed && REFUSAL_REASONS.include?(report.reason)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
REFUSAL_REASONS = %i[no_criterion no_funnel no_frozen_baseline metric_mismatch no_fold
|
|
111
|
+
baseline_span_short no_baseline_rate budget_exceeded].freeze
|
|
112
|
+
|
|
113
|
+
def emit(type, candidate, result, eval_report, conversion_report)
|
|
114
|
+
@event_stream.emit(Insika::Event.new(
|
|
115
|
+
type: type,
|
|
116
|
+
# ids and verdicts, never the skill body
|
|
117
|
+
data: { run_id: candidate.run_id, candidate_id: candidate.id,
|
|
118
|
+
agent: candidate.agent,
|
|
119
|
+
eval_passed: eval_report.passed,
|
|
120
|
+
conversion_passed: conversion_report.passed,
|
|
121
|
+
reason: verdict_reason(result, eval_report, conversion_report) },
|
|
122
|
+
meta: { at: Time.now.utc.iso8601 }
|
|
123
|
+
))
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# The refusal/failure reason as an id the operator stream can act on —
|
|
127
|
+
# the symbol, not the human note.
|
|
128
|
+
def verdict_reason(result, eval_report, conversion_report)
|
|
129
|
+
return nil if result.status == "awaiting_approval"
|
|
130
|
+
|
|
131
|
+
return eval_report.reason unless eval_report.passed
|
|
132
|
+
return conversion_report.reason if conversion_report.reason
|
|
133
|
+
|
|
134
|
+
result.decision && result.decision["note"]
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
@@ -4,21 +4,21 @@ require "time"
|
|
|
4
4
|
|
|
5
5
|
module Insika
|
|
6
6
|
module Commands
|
|
7
|
-
# Control command
|
|
7
|
+
# Control command: takes a CANDIDATE for a completed
|
|
8
8
|
# refinement run, validates it against the agent's write allowlist, and scores it
|
|
9
9
|
# by actually running it — clone the agent, apply the edits to the clone, replay
|
|
10
|
-
# the golden set, compare to the accepted baseline
|
|
10
|
+
# the golden set, compare to the accepted baseline.
|
|
11
11
|
#
|
|
12
12
|
# Synchronous like `run_refinement`, and for the same reason: it is operator-paced
|
|
13
13
|
# work with a human waiting on the answer. It is NOT cheap — the replay is a real
|
|
14
14
|
# conversation per golden case — so it is fired deliberately, never on a timer
|
|
15
|
-
# (
|
|
15
|
+
# (the engine has no scheduler and this design does not add one).
|
|
16
16
|
#
|
|
17
17
|
# Payload:
|
|
18
18
|
# run_id (required) a run in :completed — the evidence the candidate answers
|
|
19
19
|
# candidate { proposer?, rationale?, edits: [ {file, op, anchor?, before, after,
|
|
20
20
|
# addresses?} ] } — or omit it and pass `propose: true` to have the
|
|
21
|
-
# configured model(s) write one from the run's findings
|
|
21
|
+
# configured model(s) write one from the run's findings.
|
|
22
22
|
# propose truthy — write the candidate with the agent's proposer PANEL.
|
|
23
23
|
# tolerance Float — overrides the configured judge-score tolerance for this gate
|
|
24
24
|
#
|
|
@@ -30,14 +30,14 @@ module Insika
|
|
|
30
30
|
# forgot the candidate should get an error, not a bill.
|
|
31
31
|
#
|
|
32
32
|
# Two refusals happen BEFORE anything is cloned, because both mean the operator
|
|
33
|
-
# has not actually enabled this: an agent still in `mode: report` (
|
|
33
|
+
# has not actually enabled this: an agent still in `mode: report` (writing
|
|
34
34
|
# is opt-in and an absent config is report-only), and a candidate whose every edit
|
|
35
35
|
# was dropped (stale, off-allowlist, over budget). Neither is worth a provider bill.
|
|
36
36
|
#
|
|
37
|
-
#
|
|
37
|
+
# makes the proposal a PANEL and the run's spend a BUDGET. The
|
|
38
38
|
# command's shape does not change: N models write N candidates, the gate scores
|
|
39
39
|
# each, and the best survivor is the one proposal a human is shown. A deployment
|
|
40
|
-
# with one proposer and no budget behaves exactly as
|
|
40
|
+
# with one proposer and no budget behaves exactly as did.
|
|
41
41
|
class GateRefinement
|
|
42
42
|
WRITE_MODES = %w[propose auto_apply].freeze
|
|
43
43
|
|
|
@@ -49,7 +49,7 @@ module Insika
|
|
|
49
49
|
|
|
50
50
|
# proposer_factory: ->(refinement_config) { [Refinement::Proposer] | Proposer | nil }.
|
|
51
51
|
# Optional — a deployment with none can still gate a candidate that arrives from
|
|
52
|
-
# the API, which is exactly what
|
|
52
|
+
# the API, which is exactly what shipped before the proposer existed.
|
|
53
53
|
# resolver: the :resolve_refinement handler, used ONLY for `mode: auto_apply`.
|
|
54
54
|
# Reusing it rather than writing files here is what keeps auto-apply honest: it
|
|
55
55
|
# goes through the same staleness re-check, the same versioned write and the
|
|
@@ -125,7 +125,7 @@ module Insika
|
|
|
125
125
|
end
|
|
126
126
|
|
|
127
127
|
# Editing an agent's instructions is opt-in, explicitly, per agent — the whole
|
|
128
|
-
# point of
|
|
128
|
+
# point of is that the reachable surface is small and declared. An absent
|
|
129
129
|
# or `report` config is not "not configured yet", it is a NO.
|
|
130
130
|
def require_write_mode!(config, agent_id)
|
|
131
131
|
mode = Coercion.presence(config["mode"]) || "report"
|
|
@@ -148,7 +148,7 @@ module Insika
|
|
|
148
148
|
raise ArgumentError, "run #{run.id} is #{run.status}, expected completed"
|
|
149
149
|
end
|
|
150
150
|
|
|
151
|
-
# The model(s) write the candidates
|
|
151
|
+
# The model(s) write the candidates. Each is shown the run's findings
|
|
152
152
|
# and the CURRENT content of the allowlisted files only — the same allowlist the
|
|
153
153
|
# builder then enforces, so a proposal cannot even name a file it may not touch.
|
|
154
154
|
#
|
|
@@ -194,7 +194,7 @@ module Insika
|
|
|
194
194
|
allow
|
|
195
195
|
end
|
|
196
196
|
|
|
197
|
-
# `mode: auto_apply`
|
|
197
|
+
# `mode: auto_apply` — the ONE path where a prompt changes with no
|
|
198
198
|
# human in the loop. Off by default and deliberately narrow: it needs the mode,
|
|
199
199
|
# a gate PASS with zero regressions, and a diff under `auto_apply_max_edits`.
|
|
200
200
|
# Everything else parks at :awaiting_approval, which is the product.
|
|
@@ -232,7 +232,7 @@ module Insika
|
|
|
232
232
|
end
|
|
233
233
|
|
|
234
234
|
# Counts and ids only, never file content: these events reach the operator
|
|
235
|
-
# stream and the same rule the findings follow applies here
|
|
235
|
+
# stream and the same rule the findings follow applies here.
|
|
236
236
|
def emit(type, run, **data)
|
|
237
237
|
@event_stream.emit(Insika::Event.new(
|
|
238
238
|
type: type,
|
|
@@ -4,7 +4,7 @@ require "time"
|
|
|
4
4
|
|
|
5
5
|
module Insika
|
|
6
6
|
module Commands
|
|
7
|
-
# Control command: LIVE MCP ingestion
|
|
7
|
+
# Control command: LIVE MCP ingestion. Receives the NAME of
|
|
8
8
|
# an MCP instance, delegates to McpToolIngestor (discovers via an injectable client
|
|
9
9
|
# -> builds the manifest -> reuses :import_tools: batch upsert + hot reload)
|
|
10
10
|
# and returns the per-tool report in the shape of :import_tools, plus
|
|
@@ -4,18 +4,18 @@ require "time"
|
|
|
4
4
|
|
|
5
5
|
module Insika
|
|
6
6
|
module Commands
|
|
7
|
-
# BATCH ingestion of data-tools from a manifest
|
|
7
|
+
# BATCH ingestion of data-tools from a manifest.
|
|
8
8
|
# Normalizes each tool (defaults + envelope adapter + endpoint→url + secret/
|
|
9
9
|
# env) via ToolManifest, UPSERTs it into the ToolStore, and RELOADS the overlay +
|
|
10
|
-
# catalog ONCE at the end — takes effect without a restart
|
|
10
|
+
# catalog ONCE at the end — takes effect without a restart. Idempotent
|
|
11
11
|
# (re-importing reconciles). Injects the deployment's `{{secret.*}}`/`{{env.*}}`
|
|
12
|
-
# resolvers
|
|
12
|
+
# resolvers: the secret NEVER comes in the manifest.
|
|
13
13
|
#
|
|
14
14
|
# ISOLATED PARTIAL FAILURE (R4): a malformed tool (invalid envelope, missing
|
|
15
15
|
# endpoint, unconfigured secret, collision with a code tool, invalid url)
|
|
16
16
|
# does NOT bring down the batch — it becomes an entry in `errors[]`. Only a STRUCTURAL error in the
|
|
17
17
|
# manifest (defaults/tools of the wrong type) raises (transport -> 422).
|
|
18
|
-
# Per-tool report in the shape of the
|
|
18
|
+
# Per-tool report in the shape of the pack importer.
|
|
19
19
|
# -> { version, created: [names], updated: [names], errors: [{tool,error}] }
|
|
20
20
|
class ImportTools
|
|
21
21
|
def initialize(tool_store:, registry:, tool_catalog:, event_stream:, secrets: ENV, env: ENV)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
module Commands
|
|
5
|
+
# WS1: issues a PER-TENANT token (multi_tenant mode). Operator-only BY
|
|
6
|
+
# CONSTRUCTION: the edge refuses a tenant principal on POST /v1/commands
|
|
7
|
+
# (403), and an internal command stamped with a tenant is refused here —
|
|
8
|
+
# a tenant can never mint credentials. The plaintext token is the response
|
|
9
|
+
# and exists nowhere else; the store keeps only its hash. -> Issue.to_h.
|
|
10
|
+
class IssueTenantToken
|
|
11
|
+
def initialize(token_store:, event_stream:)
|
|
12
|
+
@token_store = token_store
|
|
13
|
+
@event_stream = event_stream
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call(command)
|
|
17
|
+
raise Insika::ValidationError, "token commands are operator-only" if command.meta[:tenant]
|
|
18
|
+
|
|
19
|
+
tenant_id = Insika::Coercion.presence(
|
|
20
|
+
command.payload[:tenant_id] || command.payload["tenant_id"]
|
|
21
|
+
)
|
|
22
|
+
raise Insika::ValidationError, "tenant_id is required" if tenant_id.nil?
|
|
23
|
+
|
|
24
|
+
label = command.payload[:label] || command.payload["label"] || "default"
|
|
25
|
+
issue = @token_store.issue(tenant_id: tenant_id, label: label)
|
|
26
|
+
emit(issue.id, tenant_id)
|
|
27
|
+
{ token: issue.token, id: issue.id, tenant_id: tenant_id, label: label.to_s }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def emit(token_id, tenant_id)
|
|
33
|
+
@event_stream.emit(Insika::Event.new(
|
|
34
|
+
type: :tenant_token_issued,
|
|
35
|
+
data: { token_id: token_id, tenant_id: tenant_id },
|
|
36
|
+
meta: { at: Time.now.utc.iso8601 }
|
|
37
|
+
))
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
module Commands
|
|
7
|
+
# C7 — turn complete shadow pairs into judged pairs, using the judges the
|
|
8
|
+
# operator ALREADY configured (settings["evals"], through JudgePanel — the
|
|
9
|
+
# one builder the eval CLI and the refinement gate use too). Never automatic:
|
|
10
|
+
# a Studio button or the CLI dispatches it, and it states its cost
|
|
11
|
+
# (2 provider calls per judge per pair, both orders — position-bias
|
|
12
|
+
# rule) before it runs.
|
|
13
|
+
#
|
|
14
|
+
# Refusals are visible, a fake judge is not: no configured judges, or fewer
|
|
15
|
+
# models than the criterion demands, is a ValidationError — an unjudged
|
|
16
|
+
# window reads :invalid, where a window judged by judges nobody chose reads
|
|
17
|
+
# as evidence.
|
|
18
|
+
class JudgeShadowPairs
|
|
19
|
+
DEFAULT_LIMIT = 50
|
|
20
|
+
DEFAULT_EXPIRE_HOURS = 24
|
|
21
|
+
|
|
22
|
+
def initialize(shadow_pairs:, settings_store:, criterion:, llm: nil, event_stream: nil)
|
|
23
|
+
@shadow_pairs = shadow_pairs
|
|
24
|
+
@settings_store = settings_store
|
|
25
|
+
@criterion = criterion
|
|
26
|
+
@llm = llm
|
|
27
|
+
@event_stream = event_stream
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# payload: { limit: Integer = 50, agent: String|nil, expire_after_hours: Integer|nil }
|
|
31
|
+
# -> { judged:, skipped:, expired:, failed:, models: [..] }
|
|
32
|
+
def call(command)
|
|
33
|
+
payload = command.payload
|
|
34
|
+
limit = begin
|
|
35
|
+
Integer(payload[:limit] || payload["limit"] || DEFAULT_LIMIT)
|
|
36
|
+
rescue ArgumentError
|
|
37
|
+
raise ValidationError, "limit must be an integer"
|
|
38
|
+
end
|
|
39
|
+
agent = Coercion.presence(payload[:agent] || payload["agent"])
|
|
40
|
+
expire_hours = begin
|
|
41
|
+
Integer(payload[:expire_after_hours] || payload["expire_after_hours"] || DEFAULT_EXPIRE_HOURS)
|
|
42
|
+
rescue ArgumentError
|
|
43
|
+
raise ValidationError, "expire_after_hours must be an integer"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Judges BEFORE the expire: a refusal (no judges, too few models) must
|
|
47
|
+
# leave every pair untouched — expiring first would stamp :incomplete on
|
|
48
|
+
# pairs and that is terminal evidence against a click that judged nothing.
|
|
49
|
+
panel = Insika::Evals::JudgePanel.pairwise(settings, llm: @llm)
|
|
50
|
+
raise ValidationError, "no judges configured in settings['evals'] — refusing to judge without judges" if panel.nil?
|
|
51
|
+
|
|
52
|
+
pairwise, models = panel
|
|
53
|
+
minimum = @criterion.rule.min_judge_models
|
|
54
|
+
distinct = models.uniq
|
|
55
|
+
if distinct.length < minimum
|
|
56
|
+
raise ValidationError, "the criterion requires at least #{minimum} distinct judge models; " \
|
|
57
|
+
"#{distinct.length} configured (#{distinct.join(', ')})"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# A mirror that dropped a half stops looking like pending work.
|
|
61
|
+
expired = @shadow_pairs.expire(older_than: Time.now.utc - (expire_hours * 3600))
|
|
62
|
+
|
|
63
|
+
judged = 0
|
|
64
|
+
failed = 0
|
|
65
|
+
@shadow_pairs.unjudged(limit: limit, agent: agent).each do |pair|
|
|
66
|
+
if judge_pair(pair, pairwise, models)
|
|
67
|
+
judged += 1
|
|
68
|
+
else
|
|
69
|
+
failed += 1
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
skipped = @shadow_pairs.unjudged(agent: agent).length
|
|
73
|
+
|
|
74
|
+
emit_summary(judged: judged, skipped: skipped, expired: expired, failed: failed, models: models)
|
|
75
|
+
{ judged: judged, skipped: skipped, expired: expired, failed: failed, models: models }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def settings
|
|
81
|
+
(@settings_store.get || {})["evals"] || {}
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# -> true when the pair was judged. A raising judge leaves the pair
|
|
85
|
+
# :complete (retryable on the next press) and never aborts the batch; an
|
|
86
|
+
# empty side is recorded :unknown — visible, never a preference. The rescue
|
|
87
|
+
# is broad on purpose: a provider outage raises the transport's own error
|
|
88
|
+
# class (RubyLLM::Error, an HTTP status as StandardError), NOT an
|
|
89
|
+
# Insika::Error — and one broken provider must not sink the rest of the
|
|
90
|
+
# batch against this method's own contract.
|
|
91
|
+
def judge_pair(pair, pairwise, models)
|
|
92
|
+
verdict = pairwise.compare_texts(
|
|
93
|
+
ours: Insika::Parity.transcript(pair.inbound, pair.insika_reply),
|
|
94
|
+
theirs: Insika::Parity.transcript(pair.inbound, pair.incumbent_reply)
|
|
95
|
+
)
|
|
96
|
+
verdict ||= Insika::Evals::Pairwise::Verdict.new(
|
|
97
|
+
outcome: "unknown", reason: "a side of the pair was empty", vs: "agent",
|
|
98
|
+
judges: [], order_dependent: false
|
|
99
|
+
)
|
|
100
|
+
blob = verdict.to_h.merge("judged_at" => Time.now.utc.iso8601, "models" => models)
|
|
101
|
+
@shadow_pairs.record_verdict(pair.id, verdict: blob)
|
|
102
|
+
@event_stream&.emit(Insika::Event.new(
|
|
103
|
+
type: :shadow_judged,
|
|
104
|
+
data: { pair_id: pair.id, agent: pair.agent, outcome: blob[:outcome] },
|
|
105
|
+
meta: { at: Time.now.utc.iso8601 }
|
|
106
|
+
))
|
|
107
|
+
true
|
|
108
|
+
rescue StandardError
|
|
109
|
+
false
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def emit_summary(judged:, skipped:, expired:, failed:, models:)
|
|
113
|
+
return unless @event_stream
|
|
114
|
+
|
|
115
|
+
@event_stream.emit(Insika::Event.new(
|
|
116
|
+
type: :shadow_judge_batch,
|
|
117
|
+
data: { judged: judged, skipped: skipped, expired: expired,
|
|
118
|
+
failed: failed, models: models },
|
|
119
|
+
meta: { at: Time.now.utc.iso8601 }
|
|
120
|
+
))
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -7,10 +7,15 @@ module Insika
|
|
|
7
7
|
# Control command: forgets (removes) a fact from the
|
|
8
8
|
# agent's memory. Idempotent: forgetting something that doesn't exist is not an error
|
|
9
9
|
# (`existed: false`). Scoped by `tenant`. -> { existed: bool }.
|
|
10
|
+
#
|
|
11
|
+
# with an `audit_store:` collaborator, appends a content-free
|
|
12
|
+
# audit line (old_hash only — the deleted value's digest, never the
|
|
13
|
+
# value). Optional: nil = no audit.
|
|
10
14
|
class MemoryForgetFact
|
|
11
|
-
def initialize(memory_store:, event_stream:)
|
|
15
|
+
def initialize(memory_store:, event_stream:, audit_store: nil)
|
|
12
16
|
@memory_store = memory_store
|
|
13
17
|
@event_stream = event_stream
|
|
18
|
+
@audit_store = audit_store
|
|
14
19
|
end
|
|
15
20
|
|
|
16
21
|
def call(command)
|
|
@@ -19,14 +24,25 @@ module Insika
|
|
|
19
24
|
raise Insika::ValidationError, "key is required" if key.nil?
|
|
20
25
|
|
|
21
26
|
tenant = AgentPayload.presence(p[:tenant]) || command.meta[:tenant]
|
|
22
|
-
|
|
27
|
+
customer = AgentPayload.presence(p[:customer])
|
|
28
|
+
operator = AgentPayload.presence(p[:operator]) || command.meta[:operator] || "operator"
|
|
29
|
+
|
|
30
|
+
scope = @memory_store.cell_for(tenant, customer)
|
|
31
|
+
old = @memory_store.get_fact(tenant: tenant, key: key, customer: customer)
|
|
32
|
+
existed = @memory_store.forget_fact(tenant: tenant, key: key, customer: customer)
|
|
33
|
+
@audit_store&.record(
|
|
34
|
+
cell: scope, action: "forget", actor: operator, key: key,
|
|
35
|
+
tenant: tenant, customer: customer,
|
|
36
|
+
old_hash: old && MemoryAuditStore.digest(old.value)
|
|
37
|
+
)
|
|
23
38
|
@event_stream.emit(Insika::Event.new(
|
|
24
39
|
type: :memory_fact_forgotten,
|
|
25
|
-
data: { tenant: tenant, key: key,
|
|
40
|
+
data: { tenant: tenant, key: key, customer: customer,
|
|
41
|
+
existed: existed }.compact,
|
|
26
42
|
meta: { at: Time.now.utc.iso8601 }
|
|
27
43
|
))
|
|
28
44
|
{ existed: existed }
|
|
29
45
|
end
|
|
30
46
|
end
|
|
31
47
|
end
|
|
32
|
-
end
|
|
48
|
+
end
|
|
@@ -9,10 +9,16 @@ module Insika
|
|
|
9
9
|
# from WITHIN the turn (the `remember` tool); this Command is the HTTP surface the
|
|
10
10
|
# Studio uses to edit facts directly. Scoped by `tenant` (nil = _default).
|
|
11
11
|
# Synchronous; does not create a Task. -> Fact.
|
|
12
|
+
#
|
|
13
|
+
# the OPERATOR surface — every write stamps `origin: "operator"`
|
|
14
|
+
# (the `remember` tool keeps "engine") and, with an `audit_store:`
|
|
15
|
+
# collaborator, appends a content-free audit line (old/new digests). The
|
|
16
|
+
# audit is optional: nil = no audit (specs, minimal graphs).
|
|
12
17
|
class MemoryPutFact
|
|
13
|
-
def initialize(memory_store:, event_stream:)
|
|
18
|
+
def initialize(memory_store:, event_stream:, audit_store: nil)
|
|
14
19
|
@memory_store = memory_store
|
|
15
20
|
@event_stream = event_stream
|
|
21
|
+
@audit_store = audit_store
|
|
16
22
|
end
|
|
17
23
|
|
|
18
24
|
def call(command)
|
|
@@ -22,14 +28,27 @@ module Insika
|
|
|
22
28
|
raise Insika::ValidationError, "value is required" if p[:value].nil?
|
|
23
29
|
|
|
24
30
|
tenant = AgentPayload.presence(p[:tenant]) || command.meta[:tenant]
|
|
25
|
-
|
|
31
|
+
customer = AgentPayload.presence(p[:customer])
|
|
32
|
+
operator = AgentPayload.presence(p[:operator]) || command.meta[:operator] || "operator"
|
|
33
|
+
|
|
34
|
+
scope = @memory_store.cell_for(tenant, customer)
|
|
35
|
+
old = @memory_store.get_fact(tenant: tenant, key: key, customer: customer)
|
|
36
|
+
fact = @memory_store.put_fact(tenant: tenant, key: key, value: p[:value],
|
|
37
|
+
customer: customer, origin: "operator",
|
|
38
|
+
expires_at: AgentPayload.presence(p[:expires_at]))
|
|
39
|
+
@audit_store&.record(
|
|
40
|
+
cell: scope, action: "put", actor: operator, key: key,
|
|
41
|
+
tenant: tenant, customer: customer,
|
|
42
|
+
old_hash: old && MemoryAuditStore.digest(old.value),
|
|
43
|
+
new_hash: MemoryAuditStore.digest(fact.value)
|
|
44
|
+
)
|
|
26
45
|
@event_stream.emit(Insika::Event.new(
|
|
27
46
|
type: :memory_fact_put,
|
|
28
|
-
data: { tenant: tenant, key: key },
|
|
47
|
+
data: { tenant: tenant, key: key, customer: customer }.compact,
|
|
29
48
|
meta: { at: Time.now.utc.iso8601 }
|
|
30
49
|
))
|
|
31
50
|
fact
|
|
32
51
|
end
|
|
33
52
|
end
|
|
34
53
|
end
|
|
35
|
-
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
module Commands
|
|
7
|
+
# — the human's answer, the ONLY path that lands a
|
|
8
|
+
# skill. Snapshot FIRST (the pre-promotion state), then the two existing
|
|
9
|
+
# write commands, then the append-only log row (D8). A promotion row's
|
|
10
|
+
# `rolled_back_at` makes the log the single ledger; re-promotion after a
|
|
11
|
+
# rollback is a NEW row with a NEW snapshot. The candidate flips to
|
|
12
|
+
# `promoted` ONLY after the writes land (record-after).
|
|
13
|
+
class PromoteHarvest
|
|
14
|
+
def initialize(harvest_store:, skill_store:, skill_catalog:, profile_source:,
|
|
15
|
+
criterion:, conversion_gate:, event_stream:,
|
|
16
|
+
write_skill: nil, set_skill_agents: nil)
|
|
17
|
+
@harvest_store = harvest_store
|
|
18
|
+
@skill_store = skill_store
|
|
19
|
+
@skill_catalog = skill_catalog
|
|
20
|
+
@profiles = ProfileSource.coerce(profile_source)
|
|
21
|
+
@criterion = criterion
|
|
22
|
+
@conversion_gate = conversion_gate
|
|
23
|
+
@event_stream = event_stream
|
|
24
|
+
@write_skill = write_skill ||
|
|
25
|
+
Insika::Commands::WriteSkill.new(skill_store: skill_store,
|
|
26
|
+
skill_catalog: skill_catalog,
|
|
27
|
+
event_stream: event_stream)
|
|
28
|
+
@set_skill_agents = set_skill_agents ||
|
|
29
|
+
Insika::Commands::SetSkillAgents.new(profile_source: @profiles,
|
|
30
|
+
event_stream: event_stream)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# payload: { candidate_id:, operator?, note? }
|
|
34
|
+
# operator from payload || command.meta[:operator] || "operator"
|
|
35
|
+
# -> Candidate (promoted | gated — the conversion re-check moved, D8-bis)
|
|
36
|
+
def call(command)
|
|
37
|
+
p = AgentPayload.symbolize(command.payload)
|
|
38
|
+
candidate_id = AgentPayload.presence(p[:candidate_id])
|
|
39
|
+
raise Insika::ValidationError, "candidate_id is required" if candidate_id.nil?
|
|
40
|
+
|
|
41
|
+
candidate = @harvest_store.find_candidate(candidate_id) ||
|
|
42
|
+
(raise Insika::NotFoundError, "harvest candidate not found: #{candidate_id}")
|
|
43
|
+
operator = (AgentPayload.presence(p[:operator]) || command.meta[:operator] || "operator").to_s
|
|
44
|
+
|
|
45
|
+
# D8-bis — the conversion ruler is RE-READ at the moment the skill
|
|
46
|
+
# lands (cheap: a fold read). A dip below the frozen baseline since
|
|
47
|
+
# gating PARKS the candidate at gated with the current numbers — the
|
|
48
|
+
# operator re-decides alertly. The eval gate is NOT re-run (expensive,
|
|
49
|
+
# and the skill body is not anchored to instruction files — nothing
|
|
50
|
+
# drifted to re-validate). A base graph without a conversion gate
|
|
51
|
+
# refuses with the named reason — never passes, never crashes.
|
|
52
|
+
current = if @conversion_gate
|
|
53
|
+
@conversion_gate.call(tenant: command.meta[:tenant], agent: candidate.agent)
|
|
54
|
+
else
|
|
55
|
+
Insika::Harvest::ConversionGate::Result.new(
|
|
56
|
+
passed: false, reason: :no_conversion_gate_wired, metric: nil, window: nil,
|
|
57
|
+
current: nil, baseline: nil, threshold: nil, snapshot_ref: nil
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
return @harvest_store.recheck_conversion(candidate.id, conversion_gate: current.to_h) unless current.passed
|
|
61
|
+
|
|
62
|
+
# The criterion must STILL be the frozen one (D8-bis): a criterion that
|
|
63
|
+
# changed between boot and promotion is a criterion nobody froze.
|
|
64
|
+
unless @criterion && @criterion.sha == candidate.criterion_sha
|
|
65
|
+
raise Insika::ValidationError,
|
|
66
|
+
"the harvest criterion changed since boot (#{candidate.criterion_sha.inspect} -> " \
|
|
67
|
+
"#{@criterion&.sha.inspect}) — re-gate the candidate against the new frozen file"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Snapshot first, then the writes, then the log row, then the flip.
|
|
71
|
+
existed = @skill_store.get(candidate.name, agent: candidate.agent)
|
|
72
|
+
# D8 + the review fix: the snapshot records EVERY agent the skill was
|
|
73
|
+
# enabled for BEFORE this promotion (SetSkillAgents' vocabulary). A
|
|
74
|
+
# homonymous skill allowed by B must stay allowed when A promotes —
|
|
75
|
+
# and the rollback restores exactly this set, B included.
|
|
76
|
+
enabled_for = agents_with_skill(candidate.name)
|
|
77
|
+
snapshot = @harvest_store.create_snapshot(
|
|
78
|
+
agent: candidate.agent, skill: candidate.name, content: existed,
|
|
79
|
+
existed: !existed.nil?,
|
|
80
|
+
enabled_for: enabled_for
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
@write_skill.call(Insika::Command.build(:write_skill,
|
|
84
|
+
{ name: candidate.name, content: candidate.body,
|
|
85
|
+
agent: candidate.agent }))
|
|
86
|
+
# ENABLE for the candidate's agent WITHOUT disabling anyone else who
|
|
87
|
+
# already had it: the wanted set is the pre-promotion holders plus the
|
|
88
|
+
# candidate.
|
|
89
|
+
@set_skill_agents.call(Insika::Command.build(:set_skill_agents,
|
|
90
|
+
{ name: candidate.name,
|
|
91
|
+
agent_ids: enabled_for | [candidate.agent] }))
|
|
92
|
+
|
|
93
|
+
promotion = @harvest_store.append_promotion(
|
|
94
|
+
id: SecureRandom.uuid, agent: candidate.agent, skill: candidate.name,
|
|
95
|
+
origin: candidate.origin, eval_ref: "cand:#{candidate.id}",
|
|
96
|
+
conversion_ref: candidate.conversion_gate && candidate.conversion_gate["snapshot_ref"],
|
|
97
|
+
approver: operator, snapshot_ref: snapshot.id,
|
|
98
|
+
criterion_sha: candidate.criterion_sha
|
|
99
|
+
)
|
|
100
|
+
@harvest_store.mark_promoted(candidate.id, promotion_ref: promotion.id)
|
|
101
|
+
|
|
102
|
+
emit(:skill_promoted, agent: candidate.agent, skill: candidate.name,
|
|
103
|
+
candidate_id: candidate.id, snapshot_ref: snapshot.id,
|
|
104
|
+
promotion_ref: promotion.id, approver: operator)
|
|
105
|
+
@harvest_store.find_candidate(candidate.id)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
def profile_of(agent_id)
|
|
111
|
+
@profiles.fetch(agent_id) ||
|
|
112
|
+
(raise Insika::NotFoundError, "agent '#{agent_id}' not configured")
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Every agent whose allowlist currently names the skill — the pre-
|
|
116
|
+
# promotion holder set (an agent with skills=nil has ALL, so it holds
|
|
117
|
+
# every name and is not "removable"; SetSkillAgents skips it).
|
|
118
|
+
def agents_with_skill(name)
|
|
119
|
+
@profiles.all.select { |p| Array(p.skills).include?(name) }.map(&:id)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def emit(type, **data)
|
|
123
|
+
# ids and refs only — the skill body never enters the stream (D7).
|
|
124
|
+
@event_stream.emit(Insika::Event.new(
|
|
125
|
+
type: type, data: data, meta: { at: Time.now.utc.iso8601 }
|
|
126
|
+
))
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|