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,245 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
module Commands
|
|
7
|
+
# Control command: takes a CANDIDATE for a completed
|
|
8
|
+
# refinement run, validates it against the agent's write allowlist, and scores it
|
|
9
|
+
# by actually running it — clone the agent, apply the edits to the clone, replay
|
|
10
|
+
# the golden set, compare to the accepted baseline.
|
|
11
|
+
#
|
|
12
|
+
# Synchronous like `run_refinement`, and for the same reason: it is operator-paced
|
|
13
|
+
# work with a human waiting on the answer. It is NOT cheap — the replay is a real
|
|
14
|
+
# conversation per golden case — so it is fired deliberately, never on a timer
|
|
15
|
+
# (the engine has no scheduler and this RFC does not add one).
|
|
16
|
+
#
|
|
17
|
+
# Payload:
|
|
18
|
+
# run_id (required) a run in :completed — the evidence the candidate answers
|
|
19
|
+
# candidate { proposer?, rationale?, edits: [ {file, op, anchor?, before, after,
|
|
20
|
+
# addresses?} ] } — or omit it and pass `propose: true` to have the
|
|
21
|
+
# configured model(s) write one from the run's findings.
|
|
22
|
+
# propose truthy — write the candidate with the agent's proposer PANEL.
|
|
23
|
+
# tolerance Float — overrides the configured judge-score tolerance for this gate
|
|
24
|
+
#
|
|
25
|
+
# -> the Run, now :awaiting_approval (gate passed), :applied (`mode: auto_apply`
|
|
26
|
+
# and the candidate cleared the extra bar) or :rejected (the gate said no).
|
|
27
|
+
#
|
|
28
|
+
# **`propose: true` is required rather than inferred from a missing candidate.**
|
|
29
|
+
# Both a proposal and a gate cost real provider money, and a caller who simply
|
|
30
|
+
# forgot the candidate should get an error, not a bill.
|
|
31
|
+
#
|
|
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` (writing
|
|
34
|
+
# is opt-in and an absent config is report-only), and a candidate whose every edit
|
|
35
|
+
# was dropped (stale, off-allowlist, over budget). Neither is worth a provider bill.
|
|
36
|
+
#
|
|
37
|
+
# makes the proposal a PANEL and the run's spend a BUDGET. The
|
|
38
|
+
# command's shape does not change: N models write N candidates, the gate scores
|
|
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 did.
|
|
41
|
+
class GateRefinement
|
|
42
|
+
WRITE_MODES = %w[propose auto_apply].freeze
|
|
43
|
+
|
|
44
|
+
# An unattended write is held to a SMALLER bar than an approved one: one edit,
|
|
45
|
+
# by default. `auto_apply` exists so a well-understood, well-golden'd agent can
|
|
46
|
+
# fix a typo'd instruction overnight — not so it can rewrite three files while
|
|
47
|
+
# nobody is looking. The operator raises it deliberately.
|
|
48
|
+
DEFAULT_AUTO_APPLY_MAX_EDITS = 1
|
|
49
|
+
|
|
50
|
+
# proposer_factory: ->(refinement_config) { [Refinement::Proposer] | Proposer | nil }.
|
|
51
|
+
# Optional — a deployment with none can still gate a candidate that arrives from
|
|
52
|
+
# the API, which is exactly what shipped before the proposer existed.
|
|
53
|
+
# resolver: the :resolve_refinement handler, used ONLY for `mode: auto_apply`.
|
|
54
|
+
# Reusing it rather than writing files here is what keeps auto-apply honest: it
|
|
55
|
+
# goes through the same staleness re-check, the same versioned write and the
|
|
56
|
+
# same `:refinement_applied` event a human approval does.
|
|
57
|
+
def initialize(profiles:, refinement_store:, agent_file_store:, gate:, event_stream:,
|
|
58
|
+
proposer_factory: nil, resolver: nil)
|
|
59
|
+
@profiles = ProfileSource.coerce(profiles)
|
|
60
|
+
@runs = refinement_store
|
|
61
|
+
@agent_files = agent_file_store
|
|
62
|
+
@gate = gate
|
|
63
|
+
@event_stream = event_stream
|
|
64
|
+
@proposer_factory = proposer_factory
|
|
65
|
+
@resolver = resolver
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def call(command)
|
|
69
|
+
p = AgentPayload.symbolize(command.payload)
|
|
70
|
+
run_id = AgentPayload.presence(p[:run_id])
|
|
71
|
+
raise Insika::ValidationError, "run_id is required" if run_id.nil?
|
|
72
|
+
|
|
73
|
+
run = @runs.find(run_id) || (raise Insika::NotFoundError, "refinement run not found: #{run_id}")
|
|
74
|
+
profile = @profiles[run.agent_id] ||
|
|
75
|
+
(raise Insika::NotFoundError, "agent '#{run.agent_id}' not configured")
|
|
76
|
+
config = refinement_config(profile)
|
|
77
|
+
require_write_mode!(config, run.agent_id)
|
|
78
|
+
require_completed!(run)
|
|
79
|
+
|
|
80
|
+
result = run_panel(run, config, p)
|
|
81
|
+
report = (result.winner || Refinement::Panel.best_refusal(result.entries)).report
|
|
82
|
+
|
|
83
|
+
gated = @runs.gated(run.id, report: report, panel: result.entries,
|
|
84
|
+
cost: result.budget)
|
|
85
|
+
emit(:refinement_gated, gated, passed: report.passed, reason: report.reason,
|
|
86
|
+
cases: report.cases, passed_cases: report.passed_cases,
|
|
87
|
+
regressions: report.regressions.size,
|
|
88
|
+
candidates: result.entries.size,
|
|
89
|
+
tokens: result.budget.to_h["spent"])
|
|
90
|
+
auto_apply(gated, config, report) || gated
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
# Propose (a panel, or the caller's own candidate), build, gate, rank. The
|
|
96
|
+
# `gating` status and the `:refinement_proposed` event land BEFORE any scoring,
|
|
97
|
+
# because the scoring is minutes of real replay and a run that says nothing
|
|
98
|
+
# until it finishes looks hung.
|
|
99
|
+
def run_panel(run, config, payload)
|
|
100
|
+
panel = Refinement::Panel.new(
|
|
101
|
+
gate: @gate, proposers: proposers_for(run, config, payload),
|
|
102
|
+
budget: Refinement::Budget.new(tokens: budget_tokens(config))
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
panel.run(agent_id: run.agent_id, run_id: run.id, findings: run.findings,
|
|
106
|
+
files: writable_files(run.agent_id, config),
|
|
107
|
+
allowlist: allowlist!(run.agent_id, config),
|
|
108
|
+
contents: current_files(run.agent_id), limits: config,
|
|
109
|
+
raw: payload[:candidate], tolerance: payload[:tolerance]) do |entries|
|
|
110
|
+
@runs.gating(run.id, candidates: entries)
|
|
111
|
+
emit(:refinement_proposed, run, candidates: entries.size,
|
|
112
|
+
proposers: entries.flat_map(&:proposers).uniq,
|
|
113
|
+
edits: entries.sum { |e| e.candidate.edits.size })
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def budget_tokens(config)
|
|
118
|
+
budget = config["budget"]
|
|
119
|
+
budget.is_a?(Hash) ? budget["tokens"] : nil
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def refinement_config(profile)
|
|
123
|
+
raw = profile.respond_to?(:refinement) ? profile.refinement : nil
|
|
124
|
+
raw.is_a?(Hash) ? Coercion.deep_stringify(raw) : {}
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Editing an agent's instructions is opt-in, explicitly, per agent — the whole
|
|
128
|
+
# point of is that the reachable surface is small and declared. An absent
|
|
129
|
+
# or `report` config is not "not configured yet", it is a NO.
|
|
130
|
+
def require_write_mode!(config, agent_id)
|
|
131
|
+
mode = Coercion.presence(config["mode"]) || "report"
|
|
132
|
+
return if WRITE_MODES.include?(mode)
|
|
133
|
+
|
|
134
|
+
raise Insika::ValidationError,
|
|
135
|
+
"agent '#{agent_id}' is in refinement mode '#{mode}' — set `refinement.mode` to " \
|
|
136
|
+
"propose (and list the writable files) before gating a candidate"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# `RefinementStore#gating` refuses a run that is not :completed — but it is
|
|
140
|
+
# called AFTER the proposal, so the refusal used to arrive with the provider
|
|
141
|
+
# bill already paid. Found live: a run_id pointing at an :awaiting_approval run
|
|
142
|
+
# burned a minute and two model calls to learn the run could not be gated.
|
|
143
|
+
# Same rule as mode and allowlist: everything the engine can refuse for free
|
|
144
|
+
# is refused before a model is asked anything.
|
|
145
|
+
def require_completed!(run)
|
|
146
|
+
return if run.status == :completed
|
|
147
|
+
|
|
148
|
+
raise ArgumentError, "run #{run.id} is #{run.status}, expected completed"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# The model(s) write the candidates. Each is shown the run's findings
|
|
152
|
+
# and the CURRENT content of the allowlisted files only — the same allowlist the
|
|
153
|
+
# builder then enforces, so a proposal cannot even name a file it may not touch.
|
|
154
|
+
#
|
|
155
|
+
# A caller who supplied their own candidate needs no proposer at all; that path
|
|
156
|
+
# is what an API client and the phase-C Studio button use.
|
|
157
|
+
def proposers_for(run, config, payload)
|
|
158
|
+
return [] if payload[:candidate]
|
|
159
|
+
|
|
160
|
+
unless Insika::EnvSchema.truthy?(payload[:propose])
|
|
161
|
+
raise Insika::ValidationError,
|
|
162
|
+
"candidate is required (or pass propose: true to have the configured model write one)"
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
panel = Array(@proposer_factory&.call(config))
|
|
166
|
+
if panel.empty?
|
|
167
|
+
raise Insika::ValidationError,
|
|
168
|
+
"no proposer is configured for '#{run.agent_id}' — set `refinement.proposers` " \
|
|
169
|
+
"(or `refinement.proposer`, or a platform utility_model) to a model that can " \
|
|
170
|
+
"write the candidate"
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
panel
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# The allowlist ∩ what the agent actually has. A configured file the agent never
|
|
177
|
+
# got is not shown and not editable — the builder would drop the edit anyway
|
|
178
|
+
# ("does not exist for this agent"), and paying a model to write one is waste.
|
|
179
|
+
def writable_files(agent_id, config)
|
|
180
|
+
allow = allowlist!(agent_id, config)
|
|
181
|
+
current_files(agent_id).select { |name, _| allow.include?(name) }
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Checked before the proposer runs as well as before the gate: an empty
|
|
185
|
+
# allowlist is `mode: propose` with nothing switched on, and it is the one
|
|
186
|
+
# refusal that must land before a provider call, not after it.
|
|
187
|
+
def allowlist!(agent_id, config)
|
|
188
|
+
allow = Array(config["files"]).map(&:to_s)
|
|
189
|
+
if allow.empty?
|
|
190
|
+
raise Insika::ValidationError,
|
|
191
|
+
"agent '#{agent_id}' has an empty refinement.files allowlist — nothing is writable"
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
allow
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# `mode: auto_apply` — the ONE path where a prompt changes with no
|
|
198
|
+
# human in the loop. Off by default and deliberately narrow: it needs the mode,
|
|
199
|
+
# a gate PASS with zero regressions, and a diff under `auto_apply_max_edits`.
|
|
200
|
+
# Everything else parks at :awaiting_approval, which is the product.
|
|
201
|
+
#
|
|
202
|
+
# A candidate that passed but is too large is NOT rejected — it waits for a
|
|
203
|
+
# person. "Too big to apply unattended" and "wrong" are different verdicts, and
|
|
204
|
+
# collapsing them would throw away a proposal the gate already paid to score.
|
|
205
|
+
#
|
|
206
|
+
# Without a wired resolver this returns nil and the run stays awaiting approval:
|
|
207
|
+
# a deployment that cannot apply must not record that it did.
|
|
208
|
+
# -> the applied Run, or nil when nothing was applied.
|
|
209
|
+
def auto_apply(run, config, report)
|
|
210
|
+
return nil unless Coercion.presence(config["mode"]) == "auto_apply"
|
|
211
|
+
return nil unless run.awaiting_approval? && report.passed && report.regressions.empty?
|
|
212
|
+
return nil if @resolver.nil?
|
|
213
|
+
|
|
214
|
+
max = (config["auto_apply_max_edits"] || DEFAULT_AUTO_APPLY_MAX_EDITS).to_i
|
|
215
|
+
edits = run.edits.size
|
|
216
|
+
if edits > max
|
|
217
|
+
emit(:refinement_auto_apply_skipped, run, edits: edits, max_edits: max)
|
|
218
|
+
return nil
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
@resolver.call(Insika::Command.build(:resolve_refinement,
|
|
222
|
+
{ run_id: run.id, decision: "approved",
|
|
223
|
+
operator: "auto_apply",
|
|
224
|
+
note: "auto_apply: gate passed #{report.passed_cases}/" \
|
|
225
|
+
"#{report.cases} with no regression" }))
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def current_files(agent_id)
|
|
229
|
+
@agent_files.list(agent_id).each_with_object({}) do |name, acc|
|
|
230
|
+
acc[name] = @agent_files.read(agent_id, name).to_s
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Counts and ids only, never file content: these events reach the operator
|
|
235
|
+
# stream and the same rule the findings follow applies here.
|
|
236
|
+
def emit(type, run, **data)
|
|
237
|
+
@event_stream.emit(Insika::Event.new(
|
|
238
|
+
type: type,
|
|
239
|
+
data: { agent: run.agent_id, run_id: run.id, status: run.status.to_s, **data },
|
|
240
|
+
meta: { at: Time.now.utc.iso8601 }
|
|
241
|
+
))
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
module Commands
|
|
7
|
+
# Control command: LIVE MCP ingestion. Receives the NAME of
|
|
8
|
+
# an MCP instance, delegates to McpToolIngestor (discovers via an injectable client
|
|
9
|
+
# -> builds the manifest -> reuses :import_tools: batch upsert + hot reload)
|
|
10
|
+
# and returns the per-tool report in the shape of :import_tools, plus
|
|
11
|
+
# `instance:`. Idempotent (re-ingesting reconciles). A per-tool failure is isolated
|
|
12
|
+
# in `errors[]` (R4); an absent/disabled/url-less instance raises.
|
|
13
|
+
# -> { instance, version, created: [names], updated: [names], errors: [{tool,error}] }
|
|
14
|
+
class ImportMcpTools
|
|
15
|
+
def initialize(ingestor:, event_stream:)
|
|
16
|
+
@ingestor = ingestor
|
|
17
|
+
@event_stream = event_stream
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def call(command)
|
|
21
|
+
p = symbolize(command.payload)
|
|
22
|
+
name = Insika::Coercion.presence(p[:name]) ||
|
|
23
|
+
(raise Insika::ValidationError, "import_mcp_tools: 'name' (MCP instance) is required")
|
|
24
|
+
|
|
25
|
+
report = @ingestor.ingest(name)
|
|
26
|
+
emit(report)
|
|
27
|
+
report
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
# Emits only COUNTS + the instance name (never headers/env/secrets): 0 leakage.
|
|
33
|
+
def emit(report)
|
|
34
|
+
@event_stream.emit(Insika::Event.new(
|
|
35
|
+
type: :mcp_tools_imported,
|
|
36
|
+
data: { instance: report[:instance],
|
|
37
|
+
created: report[:created].size, updated: report[:updated].size,
|
|
38
|
+
errors: report[:errors].size },
|
|
39
|
+
meta: { at: Time.now.utc.iso8601 }
|
|
40
|
+
))
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def symbolize(payload)
|
|
44
|
+
(payload || {}).each_with_object({}) { |(k, v), acc| acc[k.to_sym] = v }
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -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.
|
|
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. Idempotent
|
|
11
|
+
# (re-importing reconciles). Injects the deployment's `{{secret.*}}`/`{{env.*}}`
|
|
12
|
+
# resolvers: 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 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,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,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: 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
|