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
data/lib/insika/dsl.rb
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "pack"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
# Public Ruby DSL — the OSS "business card":
|
|
7
|
+
#
|
|
8
|
+
# agent = Insika.agent("assistant") do
|
|
9
|
+
# model "deepseek-v4-flash"
|
|
10
|
+
# instructions "You are a concise, friendly assistant."
|
|
11
|
+
# end
|
|
12
|
+
# puts agent.reply("hi, what can you do?") # one turn, in-process
|
|
13
|
+
# agent.serve # control UI + /v1 on :9292
|
|
14
|
+
#
|
|
15
|
+
# It is THIN SUGAR that GENERATES the data (a Insika::Pack), never a bypass of
|
|
16
|
+
# config-over-code (COMPETITIVE-ANALYSIS). `Insika.agent { … }.to_pack`
|
|
17
|
+
# is the same portable artifact the PackImporter consumes at runtime — the DSL
|
|
18
|
+
# and a hand-written pack produce the SAME profile (the parity spec proves it),
|
|
19
|
+
# because BOTH go through the standard import → StoredProfileSource round-trip.
|
|
20
|
+
#
|
|
21
|
+
# Nothing here loads ruby_llm or the HTTP server: `require "insika"` stays light.
|
|
22
|
+
# The runtime (chat/serve) is pulled in lazily by Definition (dsl/runtime.rb).
|
|
23
|
+
module DSL
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
# Insika.agent("id") { … } → Definition (see #agent below on the module).
|
|
27
|
+
def agent(id, &block)
|
|
28
|
+
Builder.new(id).build(&block)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Insika.system { agent("a") { … }; agent("b") { … } } → System.
|
|
32
|
+
def system(&block)
|
|
33
|
+
SystemBuilder.new.build(&block)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Insika.embed(backend:) { … } → System (see #embed below on the module).
|
|
37
|
+
def embed(backend:, &block)
|
|
38
|
+
SystemBuilder.new.build(backend: backend, &block)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Collects several agents into ONE runtime. A single agent is a Definition;
|
|
42
|
+
# more than one needs a container, because delegation (`subagents`) and any
|
|
43
|
+
# multi-agent pattern only mean something when the children live in the same
|
|
44
|
+
# graph. It adds no new engine path: each agent is still its own Pack,
|
|
45
|
+
# imported through the standard PackImporter.
|
|
46
|
+
class SystemBuilder
|
|
47
|
+
def initialize
|
|
48
|
+
@definitions = []
|
|
49
|
+
@workflows = []
|
|
50
|
+
@runtime = {}
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def build(backend: nil, &block)
|
|
54
|
+
instance_eval(&block) if block
|
|
55
|
+
raise ArgumentError, "Insika.system needs at least one agent" if @definitions.empty?
|
|
56
|
+
|
|
57
|
+
System.new(definitions: @definitions, workflows: @workflows, runtime: @runtime, backend: backend)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Declares one agent — the SAME block the standalone `Insika.agent` takes.
|
|
61
|
+
# Returns its Definition, so a script can keep a handle if it wants one.
|
|
62
|
+
def agent(id, &block)
|
|
63
|
+
definition = Builder.new(id).build(&block)
|
|
64
|
+
if @definitions.any? { |d| d.id == definition.id }
|
|
65
|
+
raise ArgumentError, "duplicate agent id in system: #{definition.id}"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
@definitions << definition
|
|
69
|
+
definition
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Declares a WORKFLOW: deterministic Ruby orchestrating agent turns, for the
|
|
73
|
+
# shapes a single tool-loop should not decide on its own — chaining, routing,
|
|
74
|
+
# evaluate-and-retry. It is registered in the same WorkflowRegistry a
|
|
75
|
+
# deployment uses, so it gets a durable run (the run id IS a Task),
|
|
76
|
+
# `:workflow_started`/`:workflow_completed` on the event stream, and — when
|
|
77
|
+
# served — `GET /v1/workflows` + `POST /v1/workflows/:name`.
|
|
78
|
+
#
|
|
79
|
+
# workflow "draft", input: { type: "object", required: ["topic"], … } do |input, ctx|
|
|
80
|
+
# draft = ctx.ask("writer", "Write about #{input['topic']}")
|
|
81
|
+
# ctx.ask("editor", "Tighten this:\n#{draft}")
|
|
82
|
+
# end
|
|
83
|
+
#
|
|
84
|
+
# `input:`/`output:` take a JSON Schema Hash (validated by the engine's
|
|
85
|
+
# zero-dep validator) or any dry-schema-compatible `#call`-able. A bad input
|
|
86
|
+
# is refused synchronously, with NO run created.
|
|
87
|
+
def workflow(name, description: nil, input: nil, output: nil, &block)
|
|
88
|
+
raise ArgumentError, "workflow '#{name}' needs a block" if block.nil?
|
|
89
|
+
|
|
90
|
+
name = name.to_s
|
|
91
|
+
raise ArgumentError, "duplicate workflow in system: #{name}" if @workflows.any? { |w| w[:name] == name }
|
|
92
|
+
|
|
93
|
+
@workflows << { name: name, description: description,
|
|
94
|
+
input_schema: input, output_schema: output, block: block }
|
|
95
|
+
name
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# System-wide runtime knobs (NOT part of any pack): they configure the LLM
|
|
99
|
+
# clients for every agent. A per-agent `provider` still wins for that agent;
|
|
100
|
+
# this is the default and the place to put a shared key.
|
|
101
|
+
def provider(name) = @runtime[:provider] = name.to_s
|
|
102
|
+
def api_key(value) = @runtime[:api_key] = value.to_s
|
|
103
|
+
def api_base(value) = @runtime[:api_base] = value.to_s
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Collects the declarations and emits a Insika::Pack. Declarations map 1:1 to
|
|
107
|
+
# the pack manifest (AgentProfile.build attrs) + the pack's files/skills/tools —
|
|
108
|
+
# so what you write is exactly the data the engine stores.
|
|
109
|
+
class Builder
|
|
110
|
+
def initialize(id)
|
|
111
|
+
@id = id.to_s
|
|
112
|
+
@config = {}
|
|
113
|
+
@files = {}
|
|
114
|
+
@skills = {}
|
|
115
|
+
@tools = []
|
|
116
|
+
# Auto-enable the allowlist policies: harmless when the allowlist is nil=all,
|
|
117
|
+
# correct once you restrict tools/skills. Visible in #to_pack — no hidden magic.
|
|
118
|
+
@config[:policies] = %i[tool_allowlist skill_allowlist]
|
|
119
|
+
@runtime = {} # non-pack knobs (llm provider/key/base) consumed by the runtime
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def build(&block)
|
|
123
|
+
instance_eval(&block) if block
|
|
124
|
+
Definition.new(pack: to_pack, runtime: @runtime)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# --- identity & model ------------------------------------------------
|
|
128
|
+
def model(name) = @config[:model] = name.to_s
|
|
129
|
+
|
|
130
|
+
# Provider for both the profile AND the RubyLLM configuration at run time.
|
|
131
|
+
def provider(name)
|
|
132
|
+
@config[:provider] = name.to_s
|
|
133
|
+
@runtime[:provider] ||= name.to_s
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def instructions(text) = @config[:base_prompt] = text.to_s
|
|
137
|
+
alias_method :prompt, :instructions
|
|
138
|
+
|
|
139
|
+
# An extra prompt FILE (identity fragment). Name = the file name (e.g. "SOUL.md").
|
|
140
|
+
def prompt_file(name, content)
|
|
141
|
+
@files[name.to_s] = content.to_s
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# --- tools -----------------------------------------------------------
|
|
145
|
+
# tools "a", "b" → allowlist [names]. Not called → nil = all (parity).
|
|
146
|
+
def tools(*names)
|
|
147
|
+
@config[:tools_allow] = names.flatten.map(&:to_s)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def deny_tools(*names)
|
|
151
|
+
@config[:tools_deny] = names.flatten.map(&:to_s)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# A DATA-DEFINED (declarative HTTP) tool — pure config-over-code. `defn` is a
|
|
155
|
+
# ToolDefinition hash (name/description/parameters/binding…). Its name is
|
|
156
|
+
# auto-added to the allowlist so the agent can call its own tool.
|
|
157
|
+
def data_tool(defn)
|
|
158
|
+
h = defn.transform_keys(&:to_s)
|
|
159
|
+
@tools << h
|
|
160
|
+
name = h["name"].to_s
|
|
161
|
+
(@config[:tools_allow] ||= []) << name unless name.empty? || Array(@config[:tools_allow]).include?(name)
|
|
162
|
+
h
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# --- skills ----------------------------------------------------------
|
|
166
|
+
# skill "escalate", "<full SKILL.md>" — or —
|
|
167
|
+
# skill "escalate", description: "…", instructions: "…"
|
|
168
|
+
# The name is auto-added to the agent's skill allowlist.
|
|
169
|
+
def skill(name, content = nil, description: nil, instructions: nil)
|
|
170
|
+
n = name.to_s
|
|
171
|
+
@skills[n] = normalize_skill(n, content, description, instructions)
|
|
172
|
+
(@config[:skills] ||= []) << n unless @config.fetch(:skills, []).include?(n)
|
|
173
|
+
n
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# skills_eager — turns progressive disclosure off for THIS agent, wholly or in
|
|
177
|
+
# part. The body of an eager skill is in the prompt on every turn, so its
|
|
178
|
+
# activation is not a decision and cannot be missed; it is paid for on every
|
|
179
|
+
# turn, so measure the bodies against `context_budget` first (a stable position
|
|
180
|
+
# makes them a cacheable prefix).
|
|
181
|
+
#
|
|
182
|
+
# skills_eager # every allowed skill
|
|
183
|
+
# skills_eager "formato", "markers" # exactly these
|
|
184
|
+
# skills_eager false # none (the default)
|
|
185
|
+
#
|
|
186
|
+
# A LIST and not a per-skill flag because skills are shared: `escalation-to-human`
|
|
187
|
+
# sits in several allowlists, and one flag on the skill would force one decision
|
|
188
|
+
# onto every agent holding it.
|
|
189
|
+
def skills_eager(*names)
|
|
190
|
+
flat = names.flatten
|
|
191
|
+
@config[:skills_eager] =
|
|
192
|
+
if flat.empty? then true
|
|
193
|
+
elsif flat == [true] || flat == [false] then flat.first
|
|
194
|
+
else flat.map(&:to_s)
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# --- delegation ------------------------------------------------------
|
|
199
|
+
# subagents "security", "performance" → the child agents this one MAY
|
|
200
|
+
# spawn. CAPACITY field: opt-in, never inherited, and the ids
|
|
201
|
+
# must be agents of the same system (`Insika.system { … }`) or already in
|
|
202
|
+
# the store. Present ⇒ the engine wires `spawn_subagent`/`spawn_subagents`.
|
|
203
|
+
def subagents(*ids)
|
|
204
|
+
@config[:subagents] = ids.flatten.map(&:to_s)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# --- knobs -----------------------------------------------------------
|
|
208
|
+
def memory(on = true) = @config[:memory] = on
|
|
209
|
+
|
|
210
|
+
# Spend caps per calendar window (WS2): daily/monthly token budgets for
|
|
211
|
+
# this agent, per (tenant, agent) when multi-tenant. HARD is the default:
|
|
212
|
+
# absent `soft:` (or `soft: false`) turns the cap into a hard wall (the
|
|
213
|
+
# turn fails with budget_exceeded + retry_after); `soft: true` warns once
|
|
214
|
+
# per window and keeps running.
|
|
215
|
+
# budget daily: 100_000, monthly: 2_000_000, soft: false
|
|
216
|
+
def budget(hash) = (@config[:budget] ||= {}).merge!(hash.transform_keys(&:to_s))
|
|
217
|
+
|
|
218
|
+
# Provider-interaction reliability, as DATA (WS3): retries + exponential
|
|
219
|
+
# backoff on transient failures, a fallback model chain (mid-turn
|
|
220
|
+
# rotation), and a circuit breaker per (tenant, provider/model) that
|
|
221
|
+
# fail-fasts once the window trips. `fallback`/`circuit_breaker` entries
|
|
222
|
+
# are "provider/model" refs or plain model ids.
|
|
223
|
+
# reliability retries: 3, backoff: "exponential",
|
|
224
|
+
# fallback: ["gpt-4o-mini"], circuit_breaker: { after: 10, within: 60, cooldown: 300 }
|
|
225
|
+
def reliability(hash)
|
|
226
|
+
(@config[:reliability] ||= {}).merge!(hash.transform_keys(&:to_s))
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Operator alert delivery (WS6): POST this agent's budget_warning /
|
|
230
|
+
# breaker_open / delivery_failed events to the webhook, as JSON.
|
|
231
|
+
# alerts webhook: "https://ops.example.com/insika-alerts"
|
|
232
|
+
def alerts(hash) = (@config[:alerts] ||= {}).merge!(hash.transform_keys(&:to_s))
|
|
233
|
+
|
|
234
|
+
# The agent may signal it cannot proceed (WS5): when on, the model
|
|
235
|
+
# can call `signal_stuck`, which ends the turn with `outcome: :stuck` + a final
|
|
236
|
+
# message + a `:turn_stuck` event. What "stuck" means is the consumer's call.
|
|
237
|
+
# stuck_signal true
|
|
238
|
+
def stuck_signal(on = true) = @config[:stuck_signal] = on
|
|
239
|
+
|
|
240
|
+
# Mechanical tool-result dedupe in the replayed history
|
|
241
|
+
# (no-LLM compaction, apt for bloated transcripts). CHANGES WHAT THE MODEL
|
|
242
|
+
# SEES: repeated identical tool results collapse to a back-reference.
|
|
243
|
+
def tool_output_compression(on = true) = @config[:tool_output_compression] = on
|
|
244
|
+
|
|
245
|
+
# Content-safety guardrails — opt-in and configurable per agent.
|
|
246
|
+
# Pure config-over-code: the hash is stored on the profile and consumed by
|
|
247
|
+
# Safety::Config.from_profile. Merges, so repeated calls accumulate.
|
|
248
|
+
# guardrails input: true, output: true, strictness: "medium",
|
|
249
|
+
# moderator: "deepseek/deepseek-v4-flash",
|
|
250
|
+
# responses: { "injection" => "I can't help with that." }
|
|
251
|
+
def guardrails(hash) = (@config[:guardrails] ||= {}).merge!(hash.transform_keys(&:to_s))
|
|
252
|
+
|
|
253
|
+
# Refinement — how the agent's own instruction files may be
|
|
254
|
+
# improved from real traffic. Same config-over-code shape as `guardrails`;
|
|
255
|
+
# omitting it entirely leaves the agent report-only (writes nothing).
|
|
256
|
+
# refine mode: "propose", window: { last_sessions: 200 }, files: %w[TOOLS.md],
|
|
257
|
+
# proposers: ["deepseek/deepseek-v4-flash", "gpt-5-mini"],
|
|
258
|
+
# budget: { tokens: 200_000 }
|
|
259
|
+
def refine(hash) = (@config[:refinement] ||= {}).merge!(hash.transform_keys(&:to_s))
|
|
260
|
+
|
|
261
|
+
# Facts about THIS deployment that are not tools, so an eval
|
|
262
|
+
# case can declare what it needs and be skipped where it is absent instead of
|
|
263
|
+
# failing for the wrong reason.
|
|
264
|
+
# declares "promotions", "human_handoff"
|
|
265
|
+
def declares(*names)
|
|
266
|
+
(@config[:capabilities_declared] ||= []).concat(names.flatten.map(&:to_s))
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# Which internal channels may cross to the CUSTOMER. Both off by default: the
|
|
270
|
+
# answer is the answer, and the provider's reasoning (`thinking`) or the model
|
|
271
|
+
# narrating its tool loop (`intermediate`) is for the Studio and the trace.
|
|
272
|
+
# Each opted-in channel gets its OWN frame type at `/v1/responses` — never the
|
|
273
|
+
# answer's — so a consumer that only reads the answer is unaffected either way.
|
|
274
|
+
# edge_stream thinking: true, intermediate: false
|
|
275
|
+
def edge_stream(hash) = (@config[:edge_stream] ||= {}).merge!(hash.transform_keys(&:to_s))
|
|
276
|
+
|
|
277
|
+
# LLM generation params. `param:temperature, 0.2` or `params(...)`.
|
|
278
|
+
def param(key, value) = (@config[:params] ||= {})[key.to_sym] = value
|
|
279
|
+
def params(hash) = (@config[:params] ||= {}).merge!(hash.transform_keys(&:to_sym))
|
|
280
|
+
def temperature(value) = param(:temperature, value)
|
|
281
|
+
def max_tokens(value) = param(:max_tokens, value)
|
|
282
|
+
|
|
283
|
+
# Per-agent limits (timeouts/budgets). `limit :turn_timeout, 120` or `limits(...)`.
|
|
284
|
+
def limit(key, value) = (@config[:limits] ||= {})[key.to_sym] = value
|
|
285
|
+
def limits(hash) = (@config[:limits] ||= {}).merge!(hash.transform_keys(&:to_sym))
|
|
286
|
+
|
|
287
|
+
def policies(*names) = @config[:policies] = names.flatten.map(&:to_sym)
|
|
288
|
+
def metadata(hash) = (@config[:metadata] ||= {}).merge!(hash.transform_keys(&:to_s))
|
|
289
|
+
|
|
290
|
+
# --- runtime (LLM provider) config — NOT part of the pack ------------
|
|
291
|
+
# Configures RubyLLM at chat/serve time. Defaults: provider = the agent's
|
|
292
|
+
# provider; key = ENV["<PROVIDER>_API_KEY"].
|
|
293
|
+
def api_key(value) = @runtime[:api_key] = value.to_s
|
|
294
|
+
def api_base(value) = @runtime[:api_base] = value.to_s
|
|
295
|
+
|
|
296
|
+
# The generated portable artifact — the heart of "generates the data".
|
|
297
|
+
def to_pack
|
|
298
|
+
Insika::Pack.from_h(
|
|
299
|
+
config: @config.merge(id: @id),
|
|
300
|
+
files: @files, skills: @skills, tools: @tools
|
|
301
|
+
)
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
private
|
|
305
|
+
|
|
306
|
+
# Ensure the skill body is a valid SKILL.md (YAML frontmatter with `name`),
|
|
307
|
+
# which is what SkillCatalog parses. Raw content with frontmatter passes
|
|
308
|
+
# through untouched; a bare body / structured args get wrapped.
|
|
309
|
+
def normalize_skill(name, content, description, instructions)
|
|
310
|
+
return content.to_s if content.is_a?(String) && content.lstrip.start_with?("---")
|
|
311
|
+
|
|
312
|
+
body = (instructions || content).to_s
|
|
313
|
+
desc = (description || first_line(body) || name).to_s
|
|
314
|
+
<<~SKILL
|
|
315
|
+
---
|
|
316
|
+
name: #{name}
|
|
317
|
+
description: #{desc}
|
|
318
|
+
---
|
|
319
|
+
|
|
320
|
+
#{body}
|
|
321
|
+
SKILL
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def first_line(text) = text.to_s.strip.lines.first&.strip
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
module_function
|
|
329
|
+
|
|
330
|
+
# Top-level entry point (see Insika::DSL). Returns a Insika::DSL::Definition.
|
|
331
|
+
def agent(id, &block)
|
|
332
|
+
DSL.agent(id, &block)
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# Several agents in one runtime — the shape every multi-agent pattern needs
|
|
336
|
+
# (delegation, fan-out/fan-in, routing). Returns a Insika::DSL::System.
|
|
337
|
+
def system(&block)
|
|
338
|
+
DSL.system(&block)
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# the front door for MOUNTING Insika into an app you already
|
|
342
|
+
# have. Same block as `Insika.system`, one added obligation: the caller names
|
|
343
|
+
# the store, so the graph stops discovering it from `INSIKA_DB` and two graphs
|
|
344
|
+
# in one process can no longer read each other's sessions.
|
|
345
|
+
#
|
|
346
|
+
# INSIKA = Insika.embed(backend: Insika::Stores::SQLite.new(path: "storage/insika.sqlite3")) do
|
|
347
|
+
# agent "support" do
|
|
348
|
+
# model "deepseek-v4-flash"
|
|
349
|
+
# instructions "…"
|
|
350
|
+
# end
|
|
351
|
+
# end
|
|
352
|
+
# # config/routes.rb — mount the /v1 transport as a value:
|
|
353
|
+
# mount Insika::Server.rack_app(INSIKA, token: ENV.fetch("INSIKA_TOKEN")), at: "/ai"
|
|
354
|
+
#
|
|
355
|
+
# It is a thin front door over the SAME assembly `Insika.system` uses — there is
|
|
356
|
+
# one pipeline, and the parity spec holds it to that. What an embedded
|
|
357
|
+
# graph owns, and what it still shares with the process, is docs/EMBEDDING.md.
|
|
358
|
+
def embed(backend:, &block)
|
|
359
|
+
DSL.embed(backend: backend, &block)
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
require_relative "dsl/definition"
|
|
364
|
+
require_relative "dsl/system"
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "middleware"
|
|
4
|
+
require_relative "coercion"
|
|
5
|
+
|
|
6
|
+
module Insika
|
|
7
|
+
# The production edge: THE named place where volume/cost
|
|
8
|
+
# abuse is cut. A Middleware with two independent limits, both OPT-IN
|
|
9
|
+
# (nil/0 = off — a bare wiring behaves exactly as before):
|
|
10
|
+
#
|
|
11
|
+
# · chat rate limit — turn ATTEMPTS per chat per window. Counted on entry
|
|
12
|
+
# (a blocked attempt still counts), so a flood keeps hitting the wall.
|
|
13
|
+
# · agent token ceiling — total tokens per agent per window. Checked on entry
|
|
14
|
+
# against the accumulated ledger; the turn's own usage is recorded AFTER the
|
|
15
|
+
# terminal returns (the Middleware wraps stages 5-9, so state.usage is set).
|
|
16
|
+
# · calendar budget — WS2: `AgentProfile#budget` caps the spend per
|
|
17
|
+
# (tenant, agent) over CALENDAR windows (daily/monthly), on the
|
|
18
|
+
# BudgetLedger. Hard (default): crossing the cap raises the typed
|
|
19
|
+
# Insika::BudgetExceeded (the envelope quotes budget_exceeded +
|
|
20
|
+
# retry_after); soft: crossing warns instead — ONE budget_warning event
|
|
21
|
+
# per window plus a note injected into the context. Crossing `alert_at`
|
|
22
|
+
# (default 0.8 of the cap) warns the same way, before the wall. The turn's
|
|
23
|
+
# billed spend (input+output+cached+cache_creation — the A4 rule) lands on
|
|
24
|
+
# the windows after the terminal.
|
|
25
|
+
#
|
|
26
|
+
# Config resolution, per turn (configuration over convention):
|
|
27
|
+
# profile.limits[:chat_rate_limit / :agent_token_ceiling] — per-agent override
|
|
28
|
+
# settings["edge"] — platform default
|
|
29
|
+
# A per-agent 0 explicitly disables a platform default for that agent.
|
|
30
|
+
#
|
|
31
|
+
# On breach it uses the graceful-halt contract: halt_response
|
|
32
|
+
# (the safe reply) + guardrail_block (audit -> :guardrail_blocked) and does NOT
|
|
33
|
+
# call `nxt` — the turn completes with ZERO LLM calls. It sits BEFORE the
|
|
34
|
+
# InputGuardrail in the stack so a flood can't spend the LLM moderator either.
|
|
35
|
+
# The BUDGET breach is the ONE deliberate exception: it is a typed failure
|
|
36
|
+
# (BudgetExceeded), not a customer-facing reply — the operator wants the
|
|
37
|
+
# envelope to say "budget" and quote when the window rolls, not to hand the
|
|
38
|
+
# customer a cost message.
|
|
39
|
+
class EdgeLimiter < Middleware
|
|
40
|
+
CHAT_KIND = "chat"
|
|
41
|
+
TOKENS_KIND = "tokens"
|
|
42
|
+
|
|
43
|
+
DEFAULT_CHAT_WINDOW = 60 # seconds
|
|
44
|
+
DEFAULT_TOKEN_WINDOW = 86_400 # seconds (daily ceiling)
|
|
45
|
+
|
|
46
|
+
# Neutral fallback, same contract as Safety::SafeResponses (pt-BR — the
|
|
47
|
+
# pilot's language; override via settings edge.limit_response).
|
|
48
|
+
DEFAULT_RESPONSE = "Estou recebendo muitas mensagens agora. Aguarde um " \
|
|
49
|
+
"momento e tente novamente, por favor."
|
|
50
|
+
|
|
51
|
+
def initialize(ledger:, settings_store: nil, budget_ledger: nil, event_stream: nil)
|
|
52
|
+
@ledger = ledger
|
|
53
|
+
@settings = settings_store
|
|
54
|
+
# WS2: the calendar-window ledger. nil = budget off (parity — the bare
|
|
55
|
+
# wiring is byte-identical to before).
|
|
56
|
+
@budget_ledger = budget_ledger
|
|
57
|
+
@event_stream = event_stream
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def call(state, &nxt)
|
|
61
|
+
edge = platform_edge
|
|
62
|
+
limits = state.profile.limits || {}
|
|
63
|
+
# A resume (crash/pause recovery) re-enters the pipeline for a turn that was
|
|
64
|
+
# ALREADY admitted: re-counting it would swallow a legitimate message with
|
|
65
|
+
# the rate-limit reply exactly when the window is saturated. Entry checks
|
|
66
|
+
# are skipped; the turn's usage still lands on the ledger below.
|
|
67
|
+
resumed = state.resumed
|
|
68
|
+
|
|
69
|
+
if !resumed && (limit = positive(limits.key?(:chat_rate_limit) ? limits[:chat_rate_limit] : edge["chat_rate_limit"]))
|
|
70
|
+
breach = check_chat_rate(state, limit, edge)
|
|
71
|
+
return block(state, edge, **breach) if breach
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# NB: a per-agent key PRESENT with nil (e.g. an imported pack carrying
|
|
75
|
+
# `"chat_rate_limit": null`) reads as OFF for that agent, not "inherit".
|
|
76
|
+
if (ceiling = positive(limits.key?(:agent_token_ceiling) ? limits[:agent_token_ceiling] : edge["agent_token_ceiling"]))
|
|
77
|
+
token_window = positive(edge["agent_token_window"]) || DEFAULT_TOKEN_WINDOW
|
|
78
|
+
unless resumed
|
|
79
|
+
spent = @ledger.count(TOKENS_KIND, state.profile.id.to_s, window: token_window)
|
|
80
|
+
if spent >= ceiling
|
|
81
|
+
return block(state, edge, category: :token_ceiling,
|
|
82
|
+
detail: "agent #{state.profile.id}: #{spent}/#{ceiling} tokens per #{token_window}s")
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
record_after = token_window
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# WS2: calendar budgets. Entry — a HARD budget at/over the cap raises the
|
|
90
|
+
# typed error (never a customer-facing reply); the alert_at warning and
|
|
91
|
+
# the SOFT over-cap both warn once per window + inject a context note.
|
|
92
|
+
# A resumed turn (crash/pause replay) was already admitted: it is never
|
|
93
|
+
# refused twice — its spend still lands on the ledger below.
|
|
94
|
+
budget_on = budget_configured?(state)
|
|
95
|
+
budget_enforce(state) unless resumed
|
|
96
|
+
|
|
97
|
+
result = begin
|
|
98
|
+
nxt.call(state)
|
|
99
|
+
ensure
|
|
100
|
+
# A turn that FAILED after burning tokens still SPENT them: record the
|
|
101
|
+
# usage the state captured before the error propagates. The ask's usage
|
|
102
|
+
# lands on state.usage before any later stage (guardrail block, tool
|
|
103
|
+
# error, workflow schema) can fail the turn — a failed turn must count
|
|
104
|
+
# against the budget like a completed one (WS2).
|
|
105
|
+
record_usage(state, record_after) if record_after
|
|
106
|
+
record_budget_usage(state) if budget_on
|
|
107
|
+
end
|
|
108
|
+
result
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
# One KV get per turn (same order of cost as the guardrail's config read);
|
|
114
|
+
# no SettingsStore in the wiring -> per-agent limits only.
|
|
115
|
+
def platform_edge
|
|
116
|
+
(@settings&.get || {})["edge"] || {}
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Counts the ATTEMPT first, then compares — the standard fixed-window
|
|
120
|
+
# semantics (blocked attempts keep counting). A blank chat id is skipped:
|
|
121
|
+
# unrelated anonymous traffic must not share one bucket.
|
|
122
|
+
def check_chat_rate(state, limit, edge)
|
|
123
|
+
chat_id = (state.turn_context || {})[:chat_id].to_s
|
|
124
|
+
return nil if chat_id.empty?
|
|
125
|
+
|
|
126
|
+
window = positive(edge["chat_rate_window"]) || DEFAULT_CHAT_WINDOW
|
|
127
|
+
taken = @ledger.add(CHAT_KIND, chat_id, window: window)
|
|
128
|
+
return nil if taken <= limit
|
|
129
|
+
|
|
130
|
+
{ category: :rate_limit, detail: "chat #{chat_id}: #{taken}/#{limit} turns per #{window}s" }
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# The turn's real spend, accumulated on the agent's ledger. The engine's
|
|
134
|
+
# `total_tokens` is input + output and DELIBERATELY excludes the cached
|
|
135
|
+
# prefix (`Executor#usage_of` reports `cached_tokens`/`cache_creation_tokens`
|
|
136
|
+
# alongside it) — on a cached identity that prefix is ~95% of what the
|
|
137
|
+
# provider actually processed, so a ceiling reading only `total_tokens` is
|
|
138
|
+
# blind. Same billed-spend rule as `Evals::Runner#billed_tokens`.
|
|
139
|
+
# nil usage (workflow turn / provider without counts) records nothing.
|
|
140
|
+
def record_usage(state, window)
|
|
141
|
+
usage = state.usage || {}
|
|
142
|
+
tokens = usage[:total_tokens].to_i + usage[:cached_tokens].to_i +
|
|
143
|
+
usage[:cache_creation_tokens].to_i
|
|
144
|
+
return if tokens.zero?
|
|
145
|
+
|
|
146
|
+
@ledger.add(TOKENS_KIND, state.profile.id.to_s, window: window, by: tokens)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Graceful halt: safe reply + audit metadata, short-circuit (no nxt).
|
|
150
|
+
# `detail` carries only ids/counters — never message content.
|
|
151
|
+
def block(state, edge, category:, detail:)
|
|
152
|
+
state.halt_response = Coercion.presence(edge["limit_response"]) || DEFAULT_RESPONSE
|
|
153
|
+
state.guardrail_block = {
|
|
154
|
+
category: category.to_s, source: "edge", action: "refuse", detail: detail
|
|
155
|
+
}
|
|
156
|
+
nil
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def positive(value)
|
|
160
|
+
v = value.to_i
|
|
161
|
+
v.positive? ? v : nil
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# --- WS2 calendar budgets ------------------------------------------
|
|
165
|
+
|
|
166
|
+
# -> truthy when a budget is configured AND the ledger is wired.
|
|
167
|
+
def budget_configured?(state)
|
|
168
|
+
budget = state.profile.respond_to?(:budget) ? state.profile.budget : nil
|
|
169
|
+
!budget.nil? && !@budget_ledger.nil?
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# -> truthy (the budget hash) when budget checks ran. Raises BudgetExceeded
|
|
173
|
+
# on a HARD cap breach.
|
|
174
|
+
def budget_enforce(state, now: Time.now)
|
|
175
|
+
budget = state.profile.respond_to?(:budget) ? state.profile.budget : nil
|
|
176
|
+
return nil if budget.nil? || @budget_ledger.nil?
|
|
177
|
+
|
|
178
|
+
tenant = budget_tenant(state)
|
|
179
|
+
agent = state.profile.id.to_s
|
|
180
|
+
budget_windows(budget).each do |w|
|
|
181
|
+
spent = @budget_ledger.current(tenant: tenant, agent: agent, now: now)[w[:window]]
|
|
182
|
+
if spent >= w[:cap]
|
|
183
|
+
unless w[:soft]
|
|
184
|
+
raise Insika::BudgetExceeded.new(
|
|
185
|
+
window: w[:window],
|
|
186
|
+
retry_after: @budget_ledger.reset_in(w[:window], now: now)
|
|
187
|
+
)
|
|
188
|
+
end
|
|
189
|
+
warn_budget(state, tenant, agent, w, spent, now, level: "cap")
|
|
190
|
+
elsif spent >= w[:alert_at]
|
|
191
|
+
warn_budget(state, tenant, agent, w, spent, now, level: "alert_at")
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
budget
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# The (tenant, agent) scope: the COMMAND's tenant (nil -> the BudgetLedger's
|
|
198
|
+
# "platform" cell) — never state.tenant, which falls back to the session id
|
|
199
|
+
# (a per-chat bucket is not a budget).
|
|
200
|
+
def budget_tenant(state)
|
|
201
|
+
command = state.respond_to?(:task) && state.task&.command
|
|
202
|
+
return nil unless command.is_a?(Hash)
|
|
203
|
+
|
|
204
|
+
meta = command["meta"] || command[:meta] || {}
|
|
205
|
+
meta["tenant"] || meta[:tenant]
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# -> [{ window:, cap:, soft:, alert_at: }] — one entry per configured window
|
|
209
|
+
# (a 0/absent cap is off). absent `soft` = FALSE (hard): a limit that does
|
|
210
|
+
# not limit is decoration; the alert_at warning is the soft half.
|
|
211
|
+
def budget_windows(budget)
|
|
212
|
+
alert_at = budget["alert_at"].to_f
|
|
213
|
+
alert_at = 0.8 if alert_at <= 0 || alert_at >= 1
|
|
214
|
+
soft = budget["soft"] == true
|
|
215
|
+
%i[daily monthly].filter_map do |window|
|
|
216
|
+
cap = budget[window.to_s].to_i
|
|
217
|
+
cap.positive? ? { window: window, cap: cap, soft: soft,
|
|
218
|
+
alert_at: (cap * alert_at).floor } : nil
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# The warning: a note in the context (the model sees it, the customer's
|
|
223
|
+
# transcript does not) + the budget_warning event — each LEVEL once per
|
|
224
|
+
# (window) cell: the `alert_at` crossing and the real soft-cap crossing are
|
|
225
|
+
# separate markers, so the cap event is never swallowed by the 80% one that
|
|
226
|
+
# fired earlier (WS2).
|
|
227
|
+
def warn_budget(state, tenant, agent, w, spent, now, level:)
|
|
228
|
+
inject_budget_note(state,
|
|
229
|
+
"[budget: agent '#{agent}' is at #{spent}/#{w[:cap]} tokens this " \
|
|
230
|
+
"#{w[:window]} window — keep this turn cheap]")
|
|
231
|
+
return if @budget_ledger.mark_alert(tenant: tenant, agent: agent, window: w[:window],
|
|
232
|
+
level: level, now: now)
|
|
233
|
+
|
|
234
|
+
@event_stream&.emit(Insika::Event.new(
|
|
235
|
+
type: :budget_warning,
|
|
236
|
+
data: { agent: agent, tenant: tenant, window: w[:window],
|
|
237
|
+
spent: spent, cap: w[:cap], level: level },
|
|
238
|
+
meta: { task_id: state.task&.id, session_id: state.task&.session_id,
|
|
239
|
+
at: Time.now.utc.iso8601 }
|
|
240
|
+
))
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# Appends the note to the assembled system prompt: the real Data package is
|
|
244
|
+
# immutable (with), the specs' minimal Struct is mutable — both duck-typed.
|
|
245
|
+
def inject_budget_note(state, note)
|
|
246
|
+
ctx = state.context
|
|
247
|
+
return if ctx.nil?
|
|
248
|
+
|
|
249
|
+
if ctx.respond_to?(:with)
|
|
250
|
+
state.context = ctx.with(system: "#{ctx.system}\n\n#{note}")
|
|
251
|
+
elsif ctx.respond_to?(:system=)
|
|
252
|
+
ctx.system = "#{ctx.system}\n\n#{note}"
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# The turn's REAL billed spend (input + output + cached + cache_creation —
|
|
257
|
+
# the A4 rule) on the calendar windows.
|
|
258
|
+
def record_budget_usage(state, now: Time.now)
|
|
259
|
+
usage = state.usage || {}
|
|
260
|
+
tokens = usage[:total_tokens].to_i + usage[:cached_tokens].to_i +
|
|
261
|
+
usage[:cache_creation_tokens].to_i
|
|
262
|
+
return if tokens.zero?
|
|
263
|
+
|
|
264
|
+
@budget_ledger.add(tenant: budget_tenant(state), agent: state.profile.id.to_s,
|
|
265
|
+
by: tokens, now: now)
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
end
|