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,168 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Insika
|
|
7
|
+
# Called ONCE at boot, BEFORE accepting requests. Discovers
|
|
8
|
+
# interrupted tasks and dispatches the resume through the SAME path as
|
|
9
|
+
# ResumeTask — this component executes nothing, opens no Execution, changes no
|
|
10
|
+
# status of resumable tasks. Just discovery + dispatch + marking of
|
|
11
|
+
# unrecoverable tasks.
|
|
12
|
+
#
|
|
13
|
+
# Durability without an external job runner = stores +
|
|
14
|
+
# recovery at boot. The "execution" half is the ResumeTask handler.
|
|
15
|
+
#
|
|
16
|
+
# `command_bus` is consumed only through the `dispatch(command)` contract.
|
|
17
|
+
class Recovery
|
|
18
|
+
SWEEP_SCOPE = "recovery"
|
|
19
|
+
|
|
20
|
+
# The per-boot-generation sweep claim. N workers share one
|
|
21
|
+
# store, and the sweep's "orphaned :running" test is per-process: a worker
|
|
22
|
+
# booting while a sibling holds a live turn would see it as an orphan and
|
|
23
|
+
# re-run it. So the TASK sweep runs once per boot generation — the first
|
|
24
|
+
# worker to claim `boot_id` sweeps, the rest skip; a worker respawned
|
|
25
|
+
# mid-generation skips too (its own orphans wait for the next generation).
|
|
26
|
+
# Rides Store#transaction like every claim. nil/empty boot_id (single
|
|
27
|
+
# process: DSL serve, scripts, tests) -> always true, every boot sweeps.
|
|
28
|
+
def self.claim_sweep(store:, boot_id:)
|
|
29
|
+
id = boot_id.to_s
|
|
30
|
+
return true if id.empty?
|
|
31
|
+
|
|
32
|
+
store.transaction do
|
|
33
|
+
key = "sweep:#{id}"
|
|
34
|
+
if store.get(SWEEP_SCOPE, key).nil?
|
|
35
|
+
store.set(SWEEP_SCOPE, key, { "claimed_at" => Time.now.utc.iso8601 })
|
|
36
|
+
true
|
|
37
|
+
else
|
|
38
|
+
false
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# checkpoint_store: needed to query `latest`. logger optional
|
|
44
|
+
# (default nil -> silent in tests).
|
|
45
|
+
def initialize(task_store:, checkpoint_store:, command_bus:, logger: nil)
|
|
46
|
+
@task_store = task_store
|
|
47
|
+
@checkpoint_store = checkpoint_store
|
|
48
|
+
@command_bus = command_bus
|
|
49
|
+
@logger = logger
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# -> { resumed: [ids], failed: [ids] }
|
|
53
|
+
# The initial sweep runs OUTSIDE the per-task rescue: a StoreError here
|
|
54
|
+
# aborts the boot.
|
|
55
|
+
#
|
|
56
|
+
# stale_after (seconds): the periodic tick's semantics instead of
|
|
57
|
+
# boot's. Only :queued/:running tasks untouched for longer than that are
|
|
58
|
+
# candidates — :waiting/:paused are idle by nature (a human wait), so
|
|
59
|
+
# staleness cannot tell a live one from a dead one, and they stay boot
|
|
60
|
+
# recovery's. The threshold exists because a live :running turn is bounded
|
|
61
|
+
# by turn_timeout: anything older cannot be alive.
|
|
62
|
+
def run(stale_after: nil)
|
|
63
|
+
resumed = []
|
|
64
|
+
failed = []
|
|
65
|
+
# Ordered by created_at: tasks from the SAME session are reprocessed
|
|
66
|
+
# in their original order. Global time ordering is harmless for standalone tasks.
|
|
67
|
+
# 1) interrupted (crash mid-flight) -> resume from the checkpoint. Tick mode
|
|
68
|
+
# narrows this to :running — the only mid-flight state staleness can judge.
|
|
69
|
+
running = stale_after ? @task_store.with_status(:running) : @task_store.running_or_interrupted
|
|
70
|
+
sweep(running, stale_after).each { |task| process(task, resumed, failed, tick: !stale_after.nil?) }
|
|
71
|
+
# 2) queued but never started (turn in the SessionActor queue at crash time)
|
|
72
|
+
# -> re-run from scratch (the same resume_task handles :queued). Without
|
|
73
|
+
# this, a :queued turn in the volatile queue would be lost on kill -9.
|
|
74
|
+
sweep(@task_store.queued, stale_after).each { |task| process(task, resumed, failed, tick: !stale_after.nil?) }
|
|
75
|
+
log(:info, "recovery finished: #{resumed.size} resumed, #{failed.size} failed")
|
|
76
|
+
{ resumed: resumed, failed: failed }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
# Boot mode (stale_after nil) takes the candidate list as-is; tick mode keeps
|
|
82
|
+
# only tasks untouched past the threshold — the liveness gate of.
|
|
83
|
+
def sweep(tasks, stale_after)
|
|
84
|
+
tasks = tasks.sort_by(&:created_at)
|
|
85
|
+
return tasks unless stale_after
|
|
86
|
+
|
|
87
|
+
cutoff = Time.now.utc - stale_after
|
|
88
|
+
tasks.select { |task| stale?(task, cutoff) }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def stale?(task, cutoff)
|
|
92
|
+
Time.iso8601(task.updated_at.to_s) < cutoff
|
|
93
|
+
rescue ArgumentError
|
|
94
|
+
true # an unreadable timestamp is not proof of life — treat as stale
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Failing to resume ONE task does not bring down the boot: a non-store
|
|
98
|
+
# dispatch/latest error -> mark :failed and continue. StoreError -> propagate
|
|
99
|
+
# (aborts the boot).
|
|
100
|
+
#
|
|
101
|
+
# tick: true flips ONE rescue: a ValidationError from the dispatch
|
|
102
|
+
# is ResumeTask's local liveness check ("task is running") — someone alive
|
|
103
|
+
# owns it, the normal case on a timer, so the task is SKIPPED for the next
|
|
104
|
+
# tick. At boot a ValidationError means corruption (nothing may be alive),
|
|
105
|
+
# so it still fails the task there. This is the trap defused in-process:
|
|
106
|
+
# the tick must never murder a live turn with its own recovery.
|
|
107
|
+
def process(task, resumed, failed, tick: false)
|
|
108
|
+
# :queued never started (no checkpoint) but IS recoverable — ResumeTask
|
|
109
|
+
# re-runs from the Command. An interrupted task requires a checkpoint;
|
|
110
|
+
# without one, it is unrecoverable.
|
|
111
|
+
if task.status == :queued || @checkpoint_store.latest(task.id)
|
|
112
|
+
@command_bus.dispatch(resume_command(task.id))
|
|
113
|
+
resumed << task.id
|
|
114
|
+
log(:info, "resume dispatched: #{task.id}")
|
|
115
|
+
else
|
|
116
|
+
fail_task(task.id, class_name: "Insika::Error",
|
|
117
|
+
message: "unrecoverable: no checkpoint")
|
|
118
|
+
failed << task.id
|
|
119
|
+
log(:warn, "unrecoverable (no checkpoint): #{task.id}")
|
|
120
|
+
end
|
|
121
|
+
rescue Insika::StoreError
|
|
122
|
+
raise
|
|
123
|
+
rescue Insika::ValidationError => e
|
|
124
|
+
# Boot keeps the old contract (corruption -> :failed); the tick skips.
|
|
125
|
+
if tick
|
|
126
|
+
log(:info, "skipped (alive elsewhere): #{task.id} — #{e.message}")
|
|
127
|
+
else
|
|
128
|
+
fail_task(task.id, class_name: e.class.name, message: e.message, stage: "recovery")
|
|
129
|
+
failed << task.id unless failed.include?(task.id)
|
|
130
|
+
log(:warn, "failed to resume #{task.id}: #{e.class}: #{e.message}")
|
|
131
|
+
end
|
|
132
|
+
rescue StandardError => e
|
|
133
|
+
fail_task(task.id, class_name: e.class.name, message: e.message, stage: "recovery")
|
|
134
|
+
failed << task.id unless failed.include?(task.id)
|
|
135
|
+
log(:warn, "failed to resume #{task.id}: #{e.class}: #{e.message}")
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Transitions the task to :failed, writing the error into the open Execution
|
|
139
|
+
# (if any). StoreError propagates (aborts the boot). ArgumentError is
|
|
140
|
+
# absorbed: `paused -> failed` is not in the machine — the task stays in its
|
|
141
|
+
# current state, but we still report it as failed in the summary. Do not
|
|
142
|
+
# "fix" the machine here.
|
|
143
|
+
def fail_task(id, class_name:, message:, stage: nil)
|
|
144
|
+
error = { class: class_name, message: message }
|
|
145
|
+
error[:stage] = stage if stage
|
|
146
|
+
@task_store.transition(id, to: :failed, error: error)
|
|
147
|
+
rescue Insika::StoreError
|
|
148
|
+
raise
|
|
149
|
+
rescue ArgumentError
|
|
150
|
+
nil
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def resume_command(task_id)
|
|
154
|
+
# transport: :recovery identifies the origin (boot) in meta — auditing
|
|
155
|
+
# (the field is a free Symbol).
|
|
156
|
+
Insika::Command.build(:resume_task, { task_id: task_id }, transport: :recovery)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Logging is pure observability: a logger failure must NEVER alter the
|
|
160
|
+
# recovery flow (otherwise a buggy logger would corrupt the summary —
|
|
161
|
+
# e.g. an id in resumed AND failed). We swallow any logger error.
|
|
162
|
+
def log(level, message)
|
|
163
|
+
@logger&.public_send(level, "[recovery] #{message}")
|
|
164
|
+
rescue StandardError
|
|
165
|
+
nil
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
module Refinement
|
|
7
|
+
# ONE proposed change to an agent's instruction files. Data, not
|
|
8
|
+
# a diff of free text, and that is the load-bearing decision of the whole phase:
|
|
9
|
+
#
|
|
10
|
+
# · an ANCHORED edit is reviewable — the operator reads three lines, not a
|
|
11
|
+
# rewritten file, and can tell in seconds whether to approve it;
|
|
12
|
+
# · it is ATTRIBUTABLE — when the gate's score moves, it moved because of an
|
|
13
|
+
# edit you can point at, not because a model rewrote the prompt;
|
|
14
|
+
# · it is STALE-CHECKABLE — `before` must still match the file, so a proposal
|
|
15
|
+
# built from a snapshot cannot silently clobber an edit made since.
|
|
16
|
+
#
|
|
17
|
+
# A candidate arrives from anywhere (a model, the Studio, a JSON payload) and is
|
|
18
|
+
# always built through `Candidate.build`, which is the only place the bounds and
|
|
19
|
+
# the write allowlist are enforced. An edit that violates one is DROPPED with a
|
|
20
|
+
# reason rather than failing the whole candidate: a proposal with two good edits
|
|
21
|
+
# and one stale one is worth gating, and the operator should see what fell off.
|
|
22
|
+
Edit = Data.define(:file, :op, :anchor, :before, :after, :addresses) do
|
|
23
|
+
def replace? = op == "replace"
|
|
24
|
+
|
|
25
|
+
# -> the new content, or nil when this edit no longer applies to `content`.
|
|
26
|
+
# `append` puts the text at the END of the file: `anchor` is a LABEL for the
|
|
27
|
+
# reviewer ("## shipping_quote"), never a locator — the locator is `before`,
|
|
28
|
+
# and inventing a second one would give the same edit two ways to land.
|
|
29
|
+
def apply_to(content)
|
|
30
|
+
return nil if content.nil?
|
|
31
|
+
return "#{content.chomp}\n\n#{after}\n" unless replace?
|
|
32
|
+
return nil unless content.include?(before)
|
|
33
|
+
|
|
34
|
+
content.sub(before, after)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def to_h = { "file" => file, "op" => op, "anchor" => anchor, "before" => before,
|
|
38
|
+
"after" => after, "addresses" => addresses }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# A dropped edit and why. Kept ON the candidate rather than logged: "the model
|
|
42
|
+
# proposed four things and one was stale" is exactly what an operator reviewing
|
|
43
|
+
# the loop's usefulness needs, and asks them to judge precisely that.
|
|
44
|
+
Dropped = Data.define(:file, :op, :reason) do
|
|
45
|
+
def to_h = { "file" => file, "op" => op, "reason" => reason }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
Candidate = Data.define(:id, :proposer, :rationale, :edits, :dropped) do
|
|
49
|
+
def empty? = edits.empty?
|
|
50
|
+
def files = edits.map(&:file).uniq
|
|
51
|
+
|
|
52
|
+
# name => new content, for the files this candidate touches. Edits to the same
|
|
53
|
+
# file compose in order, so two edits to TOOLS.md both land.
|
|
54
|
+
def apply(contents)
|
|
55
|
+
edits.each_with_object({}) do |edit, acc|
|
|
56
|
+
current = acc[edit.file] || contents[edit.file]
|
|
57
|
+
applied = edit.apply_to(current)
|
|
58
|
+
acc[edit.file] = applied unless applied.nil?
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def to_h = { "id" => id, "proposer" => proposer, "rationale" => rationale,
|
|
63
|
+
"edits" => edits.map(&:to_h), "dropped" => dropped.map(&:to_h) }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# The bounds, all config. Defaults are deliberately small: what makes a
|
|
67
|
+
# diff reviewable is that it is short, and what keeps the gate's signal readable
|
|
68
|
+
# is that a run changed few things.
|
|
69
|
+
DEFAULT_LIMITS = { "max_edits" => 3, "max_bytes" => 1200, "max_total_growth" => 0.15 }.freeze
|
|
70
|
+
|
|
71
|
+
OPS = %w[replace append].freeze
|
|
72
|
+
|
|
73
|
+
module CandidateBuilder
|
|
74
|
+
module_function
|
|
75
|
+
|
|
76
|
+
# raw: { "proposer" =>, "rationale" =>, "edits" => [ … ] } (string keys)
|
|
77
|
+
# allowlist: the agent's `refinement.files`. EMPTY MEANS NOTHING IS WRITABLE —
|
|
78
|
+
# report-only, so every edit drops. Not "no restriction":
|
|
79
|
+
# an unset allowlist that meant "anything" would turn a missing
|
|
80
|
+
# config into the most permissive setting there is.
|
|
81
|
+
# contents: name => current content, for staleness and growth.
|
|
82
|
+
# -> Candidate (possibly empty; `empty?` never reaches the gate).
|
|
83
|
+
def build(raw, allowlist:, contents:, limits: {}, id: nil)
|
|
84
|
+
raw = Coercion.deep_stringify(raw.is_a?(Hash) ? raw : {})
|
|
85
|
+
bounds = DEFAULT_LIMITS.merge(limits.is_a?(Hash) ? Coercion.deep_stringify(limits) : {})
|
|
86
|
+
allow = Array(allowlist).map(&:to_s)
|
|
87
|
+
|
|
88
|
+
kept = []
|
|
89
|
+
dropped = []
|
|
90
|
+
# Growth is measured per FILE across the whole candidate, so three edits that
|
|
91
|
+
# are each under the cap cannot add up to a rewritten file.
|
|
92
|
+
grown = Hash.new(0)
|
|
93
|
+
|
|
94
|
+
Array(raw["edits"]).each do |edit|
|
|
95
|
+
built, reason = validate(Coercion.deep_stringify(edit), allow, contents, bounds, grown, kept.size)
|
|
96
|
+
if built
|
|
97
|
+
kept << built
|
|
98
|
+
grown[built.file] += built.after.to_s.bytesize - (built.replace? ? built.before.to_s.bytesize : 0)
|
|
99
|
+
else
|
|
100
|
+
dropped << Dropped.new(file: edit_field(edit, "file"), op: edit_field(edit, "op"), reason: reason)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
Candidate.new(id: (id || SecureRandom.uuid).to_s,
|
|
105
|
+
proposer: Coercion.presence(raw["proposer"]) || "operator",
|
|
106
|
+
rationale: raw["rationale"].to_s, edits: kept, dropped: dropped)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# -> [Edit, nil] | [nil, reason]. One reason per edit, in the order an operator
|
|
110
|
+
# would ask: is it allowed, is it well-formed, does it still apply, is it small.
|
|
111
|
+
def validate(raw, allow, contents, bounds, grown, kept_count)
|
|
112
|
+
file = raw["file"].to_s
|
|
113
|
+
op = raw["op"].to_s
|
|
114
|
+
after = raw["after"].to_s
|
|
115
|
+
|
|
116
|
+
return [nil, "candidate is over max_edits (#{bounds['max_edits']})"] if kept_count >= bounds["max_edits"].to_i
|
|
117
|
+
return [nil, "'#{file}' is not on the refinement allowlist"] unless allow.include?(file)
|
|
118
|
+
return [nil, "unknown op '#{op}' (#{OPS.join('|')})"] unless OPS.include?(op)
|
|
119
|
+
|
|
120
|
+
current = contents[file]
|
|
121
|
+
return [nil, "'#{file}' does not exist for this agent"] if current.nil?
|
|
122
|
+
return [nil, "'after' is empty"] if after.strip.empty?
|
|
123
|
+
|
|
124
|
+
if after.bytesize > bounds["max_bytes"].to_i
|
|
125
|
+
return [nil, "edit is #{after.bytesize}B, over max_bytes (#{bounds['max_bytes']})"]
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
edit = Edit.new(file: file, op: op, anchor: Coercion.presence(raw["anchor"]),
|
|
129
|
+
before: raw["before"].to_s, after: after,
|
|
130
|
+
addresses: Array(raw["addresses"]).map(&:to_s))
|
|
131
|
+
|
|
132
|
+
if edit.replace?
|
|
133
|
+
return [nil, "'before' is empty — a replace with no anchor text is a rewrite"] if edit.before.empty?
|
|
134
|
+
# STALE: the file changed since the proposal was built (or the model
|
|
135
|
+
# hallucinated the text it claims to be replacing). Either way the edit
|
|
136
|
+
# describes a file that does not exist, and applying it by fuzzy match is
|
|
137
|
+
# how a refinement loop silently clobbers a human's edit.
|
|
138
|
+
return [nil, "'before' no longer matches '#{file}' (stale or invented)"] unless current.include?(edit.before)
|
|
139
|
+
|
|
140
|
+
if current.scan(edit.before).length > 1
|
|
141
|
+
return [nil, "'before' matches '#{file}' in #{current.scan(edit.before).length} places — ambiguous"]
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
growth = grown[file] + after.bytesize - (edit.replace? ? edit.before.bytesize : 0)
|
|
146
|
+
cap = (current.bytesize * bounds["max_total_growth"].to_f).ceil
|
|
147
|
+
return [nil, "would grow '#{file}' by #{growth}B, over max_total_growth (#{cap}B)"] if growth > cap
|
|
148
|
+
|
|
149
|
+
[edit, nil]
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def edit_field(edit, key)
|
|
153
|
+
return nil unless edit.is_a?(Hash)
|
|
154
|
+
|
|
155
|
+
(edit[key] || edit[key.to_sym]).to_s
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|