insika 0.0.1 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +295 -0
- data/LICENSE +21 -0
- data/README.md +136 -2
- data/bin/insika +351 -0
- data/docs/AGENTS.md +494 -0
- data/docs/ARCHITECTURE.md +333 -0
- data/docs/BENCHMARK.md +114 -0
- data/docs/CHANNELS.md +453 -0
- data/docs/CONTEXT.md +100 -0
- data/docs/DEPLOY.md +334 -0
- data/docs/EMBEDDING.md +194 -0
- data/docs/EVALS.md +273 -0
- data/docs/LOADTEST.md +231 -0
- data/docs/OBSERVABILITY.md +365 -0
- data/docs/PLUGINS.md +211 -0
- data/docs/REFINEMENT.md +477 -0
- data/docs/RELEASING.md +70 -0
- data/docs/RUNNING-LOCAL.md +153 -0
- data/docs/SANDBOX.md +114 -0
- data/docs/SECURITY.md +362 -0
- data/docs/SKILLS.md +98 -0
- data/docs/TOOLS.md +302 -0
- data/docs/WHY.md +137 -0
- data/docs/WORKFLOWS.md +225 -0
- data/docs/build.md +14 -0
- data/docs/index.md +68 -0
- data/docs/onboarding/start.md +126 -0
- data/docs/operate.md +12 -0
- data/docs/ship.md +10 -0
- data/docs/understand.md +10 -0
- data/lib/insika/agent_file_store.rb +125 -0
- data/lib/insika/agent_profile.rb +188 -0
- data/lib/insika/allowlist.rb +28 -0
- data/lib/insika/baseline_store.rb +74 -0
- data/lib/insika/capability/resolved_tool.rb +34 -0
- data/lib/insika/capability_registry.rb +112 -0
- data/lib/insika/channel_delivery.rb +150 -0
- data/lib/insika/channel_registry.rb +30 -0
- data/lib/insika/channels/relay.rb +178 -0
- data/lib/insika/channels/web/widget.js +283 -0
- data/lib/insika/channels/web.rb +211 -0
- data/lib/insika/chat_builder.rb +254 -0
- data/lib/insika/checkpoint.rb +13 -0
- data/lib/insika/checkpoint_store.rb +153 -0
- data/lib/insika/coercion.rb +50 -0
- data/lib/insika/command.rb +32 -0
- data/lib/insika/command_bus.rb +39 -0
- data/lib/insika/commands/agent_payload.rb +41 -0
- data/lib/insika/commands/approve_action.rb +46 -0
- data/lib/insika/commands/cancel_task.rb +33 -0
- data/lib/insika/commands/create_agent.rb +54 -0
- data/lib/insika/commands/create_session.rb +67 -0
- data/lib/insika/commands/delete_agent.rb +33 -0
- data/lib/insika/commands/delete_agent_file.rb +50 -0
- data/lib/insika/commands/delete_data_tool.rb +33 -0
- data/lib/insika/commands/delete_llm_provider.rb +36 -0
- data/lib/insika/commands/delete_mcp.rb +30 -0
- data/lib/insika/commands/delete_system_file.rb +29 -0
- data/lib/insika/commands/gate_refinement.rb +245 -0
- data/lib/insika/commands/import_mcp_tools.rb +48 -0
- data/lib/insika/commands/import_tools.rb +81 -0
- data/lib/insika/commands/memory_add_note.rb +32 -0
- data/lib/insika/commands/memory_forget_fact.rb +32 -0
- data/lib/insika/commands/memory_put_fact.rb +35 -0
- data/lib/insika/commands/pause_task.rb +29 -0
- data/lib/insika/commands/resolve_refinement.rb +126 -0
- data/lib/insika/commands/restore_agent_file.rb +36 -0
- data/lib/insika/commands/restore_data_tool.rb +34 -0
- data/lib/insika/commands/restore_system_file.rb +31 -0
- data/lib/insika/commands/resume_task.rb +85 -0
- data/lib/insika/commands/run_refinement.rb +133 -0
- data/lib/insika/commands/send_message.rb +150 -0
- data/lib/insika/commands/set_agent_tools.rb +39 -0
- data/lib/insika/commands/set_skill_agents.rb +71 -0
- data/lib/insika/commands/trigger_workflow.rb +80 -0
- data/lib/insika/commands/update_agent.rb +49 -0
- data/lib/insika/commands/update_settings.rb +33 -0
- data/lib/insika/commands/upsert_llm_provider.rb +34 -0
- data/lib/insika/commands/upsert_mcp.rb +32 -0
- data/lib/insika/commands/write_agent_file.rb +57 -0
- data/lib/insika/commands/write_data_tool.rb +43 -0
- data/lib/insika/commands/write_golden.rb +58 -0
- data/lib/insika/commands/write_skill.rb +50 -0
- data/lib/insika/commands/write_system_file.rb +31 -0
- data/lib/insika/config_store.rb +85 -0
- data/lib/insika/context/builder.rb +166 -0
- data/lib/insika/context/catalog_provider.rb +23 -0
- data/lib/insika/context/fragment.rb +19 -0
- data/lib/insika/context/priority.rb +29 -0
- data/lib/insika/context/provider.rb +19 -0
- data/lib/insika/context/providers/memory.rb +60 -0
- data/lib/insika/context/providers/prompt.rb +105 -0
- data/lib/insika/context/providers/request.rb +32 -0
- data/lib/insika/context/providers/session.rb +108 -0
- data/lib/insika/context/providers/skill.rb +20 -0
- data/lib/insika/context/providers/tool_search.rb +20 -0
- data/lib/insika/delegation_store.rb +153 -0
- data/lib/insika/doctor.rb +294 -0
- data/lib/insika/dsl/definition.rb +55 -0
- data/lib/insika/dsl/runtime.rb +379 -0
- data/lib/insika/dsl/server_boot.rb +97 -0
- data/lib/insika/dsl/system.rb +93 -0
- data/lib/insika/dsl/workflow_adapter.rb +59 -0
- data/lib/insika/dsl.rb +307 -0
- data/lib/insika/edge_limiter.rb +130 -0
- data/lib/insika/egress_guard.rb +75 -0
- data/lib/insika/env_schema.rb +246 -0
- data/lib/insika/errors.rb +145 -0
- data/lib/insika/evals/assertions.rb +247 -0
- data/lib/insika/evals/baseline.rb +69 -0
- data/lib/insika/evals/golden.rb +172 -0
- data/lib/insika/evals/judge.rb +225 -0
- data/lib/insika/evals/pairwise.rb +178 -0
- data/lib/insika/evals/report.rb +115 -0
- data/lib/insika/evals/runner.rb +141 -0
- data/lib/insika/evals/transport.rb +178 -0
- data/lib/insika/event.rb +18 -0
- data/lib/insika/event_stream.rb +114 -0
- data/lib/insika/executor.rb +1680 -0
- data/lib/insika/frontmatter.rb +42 -0
- data/lib/insika/golden_store.rb +145 -0
- data/lib/insika/hooks.rb +48 -0
- data/lib/insika/http_client.rb +63 -0
- data/lib/insika/inbound_log.rb +84 -0
- data/lib/insika/llm_configurator.rb +99 -0
- data/lib/insika/llm_provider_store.rb +83 -0
- data/lib/insika/mcp_http_client.rb +67 -0
- data/lib/insika/mcp_store.rb +115 -0
- data/lib/insika/mcp_tool_ingestor.rb +143 -0
- data/lib/insika/memory_store.rb +93 -0
- data/lib/insika/message_origin.rb +76 -0
- data/lib/insika/middleware.rb +36 -0
- data/lib/insika/model_policy.rb +52 -0
- data/lib/insika/model_resolver.rb +176 -0
- data/lib/insika/model_selection.rb +114 -0
- data/lib/insika/onboarding.rb +208 -0
- data/lib/insika/outbox_store.rb +166 -0
- data/lib/insika/overlay_tool_registry.rb +103 -0
- data/lib/insika/pack.rb +102 -0
- data/lib/insika/pack_importer.rb +121 -0
- data/lib/insika/pending_action_store.rb +120 -0
- data/lib/insika/plugin/loader.rb +356 -0
- data/lib/insika/plugin.rb +35 -0
- data/lib/insika/policy/engine.rb +83 -0
- data/lib/insika/policy/policy.rb +120 -0
- data/lib/insika/policy_registry.rb +23 -0
- data/lib/insika/profile_source.rb +137 -0
- data/lib/insika/prompt_catalog.rb +61 -0
- data/lib/insika/queue_policy.rb +167 -0
- data/lib/insika/recovery.rb +127 -0
- data/lib/insika/refinement/candidate.rb +159 -0
- data/lib/insika/refinement/evidence_collector.rb +371 -0
- data/lib/insika/refinement/gate.rb +234 -0
- data/lib/insika/refinement/panel.rb +222 -0
- data/lib/insika/refinement/proposer.rb +262 -0
- data/lib/insika/refinement_store.rb +295 -0
- data/lib/insika/registry.rb +59 -0
- data/lib/insika/safety/config.rb +109 -0
- data/lib/insika/safety/detectors.rb +176 -0
- data/lib/insika/safety/factory.rb +102 -0
- data/lib/insika/safety/input_guardrail.rb +87 -0
- data/lib/insika/safety/moderator.rb +86 -0
- data/lib/insika/safety/output_filter.rb +79 -0
- data/lib/insika/safety/output_validator.rb +101 -0
- data/lib/insika/safety/safe_responses.rb +47 -0
- data/lib/insika/sandbox/boundary.rb +93 -0
- data/lib/insika/sandbox/docker.rb +74 -0
- data/lib/insika/sandbox/local.rb +33 -0
- data/lib/insika/sandbox/runner.rb +80 -0
- data/lib/insika/sandbox.rb +85 -0
- data/lib/insika/schema_guard.rb +147 -0
- data/lib/insika/secret_masking.rb +34 -0
- data/lib/insika/server/a2a/agent_card.rb +27 -0
- data/lib/insika/server/a2a/app.rb +112 -0
- data/lib/insika/server/a2a/client.rb +101 -0
- data/lib/insika/server/a2a/errors.rb +32 -0
- data/lib/insika/server/a2a/http.rb +42 -0
- data/lib/insika/server/a2a/message.rb +27 -0
- data/lib/insika/server/a2a/protocol.rb +45 -0
- data/lib/insika/server/a2a/remotes.rb +25 -0
- data/lib/insika/server/a2a/task_projection.rb +40 -0
- data/lib/insika/server/admin_auth.rb +29 -0
- data/lib/insika/server/app.rb +850 -0
- data/lib/insika/server/boot.rb +119 -0
- data/lib/insika/server/rack_app.rb +110 -0
- data/lib/insika/server/responses.rb +155 -0
- data/lib/insika/server/sse_body.rb +96 -0
- data/lib/insika/session_actor.rb +162 -0
- data/lib/insika/session_store.rb +143 -0
- data/lib/insika/settings_store.rb +154 -0
- data/lib/insika/shutdown.rb +125 -0
- data/lib/insika/skill_catalog.rb +113 -0
- data/lib/insika/skill_store.rb +79 -0
- data/lib/insika/steer_injector.rb +110 -0
- data/lib/insika/store.rb +52 -0
- data/lib/insika/stores/memory.rb +123 -0
- data/lib/insika/stores/sqlite.rb +183 -0
- data/lib/insika/studio/app.rb +1571 -0
- data/lib/insika/studio/assets/dist/application.css +1 -0
- data/lib/insika/studio/assets/dist/application.js +69 -0
- data/lib/insika/studio/forms.rb +340 -0
- data/lib/insika/studio/nav_icons.rb +31 -0
- data/lib/insika/studio/views/_message.erb +44 -0
- data/lib/insika/studio/views/agent_detail.erb +285 -0
- data/lib/insika/studio/views/agents.erb +63 -0
- data/lib/insika/studio/views/approvals.erb +41 -0
- data/lib/insika/studio/views/chats.erb +34 -0
- data/lib/insika/studio/views/evals.erb +83 -0
- data/lib/insika/studio/views/home.erb +72 -0
- data/lib/insika/studio/views/layout.erb +94 -0
- data/lib/insika/studio/views/login.erb +17 -0
- data/lib/insika/studio/views/mcp.erb +91 -0
- data/lib/insika/studio/views/not_found.erb +5 -0
- data/lib/insika/studio/views/playground.erb +47 -0
- data/lib/insika/studio/views/refinement.erb +234 -0
- data/lib/insika/studio/views/session.erb +62 -0
- data/lib/insika/studio/views/settings.erb +173 -0
- data/lib/insika/studio/views/skills.erb +86 -0
- data/lib/insika/studio/views/system_files.erb +65 -0
- data/lib/insika/studio/views/task.erb +105 -0
- data/lib/insika/studio/views/tasks.erb +33 -0
- data/lib/insika/studio/views/tool_edit.erb +107 -0
- data/lib/insika/studio/views/tools.erb +89 -0
- data/lib/insika/subagent_graph.rb +96 -0
- data/lib/insika/system_file_store.rb +96 -0
- data/lib/insika/task_actor.rb +128 -0
- data/lib/insika/task_store.rb +250 -0
- data/lib/insika/telemetry/pricing.rb +104 -0
- data/lib/insika/telemetry/recorder.rb +228 -0
- data/lib/insika/telemetry.rb +127 -0
- data/lib/insika/testing/store_contract.rb +270 -0
- data/lib/insika/token_estimator.rb +16 -0
- data/lib/insika/tool_assembly.rb +140 -0
- data/lib/insika/tool_catalog.rb +89 -0
- data/lib/insika/tool_definition.rb +518 -0
- data/lib/insika/tool_envelope.rb +140 -0
- data/lib/insika/tool_manifest.rb +218 -0
- data/lib/insika/tool_registry.rb +21 -0
- data/lib/insika/tool_store.rb +135 -0
- data/lib/insika/tool_trace_store.rb +92 -0
- data/lib/insika/tools/a2a_remote.rb +48 -0
- data/lib/insika/tools/agent_enum.rb +68 -0
- data/lib/insika/tools/concurrency.rb +54 -0
- data/lib/insika/tools/data_defined_tool.rb +220 -0
- data/lib/insika/tools/load_skill.rb +41 -0
- data/lib/insika/tools/remember.rb +53 -0
- data/lib/insika/tools/subagent.rb +75 -0
- data/lib/insika/tools/subagents.rb +77 -0
- data/lib/insika/tools/tool_search.rb +94 -0
- data/lib/insika/turn_output.rb +139 -0
- data/lib/insika/turn_state.rb +158 -0
- data/lib/insika/turn_timing.rb +56 -0
- data/lib/insika/usage_ledger.rb +47 -0
- data/lib/insika/version.rb +3 -1
- data/lib/insika/wiring/graph.rb +198 -0
- data/lib/insika/workflow.rb +185 -0
- data/lib/insika/workflow_registry.rb +33 -0
- data/lib/insika.rb +203 -4
- metadata +395 -8
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
# Source of AgentProfiles. Profiles used to be
|
|
5
|
+
# a FROZEN Hash injected into the Executor and the turn Commands — static,
|
|
6
|
+
# defined in Ruby at wiring time. For the Studio to create/edit agents at
|
|
7
|
+
# runtime, the source needs to be MUTABLE and reloadable, without changing the
|
|
8
|
+
# consumption contract.
|
|
9
|
+
#
|
|
10
|
+
# The consumption contract is minimal and duck-typed: `source[id] -> AgentProfile|nil`
|
|
11
|
+
# (like a Hash). That's why the refactor in the Executor/Commands is just
|
|
12
|
+
# normalizing the input (legacy Hash -> StaticProfileSource); the bodies that do
|
|
13
|
+
# `@profiles[agent]` stay identical. `all`/`ids` are for the Studio to list.
|
|
14
|
+
#
|
|
15
|
+
# `nil` from `[]`/`fetch` = agent not configured (the Commands raise
|
|
16
|
+
# NotFoundError) — NEVER raises (unlike Hash#fetch).
|
|
17
|
+
module ProfileSource
|
|
18
|
+
# Hash-compat sugar: `source[id]`.
|
|
19
|
+
def [](id) = fetch(id)
|
|
20
|
+
|
|
21
|
+
# Normalizes the consumers' input: a legacy Hash becomes a StaticProfileSource;
|
|
22
|
+
# a ProfileSource passes straight through. A single place for the compat seam.
|
|
23
|
+
def self.coerce(profiles)
|
|
24
|
+
return profiles if profiles.is_a?(ProfileSource)
|
|
25
|
+
|
|
26
|
+
StaticProfileSource.new(profiles || {})
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# subclasses implement: fetch(id) -> AgentProfile|nil, all -> [AgentProfile], ids -> [String]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Static source (parity): wraps the usual {id => AgentProfile} Hash.
|
|
33
|
+
# Behavior IDENTICAL to the frozen Hash — zero regression.
|
|
34
|
+
class StaticProfileSource
|
|
35
|
+
include ProfileSource
|
|
36
|
+
|
|
37
|
+
def initialize(profiles = {})
|
|
38
|
+
@profiles = profiles
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def fetch(id) = @profiles[id]
|
|
42
|
+
def all = @profiles.values
|
|
43
|
+
def ids = @profiles.keys
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Persisted source (Studio): reads/writes profiles in the ConfigStore (scope "agents").
|
|
47
|
+
# Each `fetch` reads FRESH from the store — an edit via the Studio takes effect on
|
|
48
|
+
# the next dispatch, without a restart. An in-flight turn keeps the profile it
|
|
49
|
+
# captured (the Commands resolve at the start of #call), so the turn semantics are
|
|
50
|
+
# preserved.
|
|
51
|
+
class StoredProfileSource
|
|
52
|
+
include ProfileSource
|
|
53
|
+
include Coercion
|
|
54
|
+
|
|
55
|
+
SCOPE = "agents"
|
|
56
|
+
|
|
57
|
+
def initialize(config_store:)
|
|
58
|
+
@cs = config_store
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def fetch(id)
|
|
62
|
+
record = @cs.get(SCOPE, id.to_s)
|
|
63
|
+
record && deserialize(record)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def all = @cs.all(SCOPE).map { |r| deserialize(r) }
|
|
67
|
+
def ids = @cs.keys(SCOPE)
|
|
68
|
+
|
|
69
|
+
# Write (used by the :create_agent/:update_agent Commands).
|
|
70
|
+
def put(profile)
|
|
71
|
+
@cs.put(SCOPE, profile.id, profile.to_h)
|
|
72
|
+
profile
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def delete(id) = @cs.delete(SCOPE, id.to_s)
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
# Rebuilds the AgentProfile from the record (the JSON round-trip turns symbols
|
|
80
|
+
# into strings). Re-symbolizes the fields the runtime consumes as symbols:
|
|
81
|
+
# provider, policies (names in the PolicyRegistry) and the limits keys (
|
|
82
|
+
# DEFAULT_LIMITS uses symbols and the merge would break with string keys).
|
|
83
|
+
def deserialize(record)
|
|
84
|
+
h = symbolize_top(record)
|
|
85
|
+
AgentProfile.build(
|
|
86
|
+
id: h[:id], model: h[:model],
|
|
87
|
+
provider: presence(h[:provider])&.to_sym,
|
|
88
|
+
base_prompt: h[:base_prompt].to_s,
|
|
89
|
+
prompt_files: h[:prompt_files] || [],
|
|
90
|
+
tools_allow: h[:tools_allow], tools_deny: h[:tools_deny] || [],
|
|
91
|
+
tools_allow_groups: h[:tools_allow_groups],
|
|
92
|
+
skills: h[:skills],
|
|
93
|
+
context_providers: h[:context_providers],
|
|
94
|
+
workflows_allow: h[:workflows_allow],
|
|
95
|
+
policies: Array(h[:policies]).map(&:to_sym),
|
|
96
|
+
prompt_refs: h[:prompt_refs] || [],
|
|
97
|
+
limits: symbolize_limits(h[:limits]),
|
|
98
|
+
approvals_required: h[:approvals_required],
|
|
99
|
+
capabilities: h[:capabilities],
|
|
100
|
+
# subagents (RFC-0010): allowlist of child ids; build re-normalizes to
|
|
101
|
+
# [String]. nil round-trips as nil (opt-in: NONE).
|
|
102
|
+
subagents: h[:subagents],
|
|
103
|
+
tools_deferred: h[:tools_deferred],
|
|
104
|
+
memory: h[:memory],
|
|
105
|
+
prompt_caching: h[:prompt_caching],
|
|
106
|
+
# params/model_policy (v2, §10): the resolver tolerates string keys from
|
|
107
|
+
# the JSON round-trip (ModelResolver#normalize_params / ModelPolicy), so no
|
|
108
|
+
# re-symbolization needed here.
|
|
109
|
+
params: h[:params] || {},
|
|
110
|
+
model_policy: h[:model_policy],
|
|
111
|
+
# guardrails (RFC-0009): a plain Hash; Safety::Config tolerates the JSON
|
|
112
|
+
# round-trip (string keys/values), so no re-symbolization here.
|
|
113
|
+
guardrails: h[:guardrails],
|
|
114
|
+
# sandbox (item 35): a plain config Hash; Sandbox.build tolerates the JSON
|
|
115
|
+
# round-trip (string keys), so no re-symbolization here. nil = absent.
|
|
116
|
+
sandbox: h[:sandbox],
|
|
117
|
+
# refinement (RFC-0013): a plain config Hash read with string keys by the
|
|
118
|
+
# RunRefinement handler; nil round-trips as nil (= report-only).
|
|
119
|
+
refinement: h[:refinement],
|
|
120
|
+
# capabilities_declared (RFC-0014 §3.5): flat [String]; build re-normalizes.
|
|
121
|
+
capabilities_declared: h[:capabilities_declared],
|
|
122
|
+
# edge_stream: which internal channels may cross to the customer. {} = neither.
|
|
123
|
+
edge_stream: h[:edge_stream],
|
|
124
|
+
metadata: h[:metadata] || {}
|
|
125
|
+
)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def symbolize_top(record) = record.each_with_object({}) { |(k, v), acc| acc[k.to_sym] = v }
|
|
129
|
+
|
|
130
|
+
# limits keys -> symbol; numeric values preserved by JSON.
|
|
131
|
+
def symbolize_limits(limits)
|
|
132
|
+
return {} if limits.nil?
|
|
133
|
+
|
|
134
|
+
limits.each_with_object({}) { |(k, v), acc| acc[k.to_sym] = v }
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
# Catalog of prompts: NON-executable content
|
|
7
|
+
# (it is a Catalog, not a Registry). Mirrors the SkillCatalog: each prompt is a directory
|
|
8
|
+
# with a PROMPT.md (YAML frontmatter name/description + markdown body).
|
|
9
|
+
# Source for the Prompt provider when the profile uses prompt_refs.
|
|
10
|
+
class PromptCatalog
|
|
11
|
+
Prompt = Data.define(:name, :description, :path, :body)
|
|
12
|
+
|
|
13
|
+
# roots ordered by PRECEDENCE (highest first) — same name in more than
|
|
14
|
+
# one root: the first wins (identical to SkillCatalog).
|
|
15
|
+
def initialize(roots)
|
|
16
|
+
@roots = Array(roots)
|
|
17
|
+
@prompts = load_all
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def all = @prompts.values
|
|
21
|
+
|
|
22
|
+
# -> Prompt | nil (the Prompt provider converts nil into a ContextError; it is not
|
|
23
|
+
# the catalog's job to raise).
|
|
24
|
+
def find(name) = @prompts[name.to_s]
|
|
25
|
+
|
|
26
|
+
# Rescan + atomic index swap: parity with the SkillCatalog
|
|
27
|
+
# for reload without a restart when the on-disk seed changes.
|
|
28
|
+
def reload
|
|
29
|
+
@prompts = load_all
|
|
30
|
+
self
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def load_all
|
|
36
|
+
found = {}
|
|
37
|
+
@roots.each do |root|
|
|
38
|
+
Dir.glob(File.join(root, "**", "PROMPT.md")).sort.each do |file|
|
|
39
|
+
prompt = parse(file)
|
|
40
|
+
next unless prompt
|
|
41
|
+
|
|
42
|
+
found[prompt.name] ||= prompt # precedence: first root wins
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
found
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def parse(file)
|
|
49
|
+
raw = File.read(file, encoding: "UTF-8")
|
|
50
|
+
match = raw.match(/\A---\s*\n(.*?)\n---\s*\n(.*)\z/m)
|
|
51
|
+
return nil unless match
|
|
52
|
+
|
|
53
|
+
meta = YAML.safe_load(match[1]) || {}
|
|
54
|
+
name = meta["name"]
|
|
55
|
+
return nil unless name
|
|
56
|
+
|
|
57
|
+
Prompt.new(name: name.to_s, description: meta["description"].to_s,
|
|
58
|
+
path: file, body: match[2].strip)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "coercion"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
# RFC-0015 §4 — what happens to an inbound message for a session that is ALREADY
|
|
7
|
+
# busy. Today the engine has exactly one answer, "it waits in line"; this names
|
|
8
|
+
# that answer `followup` and adds three others:
|
|
9
|
+
#
|
|
10
|
+
# collect — the message arrived BEFORE the turn started: merge the fragments
|
|
11
|
+
# into one turn (the whole mechanism is a timer at the door).
|
|
12
|
+
# steer — the turn is ALREADY running tools: append the message to the run in
|
|
13
|
+
# flight, at a tool-batch boundary, so the customer's correction lands
|
|
14
|
+
# before the model's next step instead of after the whole run.
|
|
15
|
+
# interrupt — the turn is running and is now answering the wrong question: abandon
|
|
16
|
+
# it at its next boundary and let the new message be its own turn.
|
|
17
|
+
#
|
|
18
|
+
# They never compete for the same message: `collect` only ever touches a turn that
|
|
19
|
+
# has not started; `steer` and `interrupt` only a turn that has, and they differ in
|
|
20
|
+
# whether the run in flight is still worth finishing.
|
|
21
|
+
#
|
|
22
|
+
# Resolution per message, the order EdgeLimiter already documents
|
|
23
|
+
# (`edge_limiter.rb:17`) — configuration over convention:
|
|
24
|
+
#
|
|
25
|
+
# session vars["queue_mode"] — one conversation pinned by an operator
|
|
26
|
+
# profile.limits[:<key>] — per-agent (a PRESENT key wins, incl. nil/0 = off)
|
|
27
|
+
# settings["queue"][<key>] — platform default, editable in the Studio
|
|
28
|
+
# DEFAULTS[<key>] — = today's behavior
|
|
29
|
+
#
|
|
30
|
+
# Every default is off: a bare wiring behaves exactly as it did before this
|
|
31
|
+
# existed. The knobs live in `profile.limits` next to `tool_concurrency` and
|
|
32
|
+
# `turn_timeout` because they are bounds on the same thing — how much work one
|
|
33
|
+
# turn is allowed to absorb.
|
|
34
|
+
class QueuePolicy
|
|
35
|
+
# All four of RFC-0015 are delivered, so there is no "specified but unshipped"
|
|
36
|
+
# tier any more — a mode outside this set is a typo, and it is refused rather than
|
|
37
|
+
# approximated. Treating an unknown mode as `followup` would look exactly like a
|
|
38
|
+
# mode that never fires.
|
|
39
|
+
MODES = %i[followup collect steer interrupt].freeze
|
|
40
|
+
|
|
41
|
+
DEFAULTS = {
|
|
42
|
+
queue_mode: :followup,
|
|
43
|
+
debounce_ms: 0, # 0 = no window: dequeue immediately, today's path
|
|
44
|
+
debounce_max_ms: 10_000, # ceiling on the TOTAL deferral (see #debounce_deadline_ms)
|
|
45
|
+
steer_max_messages: 5, # how many messages ONE run may absorb; overflow = followup
|
|
46
|
+
steer_join: nil # nil = the raw text; a template frames it (see #frame)
|
|
47
|
+
}.freeze
|
|
48
|
+
|
|
49
|
+
# The placeholder `steer_join` must carry, so a template that would silently
|
|
50
|
+
# drop the customer's message is a config error and not a lost message.
|
|
51
|
+
JOIN_PLACEHOLDER = "%{message}"
|
|
52
|
+
|
|
53
|
+
attr_reader :mode, :debounce_ms, :debounce_max_ms, :steer_max_messages, :steer_join
|
|
54
|
+
|
|
55
|
+
# profile: an AgentProfile (or nil); settings_store: nil = no platform layer;
|
|
56
|
+
# vars: the session's vars Hash (string keys, as the SessionStore returns).
|
|
57
|
+
def self.resolve(profile, settings_store: nil, vars: nil)
|
|
58
|
+
platform = ((settings_store&.get || {})["queue"] || {})
|
|
59
|
+
limits = profile.respond_to?(:limits) ? (profile.limits || {}) : {}
|
|
60
|
+
|
|
61
|
+
new(
|
|
62
|
+
mode: mode!(pick_mode(vars, limits, platform)),
|
|
63
|
+
debounce_ms: pick(:debounce_ms, limits, platform),
|
|
64
|
+
debounce_max_ms: pick(:debounce_max_ms, limits, platform),
|
|
65
|
+
steer_max_messages: pick(:steer_max_messages, limits, platform),
|
|
66
|
+
steer_join: pick_text(:steer_join, limits, platform)
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def initialize(mode:, debounce_ms:, debounce_max_ms:,
|
|
71
|
+
steer_max_messages: DEFAULTS[:steer_max_messages], steer_join: nil)
|
|
72
|
+
@mode = mode
|
|
73
|
+
@debounce_ms = [debounce_ms.to_i, 0].max
|
|
74
|
+
# A non-positive ceiling would mean "defer forever", which nobody wants and
|
|
75
|
+
# which a stray 0 in a config would silently buy. Fall back to the default.
|
|
76
|
+
max = debounce_max_ms.to_i
|
|
77
|
+
@debounce_max_ms = max.positive? ? max : DEFAULTS[:debounce_max_ms]
|
|
78
|
+
# 0 (or a negative) is a legitimate "never steer on this agent" — unlike the
|
|
79
|
+
# ceiling above, it forbids rather than defers forever, so it is honored.
|
|
80
|
+
@steer_max_messages = [steer_max_messages.to_i, 0].max
|
|
81
|
+
@steer_join = join!(steer_join)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Does this policy want messages held at the door at all?
|
|
85
|
+
def debounce? = @debounce_ms.positive?
|
|
86
|
+
|
|
87
|
+
# Does this policy merge into a turn that has not started yet?
|
|
88
|
+
def collect? = @mode == :collect
|
|
89
|
+
|
|
90
|
+
# Does this policy append into a turn that is already running? `steer_max_messages`
|
|
91
|
+
# of 0 is the agent saying no, so it answers false rather than steering once and
|
|
92
|
+
# then refusing.
|
|
93
|
+
def steer? = @mode == :steer && @steer_max_messages.positive?
|
|
94
|
+
|
|
95
|
+
# Does this policy abandon the turn in flight? The new message then becomes an
|
|
96
|
+
# ordinary turn of its own — which is why `interrupt`, unlike the two joining modes,
|
|
97
|
+
# needs no verdict field and works on every surface.
|
|
98
|
+
def interrupt? = @mode == :interrupt
|
|
99
|
+
|
|
100
|
+
# The content of the injected message. `steer_join` frames it when an agent needs
|
|
101
|
+
# the model to know this text arrived mid-run ("the customer just added: %{message}");
|
|
102
|
+
# nil — the default — appends exactly what the person typed. A plain gsub, not
|
|
103
|
+
# `format`: the text is a customer's, and a stray `%` in it must not raise.
|
|
104
|
+
def frame(text)
|
|
105
|
+
return text.to_s if @steer_join.nil?
|
|
106
|
+
|
|
107
|
+
@steer_join.gsub(JOIN_PLACEHOLDER, text.to_s)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# A mode name -> Symbol, or raise. Blank = the default (an absent key is not
|
|
111
|
+
# an error; a WRONG key is).
|
|
112
|
+
def self.mode!(value)
|
|
113
|
+
return DEFAULTS[:queue_mode] if Coercion.blank?(value)
|
|
114
|
+
|
|
115
|
+
name = value.to_s.strip.downcase.to_sym
|
|
116
|
+
return name if MODES.include?(name)
|
|
117
|
+
|
|
118
|
+
raise Insika::ValidationError,
|
|
119
|
+
"unknown queue_mode: #{value.inspect} (expected #{MODES.join(', ')})"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# A present key WINS even carrying nil/0 — "off for this agent", never
|
|
123
|
+
# "inherit the platform default". Same semantics EdgeLimiter documents at
|
|
124
|
+
# `edge_limiter.rb:57`, and for the same reason: an imported pack carrying an
|
|
125
|
+
# explicit null must not silently re-enable a platform behavior.
|
|
126
|
+
def self.pick(key, limits, platform)
|
|
127
|
+
return limits[key].to_i if limits.key?(key)
|
|
128
|
+
return platform[key.to_s].to_i if platform.key?(key.to_s)
|
|
129
|
+
|
|
130
|
+
DEFAULTS[key]
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Same rule as #pick for a key whose value is TEXT: `to_i` would turn a template
|
|
134
|
+
# into 0. A present-but-nil key still means "off for this agent".
|
|
135
|
+
def self.pick_text(key, limits, platform)
|
|
136
|
+
return limits[key] if limits.key?(key)
|
|
137
|
+
return platform[key.to_s] if platform.key?(key.to_s)
|
|
138
|
+
|
|
139
|
+
DEFAULTS[key]
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def self.pick_mode(vars, limits, platform)
|
|
143
|
+
from_vars = (vars || {})["queue_mode"] || (vars || {})[:queue_mode]
|
|
144
|
+
return from_vars if Coercion.present?(from_vars)
|
|
145
|
+
return limits[:queue_mode] if limits.key?(:queue_mode)
|
|
146
|
+
|
|
147
|
+
platform["queue_mode"]
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
private_class_method :pick, :pick_text, :pick_mode
|
|
151
|
+
|
|
152
|
+
private
|
|
153
|
+
|
|
154
|
+
# A template that does not carry the placeholder would drop the customer's message
|
|
155
|
+
# while the turn still looked steered. Refused where the operator is (config time),
|
|
156
|
+
# not at 14:02 on a live conversation.
|
|
157
|
+
def join!(value)
|
|
158
|
+
return nil if Coercion.blank?(value)
|
|
159
|
+
|
|
160
|
+
text = value.to_s
|
|
161
|
+
return text if text.include?(JOIN_PLACEHOLDER)
|
|
162
|
+
|
|
163
|
+
raise Insika::ValidationError,
|
|
164
|
+
"steer_join must contain #{JOIN_PLACEHOLDER} (got #{value.inspect})"
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
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 (RFC-0016 E2). 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
|
+
def run
|
|
56
|
+
resumed = []
|
|
57
|
+
failed = []
|
|
58
|
+
# Ordered by created_at: tasks from the SAME session are reprocessed
|
|
59
|
+
# in their original order. Global time ordering is harmless for standalone tasks.
|
|
60
|
+
# 1) interrupted (crash mid-flight) -> resume from the checkpoint.
|
|
61
|
+
@task_store.running_or_interrupted.sort_by(&:created_at).each { |task| process(task, resumed, failed) }
|
|
62
|
+
# 2) queued but never started (turn in the SessionActor queue at crash time)
|
|
63
|
+
# -> re-run from scratch (the same resume_task handles :queued). Without
|
|
64
|
+
# this, a :queued turn in the volatile queue would be lost on kill -9.
|
|
65
|
+
@task_store.queued.sort_by(&:created_at).each { |task| process(task, resumed, failed) }
|
|
66
|
+
log(:info, "recovery finished: #{resumed.size} resumed, #{failed.size} failed")
|
|
67
|
+
{ resumed: resumed, failed: failed }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
# Failing to resume ONE task does not bring down the boot: a non-store
|
|
73
|
+
# dispatch/latest error -> mark :failed and continue. StoreError -> propagate
|
|
74
|
+
# (aborts the boot).
|
|
75
|
+
def process(task, resumed, failed)
|
|
76
|
+
# :queued never started (no checkpoint) but IS recoverable — ResumeTask
|
|
77
|
+
# re-runs from the Command. An interrupted task requires a checkpoint;
|
|
78
|
+
# without one, it is unrecoverable.
|
|
79
|
+
if task.status == :queued || @checkpoint_store.latest(task.id)
|
|
80
|
+
@command_bus.dispatch(resume_command(task.id))
|
|
81
|
+
resumed << task.id
|
|
82
|
+
log(:info, "resume dispatched: #{task.id}")
|
|
83
|
+
else
|
|
84
|
+
fail_task(task.id, class_name: "Insika::Error",
|
|
85
|
+
message: "unrecoverable: no checkpoint")
|
|
86
|
+
failed << task.id
|
|
87
|
+
log(:warn, "unrecoverable (no checkpoint): #{task.id}")
|
|
88
|
+
end
|
|
89
|
+
rescue Insika::StoreError
|
|
90
|
+
raise
|
|
91
|
+
rescue StandardError => e
|
|
92
|
+
fail_task(task.id, class_name: e.class.name, message: e.message, stage: "recovery")
|
|
93
|
+
failed << task.id unless failed.include?(task.id)
|
|
94
|
+
log(:warn, "failed to resume #{task.id}: #{e.class}: #{e.message}")
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Transitions the task to :failed, writing the error into the open Execution
|
|
98
|
+
# (if any). StoreError propagates (aborts the boot). ArgumentError is
|
|
99
|
+
# absorbed: `paused -> failed` is not in the machine — the task stays in its
|
|
100
|
+
# current state, but we still report it as failed in the summary. Do not
|
|
101
|
+
# "fix" the machine here.
|
|
102
|
+
def fail_task(id, class_name:, message:, stage: nil)
|
|
103
|
+
error = { class: class_name, message: message }
|
|
104
|
+
error[:stage] = stage if stage
|
|
105
|
+
@task_store.transition(id, to: :failed, error: error)
|
|
106
|
+
rescue Insika::StoreError
|
|
107
|
+
raise
|
|
108
|
+
rescue ArgumentError
|
|
109
|
+
nil
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def resume_command(task_id)
|
|
113
|
+
# transport: :recovery identifies the origin (boot) in meta — auditing
|
|
114
|
+
# (the field is a free Symbol).
|
|
115
|
+
Insika::Command.build(:resume_task, { task_id: task_id }, transport: :recovery)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Logging is pure observability: a logger failure must NEVER alter the
|
|
119
|
+
# recovery flow (otherwise a buggy logger would corrupt the summary —
|
|
120
|
+
# e.g. an id in resumed AND failed). We swallow any logger error.
|
|
121
|
+
def log(level, message)
|
|
122
|
+
@logger&.public_send(level, "[recovery] #{message}")
|
|
123
|
+
rescue StandardError
|
|
124
|
+
nil
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
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 (RFC-0013 §3.4). 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 §10 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 (§3.4). 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 (§3.1/§3.8), 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
|