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,303 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
# Assembles the turn's chat (pipeline stages 5-7): instructions, eager/deferred
|
|
7
|
+
# tool partition, system tools (tool_search/load_skill/remember),
|
|
8
|
+
# history and callbacks. Extracted from the Executor so it can coordinate the
|
|
9
|
+
# pipeline without also carrying the RubyLLM glue.
|
|
10
|
+
#
|
|
11
|
+
# The Executor creates the chat (stage 6, the RubyLLM boundary) and passes it
|
|
12
|
+
# here to be configured; event numbering (monotonic seq per task) stays in the
|
|
13
|
+
# Executor, injected as the `emit` callable.
|
|
14
|
+
class ChatBuilder
|
|
15
|
+
def initialize(tool_registry:, skill_catalog:, checkpoint_store:, event_stream:,
|
|
16
|
+
hooks:, tool_catalog: nil, memory_store: nil, subagent_runner: nil,
|
|
17
|
+
tool_trace_store: nil)
|
|
18
|
+
@tool_registry = tool_registry
|
|
19
|
+
@skill_catalog = skill_catalog
|
|
20
|
+
@checkpoint_store = checkpoint_store
|
|
21
|
+
@event_stream = event_stream
|
|
22
|
+
@hooks = hooks
|
|
23
|
+
@tool_catalog = tool_catalog
|
|
24
|
+
@memory_store = memory_store
|
|
25
|
+
# only to trace load_skill, which is not enveloped — nil = no trace (parity).
|
|
26
|
+
@tool_trace_store = tool_trace_store
|
|
27
|
+
# the object exposing #run_subagent (the Executor). nil = the
|
|
28
|
+
# spawn_subagent system tool is never wired (parity for a builder used
|
|
29
|
+
# without delegation, e.g. some unit stubs).
|
|
30
|
+
@subagent_runner = subagent_runner
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Configures an already-created chat with the context (stage 2) and the
|
|
34
|
+
# Resolution (stage 3), seeds the history and wires the callbacks. `emit` is
|
|
35
|
+
# the Executor's emitter (seq+task correlation), called as emit.call(type,
|
|
36
|
+
# data). The system tools (Tools::ToolSearch/LoadSkill/Remember) were already
|
|
37
|
+
# lazy-loaded by Executor#create_chat before reaching here.
|
|
38
|
+
def assemble(chat, state, emit:)
|
|
39
|
+
configure_chat(chat, state)
|
|
40
|
+
seed_history(chat, Array(state.context.history))
|
|
41
|
+
wire_callbacks(chat, state, emit)
|
|
42
|
+
chat
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Assembles the chat with the context (stage 2) and the Resolution's tools (stage 3).
|
|
46
|
+
def configure_chat(chat, state)
|
|
47
|
+
system = state.context.system.to_s
|
|
48
|
+
apply_instructions(chat, system, state) unless system.empty?
|
|
49
|
+
|
|
50
|
+
tools = Array(state.allowed_tools).dup
|
|
51
|
+
|
|
52
|
+
# Tool Search: the partition only runs with @tool_catalog present (parity
|
|
53
|
+
# when nil — the `&&` short-circuits before reading `.name`).
|
|
54
|
+
# `deferred_allowed` = allowed_tools ∩ tools_deferred. The <available_tools>
|
|
55
|
+
# catalog comes from Context::Providers::ToolSearch (stage 2); here we only
|
|
56
|
+
# decide chat.tools.
|
|
57
|
+
deferred_allowed = if @tool_catalog
|
|
58
|
+
Array(state.profile.tools_deferred).map(&:to_s) &
|
|
59
|
+
tools.map { |t| t.name.to_s }
|
|
60
|
+
else
|
|
61
|
+
[]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
unless deferred_allowed.empty?
|
|
65
|
+
tools.reject! { |t| deferred_allowed.include?(t.name.to_s) }
|
|
66
|
+
# a system tool (outside the allowlist), like load_skill — never enveloped.
|
|
67
|
+
tools << Tools::ToolSearch.new(@tool_catalog, deferred_allowed, chat,
|
|
68
|
+
tool_registry: @tool_registry,
|
|
69
|
+
checkpoint_store: @checkpoint_store,
|
|
70
|
+
event_stream: @event_stream, state: state)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# load_skill is a system default (outside the allowlist), otherwise
|
|
74
|
+
# progressive disclosure breaks. allowed_skills comes from the Resolution
|
|
75
|
+
# (policy), minus the EAGER ones: their bodies are already in the prompt, so a
|
|
76
|
+
# call could only pay for a duplicate. Nothing lazy left -> the tool is not
|
|
77
|
+
# wired at all. Keeping it for the discretionary skills is deliberate: that
|
|
78
|
+
# call is the only record of which skill the model actually reached for.
|
|
79
|
+
skill_names = lazy_skill_names(state)
|
|
80
|
+
unless skill_names.empty?
|
|
81
|
+
tools << Tools::LoadSkill.new(@skill_catalog, skill_names,
|
|
82
|
+
trace_recorder: @tool_trace_store, state: state,
|
|
83
|
+
agent: state.profile.id)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# remember is the memory-write system tool — wired only with
|
|
87
|
+
# @memory_store present AND profile.memory (a double gate). Never enveloped.
|
|
88
|
+
if @memory_store && state.profile.memory
|
|
89
|
+
tools << Tools::Remember.new(@memory_store, state.tenant,
|
|
90
|
+
event_stream: @event_stream, state: state)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# signal_stuck is the "I cannot proceed" system tool (WS5) — wired only
|
|
94
|
+
# when the agent opted in (`profile.stuck_signal`), never enveloped. It is a
|
|
95
|
+
# deterministic signal; the consumer decides what "stuck" means. Defensive
|
|
96
|
+
# read: a minimal profile double without the reader means off (nil = parity).
|
|
97
|
+
if state.profile.respond_to?(:stuck_signal) && state.profile.stuck_signal
|
|
98
|
+
tools << Tools::StuckSignal.new(state: state)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# spawn_subagent is the delegation system tool — wired only with a
|
|
102
|
+
# runner present AND profile.subagents non-empty (a double gate, like
|
|
103
|
+
# remember). Never enveloped: in the synchronous mode the child lives in the
|
|
104
|
+
# parent's envelope. The runtime gate on WHICH agent is spawnable is the
|
|
105
|
+
# parent's subagents allowlist, enforced in Executor#run_subagent.
|
|
106
|
+
if @subagent_runner && !Array(state.profile.subagents).empty?
|
|
107
|
+
tools << Tools::Subagent.new(runner: @subagent_runner, state: state)
|
|
108
|
+
# and its parallel sibling: fan-out N children at once.
|
|
109
|
+
tools << Tools::Subagents.new(runner: @subagent_runner, state: state)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
unless tools.empty?
|
|
113
|
+
# the ONLY place the gem is told to run tool calls in parallel.
|
|
114
|
+
# `:fibers` is not a preference but the only admissible mode — `:threads`
|
|
115
|
+
# breaks ToolEnvelope's `Async::Task.current.with_timeout`, the SQLite
|
|
116
|
+
# store's fiber semaphore, and the turn's own durability (mailbox,
|
|
117
|
+
# approvals, cancellation are all expressed in fiber terms). The number
|
|
118
|
+
# the operator configured is OUR cap (ToolAssembly#install_tool_gate);
|
|
119
|
+
# the gem has none.
|
|
120
|
+
if tool_concurrency_for(state)
|
|
121
|
+
chat.with_tools(*tools, concurrency: :fibers)
|
|
122
|
+
else
|
|
123
|
+
chat.with_tools(*tools)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
chat
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# The skills the model still has to ask for: the Resolution's set minus the eager
|
|
131
|
+
# ones. Intersected by NAME against the catalog's own verdict (SkillCatalog#eager_for)
|
|
132
|
+
# so the tool and the level-1 list can never disagree about who is eager. A catalog
|
|
133
|
+
# without the reader (a unit stub) falls back to the whole set — parity.
|
|
134
|
+
def lazy_skill_names(state)
|
|
135
|
+
names = Array(state.allowed_skills).map { |s| s.respond_to?(:name) ? s.name : s.to_s }
|
|
136
|
+
return names unless @skill_catalog.respond_to?(:eager_for)
|
|
137
|
+
|
|
138
|
+
eager = @skill_catalog.eager_for(state.profile).map(&:name)
|
|
139
|
+
names - eager
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# The turn's effective tool concurrency (nil = serial), plus the ONE thing the
|
|
143
|
+
# gate owes the operator: when the profile asked for parallel tool calls and
|
|
144
|
+
# this turn silently cannot have them (an approval-required tool would
|
|
145
|
+
# deadlock two fibers on the single per-task mailbox), say so once. Otherwise
|
|
146
|
+
# the speedup just vanishes with no reason given. The rule itself lives in
|
|
147
|
+
# TurnState; a state predating those readers (a unit stub) means off.
|
|
148
|
+
def tool_concurrency_for(state)
|
|
149
|
+
return nil unless state.respond_to?(:tool_concurrency)
|
|
150
|
+
|
|
151
|
+
effective = state.tool_concurrency
|
|
152
|
+
return effective if effective
|
|
153
|
+
|
|
154
|
+
requested = state.requested_tool_concurrency
|
|
155
|
+
warn_tool_concurrency_gated(state, requested) if requested
|
|
156
|
+
nil
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def warn_tool_concurrency_gated(state, requested)
|
|
160
|
+
gated = Array(state.requires_approval)
|
|
161
|
+
@event_stream.emit(Insika::Event.new(
|
|
162
|
+
type: :provider_warning,
|
|
163
|
+
data: { provider: "tool_concurrency",
|
|
164
|
+
message: "parallel tool calls (#{requested}) disabled for this turn: " \
|
|
165
|
+
"#{gated.size} tool(s) require approval (#{gated.join(', ')})" },
|
|
166
|
+
meta: { task_id: state.task&.id, at: Time.now.utc.iso8601 }
|
|
167
|
+
))
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# R3: opt-in Anthropic prompt caching. When the agent enables
|
|
171
|
+
# prompt_caching AND the resolved provider is Anthropic, wrap the system in
|
|
172
|
+
# the provider's native Content helper with cache: true — ONE breakpoint at
|
|
173
|
+
# the END of the system block. By Anthropic's prefix order
|
|
174
|
+
# (tools -> system -> messages), a breakpoint on the last system block caches
|
|
175
|
+
# tools + system together and is immune to history eviction (messages come
|
|
176
|
+
# after it). RubyLLM::Content::Raw is Anthropic-specific: build_system_content
|
|
177
|
+
# emits its blocks verbatim, so the cache_control rides along.
|
|
178
|
+
#
|
|
179
|
+
# Any other case (caching off, or a non-Anthropic provider) uses the plain
|
|
180
|
+
# string — OpenAI caches its prefix on its own; the Raw shape would confuse
|
|
181
|
+
# non-Anthropic providers. The gem only supports MANUAL caching, and only for
|
|
182
|
+
# Anthropic.
|
|
183
|
+
#
|
|
184
|
+
# PRE-AUDIT (why this is opt-in): the system must be BYTE-STABLE between turns
|
|
185
|
+
# for a read hit. A context provider that injects volatile content into
|
|
186
|
+
# :system (timestamps, per-turn data) makes every turn a paid cache WRITE
|
|
187
|
+
# with no hit — worse than off. Enable only for stable-system agents.
|
|
188
|
+
def apply_instructions(chat, system, state)
|
|
189
|
+
if state.profile.prompt_caching && anthropic_provider?(chat)
|
|
190
|
+
chat.with_instructions(RubyLLM::Providers::Anthropic::Content.new(system, cache: true))
|
|
191
|
+
else
|
|
192
|
+
chat.with_instructions(system)
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# The RESOLVED provider (chat.model.provider is the slug string, e.g.
|
|
197
|
+
# "anthropic"), authoritative even when the agent left provider nil and
|
|
198
|
+
# RubyLLM inferred it from the model id. Any surface without a model (fakes,
|
|
199
|
+
# a provider that raises) -> false: caching silently stays off.
|
|
200
|
+
def anthropic_provider?(chat)
|
|
201
|
+
chat.respond_to?(:model) && chat.model && chat.model.provider.to_s == "anthropic"
|
|
202
|
+
rescue StandardError
|
|
203
|
+
false
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# History comes from the context/checkpoint. The {role:, content:} shape
|
|
207
|
+
# tolerates string keys (JSON from the stores). `flatten(1)` dissolves the
|
|
208
|
+
# Session provider's "eviction units" (an assistant+tool_results cycle grouped
|
|
209
|
+
# as one Array, R1) back into a flat message stream.
|
|
210
|
+
#
|
|
211
|
+
# tool_calls / tool_call_id are rehydrated ONLY when present, so a message
|
|
212
|
+
# without them keeps the 2-arg shape the specs' FakeChat expects (no unknown
|
|
213
|
+
# keyword). This is what lets the model SEE the tools it already called.
|
|
214
|
+
def seed_history(chat, messages)
|
|
215
|
+
Array(messages).flatten(1).each do |m|
|
|
216
|
+
attrs = { role: (m[:role] || m["role"]).to_sym, content: m[:content] || m["content"] }
|
|
217
|
+
tool_calls = m[:tool_calls] || m["tool_calls"]
|
|
218
|
+
tool_call_id = m[:tool_call_id] || m["tool_call_id"]
|
|
219
|
+
attrs[:tool_calls] = rehydrate_tool_calls(tool_calls) if tool_calls && !Array(tool_calls).empty?
|
|
220
|
+
attrs[:tool_call_id] = tool_call_id if tool_call_id
|
|
221
|
+
chat.add_message(**attrs)
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# [{id,name,arguments}] (string|symbol keys) -> {id => RubyLLM::ToolCall}, the
|
|
226
|
+
# shape RubyLLM seeds an assistant message with. Called only when the gem is
|
|
227
|
+
# already loaded (create_chat required it before assemble).
|
|
228
|
+
def rehydrate_tool_calls(list)
|
|
229
|
+
Array(list).each_with_object({}) do |tc, acc|
|
|
230
|
+
id = (tc[:id] || tc["id"]).to_s
|
|
231
|
+
acc[id] = RubyLLM::ToolCall.new(
|
|
232
|
+
id: id, name: (tc[:name] || tc["name"]).to_s,
|
|
233
|
+
arguments: tc[:arguments] || tc["arguments"] || {}
|
|
234
|
+
)
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# RubyLLM's additive callbacks become events. load_skill becomes
|
|
239
|
+
# :skill_activated. Adds the max_tool_calls counter: the loop is RubyLLM's;
|
|
240
|
+
# here we only count and abort.
|
|
241
|
+
def wire_callbacks(chat, state, emit)
|
|
242
|
+
# Per-TURN counter: safe as a closure local even under concurrent tool calls
|
|
243
|
+
# (MRI fibers do not preempt between the read and the write). The per-CALL
|
|
244
|
+
# correlation is NOT — it lives in fiber storage behind TurnState, because
|
|
245
|
+
# each call gets its own fiber once tool concurrency is on.
|
|
246
|
+
tool_calls = 0
|
|
247
|
+
max_tool_calls = state.profile.limits[:max_tool_calls] || 50
|
|
248
|
+
|
|
249
|
+
# the loop detector. Needs #after_message + #add_message for the
|
|
250
|
+
# batch-boundary intervention; a chat without them (smoke shim, minimal
|
|
251
|
+
# double) stays bounded by max_tool_calls alone — never half-wired.
|
|
252
|
+
detector = if %i[after_message add_message].all? { |m| chat.respond_to?(m) }
|
|
253
|
+
repeat = state.profile.limits[:max_tool_repeat] || Insika::AgentProfile::DEFAULT_LIMITS[:max_tool_repeat]
|
|
254
|
+
Insika::LoopDetector.new(chat: chat, limit: repeat, emit: emit) if repeat >= 2
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
chat.before_tool_call do |tool_call|
|
|
258
|
+
# call<->decorator correlation (side-effects/skip) — 1st line.
|
|
259
|
+
state.current_tool_call = tool_call
|
|
260
|
+
# max_tool_calls guard-rail: stays inline (not as a registered hook)
|
|
261
|
+
# because Hooks is shared across turns and has no unregister.
|
|
262
|
+
tool_calls += 1
|
|
263
|
+
if tool_calls > max_tool_calls
|
|
264
|
+
raise Insika::TimeoutError.new("tool call limit exceeded (#{max_tool_calls})",
|
|
265
|
+
stage: :tool_limit)
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# AFTER the count, BEFORE the call runs — a post-warning
|
|
269
|
+
# repeat raises here, so the stubborn loop pays for no extra call.
|
|
270
|
+
detector&.tool_call(tool_call.name, tool_call.arguments)
|
|
271
|
+
|
|
272
|
+
# :tool pair: RubyLLM's callbacks are additive — the altered subject
|
|
273
|
+
# feeds later hooks and the events, but does not rewrite the call the
|
|
274
|
+
# model executes. A hook exception here aborts the turn.
|
|
275
|
+
tool_call = @hooks.run_before(:tool, tool_call)
|
|
276
|
+
|
|
277
|
+
# The name of the (possibly hook-altered) subject, for the :tool_result
|
|
278
|
+
# label. Also fiber-scoped: as a closure local it belonged to the TURN, so
|
|
279
|
+
# under concurrency `after_tool_result` labelled every result with whichever
|
|
280
|
+
# call started last.
|
|
281
|
+
state.current_tool_name = tool_call.name.to_s
|
|
282
|
+
if state.current_tool_name == "load_skill"
|
|
283
|
+
args = tool_call.arguments || {}
|
|
284
|
+
emit.call(:skill_activated, { name: args["name"] || args[:name] })
|
|
285
|
+
else
|
|
286
|
+
emit.call(:tool_call, { name: tool_call.name, arguments: tool_call.arguments })
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
chat.after_tool_result do |result|
|
|
291
|
+
# the RAW result — the only place a Tool::Halt (halt_when) is
|
|
292
|
+
# still recognizable, and a halted batch must receive no intervention.
|
|
293
|
+
detector&.tool_result(result)
|
|
294
|
+
result = @hooks.run_after(:tool, result)
|
|
295
|
+
emit.call(:tool_result, { name: state.current_tool_name, result: result.to_s })
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# the intervention appends at the batch boundary (the Nth tool
|
|
299
|
+
# result closing) — never between two tool results of one batch.
|
|
300
|
+
chat.after_message { |message| detector.message_ended(message) } if detector
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
# Per-turn snapshot. The checkpoint of
|
|
5
|
+
# turn n holds the state AT THE START of turn n — resuming = re-executing the
|
|
6
|
+
# whole turn n. `messages` is the MATERIALIZED transcript (not a cursor): the
|
|
7
|
+
# checkpoint is self-contained and resumption does not depend on the Session Store.
|
|
8
|
+
#
|
|
9
|
+
# `completed_side_effects`: ids of non-idempotent tool calls already completed
|
|
10
|
+
# within the turn (resumption answers them with "already_executed").
|
|
11
|
+
Checkpoint = Data.define(:task_id, :turn, :session_id, :agent_id,
|
|
12
|
+
:messages, :completed_side_effects, :created_at)
|
|
13
|
+
end
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
# Domain store for checkpoints. A per-turn snapshot written
|
|
7
|
+
# in an ALL-OR-NOTHING transaction (invariant: a checkpoint is either fully
|
|
8
|
+
# valid or does not exist), a record of non-idempotent side effects in a spill
|
|
9
|
+
# key during the turn, and `prune` to bound growth.
|
|
10
|
+
#
|
|
11
|
+
# Two key families in the "checkpoints" scope:
|
|
12
|
+
# "checkpoint:<task_id>:turn:<n>" -> Checkpoint JSON
|
|
13
|
+
# "sideeffects:<task_id>:turn:<n>" -> ["tool_call_id", ...] (spill key)
|
|
14
|
+
#
|
|
15
|
+
# The spill key exists because the turn's checkpoint does not exist yet when
|
|
16
|
+
# the tool calls run (it is only saved at stage 8): it is written BEFORE the
|
|
17
|
+
# tool result goes back to the model, and the following `save` consolidates it
|
|
18
|
+
# into `completed_side_effects` and deletes it in the SAME transaction.
|
|
19
|
+
class CheckpointStore
|
|
20
|
+
include Coercion
|
|
21
|
+
|
|
22
|
+
SCOPE = "checkpoints"
|
|
23
|
+
|
|
24
|
+
def initialize(store:)
|
|
25
|
+
@store = store
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# -> Checkpoint (with the consolidated side-effect list). ALWAYS in a
|
|
29
|
+
# transaction. Order: validate monotonicity -> consolidate the previous
|
|
30
|
+
# turn's spill key -> write the checkpoint -> delete the absorbed spill key.
|
|
31
|
+
# Any exception in the middle -> full rollback (neither a partial checkpoint
|
|
32
|
+
# nor a lost spill key).
|
|
33
|
+
def save(checkpoint)
|
|
34
|
+
@store.transaction do
|
|
35
|
+
current = latest(checkpoint.task_id)
|
|
36
|
+
if current && current.turn >= checkpoint.turn
|
|
37
|
+
raise ArgumentError,
|
|
38
|
+
"checkpoint with non-monotonic turn: #{checkpoint.turn} <= #{current.turn}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Stage 8 of turn n saves turn n+1's checkpoint:
|
|
42
|
+
# the spill key to absorb is that of the turn that just executed (n).
|
|
43
|
+
spill_key = sideeffects_key(checkpoint.task_id, checkpoint.turn - 1)
|
|
44
|
+
spilled = @store.get(SCOPE, spill_key) || []
|
|
45
|
+
consolidated = Array(checkpoint.completed_side_effects).map(&:to_s) | spilled
|
|
46
|
+
|
|
47
|
+
record = deep_stringify(checkpoint.to_h)
|
|
48
|
+
record["completed_side_effects"] = consolidated
|
|
49
|
+
record["created_at"] ||= timestamp
|
|
50
|
+
@store.set(SCOPE, checkpoint_key(checkpoint.task_id, checkpoint.turn), record)
|
|
51
|
+
@store.delete(SCOPE, spill_key)
|
|
52
|
+
|
|
53
|
+
to_checkpoint(record)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# -> Checkpoint | nil (highest turn). NUMERIC ordering: `list` sorts
|
|
58
|
+
# lexicographically and "turn:9" > "turn:10" — parse n as an Integer.
|
|
59
|
+
def latest(task_id)
|
|
60
|
+
turns = checkpoint_turns(task_id)
|
|
61
|
+
return nil if turns.empty?
|
|
62
|
+
|
|
63
|
+
find(task_id, turn: turns.max)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# -> Checkpoint | nil
|
|
67
|
+
def find(task_id, turn:)
|
|
68
|
+
record = @store.get(SCOPE, checkpoint_key(task_id, turn))
|
|
69
|
+
record && to_checkpoint(record)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# -> nil; idempotent (recording twice = one entry). In a transaction
|
|
73
|
+
# (written before the tool goes back to the model).
|
|
74
|
+
def record_side_effect(task_id, turn:, tool_call_id:)
|
|
75
|
+
@store.transaction do
|
|
76
|
+
key = sideeffects_key(task_id, turn)
|
|
77
|
+
ids = @store.get(SCOPE, key) || []
|
|
78
|
+
id = tool_call_id.to_s
|
|
79
|
+
@store.set(SCOPE, key, ids + [id]) unless ids.include?(id)
|
|
80
|
+
end
|
|
81
|
+
nil
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# -> [tool_call_id] = spill key ∪ checkpoint of the same turn.
|
|
85
|
+
# Covers both places where an id may live during the cycle; since
|
|
86
|
+
# tool_call_id is globally unique, the union never causes an improper skip.
|
|
87
|
+
def side_effects(task_id, turn:)
|
|
88
|
+
spilled = @store.get(SCOPE, sideeffects_key(task_id, turn)) || []
|
|
89
|
+
from_checkpoint = find(task_id, turn: turn)&.completed_side_effects || []
|
|
90
|
+
spilled | from_checkpoint
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# -> void. Keeps the `keep` checkpoints with the highest turn (numeric); deletes the
|
|
94
|
+
# rest. Also clears spill keys of turns strictly smaller than the smallest
|
|
95
|
+
# kept turn (unreachable garbage after consolidation). In a transaction
|
|
96
|
+
# so it never leaves a partial prune. No-op if there are <= keep checkpoints.
|
|
97
|
+
def prune(task_id, keep: 1)
|
|
98
|
+
@store.transaction do
|
|
99
|
+
turns = checkpoint_turns(task_id).sort
|
|
100
|
+
next if turns.size <= keep
|
|
101
|
+
|
|
102
|
+
kept = turns.last(keep)
|
|
103
|
+
smallest_kept = kept.first
|
|
104
|
+
(turns - kept).each { |n| @store.delete(SCOPE, checkpoint_key(task_id, n)) }
|
|
105
|
+
sideeffect_turns(task_id).each do |n|
|
|
106
|
+
@store.delete(SCOPE, sideeffects_key(task_id, n)) if n < smallest_kept
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
nil
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
def checkpoint_key(task_id, turn)
|
|
115
|
+
"checkpoint:#{task_id}:turn:#{turn}"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def sideeffects_key(task_id, turn)
|
|
119
|
+
"sideeffects:#{task_id}:turn:#{turn}"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def checkpoint_turns(task_id)
|
|
123
|
+
@store.list(SCOPE, "checkpoint:#{task_id}:turn:").map { |k| turn_of(k) }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def sideeffect_turns(task_id)
|
|
127
|
+
@store.list(SCOPE, "sideeffects:#{task_id}:turn:").map { |k| turn_of(k) }
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# n is the last segment of the key "...:turn:<n>"; parse it as an Integer.
|
|
131
|
+
def turn_of(key)
|
|
132
|
+
key.split(":").last.to_i
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Materializes at the edge: `turn` as an Integer; the other fields as they come from the
|
|
136
|
+
# backend (string keys in `messages`).
|
|
137
|
+
def to_checkpoint(record)
|
|
138
|
+
Checkpoint.new(
|
|
139
|
+
task_id: record["task_id"],
|
|
140
|
+
turn: record["turn"].to_i,
|
|
141
|
+
session_id: record["session_id"],
|
|
142
|
+
agent_id: record["agent_id"],
|
|
143
|
+
messages: record["messages"],
|
|
144
|
+
completed_side_effects: record["completed_side_effects"],
|
|
145
|
+
created_at: record["created_at"]
|
|
146
|
+
)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def timestamp
|
|
150
|
+
Time.now.utc.iso8601
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
# The circuit breaker's durable state (WS3): one cell per
|
|
7
|
+
# (tenant, provider/model ref) holding the recent failures and when the
|
|
8
|
+
# circuit opened. The read-modify-write rides `@store.transaction` — the same
|
|
9
|
+
# discipline as the dispatch claim (A1): two concurrent failures of the same
|
|
10
|
+
# cell serialize, so the "10 failures in 60s" count is never lost to a race.
|
|
11
|
+
#
|
|
12
|
+
# States, standard semantics:
|
|
13
|
+
# :closed fewer than `after` failures in the `within` window — attempts flow.
|
|
14
|
+
# :open the window count is met AND the cooldown hasn't elapsed — the
|
|
15
|
+
# edge FAIL-FASTS (no provider call) with retry_after = remaining
|
|
16
|
+
# cooldown. Reached at the `after`-th failure, which stamps
|
|
17
|
+
# opened_at.
|
|
18
|
+
# :half_open the cooldown elapsed — the next attempt is the TRIAL: a success
|
|
19
|
+
# closes (record_success clears the cell), a failure reopens
|
|
20
|
+
# (a new failure count / opened_at).
|
|
21
|
+
class CircuitState
|
|
22
|
+
SCOPE = "circuit_state"
|
|
23
|
+
|
|
24
|
+
# Timestamps are pruned to COUNT_LIMIT — a pathological loop cannot grow the
|
|
25
|
+
# cell unboundedly (the window is 60s-wide; 100 reads and writes bounded).
|
|
26
|
+
COUNT_LIMIT = 100
|
|
27
|
+
|
|
28
|
+
Record = Data.define(:failures, :opened_at)
|
|
29
|
+
|
|
30
|
+
def initialize(store:)
|
|
31
|
+
@store = store
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Records ONE failure for (tenant, ref). If this failure makes the window
|
|
35
|
+
# reach `after` and the circuit is not already open, it stamps opened_at
|
|
36
|
+
# (the instant the breaker trips). -> :closed (still closed) | :open (JUST
|
|
37
|
+
# tripped — closed->open, the ONLY transition that alerts) | :reopened (an
|
|
38
|
+
# already-tripped cell re-stamped: a half-open trial failed; the circuit is
|
|
39
|
+
# open again but the node already told the operator it is sick — no NEW
|
|
40
|
+
# alert (WS3)).
|
|
41
|
+
def record_failure(tenant:, ref:, after: 10, within: 60, now: Time.now)
|
|
42
|
+
key = key_for(tenant, ref)
|
|
43
|
+
@store.transaction do
|
|
44
|
+
record = load(key)
|
|
45
|
+
cutoff = (now.to_i - within)
|
|
46
|
+
retained = record.failures.select { |t| t > cutoff }.last(COUNT_LIMIT)
|
|
47
|
+
failures = (retained + [now.to_i]).last(COUNT_LIMIT)
|
|
48
|
+
opened_at = record.opened_at
|
|
49
|
+
tripped = :closed
|
|
50
|
+
if opened_at.nil?
|
|
51
|
+
if failures.size >= after
|
|
52
|
+
opened_at = now.to_i
|
|
53
|
+
tripped = :open
|
|
54
|
+
end
|
|
55
|
+
else
|
|
56
|
+
# the circuit was tripped before; a failure here can only be a HALF-OPEN
|
|
57
|
+
# trial that failed. Re-stamp opened_at so it RE-OPENS — a breaker that
|
|
58
|
+
# never restamps is one-shot: after the first cooldown every later turn
|
|
59
|
+
# is an unlocked half-open trial (WS3).
|
|
60
|
+
opened_at = now.to_i
|
|
61
|
+
tripped = :reopened
|
|
62
|
+
end
|
|
63
|
+
@store.set(SCOPE, key, { "failures" => failures, "opened_at" => opened_at })
|
|
64
|
+
tripped
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# A successful attempt CLOSES the circuit: the cell is cleared so the
|
|
69
|
+
# failure window starts fresh (half-open trial success included).
|
|
70
|
+
def record_success(tenant:, ref:)
|
|
71
|
+
@store.delete(SCOPE, key_for(tenant, ref))
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# -> :closed | :open | :half_open
|
|
75
|
+
def state(tenant:, ref:, after: 10, within: 60, cooldown: 300, now: Time.now)
|
|
76
|
+
record = load(key_for(tenant, ref))
|
|
77
|
+
opened = record.opened_at.to_i
|
|
78
|
+
# :closed needs BOTH doors shut: the window count is under `after` AND the
|
|
79
|
+
# circuit never tripped. A surviving opened_at tombstone keeps the circuit
|
|
80
|
+
# governed by the cooldown (open, then half-open) even after its original
|
|
81
|
+
# failures age out of the rolling window — only a SUCCESS clears it (WS3:
|
|
82
|
+
# a failed half-open trial must reopen, not silently close).
|
|
83
|
+
return :closed if record.failures.size < after && opened.zero?
|
|
84
|
+
|
|
85
|
+
return :open if opened.zero? || (now.to_i - opened) < cooldown
|
|
86
|
+
|
|
87
|
+
:half_open
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Seconds until the circuit can be retried (the remaining cooldown). nil
|
|
91
|
+
# while closed.
|
|
92
|
+
def retry_after(tenant:, ref:, cooldown: 300, now: Time.now)
|
|
93
|
+
record = load(key_for(tenant, ref))
|
|
94
|
+
return nil if record.failures.empty?
|
|
95
|
+
|
|
96
|
+
opened = record.opened_at.to_i
|
|
97
|
+
return nil if opened.zero?
|
|
98
|
+
|
|
99
|
+
remaining = cooldown - (now.to_i - opened)
|
|
100
|
+
remaining.positive? ? remaining : nil
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
def key_for(tenant, ref) = "#{tenant || 'platform'}:#{ref}"
|
|
106
|
+
|
|
107
|
+
def load(key)
|
|
108
|
+
record = @store.get(SCOPE, key)
|
|
109
|
+
return Record.new([], nil) if record.nil?
|
|
110
|
+
|
|
111
|
+
Record.new(Array(record["failures"]), record["opened_at"])
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
# Shared coercions at the input/persistence boundary. They used to live
|
|
5
|
+
# copied in each store/handler; a single home keeps them from drifting.
|
|
6
|
+
module Coercion
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
# Present string or nil: nil and blank (incl. whitespace-only) become nil;
|
|
10
|
+
# everything else becomes a stripped String.
|
|
11
|
+
def presence(str)
|
|
12
|
+
s = str.to_s.strip
|
|
13
|
+
s.empty? ? nil : s
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# nil or blank (incl. whitespace-only) -> true.
|
|
17
|
+
def blank?(value) = value.nil? || value.to_s.strip.empty?
|
|
18
|
+
|
|
19
|
+
# Inverse of blank? — a usable, non-empty value.
|
|
20
|
+
def present?(value) = !blank?(value)
|
|
21
|
+
|
|
22
|
+
# The operator-facing truthy: what a form checkbox ("1"), a JSON round-trip
|
|
23
|
+
# ("true") or the DSL (true) produce for an opt-in. One list, one reading —
|
|
24
|
+
# AgentProfile#stream_public?, SkillCatalog#blanket? and the Studio all consult
|
|
25
|
+
# it, so a value that opts in on one surface cannot opt out on another.
|
|
26
|
+
TRUTHY = [true, "true", "1", "yes", "on"].freeze
|
|
27
|
+
|
|
28
|
+
def truthy?(value) = TRUTHY.include?(value)
|
|
29
|
+
|
|
30
|
+
# Bytes that come from outside the engine (sockets, pipes, subprocesses)
|
|
31
|
+
# arrive tagged BINARY, or as UTF-8 carrying invalid sequences. Both break
|
|
32
|
+
# `JSON.generate` the moment the text has an accent or an emoji — today a
|
|
33
|
+
# warning ("UTF-8 string passed as BINARY"), an exception from json 3.0 — and
|
|
34
|
+
# that text ends up in a transcript, an event and an SSE frame. Reinterprets
|
|
35
|
+
# the bytes as UTF-8 (the wire encoding of every source we read) and scrubs
|
|
36
|
+
# what is not valid, so what crosses the boundary is always serializable.
|
|
37
|
+
def utf8(str)
|
|
38
|
+
s = str.to_s
|
|
39
|
+
s = s.dup.force_encoding(Encoding::UTF_8) unless s.encoding == Encoding::UTF_8
|
|
40
|
+
s.valid_encoding? ? s : s.scrub
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Normalizes keys and Symbols to String recursively (the stores' JSON model
|
|
44
|
+
# has no Symbol). Hash -> keys and values; Array -> elements.
|
|
45
|
+
def deep_stringify(obj)
|
|
46
|
+
case obj
|
|
47
|
+
when Hash
|
|
48
|
+
obj.each_with_object({}) { |(k, v), acc| acc[k.to_s] = deep_stringify(v) }
|
|
49
|
+
when Array
|
|
50
|
+
obj.map { |v| deep_stringify(v) }
|
|
51
|
+
when Symbol
|
|
52
|
+
obj.to_s
|
|
53
|
+
else
|
|
54
|
+
obj
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Insika
|
|
7
|
+
# Every mutating interaction becomes a Command. Shared shape:
|
|
8
|
+
# type: Symbol (:create_session, :send_message, :trigger_workflow,
|
|
9
|
+
# :cancel_task, :resume_task)
|
|
10
|
+
# payload: Hash validated by the handler
|
|
11
|
+
# meta: { command_id:, tenant:, transport:, issued_at: }
|
|
12
|
+
#
|
|
13
|
+
# `Command` does not validate payload (that's the handler's job) and does not
|
|
14
|
+
# know about the bus.
|
|
15
|
+
Command = Data.define(:type, :payload, :meta) do
|
|
16
|
+
# Factory that fills in the meta defaults. `type` is
|
|
17
|
+
# normalized to Symbol; a nil `payload` becomes {} (the handler decides
|
|
18
|
+
# whether required fields are missing).
|
|
19
|
+
def self.build(type, payload = {}, transport: :internal, tenant: nil)
|
|
20
|
+
new(
|
|
21
|
+
type: type.to_sym,
|
|
22
|
+
payload: payload || {},
|
|
23
|
+
meta: {
|
|
24
|
+
command_id: SecureRandom.uuid,
|
|
25
|
+
tenant: tenant,
|
|
26
|
+
transport: transport,
|
|
27
|
+
issued_at: Time.now.utc.iso8601
|
|
28
|
+
}
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|