insika 0.9.0 → 1.0.1
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 +237 -2
- data/README.md +5 -3
- data/bin/insika +3 -0
- data/bin/insika-router +0 -1
- data/docs/AGENTS.md +90 -7
- data/docs/ARCHITECTURE.md +12 -7
- data/docs/ARTIFACTS.md +33 -3
- data/docs/BENCHMARK.md +50 -7
- data/docs/CONTEXT.md +63 -0
- data/docs/CROSS-HARNESS-BENCH.md +157 -0
- data/docs/DEPLOY.md +57 -11
- data/docs/EMBEDDING.md +10 -0
- data/docs/EVALS.md +33 -4
- data/docs/FACTS.md +8 -6
- data/docs/HARVEST.md +8 -3
- data/docs/KNOWLEDGE.md +7 -0
- data/docs/MEDIA.md +6 -2
- data/docs/OBSERVABILITY.md +143 -22
- data/docs/PERFORMANCE.md +110 -0
- data/docs/PLUGINS.md +4 -4
- data/docs/POLICY.md +22 -13
- data/docs/RELEASING.md +5 -0
- data/docs/ROUTER.md +7 -0
- data/docs/RUBYLLM_2_MIGRATION.md +307 -0
- data/docs/SECURITY.md +7 -0
- data/docs/SHARED-CONVERSATIONS.md +40 -0
- data/docs/SKILLS.md +5 -3
- data/docs/STUDIO.md +122 -0
- data/docs/TOOLS.md +111 -9
- data/docs/operate.md +3 -0
- data/lib/insika/agent_defaults.rb +140 -0
- data/lib/insika/agent_file_store.rb +12 -0
- data/lib/insika/agent_profile.rb +78 -8
- data/lib/insika/artifact_signing.rb +11 -0
- data/lib/insika/chat_builder.rb +79 -25
- data/lib/insika/checkpoint.rb +6 -1
- data/lib/insika/checkpoint_store.rb +59 -7
- data/lib/insika/commands/agent_payload.rb +3 -3
- data/lib/insika/commands/delete_kit.rb +26 -0
- data/lib/insika/commands/gate_harvest.rb +3 -0
- data/lib/insika/commands/review_guardrail_flag.rb +38 -0
- data/lib/insika/commands/run_harvest.rb +17 -1
- data/lib/insika/commands/seed_session.rb +11 -1
- data/lib/insika/commands/send_message.rb +9 -3
- data/lib/insika/commands/update_settings.rb +23 -1
- data/lib/insika/commands/write_kit.rb +27 -0
- data/lib/insika/compaction.rb +33 -7
- data/lib/insika/config_store.rb +87 -7
- data/lib/insika/context/priority.rb +3 -0
- data/lib/insika/context/provider.rb +2 -2
- data/lib/insika/context/providers/knowledge.rb +25 -2
- data/lib/insika/context/providers/memory.rb +53 -4
- data/lib/insika/context/providers/pending_confirmation.rb +52 -0
- data/lib/insika/context/providers/prompt.rb +17 -4
- data/lib/insika/context/providers/session.rb +39 -3
- data/lib/insika/context/reranker.rb +75 -0
- data/lib/insika/distill.rb +22 -13
- data/lib/insika/doctor.rb +34 -4
- data/lib/insika/dsl/runtime.rb +36 -4
- data/lib/insika/dsl/server_boot.rb +4 -12
- data/lib/insika/dsl.rb +13 -5
- data/lib/insika/edge_limiter.rb +23 -15
- data/lib/insika/egress_guard.rb +11 -3
- data/lib/insika/env_schema.rb +33 -73
- data/lib/insika/evals/assertions.rb +171 -16
- data/lib/insika/evals/golden.rb +141 -4
- data/lib/insika/evals/judge.rb +19 -10
- data/lib/insika/evals/moderator_compare.rb +47 -0
- data/lib/insika/evals/report.rb +61 -9
- data/lib/insika/evals/runner.rb +44 -4
- data/lib/insika/evals/simulator.rb +10 -1
- data/lib/insika/evals/transport.rb +200 -0
- data/lib/insika/event_stream.rb +63 -7
- data/lib/insika/evidence.rb +29 -8
- data/lib/insika/executor.rb +668 -141
- data/lib/insika/harvest.rb +26 -14
- data/lib/insika/harvest_engine.rb +1 -0
- data/lib/insika/harvest_store.rb +12 -0
- data/lib/insika/http_client.rb +87 -4
- data/lib/insika/kits.rb +54 -0
- data/lib/insika/knowledge.rb +34 -20
- data/lib/insika/llm_configurator.rb +46 -4
- data/lib/insika/llm_provider_store.rb +1 -3
- data/lib/insika/llm_trace_store.rb +54 -0
- data/lib/insika/mcp_client.rb +27 -63
- data/lib/insika/mcp_live_tool.rb +55 -9
- data/lib/insika/mcp_store.rb +32 -1
- data/lib/insika/mcp_tool_registry.rb +69 -26
- data/lib/insika/media.rb +38 -25
- data/lib/insika/memory_store.rb +4 -3
- data/lib/insika/memory_trim.rb +60 -0
- data/lib/insika/middleware.rb +12 -3
- data/lib/insika/model_metrics_store.rb +285 -0
- data/lib/insika/model_registry.rb +110 -0
- data/lib/insika/model_resolver.rb +1 -1
- data/lib/insika/model_selection.rb +14 -21
- data/lib/insika/model_visible.rb +17 -6
- data/lib/insika/onboarding.rb +1 -0
- data/lib/insika/outcome_store.rb +1 -1
- data/lib/insika/overlay_tool_registry.rb +25 -2
- data/lib/insika/pack_importer.rb +54 -9
- data/lib/insika/packaging.rb +14 -5
- data/lib/insika/pending_action_store.rb +45 -18
- data/lib/insika/plugin/loader.rb +4 -2
- data/lib/insika/profile_source.rb +4 -0
- data/lib/insika/provider_error_classifier.rb +18 -3
- data/lib/insika/queue_policy.rb +7 -2
- data/lib/insika/refinement/proposer.rb +42 -17
- data/lib/insika/reliability.rb +12 -3
- data/lib/insika/router/app.rb +14 -5
- data/lib/insika/router/session_key.rb +35 -5
- data/lib/insika/safety/config.rb +20 -3
- data/lib/insika/safety/corpus.rb +10 -3
- data/lib/insika/safety/factory.rb +39 -1
- data/lib/insika/safety/input_guardrail.rb +21 -1
- data/lib/insika/safety/judge_moderator.rb +70 -0
- data/lib/insika/safety/output_validator.rb +29 -5
- data/lib/insika/safety/prompt_echo.rb +89 -0
- data/lib/insika/schema_guard.rb +53 -76
- data/lib/insika/server/app.rb +17 -8
- data/lib/insika/server/responses.rb +19 -1
- data/lib/insika/session_actor.rb +58 -28
- data/lib/insika/session_store.rb +159 -10
- data/lib/insika/settings_store.rb +47 -1
- data/lib/insika/shared_conversations.rb +305 -0
- data/lib/insika/skill_catalog.rb +28 -2
- data/lib/insika/skill_store.rb +6 -0
- data/lib/insika/soak/runner.rb +1 -1
- data/lib/insika/steer_injector.rb +7 -48
- data/lib/insika/store.rb +29 -1
- data/lib/insika/stores/json_value.rb +37 -0
- data/lib/insika/stores/memory.rb +22 -35
- data/lib/insika/stores/postgres.rb +295 -0
- data/lib/insika/stores/sqlite.rb +257 -43
- data/lib/insika/stores/turn_counter.rb +25 -0
- data/lib/insika/studio/app.rb +1453 -161
- data/lib/insika/studio/assets/dist/application.css +1 -1
- data/lib/insika/studio/assets/dist/application.js +25 -25
- data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-400-normal.woff2 +0 -0
- data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-500-normal.woff2 +0 -0
- data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-600-normal.woff2 +0 -0
- data/lib/insika/studio/assets/dist/fonts/ibm-plex-sans-latin-wght-normal.woff2 +0 -0
- data/lib/insika/studio/forms.rb +171 -9
- data/lib/insika/studio/nav_icons.rb +3 -0
- data/lib/insika/studio/views/_agent_config_retrieval.erb +73 -0
- data/lib/insika/studio/views/_agent_picker.erb +15 -0
- data/lib/insika/studio/views/_agent_tab_config.erb +78 -242
- data/lib/insika/studio/views/_agent_tab_history.erb +56 -1
- data/lib/insika/studio/views/_agent_tab_performance.erb +34 -0
- data/lib/insika/studio/views/_agent_tab_prompts.erb +7 -9
- data/lib/insika/studio/views/_agent_tab_skills.erb +16 -7
- data/lib/insika/studio/views/_agent_tab_tools.erb +90 -0
- data/lib/insika/studio/views/_agents_master.erb +8 -12
- data/lib/insika/studio/views/_cfg_alerts.erb +4 -0
- data/lib/insika/studio/views/_cfg_budget.erb +20 -0
- data/lib/insika/studio/views/_cfg_distill.erb +26 -0
- data/lib/insika/studio/views/_cfg_followup.erb +30 -0
- data/lib/insika/studio/views/_cfg_grounding.erb +21 -0
- data/lib/insika/studio/views/_cfg_guardrails.erb +47 -0
- data/lib/insika/studio/views/_cfg_harvest.erb +35 -0
- data/lib/insika/studio/views/_cfg_reliability.erb +32 -0
- data/lib/insika/studio/views/_chats_master.erb +66 -0
- data/lib/insika/studio/views/_customers_master.erb +37 -0
- data/lib/insika/studio/views/_empty.erb +15 -0
- data/lib/insika/studio/views/_facts_master.erb +42 -0
- data/lib/insika/studio/views/_harvest_candidate_detail.erb +14 -0
- data/lib/insika/studio/views/_identity.erb +16 -0
- data/lib/insika/studio/views/_kpi.erb +17 -0
- data/lib/insika/studio/views/_message.erb +40 -2
- data/lib/insika/studio/views/_model_chart.erb +82 -0
- data/lib/insika/studio/views/_refinement_master.erb +38 -0
- data/lib/insika/studio/views/{tool_edit.erb → _tool_edit.erb} +32 -9
- data/lib/insika/studio/views/_tool_users.erb +17 -0
- data/lib/insika/studio/views/_why.erb +53 -0
- data/lib/insika/studio/views/agent_detail.erb +17 -10
- data/lib/insika/studio/views/agents.erb +28 -16
- data/lib/insika/studio/views/approvals.erb +16 -11
- data/lib/insika/studio/views/artifact.erb +20 -7
- data/lib/insika/studio/views/artifacts.erb +19 -14
- data/lib/insika/studio/views/chats.erb +22 -29
- data/lib/insika/studio/views/customer.erb +88 -43
- data/lib/insika/studio/views/customers.erb +16 -26
- data/lib/insika/studio/views/evals.erb +49 -31
- data/lib/insika/studio/views/fact_detail.erb +114 -0
- data/lib/insika/studio/views/facts.erb +15 -116
- data/lib/insika/studio/views/followups.erb +63 -52
- data/lib/insika/studio/views/funnel.erb +104 -43
- data/lib/insika/studio/views/harvest.erb +85 -59
- data/lib/insika/studio/views/home.erb +138 -23
- data/lib/insika/studio/views/inbox.erb +36 -0
- data/lib/insika/studio/views/kits.erb +176 -0
- data/lib/insika/studio/views/knowledge.erb +92 -80
- data/lib/insika/studio/views/layout.erb +42 -11
- data/lib/insika/studio/views/live.erb +45 -0
- data/lib/insika/studio/views/mcp.erb +43 -13
- data/lib/insika/studio/views/models.erb +187 -0
- data/lib/insika/studio/views/parity.erb +38 -32
- data/lib/insika/studio/views/playground.erb +13 -9
- data/lib/insika/studio/views/refinement.erb +15 -224
- data/lib/insika/studio/views/refinement_detail.erb +243 -0
- data/lib/insika/studio/views/session.erb +152 -117
- data/lib/insika/studio/views/settings.erb +86 -22
- data/lib/insika/studio/views/skills.erb +53 -19
- data/lib/insika/studio/views/system_files.erb +7 -7
- data/lib/insika/studio/views/task.erb +45 -15
- data/lib/insika/studio/views/tasks.erb +35 -18
- data/lib/insika/studio/views/tools.erb +62 -73
- data/lib/insika/task_actor.rb +4 -1
- data/lib/insika/task_store.rb +67 -1
- data/lib/insika/telemetry/pricing.rb +16 -14
- data/lib/insika/telemetry/recorder.rb +103 -9
- data/lib/insika/telemetry/ruby_llm_instrumenter.rb +110 -0
- data/lib/insika/telemetry.rb +12 -2
- data/lib/insika/templates/daily-digest/README.md +13 -0
- data/lib/insika/templates/daily-digest/agent.rb +22 -11
- data/lib/insika/testing/store_contract.rb +65 -0
- data/lib/insika/text_tool_call.rb +66 -0
- data/lib/insika/tick.rb +7 -1
- data/lib/insika/token_store.rb +5 -0
- data/lib/insika/tool_batch.rb +1 -1
- data/lib/insika/tool_catalog.rb +12 -2
- data/lib/insika/tool_definition.rb +10 -4
- data/lib/insika/tool_envelope.rb +165 -28
- data/lib/insika/tool_manifest.rb +1 -0
- data/lib/insika/tool_store.rb +3 -0
- data/lib/insika/tool_trace_store.rb +15 -8
- data/lib/insika/tool_usage_report.rb +2 -2
- data/lib/insika/tools/a2a_remote.rb +1 -1
- data/lib/insika/tools/agent_enum.rb +1 -1
- data/lib/insika/tools/confirm_pending.rb +112 -0
- data/lib/insika/tools/data_defined_tool.rb +23 -15
- data/lib/insika/tools/generate_image.rb +2 -2
- data/lib/insika/tools/load_knowledge.rb +7 -3
- data/lib/insika/tools/load_skill.rb +3 -3
- data/lib/insika/tools/present.rb +1 -8
- data/lib/insika/tools/remember.rb +7 -2
- data/lib/insika/tools/run_persona_eval.rb +3 -5
- data/lib/insika/tools/save_artifact.rb +16 -6
- data/lib/insika/tools/schedule_followup.rb +3 -3
- data/lib/insika/tools/stuck_signal.rb +3 -3
- data/lib/insika/tools/subagent.rb +5 -5
- data/lib/insika/tools/subagents.rb +3 -3
- data/lib/insika/tools/tool_search.rb +7 -5
- data/lib/insika/tools/tts.rb +2 -2
- data/lib/insika/tools/update_briefing.rb +4 -4
- data/lib/insika/turn_output.rb +2 -2
- data/lib/insika/turn_state.rb +18 -0
- data/lib/insika/turn_timing.rb +51 -14
- data/lib/insika/version.rb +1 -1
- data/lib/insika/wiring/graph.rb +81 -16
- data/lib/insika/workflow.rb +25 -64
- data/lib/insika.rb +19 -3
- metadata +69 -13
- data/lib/insika/commands/import_mcp_tools.rb +0 -48
- data/lib/insika/mcp_http_client.rb +0 -67
- data/lib/insika/mcp_tool_ingestor.rb +0 -165
data/lib/insika/telemetry.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "logger"
|
|
4
|
+
|
|
3
5
|
require "async"
|
|
4
6
|
require_relative "telemetry/pricing"
|
|
5
7
|
require_relative "telemetry/recorder"
|
|
@@ -15,7 +17,7 @@ module Insika
|
|
|
15
17
|
# (`OTEL_EXPORTER_OTLP_ENDPOINT`, `OTEL_TRACES_EXPORTER`). The destination/protocol
|
|
16
18
|
# follows the OTEL SDK's default config (env) — SigNoz/Tempo/Jaeger/Collector. The
|
|
17
19
|
# OTEL_* keys are the OpenTelemetry SDK's own env and stay verbatim; only our opt-in
|
|
18
|
-
# toggle is
|
|
20
|
+
# toggle is INSIKA_OTEL.
|
|
19
21
|
#
|
|
20
22
|
# Metrics ride the same switch and the SAME standard env: the SDK
|
|
21
23
|
# registers a periodic metric reader when the OTLP metrics exporter is loadable,
|
|
@@ -39,6 +41,7 @@ module Insika
|
|
|
39
41
|
require "opentelemetry/sdk"
|
|
40
42
|
require "opentelemetry/exporter/otlp"
|
|
41
43
|
load_metrics_sdk
|
|
44
|
+
OpenTelemetry.logger = sdk_logger
|
|
42
45
|
unless @configured
|
|
43
46
|
OpenTelemetry::SDK.configure { |c| c.service_name = service_name }
|
|
44
47
|
@configured = true
|
|
@@ -49,6 +52,13 @@ module Insika
|
|
|
49
52
|
meter: meter, pricing: pricing(env))
|
|
50
53
|
end
|
|
51
54
|
|
|
55
|
+
# The SDK's default logger writes to $stdout, which Ruby buffers when it is a
|
|
56
|
+
# pipe (Falcon workers, a container log): a rejected export (404 from an
|
|
57
|
+
# ingest that wants a header) never reached the log. $stderr is unbuffered.
|
|
58
|
+
def sdk_logger(io = $stderr)
|
|
59
|
+
Logger.new(io, progname: "otel", level: ENV.fetch("OTEL_LOG_LEVEL", "warn"))
|
|
60
|
+
end
|
|
61
|
+
|
|
52
62
|
# Did `setup` wire the metric instruments too (SDK present, at least one reader)?
|
|
53
63
|
# Only meaningful after `setup` — it exists for the boot banners.
|
|
54
64
|
def metrics? = @metrics == true
|
|
@@ -68,7 +78,7 @@ module Insika
|
|
|
68
78
|
# Operator-declared rates (USD per million tokens) as JSON in
|
|
69
79
|
# INSIKA_MODEL_PRICING. Unset/malformed -> an empty table -> no cost is reported.
|
|
70
80
|
def pricing(env = ENV)
|
|
71
|
-
table = Pricing.
|
|
81
|
+
table = Pricing.from_env(env)
|
|
72
82
|
table.empty? ? nil : table
|
|
73
83
|
end
|
|
74
84
|
|
|
@@ -20,6 +20,19 @@ Then open `/studio`, log in with the printed token, and either wait for the
|
|
|
20
20
|
schedule (22:00 America/Sao_Paulo) or send a message to the "reporter" agent
|
|
21
21
|
in the Playground — the artifact lands on the Artifacts tab either way.
|
|
22
22
|
|
|
23
|
+
## Report design
|
|
24
|
+
|
|
25
|
+
The skill asks for a dated executive takeaway, consistent KPI cards, warm neutrals
|
|
26
|
+
with one amber accent, labeled charts, sources and limitations. Reports adapt to
|
|
27
|
+
phone widths and the system light/dark preference. Native Chart/Table radio choices,
|
|
28
|
+
CSS hover/focus highlights and expandable details work without JavaScript. Exact
|
|
29
|
+
values remain accessible on touch and in a table; focus is visible, 140ms transitions
|
|
30
|
+
respect reduced motion, and print includes the full table and details.
|
|
31
|
+
|
|
32
|
+
See the [fictional-data example](https://github.com/guizaols/insika/blob/main/examples/scheduled-report/interactive-report.html).
|
|
33
|
+
These interactions explore a saved snapshot, not live data. This guidance applies
|
|
34
|
+
to newly generated reports; existing artifacts stay unchanged.
|
|
35
|
+
|
|
23
36
|
## What's real here
|
|
24
37
|
|
|
25
38
|
- The **schedule** is a live engine feature: the tick actually fires this
|
|
@@ -47,21 +47,32 @@ reporter = Insika.agent("reporter") do
|
|
|
47
47
|
overrides: { turn_timeout: 600, max_tool_calls: 120 }
|
|
48
48
|
|
|
49
49
|
# The generic, store-free skill: how a report is BUILT — the inline-SVG
|
|
50
|
-
# pattern (palette, table,
|
|
50
|
+
# pattern (palette, table, native controls, light/dark). No store ids, no
|
|
51
51
|
# queries — that half belongs in a merchant's pack, not here.
|
|
52
52
|
skill "daily-digest",
|
|
53
53
|
description: "How to build the daily sales digest as a self-contained HTML report",
|
|
54
54
|
instructions: <<~MD
|
|
55
|
-
Build
|
|
56
|
-
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
55
|
+
Build ONE self-contained executive HTML report with a <style> block:
|
|
56
|
+
- Lead with a clear title, period, generation date and concise,
|
|
57
|
+
evidence-backed takeaway. Name sources and limitations. Use only
|
|
58
|
+
supplied numbers; show missing data honestly and label demo data.
|
|
59
|
+
- Use warm neutrals and one amber accent (or the merchant's accent),
|
|
60
|
+
consistent type, spacing and KPI cards, and system dark mode.
|
|
61
|
+
- Include a responsive inline SVG or HTML bar chart with clear labels,
|
|
62
|
+
units and exact values, plus a captioned table with column headers.
|
|
63
|
+
- Offer Chart/Table through labeled, focusable native radios grouped
|
|
64
|
+
in a fieldset with a legend; use CSS :checked, not ARIA tabs.
|
|
65
|
+
- Use hover/focus tooltips in reserved space; keep them visible when
|
|
66
|
+
hovered. Add CSS highlights, a visible 2px focus outline, and
|
|
67
|
+
details/summary for supporting evidence. Exact values
|
|
68
|
+
must remain available on touch without hovering.
|
|
69
|
+
- Use 140ms color/opacity transitions; disable them for reduced motion.
|
|
70
|
+
No decorative animation. Print the full table and supporting details
|
|
71
|
+
regardless of the selected view or collapsed state; hide controls.
|
|
72
|
+
- This is a dated data snapshot, not a live feed. No scripts, event
|
|
73
|
+
handlers, forms, iframe, external fonts or fetches. The report's
|
|
74
|
+
default-src 'none' policy blocks external resources.
|
|
75
|
+
Keep it readable on a phone. The channel message is just the link.
|
|
65
76
|
MD
|
|
66
77
|
end
|
|
67
78
|
|
|
@@ -117,6 +117,12 @@ RSpec.shared_examples "an Insika store" do
|
|
|
117
117
|
expect(store.list("s", "task:")).to eq(%w[task:1 task:2])
|
|
118
118
|
end
|
|
119
119
|
|
|
120
|
+
it "keeps the prefix boundary exact, ASCII or not" do
|
|
121
|
+
%w[task task: task:1 task:é task; tasks sessão:1 sessão:2 sessãoX run9 run9a run: taz taz1 ta{].each { |k| store.set("s", k, 1) }
|
|
122
|
+
expect([store.list("s", "task:"), store.list("s", "sessão:"), store.list("s", "run9"), store.list("s", "taz")])
|
|
123
|
+
.to eq([%w[task: task:1 task:é], %w[sessão:1 sessão:2], %w[run9 run9a], %w[taz taz1]])
|
|
124
|
+
end
|
|
125
|
+
|
|
120
126
|
it " returns [] for an empty scope" do #
|
|
121
127
|
expect(store.list("s")).to eq([])
|
|
122
128
|
end
|
|
@@ -128,6 +134,65 @@ RSpec.shared_examples "an Insika store" do
|
|
|
128
134
|
end
|
|
129
135
|
end
|
|
130
136
|
|
|
137
|
+
# Bulk read: what a caller that lists keys and reads each one needs, in one
|
|
138
|
+
# call. A backend may answer it in one query; the default is list + get.
|
|
139
|
+
describe "#entries" do
|
|
140
|
+
it "returns [key, value] pairs sorted by key, values deserialized" do
|
|
141
|
+
store.set("s", "b", { "n" => 2 })
|
|
142
|
+
store.set("s", "a", [1])
|
|
143
|
+
store.set("other", "a", "x")
|
|
144
|
+
expect(store.entries("s")).to eq([["a", [1]], ["b", { "n" => 2 }]])
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
it "filters by prefix with start_with?, ASCII or not" do
|
|
148
|
+
store.set("s", "ab", 1)
|
|
149
|
+
store.set("s", "abc", 2)
|
|
150
|
+
store.set("s", "b", 3)
|
|
151
|
+
store.set("s", "é1", 4)
|
|
152
|
+
expect(store.entries("s", "ab")).to eq([["ab", 1], ["abc", 2]])
|
|
153
|
+
expect(store.entries("s", "é")).to eq([["é1", 4]])
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
it "returns [] for an empty scope" do
|
|
157
|
+
expect(store.entries("nothing")).to eq([])
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# The last-written keys, newest first: what a "recent" list needs without
|
|
162
|
+
# reading the whole scope.
|
|
163
|
+
describe "#recent" do
|
|
164
|
+
it "returns the most recently written pairs first, capped at limit" do
|
|
165
|
+
store.set("s", "a", { "n" => 1 })
|
|
166
|
+
store.set("s", "b", { "n" => 2 })
|
|
167
|
+
store.set("s", "c", { "n" => 3 })
|
|
168
|
+
store.set("s", "a", { "n" => 4 }) # rewritten: now the newest
|
|
169
|
+
expect(store.recent("s", nil, 2)).to eq([["a", { "n" => 4 }], ["c", { "n" => 3 }]])
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
it "skips the newest `offset` pairs (the next page)" do
|
|
173
|
+
%w[a b c d e].each_with_index { |k, i| store.set("s", "p:#{k}", i) }
|
|
174
|
+
expect(store.recent("s", "p:", 2, 0).map(&:first)).to eq(%w[p:e p:d])
|
|
175
|
+
expect(store.recent("s", "p:", 2, 2).map(&:first)).to eq(%w[p:c p:b])
|
|
176
|
+
expect(store.recent("s", "p:", 2, 4).map(&:first)).to eq(%w[p:a])
|
|
177
|
+
expect(store.recent("s", nil, 10, 5)).to eq([])
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
it "an amended key keeps its place; an absent one is written" do
|
|
181
|
+
store.set("s", "a", { "n" => 1 })
|
|
182
|
+
store.set("s", "b", { "n" => 2 })
|
|
183
|
+
store.amend("s", "a", { "n" => 3 })
|
|
184
|
+
store.amend("s", "c", { "n" => 4 })
|
|
185
|
+
expect(store.recent("s", nil, 3)).to eq([["c", { "n" => 4 }], ["b", { "n" => 2 }], ["a", { "n" => 3 }]])
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
it "keeps to the scope and the prefix" do
|
|
189
|
+
store.set("s", "x:1", 1)
|
|
190
|
+
store.set("other", "x:2", 2)
|
|
191
|
+
store.set("s", "y:1", 3)
|
|
192
|
+
expect(store.recent("s", "x:", 10)).to eq([["x:1", 1]])
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
131
196
|
describe "#scopes" do
|
|
132
197
|
it " returns scope names sorted lexicographically" do #
|
|
133
198
|
store.set("zeta", "k", 1)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
# A tool call the model wrote as TEXT instead of through the API's `tool_calls`.
|
|
7
|
+
#
|
|
8
|
+
# deepseek-v4-flash does it now and then: the message comes back with no tool call
|
|
9
|
+
# and a body like
|
|
10
|
+
#
|
|
11
|
+
# <tool_call>
|
|
12
|
+
# search_faq
|
|
13
|
+
# <parameter name="query">quem é a loja Exemplo</parameter>
|
|
14
|
+
# </tool_call>
|
|
15
|
+
#
|
|
16
|
+
# Left alone that is the turn's ANSWER: the tool never runs, the consumer's leak
|
|
17
|
+
# guard swallows the text and the customer gets silence — and the markup stays in
|
|
18
|
+
# the session history, where the model reads itself calling tools that way and does
|
|
19
|
+
# it again (Loja Exemplo, 23/10/2026). The Executor rewrites such a message
|
|
20
|
+
# into a real tool call before the next step, so the loop runs the tool and the
|
|
21
|
+
# history holds what a normal call would have left.
|
|
22
|
+
#
|
|
23
|
+
# Two spellings: the name-then-<parameter> one above (the one seen in production)
|
|
24
|
+
# and the Hermes/Qwen JSON one (`<tool_call>{"name": …, "arguments": {…}}`).
|
|
25
|
+
# Anything else — or a name the chat does not offer — is NOT recovered: inventing a
|
|
26
|
+
# call is worse than the silence the consumer already guards.
|
|
27
|
+
module TextToolCall
|
|
28
|
+
BLOCK = %r{<tool_call>\s*(.*?)\s*</tool_call>}m
|
|
29
|
+
PARAM = %r{<parameter\s+name\s*=\s*"([^"]+)"\s*>(.*?)</parameter>}m
|
|
30
|
+
NAME = /\A([A-Za-z_][\w.-]*)\s*(?:<|\z)/
|
|
31
|
+
|
|
32
|
+
module_function
|
|
33
|
+
|
|
34
|
+
# content: the assistant message text. known: the tool names the chat offers.
|
|
35
|
+
# -> { calls: [{ name:, arguments: }], text: what is left outside the blocks } | nil
|
|
36
|
+
def parse(content, known)
|
|
37
|
+
text = content.to_s
|
|
38
|
+
return nil unless text.include?("<tool_call>")
|
|
39
|
+
|
|
40
|
+
calls = text.scan(BLOCK).map { |(body)| call_from(body) }
|
|
41
|
+
return nil if calls.empty? || calls.any? { |call| call.nil? || !known.include?(call[:name]) }
|
|
42
|
+
|
|
43
|
+
{ calls: calls, text: text.gsub(BLOCK, "").strip }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def call_from(body)
|
|
47
|
+
return json_call(body) if body.start_with?("{")
|
|
48
|
+
|
|
49
|
+
name = body[NAME, 1] or return nil
|
|
50
|
+
# ponytail: parameter values stay strings (the spelling carries no types);
|
|
51
|
+
# every tool seen leaking takes strings. Coerce by schema if one ever doesn't.
|
|
52
|
+
{ name: name, arguments: body.scan(PARAM).to_h { |key, value| [key, value.strip] } }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def json_call(body)
|
|
56
|
+
data = JSON.parse(body)
|
|
57
|
+
return nil unless data.is_a?(Hash) && data["name"].is_a?(String)
|
|
58
|
+
|
|
59
|
+
arguments = data["arguments"]
|
|
60
|
+
arguments = JSON.parse(arguments) if arguments.is_a?(String)
|
|
61
|
+
{ name: data["name"], arguments: arguments.is_a?(Hash) ? arguments : {} }
|
|
62
|
+
rescue JSON::ParserError
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
data/lib/insika/tick.rb
CHANGED
|
@@ -34,7 +34,7 @@ module Insika
|
|
|
34
34
|
def initialize(store:, recovery:, channel_delivery:, logger: nil,
|
|
35
35
|
interval: DEFAULT_INTERVAL, stale_after: DEFAULT_STALE_AFTER,
|
|
36
36
|
sleeper: nil, retention: nil, funnel: nil, followup: nil,
|
|
37
|
-
schedule: nil)
|
|
37
|
+
schedule: nil, model_registry: nil)
|
|
38
38
|
@store = store
|
|
39
39
|
@recovery = recovery
|
|
40
40
|
@channel_delivery = channel_delivery
|
|
@@ -46,6 +46,7 @@ module Insika
|
|
|
46
46
|
@funnel = funnel # the tick-driven outcome fold; nil = none
|
|
47
47
|
@followup = followup # the tick-driven follow-up firer; nil = none
|
|
48
48
|
@schedule = schedule # the recurring-schedule firer; nil = none
|
|
49
|
+
@model_registry = model_registry # the daily model-catalog refresh; nil = none
|
|
49
50
|
end
|
|
50
51
|
|
|
51
52
|
# the fold is wired after the Tick is built (the graph passes
|
|
@@ -61,6 +62,9 @@ module Insika
|
|
|
61
62
|
# (same shape — the stores come from the spine).
|
|
62
63
|
attr_accessor :schedule
|
|
63
64
|
|
|
65
|
+
# the daily RubyLLM model-catalog refresh (ModelRegistry), same shape.
|
|
66
|
+
attr_accessor :model_registry
|
|
67
|
+
|
|
64
68
|
def enabled? = @interval.positive?
|
|
65
69
|
|
|
66
70
|
# One pass, pure (no reactor needed): the serving loop calls it on a timer,
|
|
@@ -85,6 +89,8 @@ module Insika
|
|
|
85
89
|
# the same claim-window discipline as the follow-up firer.
|
|
86
90
|
schedule_summary = @schedule&.run
|
|
87
91
|
summary[:schedule] = schedule_summary if schedule_summary
|
|
92
|
+
registry_summary = @model_registry&.run
|
|
93
|
+
summary[:model_registry] = registry_summary if registry_summary
|
|
88
94
|
return summary unless claim_window
|
|
89
95
|
|
|
90
96
|
result = @recovery.run(stale_after: @stale_after)
|
data/lib/insika/token_store.rb
CHANGED
|
@@ -112,6 +112,10 @@ module Insika
|
|
|
112
112
|
# NOTHING valid. -> { revoked: n, issue: Issue }.
|
|
113
113
|
def rotate(tenant_id:, label: "default", now: Time.now)
|
|
114
114
|
@store.transaction do
|
|
115
|
+
# A key no one writes, read first: on a backend that locks per key read,
|
|
116
|
+
# two rotations of one tenant run one after the other, so the second
|
|
117
|
+
# revokes the token the first issued.
|
|
118
|
+
@store.get(SCOPE, "#{ROTATE_PREFIX}#{tenant_id}")
|
|
115
119
|
revoked = revoke_all(tenant_id: tenant_id)
|
|
116
120
|
{ revoked: revoked, issue: issue(tenant_id: tenant_id, label: label) }
|
|
117
121
|
end
|
|
@@ -145,6 +149,7 @@ module Insika
|
|
|
145
149
|
end
|
|
146
150
|
|
|
147
151
|
RECORD_PREFIX = "r:"
|
|
152
|
+
ROTATE_PREFIX = "rotate:" # never written: only read, to serialize rotations
|
|
148
153
|
HASH_PREFIX = "h:"
|
|
149
154
|
|
|
150
155
|
def record_key(id) = "#{RECORD_PREFIX}#{id}"
|
data/lib/insika/tool_batch.rb
CHANGED
|
@@ -39,7 +39,7 @@ module Insika
|
|
|
39
39
|
# From after_tool_result, with the RAW result: a Tool::Halt is only
|
|
40
40
|
# recognizable there.
|
|
41
41
|
def halt!(result)
|
|
42
|
-
@halted = true if defined?(
|
|
42
|
+
@halted = true if defined?(Insika::ToolDefinition::Halt) && result.is_a?(Insika::ToolDefinition::Halt)
|
|
43
43
|
end
|
|
44
44
|
|
|
45
45
|
def halted? = @halted
|
data/lib/insika/tool_catalog.rb
CHANGED
|
@@ -14,13 +14,20 @@ module Insika
|
|
|
14
14
|
# no instantiation at all; a broken factory surfaces on first use (where the
|
|
15
15
|
# Executor would also catch it at stage 3), not at construction.
|
|
16
16
|
class ToolCatalog
|
|
17
|
-
Entry = Data.define(:name, :description)
|
|
17
|
+
Entry = Data.define(:name, :description, :plugin) do
|
|
18
|
+
def initialize(name:, description:, plugin: nil)
|
|
19
|
+
super
|
|
20
|
+
end
|
|
21
|
+
end
|
|
18
22
|
|
|
19
23
|
def initialize(tool_registry:)
|
|
20
24
|
@tool_registry = tool_registry
|
|
21
25
|
end
|
|
22
26
|
|
|
23
27
|
def all
|
|
28
|
+
gen = registry_generation
|
|
29
|
+
@entries = nil if gen != @generation
|
|
30
|
+
@generation = gen
|
|
24
31
|
@entries ||= build_entries
|
|
25
32
|
end
|
|
26
33
|
|
|
@@ -29,6 +36,7 @@ module Insika
|
|
|
29
36
|
# restart. An in-flight turn has already captured `all`.
|
|
30
37
|
def reload
|
|
31
38
|
@entries = build_entries
|
|
39
|
+
@generation = registry_generation
|
|
32
40
|
self
|
|
33
41
|
end
|
|
34
42
|
|
|
@@ -76,7 +84,7 @@ module Insika
|
|
|
76
84
|
|
|
77
85
|
def build_entries
|
|
78
86
|
@tool_registry.entries.map do |entry|
|
|
79
|
-
Entry.new(name: entry.name, description: entry.factory.call.description.to_s)
|
|
87
|
+
Entry.new(name: entry.name, description: entry.factory.call.description.to_s, plugin: entry.plugin)
|
|
80
88
|
end
|
|
81
89
|
end
|
|
82
90
|
|
|
@@ -85,5 +93,7 @@ module Insika
|
|
|
85
93
|
desc = entry.description.downcase
|
|
86
94
|
terms.sum { |term| (name.include?(term) ? 2 : 0) + (desc.include?(term) ? 1 : 0) }
|
|
87
95
|
end
|
|
96
|
+
|
|
97
|
+
def registry_generation = @tool_registry.respond_to?(:generation) ? @tool_registry.generation : nil
|
|
88
98
|
end
|
|
89
99
|
end
|
|
@@ -20,7 +20,7 @@ module Insika
|
|
|
20
20
|
# "group" => string|nil, "tags" => ["b2b",...] } #//
|
|
21
21
|
#
|
|
22
22
|
# `parameters` is **JSON Schema** (the interlingua of OpenAI/Anthropic/MCP):
|
|
23
|
-
# a nestable object, fed straight into RubyLLM's `
|
|
23
|
+
# a nestable object, fed straight into RubyLLM's `parameters_schema` (provider-
|
|
24
24
|
# agnostic). The **flat array** (`[{name,type,required}]`) is SUGAR for the simple
|
|
25
25
|
# case: it is lifted to JSON Schema at build time. The sugar covers scalars and
|
|
26
26
|
# `array:<scalar>` — it CANNOT express an array of objects, and says so instead of
|
|
@@ -41,6 +41,11 @@ module Insika
|
|
|
41
41
|
)
|
|
42
42
|
|
|
43
43
|
class ToolDefinition
|
|
44
|
+
# Terminal tool results finish the current batch without another model request.
|
|
45
|
+
Halt = Data.define(:content) do
|
|
46
|
+
def to_s = content.to_s
|
|
47
|
+
end
|
|
48
|
+
|
|
44
49
|
# Flat-sugar types: the SCALARS, plus `array:<scalar>` for a list. There is no bare
|
|
45
50
|
# `array`: an array without an item type is an INCOMPLETE declaration, and the
|
|
46
51
|
# engine refuses to guess one (see lift_flat_params).
|
|
@@ -163,6 +168,8 @@ module Insika
|
|
|
163
168
|
|
|
164
169
|
def presentation? = !presentation.nil?
|
|
165
170
|
|
|
171
|
+
# Public because an MCP tool has the same question to answer and there must be
|
|
172
|
+
# one reading of "these parameters may only carry an id some tool returned".
|
|
166
173
|
def self.normalize_requires_evidence(value, param_names)
|
|
167
174
|
return nil if value.nil?
|
|
168
175
|
|
|
@@ -173,7 +180,6 @@ module Insika
|
|
|
173
180
|
|
|
174
181
|
{ "params" => params.map(&:to_s).uniq }
|
|
175
182
|
end
|
|
176
|
-
private_class_method :normalize_requires_evidence
|
|
177
183
|
|
|
178
184
|
# Group: enablement label by DATA (not name convention),
|
|
179
185
|
# target of AgentProfile's `tools_allow_groups`. Trimmed; empty/nil -> nil.
|
|
@@ -542,7 +548,7 @@ module Insika
|
|
|
542
548
|
nil
|
|
543
549
|
end
|
|
544
550
|
|
|
545
|
-
# HOW `say` REACHES THE EXECUTOR.
|
|
551
|
+
# HOW `say` REACHES THE EXECUTOR. Insika::ToolDefinition::Halt carries one value, and
|
|
546
552
|
# that value is the tool's payload (the trace records it, and the model never
|
|
547
553
|
# sees it — the halt ends the loop). So a halt that has something to publish
|
|
548
554
|
# carries BOTH, under keys distinctive enough that a trace reader knows what
|
|
@@ -576,7 +582,7 @@ module Insika
|
|
|
576
582
|
|
|
577
583
|
# FLAT view of the top-level properties (name/type/description/required) — for
|
|
578
584
|
# RubyLLM's `#parameters` (discovery/tool_search) and the simple authoring UI.
|
|
579
|
-
# The full nested schema goes through `
|
|
585
|
+
# The full nested schema goes through `parameters_schema` (DataDefinedTool). Symbol-
|
|
580
586
|
# keyed for compat with callers that already consumed the flat params.
|
|
581
587
|
def top_level_params
|
|
582
588
|
props = parameters["properties"] || {}
|