insika 0.2.0 → 0.7.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 +325 -17
- data/README.md +52 -11
- data/bin/insika +895 -8
- data/bin/insika-router +87 -0
- data/docs/AGENTS.md +229 -163
- data/docs/API.md +56 -0
- data/docs/ARCHITECTURE.md +3 -2
- data/docs/ARTIFACTS.md +95 -0
- data/docs/BENCHMARK.md +2 -2
- data/docs/CHANNELS.md +111 -21
- data/docs/CONTEXT.md +132 -19
- data/docs/DEMO.md +80 -0
- data/docs/DEPLOY.md +75 -4
- data/docs/EMBEDDING.md +1 -1
- data/docs/EVALS.md +146 -2
- data/docs/FACTS.md +135 -0
- data/docs/HARVEST.md +116 -0
- data/docs/KNOWLEDGE.md +290 -0
- data/docs/LOADTEST.md +16 -10
- data/docs/MEDIA.md +128 -0
- data/docs/OBSERVABILITY.md +65 -6
- data/docs/OUTCOMES.md +137 -0
- data/docs/PLUGINS.md +51 -6
- data/docs/POLICY.md +216 -0
- data/docs/REFINEMENT.md +17 -12
- data/docs/RELEASING.md +27 -0
- data/docs/ROUTER.md +213 -0
- data/docs/RUNNING-LOCAL.md +6 -6
- data/docs/SCHEDULING.md +121 -0
- data/docs/SECURITY.md +79 -2
- data/docs/SKILLS.md +13 -4
- data/docs/SOAK.md +127 -0
- data/docs/TEMPLATES.md +134 -0
- data/docs/TOOLS.md +220 -27
- data/docs/WHY.md +1 -1
- data/docs/WORKFLOWS.md +2 -2
- data/docs/_includes/head_custom.html +5 -0
- data/docs/_includes/title.html +13 -0
- data/docs/_sass/color_schemes/insika.scss +32 -0
- data/docs/_sass/custom/custom.scss +199 -0
- data/docs/_sass/custom/setup.scss +26 -0
- data/docs/assets/img/favicon.svg +7 -0
- data/docs/assets/img/insika-mark.svg +7 -0
- data/docs/core-concepts.md +21 -0
- data/docs/domain.md +115 -0
- data/docs/improve.md +20 -0
- data/docs/index.md +9 -6
- data/docs/integrate.md +20 -0
- data/docs/operate.md +13 -6
- data/docs/prompts/ADD-TOOL.md +118 -0
- data/docs/prompts/DIAGNOSE-TURN.md +65 -0
- data/docs/prompts/GO-LIVE.md +138 -0
- data/docs/prompts/RUN-EXAMPLES.md +70 -0
- data/docs/reference.md +19 -0
- data/docs/ship.md +10 -2
- data/docs/start-here.md +18 -0
- data/lib/insika/agent_profile.rb +202 -10
- data/lib/insika/artifact_signing.rb +82 -0
- data/lib/insika/artifact_store.rb +160 -0
- data/lib/insika/balloon_splitter.rb +102 -0
- data/lib/insika/budget_ledger.rb +34 -3
- data/lib/insika/cache_series_store.rb +49 -0
- data/lib/insika/channel_delivery.rb +119 -14
- data/lib/insika/channels/relay.rb +77 -3
- data/lib/insika/channels/web.rb +2 -2
- data/lib/insika/chat_builder.rb +105 -2
- data/lib/insika/checkpoint_store.rb +16 -0
- data/lib/insika/commands/agent_payload.rb +3 -3
- data/lib/insika/commands/backfill_knowledge.rb +145 -0
- data/lib/insika/commands/cancel_followup.rb +49 -0
- data/lib/insika/commands/delete_artifact.rb +35 -0
- data/lib/insika/commands/delete_concept.rb +34 -0
- data/lib/insika/commands/delete_mcp.rb +6 -2
- data/lib/insika/commands/delete_tenant_data.rb +107 -0
- data/lib/insika/commands/export_customer_memory.rb +48 -0
- data/lib/insika/commands/forget_customer.rb +117 -0
- data/lib/insika/commands/freeze_funnel_baseline.rb +113 -0
- data/lib/insika/commands/gate_harvest.rb +138 -0
- data/lib/insika/commands/gate_refinement.rb +1 -1
- data/lib/insika/commands/judge_shadow_pairs.rb +124 -0
- data/lib/insika/commands/memory_forget_fact.rb +20 -4
- data/lib/insika/commands/memory_put_fact.rb +23 -4
- data/lib/insika/commands/promote_harvest.rb +130 -0
- data/lib/insika/commands/record_outcome.rb +46 -0
- data/lib/insika/commands/record_shadow_reply.rb +68 -0
- data/lib/insika/commands/refresh_mcp_tools.rb +47 -0
- data/lib/insika/commands/reject_harvest.rb +38 -0
- data/lib/insika/commands/resolve_proposal.rb +108 -0
- data/lib/insika/commands/restore_concept.rb +34 -0
- data/lib/insika/commands/revoke_contact.rb +49 -0
- data/lib/insika/commands/rollback_harvest.rb +86 -0
- data/lib/insika/commands/run_distillation.rb +186 -0
- data/lib/insika/commands/run_harvest.rb +393 -0
- data/lib/insika/commands/seed_demo_data.rb +31 -0
- data/lib/insika/commands/send_message.rb +103 -6
- data/lib/insika/commands/session_purge.rb +67 -0
- data/lib/insika/commands/upsert_mcp.rb +6 -3
- data/lib/insika/commands/write_concept.rb +57 -0
- data/lib/insika/contact_store.rb +183 -0
- data/lib/insika/context/builder.rb +21 -3
- data/lib/insika/context/fragment.rb +7 -3
- data/lib/insika/context/priority.rb +5 -0
- data/lib/insika/context/provider.rb +17 -3
- data/lib/insika/context/providers/briefing.rb +96 -0
- data/lib/insika/context/providers/knowledge.rb +108 -0
- data/lib/insika/context/providers/memory.rb +16 -7
- data/lib/insika/context/providers/prompt.rb +57 -23
- data/lib/insika/context/providers/skill.rb +2 -0
- data/lib/insika/context/providers/tool_search.rb +2 -0
- data/lib/insika/context_trace_store.rb +38 -2
- data/lib/insika/cron.rb +189 -0
- data/lib/insika/demo/agent_attrs.rb +43 -0
- data/lib/insika/demo/golden_cases.rb +81 -0
- data/lib/insika/demo/seeder.rb +336 -0
- data/lib/insika/distill.rb +224 -0
- data/lib/insika/distill_engine.rb +169 -0
- data/lib/insika/doctor.rb +889 -11
- data/lib/insika/dsl/definition.rb +3 -2
- data/lib/insika/dsl/runtime.rb +69 -82
- data/lib/insika/dsl/server_boot.rb +92 -1
- data/lib/insika/dsl/system.rb +10 -2
- data/lib/insika/dsl.rb +186 -5
- data/lib/insika/edge_limiter.rb +28 -7
- data/lib/insika/env_schema.rb +25 -3
- data/lib/insika/errors.rb +11 -0
- data/lib/insika/evals/assertions.rb +3 -2
- data/lib/insika/evals/golden.rb +41 -4
- data/lib/insika/evals/judge.rb +47 -2
- data/lib/insika/evals/pairwise.rb +29 -6
- data/lib/insika/evals/persona.rb +98 -0
- data/lib/insika/evals/runner.rb +9 -0
- data/lib/insika/evals/simulator.rb +225 -0
- data/lib/insika/evals/transport.rb +83 -1
- data/lib/insika/event_stream.rb +10 -0
- data/lib/insika/evidence.rb +183 -0
- data/lib/insika/executor.rb +869 -76
- data/lib/insika/followup_engine.rb +207 -0
- data/lib/insika/followup_policy.rb +198 -0
- data/lib/insika/followup_store.rb +306 -0
- data/lib/insika/funnel_declaration.rb +106 -0
- data/lib/insika/funnel_fold.rb +179 -0
- data/lib/insika/funnel_store.rb +163 -0
- data/lib/insika/golden_store.rb +17 -2
- data/lib/insika/grounding/matcher.rb +69 -0
- data/lib/insika/grounding.rb +44 -0
- data/lib/insika/harvest/conversion_gate.rb +159 -0
- data/lib/insika/harvest/criterion.rb +98 -0
- data/lib/insika/harvest/gate.rb +194 -0
- data/lib/insika/harvest/negative_list.rb +199 -0
- data/lib/insika/harvest.rb +241 -0
- data/lib/insika/harvest_engine.rb +193 -0
- data/lib/insika/harvest_store.rb +548 -0
- data/lib/insika/knowledge.rb +680 -0
- data/lib/insika/knowledge_store.rb +140 -0
- data/lib/insika/mcp_client.rb +94 -0
- data/lib/insika/mcp_json.rb +74 -0
- data/lib/insika/mcp_live_tool.rb +43 -0
- data/lib/insika/mcp_store.rb +98 -26
- data/lib/insika/mcp_tool_ingestor.rb +30 -8
- data/lib/insika/mcp_tool_registry.rb +100 -0
- data/lib/insika/media.rb +382 -0
- data/lib/insika/memory_audit_store.rb +85 -0
- data/lib/insika/memory_store.rb +264 -23
- data/lib/insika/message_origin.rb +7 -2
- data/lib/insika/middleware.rb +9 -0
- data/lib/insika/model_visible.rb +87 -0
- data/lib/insika/model_visible_trace_store.rb +66 -0
- data/lib/insika/onboarding.rb +22 -1
- data/lib/insika/outbox_store.rb +42 -4
- data/lib/insika/outcome_store.rb +147 -0
- data/lib/insika/overlay_tool_registry.rb +37 -17
- data/lib/insika/packaging.rb +163 -0
- data/lib/insika/parity/criterion.rb +79 -0
- data/lib/insika/parity/verdict.rb +318 -0
- data/lib/insika/prefix_fingerprint.rb +58 -0
- data/lib/insika/profile_source.rb +29 -1
- data/lib/insika/prompt_catalog.rb +10 -0
- data/lib/insika/proposal_store.rb +271 -0
- data/lib/insika/queue_policy.rb +4 -1
- data/lib/insika/refinement/proposer.rb +1 -1
- data/lib/insika/reliability.rb +32 -6
- data/lib/insika/retention.rb +316 -0
- data/lib/insika/router/app.rb +157 -0
- data/lib/insika/router/backend_pool.rb +98 -0
- data/lib/insika/router/hash_ring.rb +55 -0
- data/lib/insika/router/proxy_body.rb +34 -0
- data/lib/insika/router/session_key.rb +54 -0
- data/lib/insika/router.rb +18 -0
- data/lib/insika/routing.rb +101 -0
- data/lib/insika/safety/config.rb +44 -4
- data/lib/insika/safety/corpus.rb +255 -0
- data/lib/insika/safety/detectors.rb +32 -113
- data/lib/insika/safety/factory.rb +15 -2
- data/lib/insika/safety/grounding_enforcer.rb +59 -0
- data/lib/insika/safety/grounding_validator.rb +49 -0
- data/lib/insika/safety/input_guardrail.rb +1 -1
- data/lib/insika/safety/moderator.rb +1 -1
- data/lib/insika/safety/output_filter.rb +9 -5
- data/lib/insika/safety/output_validator.rb +11 -5
- data/lib/insika/schedule.rb +177 -0
- data/lib/insika/schedule_engine.rb +314 -0
- data/lib/insika/schedule_store.rb +208 -0
- data/lib/insika/schema_guard.rb +35 -0
- data/lib/insika/server/app.rb +247 -17
- data/lib/insika/server/rack_app.rb +21 -1
- data/lib/insika/server/responses.rb +40 -1
- data/lib/insika/session_actor.rb +8 -4
- data/lib/insika/session_store.rb +65 -2
- data/lib/insika/settings_store.rb +10 -0
- data/lib/insika/shadow_pair_store.rb +258 -0
- data/lib/insika/skill_catalog.rb +16 -0
- data/lib/insika/soak/envelope.rb +140 -0
- data/lib/insika/soak/report.rb +392 -0
- data/lib/insika/soak/runner.rb +554 -0
- data/lib/insika/steer_injector.rb +21 -10
- data/lib/insika/store.rb +10 -1
- data/lib/insika/stores/memory.rb +6 -0
- data/lib/insika/stores/sqlite.rb +8 -0
- data/lib/insika/studio/app.rb +1437 -54
- data/lib/insika/studio/assets/dist/application.css +1 -1
- data/lib/insika/studio/assets/dist/application.js +23 -23
- data/lib/insika/studio/assets/dist/favicon.svg +6 -0
- data/lib/insika/studio/forms.rb +308 -10
- data/lib/insika/studio/nav_icons.rb +19 -1
- data/lib/insika/studio/views/_agent_tab_cache.erb +25 -0
- data/lib/insika/studio/views/_agent_tab_config.erb +514 -0
- data/lib/insika/studio/views/_agent_tab_history.erb +24 -0
- data/lib/insika/studio/views/_agent_tab_loops.erb +54 -0
- data/lib/insika/studio/views/_agent_tab_memory.erb +51 -0
- data/lib/insika/studio/views/_agent_tab_outcomes.erb +31 -0
- data/lib/insika/studio/views/_agent_tab_prompts.erb +108 -0
- data/lib/insika/studio/views/_agent_tab_skills.erb +38 -0
- data/lib/insika/studio/views/_agents_master.erb +44 -0
- data/lib/insika/studio/views/_message.erb +49 -32
- data/lib/insika/studio/views/agent_detail.erb +62 -278
- data/lib/insika/studio/views/agents.erb +71 -54
- data/lib/insika/studio/views/approvals.erb +4 -1
- data/lib/insika/studio/views/artifact.erb +23 -0
- data/lib/insika/studio/views/artifacts.erb +59 -0
- data/lib/insika/studio/views/chats.erb +4 -1
- data/lib/insika/studio/views/customer.erb +94 -0
- data/lib/insika/studio/views/customers.erb +32 -0
- data/lib/insika/studio/views/evals.erb +6 -3
- data/lib/insika/studio/views/facts.erb +133 -0
- data/lib/insika/studio/views/followups.erb +125 -0
- data/lib/insika/studio/views/funnel.erb +106 -0
- data/lib/insika/studio/views/harvest.erb +234 -0
- data/lib/insika/studio/views/home.erb +106 -66
- data/lib/insika/studio/views/knowledge.erb +123 -0
- data/lib/insika/studio/views/layout.erb +15 -11
- data/lib/insika/studio/views/mcp.erb +174 -80
- data/lib/insika/studio/views/parity.erb +147 -0
- data/lib/insika/studio/views/playground.erb +7 -1
- data/lib/insika/studio/views/session.erb +233 -124
- data/lib/insika/studio/views/settings.erb +41 -1
- data/lib/insika/studio/views/skills.erb +1 -2
- data/lib/insika/studio/views/system_files.erb +1 -1
- data/lib/insika/studio/views/task.erb +13 -0
- data/lib/insika/studio/views/tasks.erb +4 -1
- data/lib/insika/studio/views/tools.erb +24 -10
- data/lib/insika/task_store.rb +21 -1
- data/lib/insika/templates/browser-agent/README.md +36 -0
- data/lib/insika/templates/browser-agent/agent.rb +49 -0
- data/lib/insika/templates/daily-digest/README.md +38 -0
- data/lib/insika/templates/daily-digest/agent.rb +77 -0
- data/lib/insika/templates/repo-explorer/README.md +36 -0
- data/lib/insika/templates/repo-explorer/agent.rb +45 -0
- data/lib/insika/templates/research-analyst/README.md +26 -0
- data/lib/insika/templates/research-analyst/agent.rb +58 -0
- data/lib/insika/templates/review-panel/README.md +20 -0
- data/lib/insika/templates/review-panel/agent.rb +50 -0
- data/lib/insika/templates/travel-planner/README.md +35 -0
- data/lib/insika/templates/travel-planner/agent.rb +87 -0
- data/lib/insika/templates.rb +112 -0
- data/lib/insika/testing/store_contract.rb +27 -6
- data/lib/insika/tick.rb +47 -11
- data/lib/insika/timezone.rb +45 -0
- data/lib/insika/tool_definition.rb +17 -7
- data/lib/insika/tool_envelope.rb +69 -0
- data/lib/insika/tool_manifest.rb +5 -1
- data/lib/insika/tools/data_defined_tool.rb +10 -0
- data/lib/insika/tools/generate_image.rb +89 -0
- data/lib/insika/tools/load_knowledge.rb +74 -0
- data/lib/insika/tools/run_persona_eval.rb +328 -0
- data/lib/insika/tools/save_artifact.rb +95 -0
- data/lib/insika/tools/schedule_followup.rb +164 -0
- data/lib/insika/tools/tts.rb +47 -0
- data/lib/insika/tools/update_briefing.rb +126 -0
- data/lib/insika/turn_output.rb +1 -1
- data/lib/insika/turn_state.rb +49 -1
- data/lib/insika/turn_timing.rb +22 -2
- data/lib/insika/version.rb +1 -1
- data/lib/insika/vitals.rb +84 -0
- data/lib/insika/wiring/graph.rb +472 -13
- data/lib/insika/wiring/graph_chat.rb +102 -0
- data/lib/insika.rb +149 -3
- metadata +181 -5
- data/docs/build.md +0 -14
- data/docs/understand.md +0 -10
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
require "zlib"
|
|
7
|
+
require "fileutils"
|
|
8
|
+
require "time"
|
|
9
|
+
require "optparse"
|
|
10
|
+
|
|
11
|
+
module Insika
|
|
12
|
+
module Soak
|
|
13
|
+
# The arrival-rate runner: sustain a declared load against a
|
|
14
|
+
# live deploy for N hours, poll vitals hourly, append every observation to
|
|
15
|
+
# the results file as it happens. It computes NO verdict (C5 does, offline)
|
|
16
|
+
# and never retries a turn — a failed turn is evidence, not a problem to
|
|
17
|
+
# smooth over. Threads, not fibers: the tool that measures the reactor
|
|
18
|
+
# should not share its failure modes.
|
|
19
|
+
class Runner
|
|
20
|
+
# One failed precondition, named (P1..P5 per the techspec §7.2).
|
|
21
|
+
Failure = Data.define(:check, :message)
|
|
22
|
+
|
|
23
|
+
# Preflight refused the run. The failures name what was wrong; nothing
|
|
24
|
+
# was written and no traffic past the probes was fired.
|
|
25
|
+
class PreflightError < Insika::Error
|
|
26
|
+
attr_reader :failures
|
|
27
|
+
|
|
28
|
+
def initialize(failures)
|
|
29
|
+
@failures = failures
|
|
30
|
+
super(failures.map(&:message).join("; "))
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
USAGE = <<~TXT
|
|
35
|
+
insika soak — the 72h soak runner
|
|
36
|
+
|
|
37
|
+
Usage: insika soak --run | --verify FILE | --preflight [options]
|
|
38
|
+
|
|
39
|
+
Modes:
|
|
40
|
+
--run fire the soak (mutually exclusive with --verify)
|
|
41
|
+
--verify FILE re-read an archived run; NO traffic, no network
|
|
42
|
+
--preflight run every precondition check and exit
|
|
43
|
+
--dry-run print the plan + one sample request, send nothing
|
|
44
|
+
|
|
45
|
+
Options:
|
|
46
|
+
--envelope PATH the frozen envelope (required)
|
|
47
|
+
--agent ID the soak agent (default: from envelope)
|
|
48
|
+
--out DIR where the results file lands (default: soak-out)
|
|
49
|
+
--resume FILE continue an interrupted run, recording the gap
|
|
50
|
+
|
|
51
|
+
Environment:
|
|
52
|
+
INSIKA_URL base URL of the engine (default: http://localhost:9292)
|
|
53
|
+
OPENCLAW_GATEWAY_TOKEN Bearer; falls back to ADMIN_TOKEN, then "local-demo"
|
|
54
|
+
TXT
|
|
55
|
+
|
|
56
|
+
# Poisson inter-arrival seconds: `-mean * Math.log(1.0 - rand)`. Seeded,
|
|
57
|
+
# so a re-run with the same seed is the same arrival sequence. The rate is
|
|
58
|
+
# expressed in the ARRIVAL unit (sessions per hour when session_turns > 1);
|
|
59
|
+
# the envelope's turns_per_hour is divided by session_turns before this.
|
|
60
|
+
def self.arrivals(seed:, rate_per_hour:, count:)
|
|
61
|
+
rng = Random.new(seed)
|
|
62
|
+
mean = 3600.0 / rate_per_hour
|
|
63
|
+
Array.new(count) { -mean * Math.log(1.0 - rng.rand) }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# CLI entry (bin/insika delegates here). -> exit status.
|
|
67
|
+
def self.main(argv, stdout: $stdout, stderr: $stderr, env: ENV, http: nil)
|
|
68
|
+
opts = { out: "soak-out" }
|
|
69
|
+
modes = []
|
|
70
|
+
OptionParser.new do |o|
|
|
71
|
+
o.banner = "Usage: insika soak --run | --verify FILE | --preflight [options]"
|
|
72
|
+
o.on("-h", "--help", "show this help") { stdout.puts USAGE; return 0 }
|
|
73
|
+
o.on("--run", "fire the soak") { modes << :run }
|
|
74
|
+
o.on("--verify FILE", "re-read an archived run") { |v| modes << [:verify, v] }
|
|
75
|
+
o.on("--preflight", "run every precondition check") { modes << :preflight }
|
|
76
|
+
o.on("--dry-run", "print the plan, send nothing") { modes << :dry_run }
|
|
77
|
+
o.on("--envelope PATH") { |v| opts[:envelope] = v }
|
|
78
|
+
o.on("--agent ID") { |v| opts[:agent] = v }
|
|
79
|
+
o.on("--out DIR") { |v| opts[:out] = v }
|
|
80
|
+
o.on("--resume FILE") { |v| opts[:resume] = v }
|
|
81
|
+
end.parse!(argv)
|
|
82
|
+
|
|
83
|
+
if modes.length != 1
|
|
84
|
+
stderr.puts "insika soak: choose exactly one of --run, --verify, --preflight, --dry-run\n\n#{USAGE}"
|
|
85
|
+
return 2
|
|
86
|
+
end
|
|
87
|
+
if opts[:envelope].nil?
|
|
88
|
+
stderr.puts "insika soak: --envelope PATH is required (the deployment's frozen envelope)\n\n#{USAGE}"
|
|
89
|
+
return 2
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
mode = modes.first
|
|
93
|
+
if mode.is_a?(Array) && mode.first == :verify
|
|
94
|
+
return verify(mode[1], envelope_path: opts[:envelope], stdout: stdout, stderr: stderr)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
envelope = Envelope.load(opts[:envelope])
|
|
98
|
+
runner = new(envelope: envelope, agent: opts[:agent], out: opts[:out],
|
|
99
|
+
env: env, http: http, stdout: stdout, stderr: stderr)
|
|
100
|
+
|
|
101
|
+
case mode
|
|
102
|
+
when :dry_run
|
|
103
|
+
stdout.puts runner.dry_run_plan
|
|
104
|
+
0
|
|
105
|
+
when :preflight
|
|
106
|
+
failures = runner.preflight
|
|
107
|
+
if failures.empty?
|
|
108
|
+
stdout.puts "preflight OK — every precondition holds"
|
|
109
|
+
0
|
|
110
|
+
else
|
|
111
|
+
failures.each { |f| stderr.puts "preflight #{f.check}: #{f.message}" }
|
|
112
|
+
2
|
|
113
|
+
end
|
|
114
|
+
when :run
|
|
115
|
+
status = runner.run(resume: opts[:resume], traps: true)
|
|
116
|
+
status == :complete ? 0 : 1
|
|
117
|
+
end
|
|
118
|
+
rescue OptionParser::ParseError => e
|
|
119
|
+
stderr.puts "insika soak: #{e.message}\n\n#{USAGE}"
|
|
120
|
+
2
|
|
121
|
+
rescue PreflightError => e
|
|
122
|
+
e.failures.each { |f| stderr.puts "preflight #{f.check}: #{f.message}" }
|
|
123
|
+
stderr.puts "insika soak: refusing to start"
|
|
124
|
+
2
|
|
125
|
+
rescue Insika::ConfigError => e
|
|
126
|
+
stderr.puts "insika soak: #{e.message}"
|
|
127
|
+
2
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Pure fold over the archived file — no traffic, no network, no clock.
|
|
131
|
+
def self.verify(path, envelope_path: nil, stdout: $stdout, stderr: $stderr)
|
|
132
|
+
raise Insika::ConfigError, "soak --verify needs --envelope PATH (the frozen envelope the run was declared with)" if envelope_path.nil?
|
|
133
|
+
|
|
134
|
+
envelope = Envelope.load(envelope_path)
|
|
135
|
+
records = read_records(path)
|
|
136
|
+
result = Report.fold(records, envelope: envelope)
|
|
137
|
+
stdout.puts result.to_s
|
|
138
|
+
result.pass? ? 0 : 1
|
|
139
|
+
rescue Insika::ConfigError => e
|
|
140
|
+
stderr.puts "insika soak: #{e.message}"
|
|
141
|
+
2
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Lazy line reader: .jsonl while running, .jsonl.gz once archived. Lines
|
|
145
|
+
# that fail to parse travel through RAW so the fold can count them
|
|
146
|
+
# (a truncated file must not read as a clean one).
|
|
147
|
+
def self.read_records(path)
|
|
148
|
+
lines = if path.end_with?(".gz")
|
|
149
|
+
Zlib::GzipReader.open(path) { |gz| gz.each_line.to_a }
|
|
150
|
+
else
|
|
151
|
+
File.foreach(path).to_a
|
|
152
|
+
end
|
|
153
|
+
lines.map do |line|
|
|
154
|
+
JSON.parse(line)
|
|
155
|
+
rescue JSON::ParserError
|
|
156
|
+
line
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
attr_reader :envelope
|
|
161
|
+
|
|
162
|
+
def initialize(envelope:, out: "soak-out", agent: nil, env: ENV, http: nil,
|
|
163
|
+
seed: nil, clock: nil, sleeper: nil, stdout: $stdout, stderr: $stderr)
|
|
164
|
+
@envelope = envelope
|
|
165
|
+
@out = out
|
|
166
|
+
@agent = agent || envelope[:agent]
|
|
167
|
+
@env = env
|
|
168
|
+
@http = http || Http.new(token: resolve_token(env))
|
|
169
|
+
@seed = seed || Time.now.to_i
|
|
170
|
+
@clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
171
|
+
@sleeper = sleeper || ->(s) { sleep s }
|
|
172
|
+
@stdout = stdout
|
|
173
|
+
@stderr = stderr
|
|
174
|
+
@stop_reason = nil
|
|
175
|
+
@lanes = []
|
|
176
|
+
@in_flight = 0
|
|
177
|
+
@mutex = Mutex.new
|
|
178
|
+
@write_mutex = Mutex.new
|
|
179
|
+
@cond = ConditionVariable.new
|
|
180
|
+
@vitals_failures = 0
|
|
181
|
+
@vitals_degraded_noted = false
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def target_url
|
|
185
|
+
(@env["INSIKA_URL"] || @env["HARNESS_URL"] || "http://localhost:9292").sub(%r{/$}, "")
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def corpus
|
|
189
|
+
path = @envelope[:corpus]
|
|
190
|
+
raise Insika::ConfigError, "soak envelope declares no corpus" if path.to_s.empty?
|
|
191
|
+
|
|
192
|
+
lines = File.readlines(path, chomp: true).map(&:strip).reject(&:empty?)
|
|
193
|
+
raise Insika::ConfigError, "soak corpus #{path} has no usable lines" if lines.empty?
|
|
194
|
+
|
|
195
|
+
lines
|
|
196
|
+
rescue Errno::ENOENT
|
|
197
|
+
raise Insika::ConfigError, "soak corpus not found: #{path}"
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# The five preconditions (techspec §7.2). Each is a REFUSAL TO START, not
|
|
201
|
+
# a runtime behaviour: a soak must not measure its own limiter, a wrong
|
|
202
|
+
# deploy, or a box that hides what it runs on.
|
|
203
|
+
def preflight
|
|
204
|
+
failures = []
|
|
205
|
+
vitals = @http.get_vitals(target_url)
|
|
206
|
+
|
|
207
|
+
if vitals[:status] != 200 || !vitals.dig(:body, "rss_bytes") || vitals.dig(:body, "boot_id").to_s.empty?
|
|
208
|
+
failures << Failure.new("P1", "GET /v1/vitals must return 200 with a non-null rss_bytes and a non-empty boot_id")
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
probe = @http.post_turn(target_url, @agent, user: "soak-preflight", message: "ok",
|
|
212
|
+
timeout: (@envelope[:request_timeout_s] || 120))
|
|
213
|
+
unless probe.dig(:timing).is_a?(Hash) && probe[:timing].any?
|
|
214
|
+
failures << Failure.new("P2", "the probe turn carries no timing block (INSIKA_TURN_TIMING off on the target)")
|
|
215
|
+
end
|
|
216
|
+
unless probe.dig(:usage, "total_tokens").to_i.positive?
|
|
217
|
+
failures << Failure.new("P3", "the probe turn called no model (no usage) — the run would measure the edge limiter")
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
pids = [vitals.dig(:body, "pid")]
|
|
221
|
+
2.times do
|
|
222
|
+
@sleeper.call(5)
|
|
223
|
+
pids << @http.get_vitals(target_url).dig(:body, "pid")
|
|
224
|
+
end
|
|
225
|
+
if pids.compact.uniq.length != 1
|
|
226
|
+
failures << Failure.new("P4", "vitals.pid is not stable across three polls — the RSS series would be a random worker per hour")
|
|
227
|
+
end
|
|
228
|
+
if @envelope[:web_concurrency] != 1
|
|
229
|
+
failures << Failure.new("P4", "the envelope declares web_concurrency #{@envelope[:web_concurrency].inspect}, not 1")
|
|
230
|
+
end
|
|
231
|
+
unless URI(target_url).host == @envelope[:target_url_host]
|
|
232
|
+
failures << Failure.new("P5", "target host #{URI(target_url).host.inspect} does not match the envelope's #{@envelope[:target_url_host].inspect}")
|
|
233
|
+
end
|
|
234
|
+
if @envelope[:agent] && @agent != @envelope[:agent]
|
|
235
|
+
failures << Failure.new("P5", "agent #{@agent.inspect} does not match the envelope's #{@envelope[:agent].inspect}")
|
|
236
|
+
end
|
|
237
|
+
failures
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def dry_run_plan
|
|
241
|
+
lines = [
|
|
242
|
+
"soak dry-run — no requests sent",
|
|
243
|
+
" envelope: #{@envelope[:target]} (sha #{@envelope.sha})",
|
|
244
|
+
" target: #{target_url}",
|
|
245
|
+
" agent: #{@agent}",
|
|
246
|
+
" shape: #{@envelope[:turns_per_hour]} turns/h poisson, #{@envelope[:session_turns]}-turn sessions, " \
|
|
247
|
+
"cap #{@envelope[:concurrency_cap]}, #{@envelope[:duration_hours]}h (#{@envelope[:warmup_hours]}h warmup)",
|
|
248
|
+
" sample: POST #{URI.join(target_url + '/', 'v1/responses')} model=openclaw:#{@agent} user=soak-1",
|
|
249
|
+
" out: #{@out}/"
|
|
250
|
+
]
|
|
251
|
+
unless @envelope.calibrated?
|
|
252
|
+
lines << " NOTE: not calibrated — a #{@envelope[:duration_hours]}h run will refuse to start (E1 first)"
|
|
253
|
+
end
|
|
254
|
+
lines.join("\n")
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Fires the soak. -> :complete | :interrupted | :aborted.
|
|
258
|
+
def run(resume: nil, traps: false)
|
|
259
|
+
unless @envelope.calibrated? || @envelope.dry_run?
|
|
260
|
+
raise Insika::ConfigError, "the #{@envelope[:duration_hours]}h run refuses to start: " \
|
|
261
|
+
"the envelope is not calibrated (run E1 first, then write the three ceilings)"
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
failures = preflight
|
|
265
|
+
raise PreflightError, failures unless failures.empty?
|
|
266
|
+
|
|
267
|
+
install_traps if traps
|
|
268
|
+
|
|
269
|
+
header, lane_id = resume ? resume_state(resume) : [new_header, 0]
|
|
270
|
+
path = resume || File.join(@out, "#{header['run_id']}.jsonl")
|
|
271
|
+
FileUtils.mkdir_p(File.dirname(path)) unless resume
|
|
272
|
+
@file = File.open(path, "a")
|
|
273
|
+
if resume
|
|
274
|
+
append_gap_record(resume)
|
|
275
|
+
else
|
|
276
|
+
append(header)
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
run_id = header["run_id"]
|
|
280
|
+
@stdout.puts "soak -> target=#{target_url} agent=#{@agent} duration=#{@envelope[:duration_hours]}h " \
|
|
281
|
+
"turns=#{@envelope[:turns_per_hour]}/h seed=#{@seed} out=#{path}"
|
|
282
|
+
@stdout.puts "deploys are frozen for the window: any restart (boot_id or pid change) fails the run"
|
|
283
|
+
|
|
284
|
+
duration_hours = @envelope[:duration_hours]
|
|
285
|
+
offset_hours = resume ? elapsed_hours(header["started_at"]) : 0
|
|
286
|
+
start = @clock.call
|
|
287
|
+
deadline = start + (duration_hours - offset_hours) * 3600.0
|
|
288
|
+
# The envelope declares TURNS per hour; each arrival is a SESSION of
|
|
289
|
+
# session_turns turns, so arrivals arrive at turns_per_hour/session_turns
|
|
290
|
+
# per hour — otherwise a 60/h x 7-turn envelope fires 420 turns/h.
|
|
291
|
+
sessions_per_hour = @envelope[:turns_per_hour].to_f / @envelope[:session_turns]
|
|
292
|
+
arrivals = self.class.arrivals(seed: @seed, rate_per_hour: sessions_per_hour,
|
|
293
|
+
count: (sessions_per_hour * duration_hours).ceil)
|
|
294
|
+
messages = corpus
|
|
295
|
+
next_turn = start
|
|
296
|
+
snapshot_index = 1
|
|
297
|
+
turn_index = 0
|
|
298
|
+
|
|
299
|
+
loop do
|
|
300
|
+
break if @stop_reason
|
|
301
|
+
|
|
302
|
+
now = @clock.call
|
|
303
|
+
# Hour 1 is due at start+3600 — the LAST snapshot (hour 72) is due
|
|
304
|
+
# exactly at the deadline, so the snapshot fires before the loop
|
|
305
|
+
# ends. Freshly computed from the index, never accumulated (float
|
|
306
|
+
# drift must not eat the final hour).
|
|
307
|
+
snapshot_due = start + snapshot_index * 3600.0
|
|
308
|
+
if now >= snapshot_due - 1e-9 && snapshot_due <= deadline + 1e-9
|
|
309
|
+
append_snapshot(offset_hours + snapshot_index)
|
|
310
|
+
snapshot_index += 1
|
|
311
|
+
next
|
|
312
|
+
end
|
|
313
|
+
break if now >= deadline
|
|
314
|
+
|
|
315
|
+
# All scheduled arrivals consumed: nothing turn-shaped is ever due
|
|
316
|
+
# again, so neither the spawn branch nor the wait math may keep
|
|
317
|
+
# waking on next_turn.
|
|
318
|
+
next_turn = Float::INFINITY if turn_index >= arrivals.length
|
|
319
|
+
|
|
320
|
+
if now >= next_turn && next_turn <= deadline
|
|
321
|
+
spawn_lane(lane_id, turn_index, messages)
|
|
322
|
+
@lanes.reject! { |t| !t.alive? }
|
|
323
|
+
lane_id += 1
|
|
324
|
+
next_turn += arrivals[turn_index]
|
|
325
|
+
turn_index += 1
|
|
326
|
+
next
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
wait = [[snapshot_due, next_turn].min, deadline].min - now
|
|
330
|
+
break if wait <= 0
|
|
331
|
+
|
|
332
|
+
@sleeper.call(wait)
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
@lanes.each(&:join)
|
|
336
|
+
reason = @stop_reason || "complete"
|
|
337
|
+
append({ "t" => "end", "at" => Time.now.utc.iso8601, "reason" => reason })
|
|
338
|
+
@file.close
|
|
339
|
+
archive_path = gzip!(path)
|
|
340
|
+
|
|
341
|
+
result = Report.fold(self.class.read_records(archive_path), envelope: @envelope)
|
|
342
|
+
@stdout.puts result.to_s
|
|
343
|
+
reason == "complete" ? :complete : :interrupted
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
private
|
|
347
|
+
|
|
348
|
+
def resolve_token(env) = env["OPENCLAW_GATEWAY_TOKEN"] || env["ADMIN_TOKEN"] || "local-demo"
|
|
349
|
+
|
|
350
|
+
def install_traps
|
|
351
|
+
%w[INT TERM].each { |sig| Signal.trap(sig) { @stop_reason = "interrupted" } }
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def new_header
|
|
355
|
+
started = Time.now.utc.iso8601
|
|
356
|
+
{
|
|
357
|
+
"t" => "header", "run_id" => "#{@envelope[:target]}-#{started.tr(':', '-')}",
|
|
358
|
+
"envelope_sha" => @envelope.sha, "envelope" => @envelope.values,
|
|
359
|
+
"target_url" => target_url, "agent" => @agent,
|
|
360
|
+
"seed" => @seed, "runner_version" => "insika #{Insika::VERSION}",
|
|
361
|
+
"started_at" => started
|
|
362
|
+
}
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
# The header plus the next free lane id: lane ids are per-user session
|
|
366
|
+
# ids on the target, so a resume MUST NOT reuse them — a lane 0 that
|
|
367
|
+
# existed before the gap would continue a conversation, not start one.
|
|
368
|
+
def resume_state(path)
|
|
369
|
+
header = nil
|
|
370
|
+
last_lane = 0
|
|
371
|
+
File.foreach(path) do |line|
|
|
372
|
+
record = JSON.parse(line)
|
|
373
|
+
if record["t"] == "header"
|
|
374
|
+
header ||= record
|
|
375
|
+
elsif record["t"] == "turn" && record["lane"].is_a?(Integer)
|
|
376
|
+
last_lane = record["lane"] if record["lane"] >= last_lane
|
|
377
|
+
end
|
|
378
|
+
rescue JSON::ParserError
|
|
379
|
+
next
|
|
380
|
+
end
|
|
381
|
+
raise Insika::ConfigError, "#{path} has no header record" unless header
|
|
382
|
+
|
|
383
|
+
[header, last_lane + 1]
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
def elapsed_hours(started_iso)
|
|
387
|
+
[(Time.now.utc - Time.parse(started_iso)) / 3600, 0].max.floor
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
def append_gap_record(path)
|
|
391
|
+
last_at = self.class.read_records(path).filter_map { |r| r.is_a?(Hash) ? r["at"] : nil }.compact.last
|
|
392
|
+
to = Time.now.utc
|
|
393
|
+
from = Time.parse(last_at) if last_at
|
|
394
|
+
append({ "t" => "gap", "from" => (from || to).iso8601, "to" => to.iso8601,
|
|
395
|
+
"seconds" => (to - (from || to)).round, "reason" => "resume" })
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
def append(record)
|
|
399
|
+
@write_mutex.synchronize do
|
|
400
|
+
@file.puts(JSON.generate(record))
|
|
401
|
+
@file.fsync
|
|
402
|
+
end
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
def append_snapshot(hour)
|
|
406
|
+
vitals = @http.get_vitals(target_url)
|
|
407
|
+
if vitals[:status] == 200 && vitals[:body].is_a?(Hash)
|
|
408
|
+
@vitals_failures = 0
|
|
409
|
+
append({ "t" => "snapshot", "at" => Time.now.utc.iso8601, "hour" => hour,
|
|
410
|
+
"envelope_sha" => @envelope.sha, "vitals" => vitals[:body], "error" => nil })
|
|
411
|
+
else
|
|
412
|
+
@vitals_failures += 1
|
|
413
|
+
append({ "t" => "snapshot", "at" => Time.now.utc.iso8601, "hour" => hour,
|
|
414
|
+
"envelope_sha" => @envelope.sha, "vitals" => nil, "error" => "vitals poll failed" })
|
|
415
|
+
if @vitals_failures >= 3 && !@vitals_degraded_noted
|
|
416
|
+
@vitals_degraded_noted = true
|
|
417
|
+
append({ "t" => "note", "at" => Time.now.utc.iso8601, "note" => "vitals_degraded" })
|
|
418
|
+
end
|
|
419
|
+
end
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
def spawn_lane(lane_id, turn_index, messages)
|
|
423
|
+
@lanes << Thread.new do
|
|
424
|
+
queued_at = @clock.call
|
|
425
|
+
@mutex.synchronize do
|
|
426
|
+
@cond.wait(@mutex, 0.2) while @in_flight >= @envelope[:concurrency_cap]
|
|
427
|
+
@in_flight += 1
|
|
428
|
+
end
|
|
429
|
+
queued_ms = ((@clock.call - queued_at) * 1000).round
|
|
430
|
+
begin
|
|
431
|
+
@envelope[:session_turns].times do |step|
|
|
432
|
+
# step advances the corpus read: turn 7 of a session must not
|
|
433
|
+
# repeat turn 1's line.
|
|
434
|
+
line = messages[(turn_index + step) % messages.length]
|
|
435
|
+
result = @http.post_turn(target_url, @agent, user: "soak-#{lane_id}", message: line,
|
|
436
|
+
timeout: (@envelope[:request_timeout_s] || 120))
|
|
437
|
+
append({
|
|
438
|
+
"t" => "turn", "at" => Time.now.utc.iso8601, "lane" => lane_id, "step" => step + 1,
|
|
439
|
+
"corpus_line" => ((turn_index + step) % messages.length) + 1,
|
|
440
|
+
"ok" => result[:ok] == true, "status" => result[:status],
|
|
441
|
+
"queued_ms" => queued_ms, "ttfb_ms" => result[:ttfb_ms],
|
|
442
|
+
"total_ms" => result[:total_ms], "timing" => result[:timing],
|
|
443
|
+
"usage" => result[:usage], "error" => result[:error]
|
|
444
|
+
})
|
|
445
|
+
end
|
|
446
|
+
ensure
|
|
447
|
+
@mutex.synchronize do
|
|
448
|
+
@in_flight -= 1
|
|
449
|
+
@cond.signal
|
|
450
|
+
end
|
|
451
|
+
end
|
|
452
|
+
end
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
def gzip!(path)
|
|
456
|
+
gz_path = "#{path}.gz"
|
|
457
|
+
Zlib::GzipWriter.open(gz_path) { |gz| File.open(path, "rb") { |f| IO.copy_stream(f, gz) } }
|
|
458
|
+
File.delete(path)
|
|
459
|
+
gz_path
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
# stdlib HTTP transport, deliberately Net::HTTP — the same choice
|
|
463
|
+
# loadtest.rb made, and the runner measures the reactor from outside it.
|
|
464
|
+
class Http
|
|
465
|
+
def initialize(token:)
|
|
466
|
+
@token = token
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
def get_vitals(base_url)
|
|
470
|
+
uri = URI.join(base_url + "/", "v1/vitals")
|
|
471
|
+
req = Net::HTTP::Get.new(uri)
|
|
472
|
+
req["Authorization"] = "Bearer #{@token}"
|
|
473
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
474
|
+
http.use_ssl = uri.scheme == "https"
|
|
475
|
+
http.open_timeout = 10
|
|
476
|
+
http.read_timeout = 30
|
|
477
|
+
res = http.request(req)
|
|
478
|
+
{ status: res.code.to_i, body: safe_json(res.body) }
|
|
479
|
+
rescue StandardError => e
|
|
480
|
+
{ status: nil, body: nil, error: e.class.to_s }
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
# One turn: streaming POST /v1/responses; measures TTFB/total and
|
|
484
|
+
# extracts the last usage + timing frames. No retry — a failed turn is
|
|
485
|
+
# evidence.
|
|
486
|
+
def post_turn(base_url, agent, user:, message:, timeout:)
|
|
487
|
+
uri = URI.join(base_url + "/", "v1/responses")
|
|
488
|
+
req = Net::HTTP::Post.new(uri)
|
|
489
|
+
req["Authorization"] = "Bearer #{@token}"
|
|
490
|
+
req["Content-Type"] = "application/json"
|
|
491
|
+
req["Accept"] = "text/event-stream"
|
|
492
|
+
req.body = JSON.generate(model: "openclaw:#{agent}", user: user, stream: true, input: message)
|
|
493
|
+
|
|
494
|
+
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
495
|
+
ttfb = nil
|
|
496
|
+
usage = nil
|
|
497
|
+
timing = nil
|
|
498
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
499
|
+
http.use_ssl = uri.scheme == "https"
|
|
500
|
+
http.read_timeout = timeout
|
|
501
|
+
http.open_timeout = 10
|
|
502
|
+
|
|
503
|
+
status = nil
|
|
504
|
+
http.start do
|
|
505
|
+
http.request(req) do |res|
|
|
506
|
+
status = res.code.to_i
|
|
507
|
+
return { ok: false, status: status, error: "http #{status}" } unless status.between?(200, 299)
|
|
508
|
+
|
|
509
|
+
buffer = +""
|
|
510
|
+
res.read_body do |chunk|
|
|
511
|
+
ttfb ||= Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0
|
|
512
|
+
buffer << chunk
|
|
513
|
+
while (i = buffer.index("\n\n"))
|
|
514
|
+
frame = buffer.slice!(0..i + 1)
|
|
515
|
+
frame.each_line do |line|
|
|
516
|
+
next unless line.start_with?("data:")
|
|
517
|
+
|
|
518
|
+
payload = line.sub(/^data:\s*/, "").strip
|
|
519
|
+
next if payload.empty? || payload == "[DONE]"
|
|
520
|
+
|
|
521
|
+
begin
|
|
522
|
+
obj = JSON.parse(payload)
|
|
523
|
+
u = obj.dig("response", "usage") || obj["usage"]
|
|
524
|
+
usage = u if u
|
|
525
|
+
tm = obj.dig("response", "timing")
|
|
526
|
+
timing = tm if tm
|
|
527
|
+
rescue JSON::ParserError
|
|
528
|
+
nil
|
|
529
|
+
end
|
|
530
|
+
end
|
|
531
|
+
end
|
|
532
|
+
end
|
|
533
|
+
end
|
|
534
|
+
end
|
|
535
|
+
{ ok: true, status: status, ttfb_ms: (ttfb || 0) * 1000.0,
|
|
536
|
+
total_ms: (Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1000.0,
|
|
537
|
+
usage: usage, timing: timing, error: nil }
|
|
538
|
+
rescue ::Timeout::Error
|
|
539
|
+
{ ok: false, status: status, error: "timeout" }
|
|
540
|
+
rescue StandardError => e
|
|
541
|
+
{ ok: false, status: status, error: e.class.to_s }
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
private
|
|
545
|
+
|
|
546
|
+
def safe_json(body)
|
|
547
|
+
JSON.parse(body)
|
|
548
|
+
rescue JSON::ParserError
|
|
549
|
+
nil
|
|
550
|
+
end
|
|
551
|
+
end
|
|
552
|
+
end
|
|
553
|
+
end
|
|
554
|
+
end
|
|
@@ -69,14 +69,32 @@ module Insika
|
|
|
69
69
|
inject! if @seen >= @expected
|
|
70
70
|
end
|
|
71
71
|
|
|
72
|
+
# Tail-appends whatever is in the mailbox right now and reports how many. Called
|
|
73
|
+
# at a batch boundary (#inject!) and ONCE MORE by the Executor when the run ended
|
|
74
|
+
# with a message no boundary ever arrived for (a text-only turn closes no batch) —
|
|
75
|
+
# the append, the running count and the `:turn_steered` event belong in one place
|
|
76
|
+
# either way.
|
|
77
|
+
def absorb_pending!
|
|
78
|
+
texts = @actor.take_user_messages!
|
|
79
|
+
return 0 if texts.empty?
|
|
80
|
+
|
|
81
|
+
texts.each { |text| @chat.add_message(role: :user, content: @policy.frame(text)) }
|
|
82
|
+
@injected += texts.size
|
|
83
|
+
# Counts only, never content — the text is already in the transcript, which is the
|
|
84
|
+
# surface that is allowed to carry it. `task_id`/`session_id` are the event's meta.
|
|
85
|
+
@emit.call(:turn_steered, { count: texts.size, total: @injected })
|
|
86
|
+
texts.size
|
|
87
|
+
end
|
|
88
|
+
|
|
72
89
|
private
|
|
73
90
|
|
|
74
91
|
def open_batch(message)
|
|
75
92
|
calls = field(message, :tool_calls)
|
|
76
93
|
size = calls.respond_to?(:size) ? calls.size : 0
|
|
77
94
|
# A message with no tool call is the model talking, not a batch: leave any
|
|
78
|
-
# pending message where it is
|
|
79
|
-
#
|
|
95
|
+
# pending message where it is. The turn is about to end, and the Executor
|
|
96
|
+
# absorbs it there in ONE extra round (#absorb_pending!) — a text-only turn
|
|
97
|
+
# closes no batch, and answering it half-way is not an option.
|
|
80
98
|
return @expected = nil if size.zero?
|
|
81
99
|
|
|
82
100
|
@expected = size
|
|
@@ -88,14 +106,7 @@ module Insika
|
|
|
88
106
|
@expected = nil
|
|
89
107
|
return if @halted # nothing will read it: leave it in the mailbox
|
|
90
108
|
|
|
91
|
-
|
|
92
|
-
return if texts.empty?
|
|
93
|
-
|
|
94
|
-
texts.each { |text| @chat.add_message(role: :user, content: @policy.frame(text)) }
|
|
95
|
-
@injected += texts.size
|
|
96
|
-
# Counts only, never content — the text is already in the transcript, which is the
|
|
97
|
-
# surface that is allowed to carry it. `task_id`/`session_id` are the event's meta.
|
|
98
|
-
@emit.call(:turn_steered, { count: texts.size, total: @injected })
|
|
109
|
+
absorb_pending!
|
|
99
110
|
end
|
|
100
111
|
|
|
101
112
|
def halt?(result) = defined?(RubyLLM::Tool::Halt) && result.is_a?(RubyLLM::Tool::Halt)
|
data/lib/insika/store.rb
CHANGED
|
@@ -17,10 +17,13 @@ module Insika
|
|
|
17
17
|
# normalizes at the boundary)
|
|
18
18
|
# - list(scope) returns only keys of the scope, ordered lexicographically;
|
|
19
19
|
# prefix filters by start_with?
|
|
20
|
+
# - scopes(prefix) returns the scope NAMES that start with the prefix,
|
|
21
|
+
# ordered lexicographically (an additive enumeration, for domain stores
|
|
22
|
+
# that name cells hierarchically — "memory:acme", "memory:acme:123")
|
|
20
23
|
# - a nested transaction reuses the outer transaction (no SAVEPOINT)
|
|
21
24
|
# - a serialization failure on write -> Insika::StoreError (fail-fast)
|
|
22
25
|
#
|
|
23
|
-
# Backends `include Store` and override the
|
|
26
|
+
# Backends `include Store` and override the six methods; any forgotten
|
|
24
27
|
# method raises NotImplementedError (fail-fast, better than a distant
|
|
25
28
|
# NoMethodError).
|
|
26
29
|
module Store
|
|
@@ -44,6 +47,12 @@ module Insika
|
|
|
44
47
|
raise NotImplementedError, "#{self.class}#list"
|
|
45
48
|
end
|
|
46
49
|
|
|
50
|
+
# -> [String] scope names ordered lexicographically, filtered by prefix
|
|
51
|
+
# (start_with?). Added for domain stores with hierarchical scope names.
|
|
52
|
+
def scopes(prefix = nil)
|
|
53
|
+
raise NotImplementedError, "#{self.class}#scopes"
|
|
54
|
+
end
|
|
55
|
+
|
|
47
56
|
# -> the block's result; atomic if the backend supports it
|
|
48
57
|
def transaction(&blk)
|
|
49
58
|
raise NotImplementedError, "#{self.class}#transaction"
|
data/lib/insika/stores/memory.rb
CHANGED
|
@@ -39,6 +39,12 @@ module Insika
|
|
|
39
39
|
prefix ? keys.select { |k| k.start_with?(prefix) } : keys
|
|
40
40
|
end
|
|
41
41
|
|
|
42
|
+
def scopes(prefix = nil)
|
|
43
|
+
names = @data.keys
|
|
44
|
+
names = names.select { |s| s.start_with?(prefix) } if prefix
|
|
45
|
+
names.sort
|
|
46
|
+
end
|
|
47
|
+
|
|
42
48
|
# Snapshot at the start of the outermost transaction; an exception at any
|
|
43
49
|
# level -> restore the snapshot and re-propagate (a REAL rollback).
|
|
44
50
|
# A nested one reuses the outer (no SAVEPOINT).
|
data/lib/insika/stores/sqlite.rb
CHANGED
|
@@ -128,6 +128,14 @@ module Insika
|
|
|
128
128
|
raise Insika::StoreError, e.message
|
|
129
129
|
end
|
|
130
130
|
|
|
131
|
+
def scopes(prefix = nil)
|
|
132
|
+
names = @db.execute("SELECT DISTINCT scope FROM kv ORDER BY scope").map(&:first)
|
|
133
|
+
names = names.select { |s| s.start_with?(prefix) } if prefix
|
|
134
|
+
names
|
|
135
|
+
rescue ::SQLite3::Exception => e
|
|
136
|
+
raise Insika::StoreError, e.message
|
|
137
|
+
end
|
|
138
|
+
|
|
131
139
|
# BEGIN IMMEDIATE ... COMMIT/ROLLBACK, serialized by the semaphore.
|
|
132
140
|
# A nested one reuses the outer transaction.
|
|
133
141
|
def transaction(&blk)
|